Concept: memory (default-on, self-healing)¶
Memory is a native part of mokata — on by default, not an add-on. There are three individually-toggleable types and every durable write is human-gated.
The memory triad (C1/C2/C3)¶
| Type | What it holds |
|---|---|
persistent |
project facts / conventions |
decision |
project decisions ("why we did X") |
episodic |
past conversation turns (searchable) |
Each item carries provenance, a TTL (expires_at / valid_for), and
supersedes/depends_on edges; status is active / superseded / stale.
Typed memory — the project "brain" (Stage 36)¶
On top of the storage type, each item carries a first-class kind — the institutional
knowledge a team wants mokata to honour, captured via the guided /onboard
(how-to) and stored structured, not
verbatim (the LLM distils, types, normalises, and dedups the input):
| kind | holds | surfaced |
|---|---|---|
rule |
hard rules | always-on (briefing every run) |
guardrail |
safety/quality constraints | always-on |
best-practice |
recommended patterns | JIT (when relevant) |
context |
domain facts / formulas | JIT |
reference |
distilled doc key-points + source | JIT |
See the whole brain grouped by kind with mokata memory [--kind <k>], and update any entry —
human-gated, routed through self-healing — with mokata memory edit <subject>.
Frugal surfacing (P11) is the point. Only rule/guardrail go always-on, injected
into the SessionStart briefing within the rules line budget — if more are captured than
fit, the most relevant are shown and the rest flagged (mokata memory --kind rule); the budget
is never blown. context/reference/best-practice are retrieved just-in-time — only
the entries relevant to the task in play, never the whole corpus. More captured knowledge, not
more tokens per run.
Pluggable backends (C4) — storage only¶
Chosen through the router (memory_store): SQLite (default, stdlib, the guaranteed
floor) or Postgres (a shared database — below). Storage only — the memory logic is
mokata's own. When a richer backend isn't reachable, selection degrades to the SQLite floor.
REMOVED in 0.0.18: Obsidian and native-memory. The Obsidian markdown-vault backend and native-memory (an adapter delegating to an injected client) were deprecated in 0.0.15 and are gone. Nothing on your disk was touched — an Obsidian vault is still a directory of markdown files exactly as mokata left it. A repo whose manifest still names one is told so: it refuses loudly where there is data behind it (naming the vault and the remedy) rather than serving an empty store, and says so once per repo where there is not. To bring the items across, run the migration under the last release that shipped it —
pip install 'mokata==0.0.18',mokata migrate obsidian, then upgrade again — or drop it withmokata config set memory_store sqlite. See configure storage backends.
Shared team memory — Postgres (mokata owns the schema)¶
Point a whole team's mokata at one Postgres DSN and everyone reads/writes the same
memory store, live: a new teammate inherits the team's decisions and conventions
immediately, and one person's update is seen by all. mokata owns the schema (it creates
its own namespaced mokata_memory table and implements full CRUD), so this is a real
managed store, not a pass-through — and the dedicated name means mokata can share a database
with an app that has its own memory table without colliding.
Migration note: before 0.0.1 the table was the generic
memory; it is nowmokata_memory. If you ran an early build against a shared Postgres, rename it once —ALTER TABLE memory RENAME TO mokata_memory;— or let mokata create the new (empty) table andmokata memory migrateyour items into it. The team guarantees still hold against the shared store: writes are human-gated and provenance-carrying (who/when/source), and two clients disagreeing surface a contradiction (the self-healing old→new diff) rather than silently merging.
It's opt-in and local-first (P8): nothing connects unless you wire it, the DSN comes from
an environment variable (tools.postgres.config.dsn_env — never inline in the committed
manifest), the adapter is trust-dialed, and if psycopg (the optional mokata[postgres]
extra) is absent or the DB is unreachable it degrades to the SQLite floor — never a hard
failure. The same MemoryBackend contract generalizes to any database. See
configure storage backends & paths.
Back up & restore — memory export / memory import¶
This is the backup surface, not a sharing channel — cross-repo/team sharing is the team Postgres above and nothing else.
mokata memory export writes a committable, human-readable JSON backup you own —
by default a UTC-timestamped .mokata/backups/memory-<UTC>.json, so successive backups
never clobber each other — carrying the active items with provenance; it's read-only
on the source. mokata memory import <file> is a human-gated restore: it previews
(counts + a keys-only sample), dedups, gate-adds new items, and routes a conflicting fact
through the self-healing old→new surface (never a silent overwrite), preserving
provenance. Every restored item lands through the one WriteGate and is secret-scanned on
ingest, so a round trip is content-identical. It's local-first: nothing egresses — the backup
is a file you keep, not a mokata service. (MCP: memory_export / memory_import,
propose-only.)
Removed channel (0.0.18). The legacy
memory-share.jsonchannel is gone — the filename no longer means anything special, so writing a backup there is an ordinary export and warns about nothing. Your file is untouched. Amemory-share.jsonis a mokata memory backup, so restore it the normal way:mokata memory import --file <path>. No downgrade and no migration command —mokata migrate memory-sharenow says so and points at that file.
Move the store — memory migrate¶
mokata memory migrate --to <backend> ports the live store between sqlite / postgres /
pgvector — e.g. your local SQLite onto the team's shared
Postgres (the on-ramp to the live store above). It's human-gated, idempotent (re-run
upserts by id), and non-destructive (the source stays unless you add the separately gated
--drop-source); if the destination can't be built it reports and writes nothing — your data
is never lost. Where export/import backs the store up to a file, migrate moves the
store between databases.
Distinct from it, mokata migrate <channel> — vault — is the one-time,
human-gated migration off a deprecated channel into the canonical shape. See
the CLI reference.
Tiered retrieval — lexical → graph → semantic¶
recall_relevant(query) pulls the memory relevant to the task, not the whole corpus (P11),
by fusing up to three tiers into one ranked, top-k result with a deterministic ordering:
- lexical — a real search engine, ranked in the database: SQLite FTS5 +
bm25(), or Postgrestsvector+ts_rank()— one ranked SQL query returning top-k, not a Python scan over the whole store. Where a backend can't rank in SQL (orsqlite3was built without FTS5), it degrades honestly to Jaccard keyword overlap — the always-present zero-dep floor — and says so.mokata doctorprints which engine is actually ranking you (fts5/tsvector/jaccard), so the tier is never a mystery.
🔴 Known limitation — the SQLite FTS5/BM25 tier currently ranks worse than the Jaccard floor it replaced on a large store, and it still does in this release.
normalize_lexical_scoresscales each engine's scores against the best score in its own result set, which flattens exactly the gap that would have ranked a mid-pack answer. Measured on a 100,000-item benchmark against the Jaccard floor — same probes, same code, only the corpus size differs — the FTS tier is −5.6pp recall (0.5000 → 0.4444) and −10.8pp MRR@10 (0.8334 → 0.7258). At 5,000 items the same comparison loses no recall and only −3.3pp MRR, so a small store hides more than half of it. First disclosed in the 0.0.16 notes and still unfixed: 0.0.17 and 0.0.18 each shipped no ranking work, and neither does the release you are reading. The repair is rank-preserving normalization rather than a constant to tune. If you run a large store and your lexical results look mis-ordered, this is why. 2. graph-proximity — a code-graph-keyed boost that is live by default: when a memory store is built from a repo, it auto-wires the knowledge layer, so an item referencing a symbol the code graph confirms is real and related to the query is lifted. It keys on an adopted graph specifically: on either floor — the embedded AST floor or grep — the tier silently contributes nothing and lexical + semantic hold. A graph that errors mid-recall is reported rather than looking like a graph that simply found no anchor. 3. semantic — embedding similarity (the top tier), via a vector backend (pgvector — mokata owns themokata_memory_vectorsschema and queries the index, no full-store scan) or, for any other backend, the embedding stamped on each item at write time.
The embedder is a pluggable seam wired by settings.memory.embedder, and the tier is
opt-in: with no embedder configured the semantic tier is simply OFF and lexical
(+ graph) still work. Two embedders ship:
hashing— deterministic, local, dependency-free. It works with zero deps and no network, and mokata says plainly that it is token-hash overlap, not meaning.model2vec— the blessed extra,mokata[embeddings](static embeddings, numpy-only, no torch, CPU-fast, ~30MB, runs locally, no API key). This is what makes semantic recall actually semantic.
The extra is consented, never assumed. mokata asks once — during the onboarding
wizard, and on mokata init --mode memory|full when interactive — then installs, verifies
the import, and records the decision. It fails closed: with no TTY it does not install,
because "nobody answered" is not consent. A decline is remembered (user-scoped), so mokata
never nags; pip install 'mokata[embeddings]' any time. --mode seatbelt structurally never
offers it. settings.memory.embedder = auto then picks the extra when it is usable and
falls back to hashing when it isn't.
Vectors from two different embedders are not comparable, so a vector index is stamped with
the embedder that built it; a mismatch refuses rather than ranking nonsense, and the way
out is the gated, previewed mokata memory reembed (it shows the item count and both
embedder ids before writing anything).
Degrade-clean end to end: no psycopg / no pgvector / no embedder / a model that won't load
⇒ semantic silently absent (or honestly downgraded to hashing, with the reason), nothing
crashes, no partial writes. Frugal: embeddings are computed once, on the gated write;
recall embeds only the query and returns only the top-k.
mokata doctor prints the retrieval stack — the semantic and lexical engines actually
ranking your recall, each with an honest label — so a hashing+jaccard install is a supported,
legible configuration rather than a silent one.
Explainable retrieval (Stage 59)¶
Every recall hit carries a short, deterministic "why it surfaced" phrase so a surfaced memory is never a black box — it names the strongest signal that pulled the item in plus its kind:
| phrase | what pulled it in |
|---|---|
[context] matched "auth" |
a lexical keyword overlap (the JIT floor) — names the query token |
[reference] semantically near your query |
an embedding neighbour (semantic tier) |
[context] graph-anchored "load" |
a code-graph anchor (graph tier) — names the matched anchor |
[guardrail] always-on (project guardrail) |
an always-on rule/guardrail, injected (no query) |
It's pure + read-only (no LLM, no wall-clock, no stat bump beyond the existing recall
instrumentation) and frugal — one short phrase per hit, so the top-k/no-corpus-dump bound
still holds. Threaded through recall_relevant (RetrievalHit.explain(query)) and jit_recall
(explain_recall(query, hits)); inside Claude Code, recall(query=…) returns each hit with its
why.
Human-gated writes (C6)¶
Nothing reaches a backend without approval. The write API takes a confirm callback or an
assume_yes flag; a declined write commits nothing.
Self-healing by surfacing (C5)¶
mokata detects contradictions (two active items, same subject, different value) and
staleness (elapsed TTL) and surfaces each as an old → new diff for you to approve /
edit / reject. It never silently rewrites; the default is no change. Detection writes
nothing — only an explicit, gated apply changes anything.
Consolidation (C7) — proposal-only¶
A pass that proposes merges (duplicates), summaries (episodic clusters), and prunes (already-stale items) — and never auto-applies. Each proposal is an old → new diff, human-gated like C5, logged to the audit ledger.
Per-type toggles (C9) & instrumentation (C8)¶
settings.memory.{persistent,decision,episodic} toggle types independently (default on);
disabling the whole memory layer turns them all off. A disabled type is refused on write
and never surfaced on read. mokata logs the read/write ratio — if writes ≫ reads, the
feature is failing.
Memory-health nudge (Stage 59)¶
The health signals above become one actionable line instead of a number you have to read. mokata turns the self-healing backlog (stale · contradictory, from C5 detection) plus the C8 read/write ratio (the unused-memory signal — writes not yet balanced by a recall) into a nudge that points at the gated review path:
mokata · memory health: 2 stale · 1 contradictory · 3 unused — review with
`mokata memory` (gated) / `mokata govern`; nothing changes until you approve.
It's surfaced on mokata memory, the mokata govern view, and the govern MCP tool. It is
read-only + deterministic (derived from values already in hand; no extra reads, no stat
bump) and proposal-only — it points at the gated resolve path and never auto-edits or
auto-prunes memory. Degrade-clean: a healthy store nudges nothing (the line is empty).
Auto-proposed guardrails (Stage 59)¶
When a correction recurs — a write you declined, a change you reverted, a spec conflict —
mokata distils it into a guardrail-rule proposal (the G5 learn_from_ledger pass). These
proposals are surfaced where you'd act on them: mokata rules, the rules MCP tool, and the
/onboard capture flow. They are proposal-only — you approve, edit, or reject
each through the normal gated capture; mokata never auto-adds a rule. Quiet and bounded
when there are none.
See how-to: use & heal memory and the configuration reference.