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.
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.
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.
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()
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.
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.
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
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.
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() — 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
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."
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.
| Store | File | The non-obvious part |
|---|---|---|
| DuckDB | duckdb_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 search | l1_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. |
| Qdrant | vector_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. |
| Redis | redis_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. |
~/.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.
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.
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
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 topThe 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.
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:
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.
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.
mimir_remember → mimir_flush → mimir_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.
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.
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:
| Store | Holds | Without 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
[[wikilinks]]; every linked entity gets a stub note so the Obsidian graph
has no dangling links.is_active=false), never delete. Contradictions flagged, never auto-resolved.Read path — recall
fts, SQL term-count fallback) and vector
search run over the active facts, top-20 each, both filtered by tenant + user.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.Five invariants
- Degrade, never fail.No LLM → verbatim facts + digest notes. No embeddings →
keyword recall. No Redis → no hot turns/cache. Every fallback is typed
(
LlmUnavailable,EmbeddingsUnavailable) and tested. - The vault is ground truth for readable memory; databases are indexes.Reads come from disk — edit a note in Obsidian and the next recall sees it. No sync step exists to break.
- Supersede, never delete.Old facts get
is_active=false+superseded_by; personas keep history; contradictions are flagged for review. The only true deletion is explicit GDPR erasure — which leaves its own audit receipt. - Tenant + user on every record, every query.Even single-user local mode runs as
tenant
local. Isolation retrofits are where memory products leak; Mimir never needs one. The LLM can't supersede ids it wasn't shown, either. - Naive UTC at the DuckDB boundary.DuckDB localizes tz-aware datetimes before
dropping the timezone — every write goes through
to_utc_naive(). Found live as facts aged "−1 days ago" on an IST machine.
Module walkthrough
app/core — the brain
Nested pydantic models
mirroring mimir.yaml; ${ENV_VAR} substitution; boots on all-defaults
when no file exists. Everything reads the settings singleton.
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.
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).
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.
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.
Turns → L1 atomic facts.
LLM path: typed + prioritized, min-priority filtered. Offline: substantive user turns verbatim,
marked extraction: verbatim for later re-extraction.
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.
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.
Pure math: RRF (k=60), exponential recency decay, log-normalized frequency, hop-based graph score, config-weighted final score.
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.
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.
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
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.
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.
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.
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
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).
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.
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