mimir/engine · complete reference

Mimir — study guide + architecture

Everything in one file: how to read the actual code (Part 1), then the structural overview and diagrams (Part 2). No claude.ai needed — this is a plain, self-contained HTML file. Open it in any browser, anywhere.

part 1

How to actually read this codebase

You already know what RAG, embeddings, and vector search are. This isn't that. This is: here's the real code, in the order that builds understanding, and here's why each piece is built the way it is instead of the obvious way. Read this next to the actual files open in an editor — every snippet below is copied verbatim from the repo.

stage 0

Before the code: the one idea everything follows from

Every module in this repo obeys the same handful of rules. Once these are load-bearing in your head, the actual code stops looking like 16 separate modules and starts looking like one idea applied 16 times.

Degrade, never fail

No LLM reachable? Facts get stored verbatim instead of extracted. No embeddings? Search falls back to keyword-only. No Redis? No hot turns, no cache — capture still lands. Every external dependency raises a typed exception on failure (LlmUnavailable, EmbeddingsUnavailable) and every caller has a fallback for it. You'll see this pattern constantly: a try/except SpecificError where the except branch isn't an error handler, it's a second, dumber, fully-functional code path.

Supersede, never delete

An "update" doesn't overwrite a row — it writes a brand-new fact and flags the old one is_active=false. A contradiction doesn't get auto-resolved — it gets logged and both facts stay. This is a deliberate stance: memory systems that silently pick a winner between two things you said are making a judgment call you didn't ask for.

The vault is truth; the databases are indexes

DuckDB and Qdrant exist purely to make search fast. If you deleted them both and kept only the vault/ folder, you'd have lost nothing except search speed — every fact is still sitting there as a readable markdown file. This is why reads always go back to disk (vault.read_note re-reads the file every time) instead of trusting a cached copy: a hand edit in Obsidian has to be the truth, immediately, with no sync step.

These three rules aren't independent — they're the same instinct three times. "Don't lose data because a dependency died," "don't lose data because two facts disagreed," "don't lose data because a database and a file disagreed" are all the same sentence with the noun swapped out.
stage 1

The format: app/core/okf.py

Start here because it has zero dependencies on anything else in the repo — no database, no filesystem, no config. It's pure text transformation, which makes it the easiest place to build a mental model before anything gets stateful.

An OKF note is just this:

---
id: scene_8f9a2b
type: scene
created: 2025-07-08T14:30:00Z
---
# Scene: ...
body with [[wikilinks]]

Two functions do all the work. render_note turns a dict + string into that text. parse_note turns it back. The interesting part is what happens when parsing fails:

def parse_note(raw: str) -> tuple[dict, str]:
    """Splits a note into (frontmatter dict, body). A file without valid
    frontmatter — e.g. a note the user created by hand in Obsidian — parses
    as ({}, whole file) rather than erroring; hand-edited files are a
    first-class input in this system, never a corruption case."""
    match = _FRONTMATTER_PATTERN.match(raw)
    if not match:
        return {}, raw.strip()
    try:
        frontmatter = yaml.safe_load(match.group(1)) or {}
        if not isinstance(frontmatter, dict):
            return {}, raw.strip()
    except yaml.YAMLError:
        return {}, raw.strip()
    return frontmatter, raw[match.end() :].strip()
Three separate ways this can "fail" (no frontmatter block, YAML that doesn't parse, YAML that parses but isn't a dict), and all three return the exact same shape: empty dict, whole text as body. No exception anywhere. That's not laziness — it's the "vault is truth" rule from stage 0 applied literally: if you open a note in Obsidian and delete the frontmatter by accident, Mimir has to keep working, not crash.

The wikilink bug that a real screenshot caught

This is worth reading closely because it's a genuine bug-and-fix you can trace end to end, not a toy example:

def wikify(text: str, entities: list[str]) -> str:
    for entity in sorted(entities, key=len, reverse=True):
        pattern = re.compile(
            r"(?<!\[\[)" + re.escape(entity) + r"(?!\]\])(?![^\[]*\]\])"
        )
        slug = slugify(entity)
        replacement = f"[[{entity}]]" if slug == entity.lower() else f"[[{slug}|{entity}]]"
        text = pattern.sub(replacement, text, count=1)
    return text

The highlighted line is the whole lesson. slugify("Iron Temple") produces "iron-temple" — but a plain [[Iron Temple]] wikilink never resolves to a file named iron-temple.md in Obsidian, because Obsidian's link matcher is case-insensitive but doesn't turn spaces into hyphens. The fix uses Obsidian's own alias syntax — [[slug|Display Text]] — so the link points at the real file while still showing the human-readable name. Single-word entities like Sarah skip the alias entirely, because slugify("Sarah") already equals "sarah" and case-insensitivity alone covers that.

Open slugify at the bottom of the file and run it in a Python shell against a few strings of your own — accented characters, punctuation, an empty string. Notice it never raises and never returns an empty result ("untitled" is the floor). That's the same "never fail" instinct from stage 0, at the smallest possible scale.
↑ back to top
stage 2

The brain on disk: app/core/vault.py

This is where okf.py's pure functions meet an actual filesystem. Every function here does exactly one filesystem operation and has one job. Two are worth understanding closely because they encode the "vault is truth" rule as actual behavior, not just a comment.

def ensure_entity_stub(tenant_id: str, user_id: str, entity_name: str) -> Path:
    """Creates entities/{slug}.md if missing. Never overwrites an existing
    note — the user may have enriched it by hand, and their version wins."""
    entities_dir = _user_root(tenant_id, user_id) / "entities"
    entities_dir.mkdir(parents=True, exist_ok=True)
    path = entities_dir / f"{okf.slugify(entity_name)}.md"
    if not path.exists():
        frontmatter = {"type": "entity", "created": ..., "aliases": []}
        path.write_text(okf.render_note(frontmatter, f"# {entity_name}\n"), encoding="utf-8")
    return path

That single if not path.exists() is the entire "your edits win" guarantee. Every time Mimir mentions an entity again, it calls this function again — and every time after the first, it's a no-op. If you'd opened that note and written three paragraphs about your coach, this function will never touch it again.

The graph walk

This is the function that makes recall feel associative instead of purely keyword-driven — it's a plain breadth-first search over wikilinks, capped at a hop count:

def expand_links(tenant_id, user_id, bodies: list[str], hops: int = 2) -> dict[str, str]:
    index = _note_index(tenant_id, user_id)
    collected: dict[str, str] = {}
    frontier = [t for body in bodies for t in okf.extract_wikilinks(body)]

    for _ in range(hops):
        next_frontier: list[str] = []
        for target in frontier:
            slug = okf.slugify(target)
            if target in collected or slug not in index:
                continue
            _, note_body = okf.parse_note(index[slug].read_text(encoding="utf-8"))
            collected[target] = note_body
            next_frontier.extend(okf.extract_wikilinks(note_body))
        frontier = next_frontier
    return collected
The highlighted if target in collected check is doing double duty: it skips dangling links (nothing to expand) and it's what stops this from looping forever if two notes link to each other — Alpha links to Beta, Beta links back to Alpha, and without that check hops=5 would walk the same two notes ten times. There's a test for exactly this (test_expand_links_handles_cycles_without_hanging) — that's a real failure mode this function has to survive, not a hypothetical one.
↑ back to top
stage 3

The write path, in execution order

Everything from here on is stateful, so reading order matters more than file order. Follow one conversation through the system exactly as app/core/pipeline.py calls it — this is the actual sequence, not a simplification:

capture() synthesis extraction consolidation insert persona

capture() — the only part that has to succeed

def capture(tenant_id, user_id, session_id, messages: list[dict]) -> list[str]:
    for offset, message in enumerate(messages):
        message_id = str(uuid.uuid4())
        duckdb_client.insert_l0_message(...)   # must succeed
        message_ids.append(message_id)
        try:
            redis_client.push_turn(...)         # best-effort
        except Exception:
            logger.warning("hot memory push failed (redis unreachable?) — capture continues")
    return message_ids

Notice the asymmetry: the DuckDB write has no try/except at all — if that fails, the whole request should fail, because losing what you said is not an acceptable outcome. The Redis write is wrapped, because losing a speed optimization is fine.

synthesize_scene() — the LLM-or-digest fork

app/core/synthesis.py turns raw turns into the human- readable scene note. It tries an LLM call for real prose; on any failure it falls back to a deterministic transcript digest — same output shape either way (a title, a body, a list of entities), so nothing downstream needs to know which path ran.

extract_facts() — turns into atomic facts

app/core/extraction.py is the same fork, applied to L1 facts instead of the scene note: LLM path returns typed, prioritized facts; offline path stores each substantive user turn verbatim, tagged extraction: verbatim so you can tell later which facts were ever actually processed by a model.

consolidate() — the dedup gate

app/core/consolidation.py is the most subtle file in the repo, so it's worth reading in full rather than skimming. Two layers, cheapest first:

# layer 1 — free, always runs, catches exact duplicates before any LLM call
candidate_norms = {_normalize(c["content"]): cid for cid, c in candidates.items()}
for fact in new_facts:
    norm = _normalize(fact["content"])
    if norm in candidate_norms or norm in seen_new_norms:
        continue   # dropped — never even reaches the LLM
    surviving.append(fact)

Only what survives layer 1 goes to the LLM, batched into one call that sees every new fact plus a pool of keyword-nearest existing memories, and returns a store/skip/update decision per fact. This next line is the one worth studying hardest in the whole file:

valid_ids = set(candidates)
...
fact["_supersedes"] = target if verdict == "update" and target in valid_ids else None
The LLM's response is a target_id string — untrusted input, effectively. Without that target in valid_ids check, a model that hallucinates an id (or is prompt- injected into naming one) could mark any fact in the entire store as superseded, including one belonging to a different session's context. The fix is simple: only ids the model was actually shown in this call's candidate pool are eligible. Never trust a model-generated id as a database key without validating it against what you gave it.

maybe_synthesize() — the persona, refreshed on a counter

app/core/persona.py re-writes the persona doc every l3_every_n_memories new facts. The trigger count isn't stored in a database — it's stored in the persona note's own frontmatter (fact_count), so the vault stays the single source of truth even for "when did this last run."

↑ back to top
stage 4

The four stores

You already know what each of these technologies does in general. Here's specifically how Mimir uses them, and the one thing about each that isn't obvious from the name.

StoreFileThe non-obvious part
DuckDBduckdb_client.py One shared connection, lazily opened. Every timestamp goes through to_utc_naive() first — DuckDB silently converts timezone-aware datetimes to local time before storing them, which if you skip this step means facts written on an IST machine come back reporting they're from the future.
Fact searchl1_store.py Real BM25 via DuckDB's fts extension when it's installed, with a plain SQL term-count fallback if it isn't. The index doesn't auto-update — a dirty flag gets set on every write, and the next search call rebuilds it lazily.
Qdrantvector_store.py QdrantClient(path=...), not host=/port= — this is embedded mode, a directory on disk, no server process anywhere. The collection isn't created until the first embedding actually happens, because its vector dimension depends on whatever embedding model ends up configured.
Redisredis_client.py Every key is scoped hot:{tenant}:{user}:{session} — tenant_id is in there even though the original design spec's own key-pattern table left it out, because two different tenants could otherwise collide on the same session id.
Delete ~/.mimir/qdrant entirely and call recall again. Nothing breaks — vector_store.search() just checks collection_exists() first and returns an empty list if it's gone. That one if statement is the whole reason "no embeddings configured" and "embeddings configured but never used yet" are the same code path.
↑ back to top
stage 5

The read path: scoring + recall

This is the payoff — the part that actually answers a query. Read scoring.py first since it's pure math with no side effects, then recall.py, which is just scoring.py wired up to the stores from stage 4.

Four signals, one formula

def final_score(semantic, frequency, recency, graph):
    w = settings.recall.weights
    return semantic * w.semantic + frequency * w.frequency + recency * w.recency + graph * w.graph

Each input is computed by its own tiny function, and each is worth reading once:

def recency_score(created_at, now=None):
    """e^(-decay_rate * age_days); today ~1.0, ~14 days ago ~0.5 at the 0.05 default."""
    age_days = max((now - created_at).total_seconds() / 86400, 0.0)
    return math.exp(-settings.recall.decay_rate * age_days)

def frequency_score(access_count, max_access_count):
    """log-normalized so one obsessively-recalled memory doesn't flatten
    the signal for everything else."""
    if max_access_count <= 0 or access_count <= 0:
        return 0.0
    return math.log(1 + access_count) / math.log(1 + max_access_count)

graph_score is the simplest of the four — it's just a lookup table, {0: 1.0, 1: 0.5, 2: 0.25}, mapping wikilink hop-distance to a score. No formula needed because the input is already a small integer.

Why log-normalize frequency instead of a plain ratio? Because access counts are unbounded and skewed — one fact that's been recalled 200 times shouldn't make a fact recalled 5 times score near zero by comparison. The log compresses the gap between "very popular" and "somewhat popular" while still keeping "never recalled" at exactly zero.

The one line that makes hybrid search actually hybrid

In recall.py, after BM25 and vector search each return their own ranked list:

keyword_ranking = [f["id"] for f in keyword_hits]
vector_ranking = sorted(semantic_by_id, key=semantic_by_id.get, reverse=True)
rrf = scoring.rrf_merge([keyword_ranking, vector_ranking])

and in scoring.py:

def rrf_merge(ranked_lists: list[list[str]]) -> dict[str, float]:
    """Reciprocal Rank Fusion: id -> summed 1/(k + rank) across every list
    it appears in. Scale-free, so BM25 scores and cosine similarities never
    need to be made comparable — only their rankings matter."""
    scores = {}
    for ranked in ranked_lists:
        for rank, item_id in enumerate(ranked):
            scores[item_id] = scores.get(item_id, 0.0) + 1.0 / (RRF_K + rank + 1)
    return scores
A BM25 score and a cosine similarity are not the same unit — one is unbounded, one is 0 to 1, and averaging them directly would be meaningless. RRF sidesteps the whole problem by throwing away the actual scores and using only rank position. A result that's #1 in both lists always beats a result that's #1 in only one — no calibration required.

Reading the fallback in context

One line worth slowing down on, since it looks small but encodes a real design decision:

semantic = semantic_by_id.get(fact_id, rrf.get(fact_id, 0.0) / max_rrf if not vector_used else 0.0)

When the vector leg actually ran, a fact's semantic score is its real cosine similarity. When there's no embedding endpoint configured at all, there's no cosine score to fall back on — so the normalized RRF rank stands in for it instead, which means keyword relevance still drives the final ranking even with zero AI models involved anywhere in the request.

↑ back to top
stage 6

The front doors

Everything above this line is the engine. Everything here is how something outside Python actually talks to it.

The semantic cache — the cheapest possible answer

semantic_cache.py checks two things before recall ever runs a real search: an exact hash of the normalized query (works with zero embeddings), then a cosine similarity against previously-cached query vectors at a 0.92 threshold. A hit on either returns instantly. Invalidation is deliberately blunt — any new memory landing for a user wipes their entire cache, not just entries that might be stale, because a wrong cached answer is worse than a slightly wasted recomputation.

Two MCP adapters, one shared engine

This is the part worth understanding if you're going to actually use this day to day. adapters/mcp_embedded.py imports app.core.pipeline directly — no network call, no server process, the engine runs inside the same Python process the MCP client spawned. adapters/mcp_server.py does the exact same three tools but via HTTP to a running gateway. Same tools, same ~/.mimir files either way — the only difference is whether there's a process in between.

Why does this matter enough to build twice? DuckDB and embedded Qdrant are single-writer. One Claude Code session at a time works fine embedded — nothing to run, nothing to keep alive. The moment you want two agent sessions open simultaneously against the same memory, embedded mode hits a file lock, and the gateway mode exists specifically for that case: one server, many clients, normal concurrent-access rules apply.
↑ back to top
stage 7

Exercises — break it on purpose

Reading code teaches you what it does. Changing it and watching what breaks teaches you why it's built that way. Try these roughly in order:

Set recall.weights.recency to 0 and recall.weights.frequency to 1.0 in a mimir.yaml. Ask the same question twice, three days apart in your head (or fake a fact's created_at in the DuckDB file directly). Watch which one final_score now favors — this is the fastest way to actually feel what the weighted formula in stage 5 is doing.
In app/core/synthesis.py, find extract_entities_naive. Feed it a sentence with a Windows file path in it. Watch it produce junk entities like "Users" or "Downloads" — this is a real, still-open bug (mentioned in the README's Status section). Try writing a one-line filter that skips any capitalized run immediately preceded by a backslash or forward slash.
Trace mimir_remembermimir_flushmimir_recall by hand: add a print() at the top of pipeline.capture, pipeline.flush_session, and recall.recall, then run all three through the embedded adapter directly in a Python shell (no MCP client needed — adapters/mcp_embedded.py's three functions are just plain Python you can call yourself). Watching the actual call order beats reading about it.
Read tests/test_orphan_recovery.py before reading its implementation in pipeline.flush_all_pending. It's a real bug that got caught live (see the git log), and the test names alone tell most of the story — try predicting what flush_all_pending does from the test names before you look at the function.
↑ back to top
part 2

Architecture reference

A local-first memory system for AI agents. Agents capture what you say; Mimir distills it into facts and Obsidian-readable notes; recall hands back a ranked memory-context block. Everything is files in ~/.mimir — no cloud, no daemon, and every external dependency is an upgrade, never a requirement.

The four stores

No single database handles meaning, time, relationships, and raw history well. Mimir splits them, and each store degrades independently:

StoreHoldsWithout it
DuckDB · ~/.mimir/memories.db Raw transcripts (L0), extracted facts (L1), contradictions, audit log. Ground truth. Nothing works — this is the one hard requirement, and it's a single file.
Vault · ~/.mimir/vault/ Human-readable markdown: scene notes, entity notes, persona. Open it in Obsidian; edits win. No graph signal, no linked-note enrichment. Recall still returns facts.
Qdrant · ~/.mimir/qdrant/ Fact vectors for semantic search (embedded mode — a directory, no server). Keyword-only recall. "protein powder" won't find "powerlifting".
Redis · optional server Hot conversation turns (24h TTL) + semantic query cache. No recent-turn injection, every cache lookup is a miss. Capture still lands.

Write path — capture & flush

agent turn → pipeline.capture()
Every message lands in l0_conversations (must succeed) and is pushed to the hot list (best-effort; Redis down just logs a warning).
▼  session ends (mimir_flush / POST /session/end)
pipeline.flush_session()
Reads the full transcript from L0 — ground truth, not the maybe-expired hot cache — then fans out:
synthesis → scene note
LLM prose summary, or offline transcript digest. Entities become [[wikilinks]]; every linked entity gets a stub note so the Obsidian graph has no dangling links.
vault/scenes/*.md
extraction → L1 facts
LLM: typed facts (persona / episodic / instruction) with priority. Offline: substantive user turns stored verbatim, re-extractable later.
l1_memories vectors (best-effort)
consolidation (L1.5 gate)
Exact duplicates dropped free, before any LLM spend. With an LLM: one batched call decides store / skip / update per fact. Updates supersede (is_active=false), never delete. Contradictions flagged, never auto-resolved.
l1_contradictions
persona (L3)
Every N new facts (counter lives in persona.md's own frontmatter), the persona doc is rewritten — LLM, or a prioritized digest offline. Old versions backed up, never lost.
vault/persona.md
semantic cache invalidated
New memories mean cached answers may be stale — the user's whole cache is dropped. Correctness beats hit rate.

Read path — recall

0 · cache intercept
Query embedded once (if embeddings exist). Exact-hash cache hits need no embeddings at all; cosine hits fire at similarity ≥ 0.92. Hit → return immediately, zero pipeline cost.
semcache
▼ miss
1–2 · hybrid search
BM25 keyword search (DuckDB fts, SQL term-count fallback) and vector search run over the active facts, top-20 each, both filtered by tenant + user.
BM25 cosine
3 · RRF merge
Reciprocal Rank Fusion (k=60) — scale-free, so BM25 scores and cosines never need to be made comparable; only rankings matter.
4 · four-signal scoring
0.45·semantic + 0.20·frequency + 0.25·recency + 0.10·graph (weights are config). Recency decays e^(−0.05·days); frequency is log-normalized access count; graph is wikilink hop distance from query entities (1.0 / 0.5 / 0.25). No vector leg? Normalized RRF stands in for semantic so keyword rank still drives order.
▼ threshold ≥ 0.30, top-5, access counts bumped (recall reinforces ranking)
5–6 · enrich & assemble
Entities in the returned memories are hop-walked through vault wikilinks — the "clicking around your Obsidian graph" pull. Output is one budget-bounded block: USER PROFILE (persona lines, read fresh from disk) → RECENT CONVERSATION → RELEVANT MEMORIES → LINKED NOTES. Weakest memories truncate first.
expand_links

Five invariants

Module walkthrough

app/core — the brain

config.py

Nested pydantic models mirroring mimir.yaml; ${ENV_VAR} substitution; boots on all-defaults when no file exists. Everything reads the settings singleton.

okf.py

The file format, pure logic. Frontmatter render/parse (hand-written notes with broken YAML parse as body, never error), wikilink extraction with [[Target|alias]] support, wikify (longest entity first, never double-wraps, aliases multi-word entities to their slug so Obsidian actually resolves the link), deterministic slugs.

vault.py

The brain on disk: write_scene (+ entity stubs so no dangling links), ensure_entity_stub (never overwrites user edits), upsert_persona (timestamped backups), read_note (fresh from disk), expand_links (cycle-safe N-hop wikilink walk — recall's graph signal and enrichment both).

llm.py · embeddings.py

Any OpenAI-compatible endpoint; keyless Ollama works (no key = no auth header, not an error). Failures raise typed *Unavailable exceptions — the signal to take the offline path.

synthesis.py

Turns → scene note (LLM JSON or deterministic digest). Home of the naive entity extractor: capitalized runs, minus the sentence-start trap — a lone sentence-opening capital only counts if seen capitalized mid-sentence elsewhere. spaCy replaces it later behind the same interface.

extraction.py

Turns → L1 atomic facts. LLM path: typed + prioritized, min-priority filtered. Offline: substantive user turns verbatim, marked extraction: verbatim for later re-extraction.

consolidation.py

The L1.5 gate. Free layer: exact normalized dups (vs store and within batch) die before any LLM call. LLM layer: one batched store/skip/update decision across all new facts; returned target ids validated against the candidate pool shown — hallucinated ids can't supersede anything.

persona.py

L3. Count-triggered via persona.md's own frontmatter (fact_count). LLM rewrite or offline digest. profile_lines feeds recall's USER PROFILE — fresh from disk.

scoring.py

Pure math: RRF (k=60), exponential recency decay, log-normalized frequency, hop-based graph score, config-weighted final score.

recall.py

The read pipeline in order: cache intercept → hot turns → hybrid search → RRF → 4-signal scoring → threshold/top-k → access bump → vault enrichment → budget-bounded context assembly → cache write.

pipeline.py

capture(), flush_session(), and flush_all_pending() — the single write-path implementation shared by the HTTP gateway and the embedded MCP adapter, so both modes behave identically. flush_all_pending also recovers sessions orphaned by a recycled adapter process, using the vault's own session: frontmatter as the record of what's already been flushed.

semantic_cache.py

Redis hash per (tenant, user). Exact-hash hits free; cosine hits at 0.92. Bluntly invalidated on any new memory. Redis down = miss, never error.

app/db — the stores

duckdb_client.py

One lazy connection; four tables (l0_conversations, l1_memories, l1_contradictions, audit_log). to_utc_naive guards every timestamp. erase_user deletes content but keeps the audit log — the erasure receipt must survive the erasure.

l1_store.py

Fact CRUD + search. Real BM25 via the fts extension (lazy index rebuild after writes, tracked by a dirty flag) with a plain-SQL term-frequency fallback. Every query: tenant AND user AND is_active.

vector_store.py

Qdrant embedded — a directory, no server. Collection created lazily on first write (dimension comes from the configured embedding model). Payload duplicates tenant/user so filtering never joins back to DuckDB.

redis_client.py

Hot turn lists (hot:{tenant}:{user}:{session}, TTL-refreshed) — tenant_id included in the key even though the original spec's example omitted it; the spec's own isolation rule wins.

app/api + adapters — the front doors

api/deps.py · api/routes.py

Tenant identity comes only from the Bearer key (constant-time compare), never the body. Keyless mode pins to tenant local on 127.0.0.1. Routes: capture, session/end (self-healing — also recovers orphaned sessions), recall, vault reads, DELETE /user/{id} (erasure across all stores + receipt), GET /export/{id} (portability).

adapters/mcp_embedded.py

The engine in-process — no gateway, nothing running between sessions. Claude Code / OpenCode / Pi spawn it per session. Constraint: single-writer stores → one session at a time per ~/.mimir.

adapters/mcp_server.py

Same three tools (mimir_recall / mimir_remember / mimir_flush) over HTTP to a running gateway — for concurrent sessions or remote use. Swap modes any time; the data doesn't change.

What's deliberately not here yet

KuZu graph DB (no Python 3.11+ wheels — vault wikilinks carry the graph signal behind the same hop interface), L2 scene aggregation as its own tier (scenes are per-session today), spaCy NER, LangChain/OpenAI-Agents adapters, PersonaMem benchmarks, PII scrubbing, the multi-tenant control plane. 83 tests pass; 3 skip without a Redis server.

↑ back to top