MarkusandClaude Opus 5 527799cbc9 fix(ui): align the content column and rebuild health as a real dashboard
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
2026-09-13 14:31:54 +00:00
2026-09-12 07:39:27 +00:00

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 instructions say 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.

  1. Build and push the image; record the digest and put it in deployment.yaml.
  2. 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>"
    
  3. In PocketID: create the mdmem_admins group, add your account, create the OIDC client with redirect URI https://memory.cloud.traunseenet.com/ui/auth/callback, and confirm the client emits groups in the ID token — decode a real token, don't assume. Client id → mdmem-env, client secret → Vault.
  4. Create the restic repo for restic-mdmem; copy secret.example.yaml to secret.yaml, fill it in, SOPS-encrypt it.
  5. Commit kubernetes/apps/apps/mdmem/**. Check: ExternalSecret Ready, PVC Bound, Deployment healthy, /readyz 200.
  6. Verify embeddings end to end from inside the cluster:
    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 2560
    
    A 404/400 means the alias or the provider prefix is wrong, not that mdmem is broken. While you are there, confirm Bifrost's semantic_cache plugin 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 live checks this and the pooling/prefix behaviour.
  7. Only then add deploy/bifrost-mcp-client.json to Bifrost's config.json and commit, so a failure is attributable to one change at a time. Create a VK scoped to the six memory_* tools.
  8. 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).
  9. Run a ReplicationSource and 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_origins in Bifrost's config.json currently lists only https://bifrost.cloud.traunseenet.com; a browser-side connector flow may need https://claude.ai added.
  • 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.

S
Description
No description provided
Readme
541 KiB
0 Stars 1 Watchers 0 Forks
Languages
Python 92.4%
HTML 3.7%
CSS 3.6%
Dockerfile 0.3%