The shell had a genuine bug: `main` carried both `max-width: var(--shell)` and a centering inline padding, and with `box-sizing: border-box` the max-width *includes* that padding. On a wide display the content column collapsed to a fraction of its intended width while the header still spanned the full shell, so the page sat narrow and misaligned with its own header. Both now use the one gutter formula and no max-width, against `100%` rather than `100vw` so a scrollbar cannot turn the gutter into overflow. Health mixed three kinds of thing into one tile grid — bare counts, a status pill, a latency with a unit, a model name — which is why it read as a ragged list. Tiles are now uniformly one label and one number, muted at zero and amber when a non-zero count wants attention (pending vectors, unresolved links, orphans). Everything with a unit or a status moved into an Embeddings card beside a Maintenance card, and a lexical-only backend now says so in a banner instead of hiding inside a pill. Mobile: tiles hold two columns down to ~340px, the nav scrolls sideways instead of wrapping onto a second line, the user's name gives way to the sign-out link, action buttons and filter controls go full width rather than forming a ragged two-and-a-half-button row, and table cells stop wrapping so the scroll container does its job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139vHnNpS2sLNELjrAHX5Cu
mdmem
Markdown-canonical agent memory service. One self-hosted process that gives every harness (Claude Code, claude.ai, Open WebUI, Hermes, opencode…) a single shared long-term memory over MCP, with markdown files as the only source of truth.
Full design and its evidence: PLAN.md. Section numbers below refer to it.
- Markdown is canonical.
/data/vault/{inbox,active,archive}/*.md. The SQLite index is derived and rebuildable; delete it and the service rebuilds from the files. - Hybrid retrieval. FTS5 (BM25,
unicode61 remove_diacritics 2, optional trigram)- sqlite-vec KNN, fused with RRF (default) or convex combination. No graph traversal in the retrieval path (§2.7).
- Agents propose, the owner disposes. Agent writes land in
inbox/; an agent editing an approved note creates a proposed revision, never an in-place change. There is no delete tool (§2.4). - Memory content is untrusted data. Tool descriptions and the MCP
instructionssay so; the UI renders markdown with raw HTML disabled.
Layout
src/mdmem/
config.py settings from the environment (§12)
notes.py frontmatter round-trip (ruamel): unknown keys, order and comments survive
vaultio.py atomic write → fsync → replace, pre-write history snapshot
chunker.py atomic notes are one chunk; long ones split on H2/H3 (§6)
db.py schema, sqlite-vec, single-writer rule (§5)
index.py vault → rows, links, duplicate hints
embed.py Bifrost client, content-hash cache, FakeEmbedder for tests (§7)
search.py the retrieval algorithm (§8)
service.py the one code path every write goes through — MCP and UI alike
mcp_server.py six memory_* tools, bearer auth, idempotency, audit log (§9)
ui/ FastAPI + Jinja + HTMX, PocketID OIDC with a group check (§10)
evalharness.py metrics, bootstrap CIs, ablation matrix (§11)
deploy/ Kubernetes manifests, laid out for the fluxcd repo (§13a)
eval/ query-set template, synthetic corpus, results
Local development
uv sync --extra dev
uv run pytest # 190+ tests, no network, no GPU
uv run pytest -m live # only the tests that hit the real Bifrost endpoint
uv run ruff check src tests && uv run ruff format --check src tests
Run it against a scratch vault with no embedding backend and no login:
VAULT_DIR=/tmp/mdmem/vault INDEX_DB=/tmp/mdmem/index.db CACHE_DB=/tmp/mdmem/cache.db \
HISTORY_DIR=/tmp/mdmem/.history MCP_TOKEN=dev SESSION_SECRET=$(openssl rand -hex 32) \
UI_AUTH_DISABLED=1 LOG_STYLE=text uv run mdmem serve
UI_AUTH_DISABLED=1 is for a port-forwarded local UI only. It logs an error on
startup, and an HTTPRoute must never point at a listener running with it (§14.19).
Other commands: mdmem reindex [--full] [--no-embed], mdmem doctor,
mdmem eval --queries eval/queries.yaml [--ablate].
Build and push
There is no CI runner in the cluster, so builds are manual from the workstation:
podman build -t harbor.cloud.traunseenet.com/library/mdmem:0.1.0 -f Containerfile .
podman push harbor.cloud.traunseenet.com/library/mdmem:0.1.0
podman inspect --format '{{index .RepoDigests 0}}' harbor.cloud.traunseenet.com/library/mdmem:0.1.0
Put tag and digest in deployment.yaml. Never :latest + imagePullPolicy: Always
for this service: without a runner, an unpinned tag means the running code is not in
git, and a pod restart becomes an unplanned upgrade of your memory store (§13a.2, §14.20).
Deploy (first install)
deploy/kubernetes/apps/apps/mdmem/** mirrors the fluxcd repo's nesting; copy it in.
The manifests were written from §13a and validated with kubectl kustomize, but not
against a live cluster — verify each step.
- Build and push the image; record the digest and put it in
deployment.yaml. - Seed Vault:
vault kv put secret/apps/mdmem \ MCP_TOKEN="$(openssl rand -hex 32)" \ BIFROST_VK="<vk from the Bifrost UI>" \ OIDC_CLIENT_SECRET="<from PocketID>" \ SESSION_SECRET="$(openssl rand -hex 32)" vault kv patch secret/apps/bifrost MDMEM_AUTH_HEADER="Bearer <the same MCP_TOKEN>" - In PocketID: create the
mdmem_adminsgroup, add your account, create the OIDC client with redirect URIhttps://memory.cloud.traunseenet.com/ui/auth/callback, and confirm the client emitsgroupsin the ID token — decode a real token, don't assume. Client id →mdmem-env, client secret → Vault. - Create the restic repo for
restic-mdmem; copysecret.example.yamltosecret.yaml, fill it in, SOPS-encrypt it. - Commit
kubernetes/apps/apps/mdmem/**. Check: ExternalSecretReady, PVCBound, Deployment healthy,/readyz200. - Verify embeddings end to end from inside the cluster:
A 404/400 means the alias or the provider prefix is wrong, not that mdmem is broken. While you are there, confirm Bifrost's
kubectl -n apps run curl --rm -it --restart=Never \ --image=harbor.cloud.traunseenet.com/docker.io/curlimages/curl -- \ sh -c 'curl -s http://bifrost.apps.svc.cluster.local:8080/v1/embeddings \ -H "Authorization: Bearer $VK" -H "content-type: application/json" \ -d "{\"model\":\"llamaswap/qwen3-embedding-4b\",\"input\":[\"test\"]}"' \ | jq '.data[0].embedding|length' # expect 2560semantic_cacheplugin does not intercept/v1/embeddings— send two slightly different inputs and check the vectors differ. A cache hit there would return a stale vector for changed text and silently corrupt the index (§14.13).uv run pytest -m livechecks this and the pooling/prefix behaviour. - Only then add
deploy/bifrost-mcp-client.jsonto Bifrost'sconfig.jsonand commit, so a failure is attributable to one change at a time. Create a VK scoped to the sixmemory_*tools. - Apply the NetworkPolicy and re-check the two things it can silently break: the pod
stays Ready (kubelet probes) and the Prometheus target stays
UP(§14.23). - Run a
ReplicationSourceand test a restore into a throwaway PVC before the vault holds anything you would miss.
Rotating MCP_TOKEN
The token lives in two Vault paths. Rotating one and not the other gives every agent a 401 that looks like an mdmem bug (§14.15):
NEW=$(openssl rand -hex 32)
vault kv patch secret/apps/mdmem MCP_TOKEN="$NEW"
vault kv patch secret/apps/bifrost MDMEM_AUTH_HEADER="Bearer $NEW"
# ESO refreshes both within 5 min; restart both pods to pick them up at once:
kubectl -n apps rollout restart deploy/mdmem deploy/bifrost
Connecting a harness
All harnesses go through Bifrost — mdmem's own port is never exposed.
- Claude Code
claude mcp add --transport http mdmem https://bifrost.cloud.traunseenet.com/mcp --header "Authorization: Bearer <vk>" - Open WebUI — External Tools → MCP (Streamable HTTP), same URL + VK header.
- claude.ai — custom connector against Bifrost's OAuth 2.1 endpoint.
client.allowed_originsin Bifrost'sconfig.jsoncurrently lists onlyhttps://bifrost.cloud.traunseenet.com; a browser-side connector flow may needhttps://claude.aiadded. - Hermes / opencode — remote MCP config, same URL + VK header.
Where the implementation deviates from PLAN.md
Each of these is a decision made while building, with the reason:
| § | Plan said | Built | Why |
|---|---|---|---|
| 3, 9 | mcp SDK, FastMCP |
mcp==2.2.0, MCPServer |
FastMCP was renamed in SDK 2.x; 1.2.0 has no Streamable HTTP at all. Same API shape, current SDK. |
| 5 | chunks_fts/chunks_tri are content='' |
ordinary FTS5 tables | Contentless FTS5 cannot delete a row before SQLite 3.43 (bookworm ships 3.40) and cannot snippet(). The index is rewritten on every edit, so deletes are the hot path. Costs a few MB of duplicated text. |
| 6 | "one asyncio task owns all SQLite writes (queue)" | one write connection behind a lock; async paths use asyncio.to_thread |
Same invariant (exactly one writer), and it also holds for the CLI and the tests, which have no event loop of the service's. |
| 8 | vec0 cosine | vec0 L2 over L2-normalised vectors, cos = 1 − d²/2 |
Monotone-equivalent and does not depend on a distance_metric= option in a pre-v1 extension. |
| 9 | — | MCP_ALLOWED_HOSTS (new setting) |
The SDK's DNS-rebinding protection checks the Host header. Without the in-cluster service name on the allow-list, every Bifrost call returns 421. Added to §14 as pitfall 24. |
| 10 | — | UI_AUTH_DISABLED (new setting) |
Makes §10's "P4 may ship without OIDC, but then without an HTTPRoute" explicit and loud instead of implicit. |
| 11 | eval/queries.yaml with 50–100 real queries |
eval/queries.example.yaml + a synthetic corpus and --fake-embeddings |
Real queries are Markus's to write, and the real model is not reachable from the build environment. The harness is complete and tested; the committed run is a machinery check, and the defaults stay as §2.5 specifies. |
Status
P0–P5 are implemented and tested (252 tests, plus 5 -m live ones that need the real
Bifrost endpoint). P6 is written but unverified: no cluster, registry, Vault or PocketID
was reachable from the build environment, so the image was never built, the manifests
were never applied, and the live checks in the deploy list above are still to do.
The implementation has been through a multi-agent review against PLAN.md, which found
35 defects — four of them data-losing (a YAML typo or a UTF-8 BOM destroying a note's
identity; Vault.delete() resolving the target from file contents and rmtree-ing
outside the vault; Vault.write() escaping the vault via a caller-supplied id; and
mdmem eval --ablate dropping the trigram table from the live index). All are fixed,
each with a regression test that was checked to fail when the fix is reverted. The
lessons are recorded as pitfalls 26–31 in PLAN.md §14.
Three tests were also found to be green for the wrong reason — a dense-retrieval test
whose query was the note body verbatim, a double-index test that slept less than the
watcher's debounce, and fusion tests that passed with RRF_K set to any value or _cc
returning a constant. Those now fail under sabotage.