Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
36d796f
docs(specs): memory-ui design — agent-first memory service + verifica…
Wuesteon Jul 10, 2026
8e003be
docs(specs): memory-ui design v2 — rethink: local-first console + obs…
Wuesteon Jul 10, 2026
6654592
docs(plans): lean-memory-console implementation plan — 17 TDD tasks
Wuesteon Jul 10, 2026
054aefd
feat(console): package scaffold + config (data-root, ns sanitizer, lo…
Wuesteon Jul 13, 2026
6282729
feat(console): _events.db sidecar — record, list, activity, atomic re…
Wuesteon Jul 13, 2026
3e129d4
feat(console): fixture builder + inspect_sql enumeration & schema tri…
Wuesteon Jul 13, 2026
087a49c
feat(console): inspect_sql facts/chain/episodes/entities readers
Wuesteon Jul 13, 2026
167633d
fix(console): quote FTS terms in _fts_query — mirror engine, defuse o…
Wuesteon Jul 13, 2026
b53770a
feat(console): EngineGateway write path, retry_busy, supersession + e…
Wuesteon Jul 13, 2026
df92107
feat(console): observing MCP wrapper (memory_add/search) + core-parit…
Wuesteon Jul 13, 2026
1bb536d
feat(console): FastAPI app factory + /views read-path router (auth, h…
Wuesteon Jul 13, 2026
ed84638
fix(console): strict local Host allowlist + committed auth/404 tests
Wuesteon Jul 13, 2026
9479cdf
feat(console): REST /v1 data plane + Docker streamable-HTTP MCP mount…
Wuesteon Jul 13, 2026
02d5330
fix(console): lifespan-only MCP session manager + transport-security …
Wuesteon Jul 13, 2026
a67e380
fix(console): LM_MCP_ALLOWED_HOSTS — configurable MCP host allowlist …
Wuesteon Jul 13, 2026
c9f6af0
feat(console): CLI serve|mcp entry point, boot validation, --print-co…
Wuesteon Jul 13, 2026
eb10e96
feat(ui): scaffold React SPA with typed API client, auth, router shell
Wuesteon Jul 13, 2026
1c06c1f
fix(ui): commit bun.lock — Task 15 Docker build requires frozen lockfile
Wuesteon Jul 13, 2026
5c27ba6
fix(ui): Fact/ChainLink use wire field `id` — only SearchHit carries …
Wuesteon Jul 13, 2026
9630ca0
feat(ui): Overview page with namespace cards, sparkline, connect snip…
Wuesteon Jul 13, 2026
cfba093
fix(ui): render supersession-chains count on namespace cards
Wuesteon Jul 13, 2026
cdb0942
feat(ui): Memories page — fact table, filters, pagination, fact drawer
Wuesteon Jul 13, 2026
17736b5
fix(ui): guard Memories load effect against stale responses
Wuesteon Jul 13, 2026
c07be6b
feat(ui): Episodes page — transcript list with per-episode fact expan…
Wuesteon Jul 13, 2026
a2d4583
feat(ui): Activity & Traces page — polled feed, score decomposition, …
Wuesteon Jul 13, 2026
45415ec
feat(console): multi-stage Docker image (slim/full) + compose, packag…
Wuesteon Jul 13, 2026
e44d691
fix(console): register static mount last — StaticFiles swallowed POST…
Wuesteon Jul 13, 2026
72c75cf
fix(console): serve --docker — wire Docker mode into the container en…
Wuesteon Jul 13, 2026
1167904
fix(deploy): pin CPU torch index in full stage — 5.47GB→CPU-only image
Wuesteon Jul 13, 2026
f704094
feat(plugin): Claude Code plugin (stdio MCP + 4 commands) and repo ma…
Wuesteon Jul 13, 2026
6be83b2
docs(console): README (both modes, data-root/t_ref/namespace traps) +…
Wuesteon Jul 13, 2026
43f55ab
fix(console): README — correct token-capture mechanism and empty-name…
Wuesteon Jul 13, 2026
f23a0a6
fix(console): wire real model backends via _build_memory
Wuesteon Jul 13, 2026
7290053
feat(console): surface resolved models mode + stub-scores banner
Wuesteon Jul 13, 2026
15bf7ae
fix(console): constant-time credential compares
Wuesteon Jul 13, 2026
a1b64f9
fix(plugin): drop dead --print-status invocation; align version to 0.1.0
Wuesteon Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "lean-memory-console",
"description": "Marketplace for the lean-memory Claude Code plugin: a local-first observing MCP and read-only verification console for agent memory.",
"owner": {
"name": "lean-memory",
"url": "https://github.com/Wuesteon/lean-memory-console"
},
"plugins": [
{
"name": "lean-memory",
"source": "./plugin",
"description": "Observing MCP + read-only console for lean-memory: store and search agent memories from Claude Code, then verify the supersession spine, per-hit score breakdown, and provenance episodes in a local UI."
}
]
}
2 changes: 2 additions & 0 deletions console/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Built SPA (produced by ui/ via `bun run build`; never committed)
src/lean_memory_console/static/
213 changes: 213 additions & 0 deletions console/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# lean-memory-console

A read-only verification console for [lean-memory](../README.md).

**Agents write and search; the human verifies.** Your agent (Claude Code via
the observing MCP, or any HTTP client in Docker mode) is the only writer of
memory content. You open the console **read-only over stored memory** — no
adding, editing, or deleting facts — to see the engine's invisible signals for
the first time: the ADD-only supersession spine, per-hit score decomposition,
and provenance episodes.

> The single write exception is the manual **test-search box**: it runs a real
> engine search and therefore bumps access stats (`touch()`). That is
> observability of live-search behavior, not a memory mutation.

## Two modes

### Local (default — zero Docker, nothing runs when the agent doesn't)

Two transient localhost processes over one shared data root:

```bash
# 1. The observing MCP wrapper — your agent stores + searches through this.
uvx lean-memory-console mcp

# 2. The console — you open this to verify. Binds 127.0.0.1, prints a
# tokened URL, Ctrl-C to stop.
uvx lean-memory-console serve
```

`serve` accepts `--root`, `--port`, and `--no-open` (skip the browser launch).
On load the SPA captures the token from `?token=…` — held in memory and sent as
the `X-Console-Token` header on every request — and strips it from the address
bar (`history.replaceState`). There is no cookie; a page refresh without the
token drops the session.

Via the Claude Code plugin (recommended), the MCP is wired automatically:

```
/plugin marketplace add Wuesteon/lean-memory-console
/plugin install lean-memory
/memory:ui # launch + open the console
/memory:status # resolved root, namespaces, connect snippets
/memory:server-up # start the Docker data plane
/memory:server-down # stop it (the data volume persists)
```

(From a local checkout you can register the marketplace with
`/plugin marketplace add ./` at the repo root instead of the GitHub slug.)

### Docker (single-tenant, long-running, container owns /data)

```bash
export LM_API_KEY=$(openssl rand -hex 24) # REQUIRED — the container refuses to boot without it
docker compose -f "$(lean-memory-console --print-compose-path)" up -d
```

Then connect an agent over streamable-HTTP MCP and open the UI:

```bash
claude mcp add --transport http lean-memory http://127.0.0.1:8377/mcp \
--header "Authorization: Bearer $LM_API_KEY"
# UI: http://127.0.0.1:8377/ (authenticate with the same LM_API_KEY)
```

Docker mode binds `0.0.0.0` so the published port is reachable; there is **no**
local-mode Host guard and no per-launch session token. The controls are the
bearer gate (`LM_API_KEY`) plus the MCP transport-security Host allowlist. If
you reach the container over a LAN hostname/IP (no reverse proxy), list the
host(s) in `LM_MCP_ALLOWED_HOSTS` (comma-separated; `:*` matches any port) or
the `/mcp` mount 421s on its Host check — a commented example sits in the
packaged `docker-compose.yml`. The compose service builds the
`full` image target; container env is `LM_DATA_ROOT` / `LM_API_KEY` (required) /
`PORT` / `LM_CONSOLE_MODELS` / `LM_MCP_ALLOWED_HOSTS`.

A plain REST mirror exists for non-MCP agents:
`POST /v1/{namespace}/memories` and `POST /v1/{namespace}/search`.

## The data-root rule (read this — it is the #1 trap)

The console serves **exactly one** data root and never auto-merges roots.
Resolution order, both `serve` and `mcp`:

```
--root > $LM_DATA_ROOT > ~/.lean_memory
```

**The ./lm_data trap.** The core `lean-memory` engine's *own* default root is
`./lm_data` (a directory in your current working directory), **not**
`~/.lean_memory`. So if you ran the engine directly (or the core stdio MCP
server pointed at the default) your memories may be under `./lm_data` while the
console defaults to `~/.lean_memory` — and you would inspect an empty root.

- `/memory:status` **prints the resolved root** and **warns** when `./lm_data`
exists in the working directory but is not the served root.
- Fix: point the console at the right root, e.g.
`uvx lean-memory-console serve --root ./lm_data`, or set
`export LM_DATA_ROOT=$(pwd)/lm_data`.

The console adds exactly one file to the data root: `_events.db` (search/add
traces). Everything else is the engine's own `<namespace>.db` files.

## Live vs. replay: the `t_ref` rule

`t_ref` (epoch-ms) is the **world/event time** that becomes a fact's `valid_at`
and anchors the temporal supersession spine.

- **Live agents omit it.** The observing MCP fills `now`, so facts are ordered
by wall-clock ingest — correct for an agent capturing memories as they happen.
- **Replay / import supplies it.** When backfilling historical conversations,
pass the original event time as `t_ref` (epoch-ms) on every add. **Omitting
`t_ref` on historical data silently collapses the spine's ordering** — every
backfilled fact gets `now`, so supersession and point-in-time queries become
meaningless.

Rule of thumb: if you are importing anything that did not happen right now,
set `t_ref`.

## One namespace per project

Namespaces replace tenants: the engine stores one SQLite file per namespace,
which is the isolation boundary. Use **one namespace per project/session**.

Cross-process writers on a *single* namespace (e.g. two Claude Code sessions
spawning the wrapper on the same data root) are supported but serialized by a
bounded retry-on-`SQLITE_BUSY` loop — not a lock manager. Pathological
contention degrades to retries and, eventually, a `SQLITE_BUSY` surfaced in the
event's `payload.error`. Keeping namespaces per-project avoids the contention
entirely.

Namespaces are created implicitly on first accepted `memory_add`. There is no
create-namespace step, and no delete-namespace surface (ADD-only discipline —
to remove a namespace, delete its `.db` file while nothing is running).
Namespaces whose sanitized name starts with `_` are **rejected** (the
`_events.db` sidecar is reserved); an empty name is not rejected — it sanitizes
to `default`.

## Image size (Docker `full`)

The default Docker image is the `full` target: it installs `lean-memory[models]`
(CPU **torch** + sentence-transformers + a cross-encoder reranker), so the image
is large (~1.46 GB) and the first run downloads model weights into the mounted
HF cache volume. The CPU torch build is pinned from
`download.pytorch.org/whl/cpu` so the resolve never pulls the multi-GB CUDA
wheels. This is deliberate — stub vectors would recreate the FakeEmbedder
first-impression failure the quality gate exists to fix. A `slim` target
(~365 MB, stub embedder, no models) exists for API/UI development
(`docker build --target slim …`) but is never the documented first-run path.

## Offline by default

The console runs fully on deterministic stub backends with no network and no
model downloads (`LM_CONSOLE_MODELS=stub`, or `auto` when `[models]` is not
importable). When scores are stub-generated the UI shows a banner saying so.

## License

Apache-2.0

## Manual E2E verification (pre-merge)

Run this before merge. It is a **manual** gate, not automated — steps that need
a live interactive Claude Code session are marked `[manual]` and are driven by a
human, not by tooling. Capture the observed outcome next to each step.

**Local mode (plugin + observing MCP):**

- [ ] `[manual]` `/plugin marketplace add ./` (from the repo root marketplace),
then `/plugin install lean-memory`.
Expected: plugin installs; the `lean-memory` MCP server appears in the
session's MCP list, and `/memory:ui|status|server-up|server-down` are
offered as commands.
- [ ] `[manual]` In a real Claude Code session, ask the agent to store a couple
of facts and then search them (through the observing MCP
`memory_add`/`memory_search`).
Expected: `memory_add` returns `{fact_ids, superseded_count}`;
`memory_search` returns hits whose `fact_text` matches what was stored;
rows land in `<root>/_events.db`.
- [ ] `[manual]` Run `/memory:ui`.
Expected: browser opens `http://127.0.0.1:8377/?token=…`; the address bar
loses `?token` after boot; no login screen (local mode).
- [ ] `[manual]` Verify all four pages render the resulting state:
- **Overview** — the namespace card shows fact counts, top predicates,
the 7-day adds/searches sparkline, supersession rate, and facts-per-add.
- **Memories** — the fact table lists the stored facts; opening a fact
drawer shows metadata, the supersession timeline (chain oldest→newest),
and the provenance episode.
- **Episodes** — the transcript shows the stored turns, each expanding to
the facts extracted from it.
- **Activity & Traces** — the polled feed shows the `add`/`search` events;
a search row expands to the per-hit score decomposition
(`0.6·relevance + 0.2·recency + 0.2·importance`, dense/sparse ranks,
RRF); the test-query box runs a live search and its row is labeled
`origin: ui`.

**Docker mode (HTTP data plane):**

- [ ] `[manual]` `export LM_API_KEY=$(openssl rand -hex 24)` then
`docker compose -f "$(lean-memory-console --print-compose-path)" up -d`
(or `/memory:server-up`).
Expected: container starts; boot validation passes (data root writable,
`LM_API_KEY` set, sqlite-vec loadable).
- [ ] `[manual]` `claude mcp add --transport http lean-memory
http://127.0.0.1:8377/mcp --header "Authorization: Bearer $LM_API_KEY"`.
Expected: the MCP connection registers; a store+search from the agent
succeeds and records events in the container's `/data/_events.db`.
- [ ] `[manual]` Open `http://127.0.0.1:8377/`.
Expected: the login screen prompts for the key; entering `LM_API_KEY`
authenticates; all four pages render the same as local mode, and the
Overview shows the Docker connect snippet.
- [ ] `[manual]` `/memory:server-down`.
Expected: container stops; the `lm_data` volume persists (re-`up` shows
the same memories).
30 changes: 30 additions & 0 deletions console/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "lean-memory-console"
version = "0.1.0"
description = "Agent-first read-only verification console for lean-memory"
requires-python = ">=3.10"
dependencies = [
"lean-memory",
"fastapi>=0.115",
"uvicorn>=0.30",
"mcp>=1.0",
]

[project.optional-dependencies]
dev = ["pytest>=8", "anyio>=4", "httpx>=0.27", "pyyaml"]

[project.scripts]
lean-memory-console = "lean_memory_console.cli:main"

[tool.hatch.build.targets.wheel]
packages = ["src/lean_memory_console"]

[tool.hatch.build.targets.wheel.force-include]
"../deploy/docker-compose.yml" = "lean_memory_console/deploy/docker-compose.yml"

[tool.pytest.ini_options]
testpaths = ["tests"]
3 changes: 3 additions & 0 deletions console/src/lean_memory_console/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""lean-memory-console — agent-first read-only verification console."""

__version__ = "0.1.0"
Loading
Loading