Markus 0d53c854d8 fix(index): dedupe ScanStats.pending by chunk_id
scan() extended stats.pending from each index_note() return and then again
from pending_embeds() (which re-reads every vec_pending=1 chunk in the db,
including the ones just indexed), so every freshly pending chunk was listed
twice and would be embedded twice.

Test: tests/test_index.py::test_scan_pending_has_no_duplicate_chunk_ids
2026-09-12 18:30:20 +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. 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.

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%