lna-lab/distill-kura

distill-kura

蒸留蔵 — distilled long-term memory for agents: recall by meaning, writing gated by evidence, one kura per agent mode. Ships as a DeepSeek Harness plugin and an MCP server.

Uncategorizedagent-memoryllm-memorylong-term-memory

蒸留蔵 — distill-kura

A long-term memory for agents that is distilled, not accumulated. Recall works by meaning, writing is gated by evidence, and one server can hold several separate memories — one per agent mode — so switching mode switches what the agent remembers.

Ships as a DeepSeek Harness plugin, an MCP server for any other host, an HTTP service, and a Python library. Standard library only; no vector database, no embeddings, no framework.

        ┌── recall ──────────────────────────────────────────────┐
        │  question → names a memory? → deterministic hit   ~2 ms│
        │      else → whole index in one prompt → picked slugs   │
        │           → walk [[links]] → the neighbourhood   ~0.4 s│
        └────────────────────────────────────────────────────────┘
        ┌── distil ──────────────────────────────────────────────┐
        │  journal → classed evidence → candidates → GATE        │
        │  → new? → composed → draft → judged → poured           │
        └────────────────────────────────────────────────────────┘

Why this exists

Two failures kill an agent's long-term memory, and they kill it from opposite sides.

Retrieval by keyword misses the thing you needed. A question about "SSD inference chips" shares no word with a memory titled "running the 2.6T model off an SSD tier" — yet they are the same subject. Word search returns nothing; the agent answers from nowhere. The fix here is not embeddings but recognition: the entire index (one line per memory, written as a recognition trigger) goes into one prompt, and a small model names what bears on the question. An index of ~500 memories is around 6k tokens — a few percent of a modern context window, and it sits in the prefix cache.

Writing everything poisons the store. An agent asserts something; a naive distiller records the assertion as a fact; the next agent reads it back as ground truth and repeats it with more confidence. That loop is self-reinforcing, and prompt instructions do not stop it — measured, not assumed. So the write path is gated by deterministic Python: every candidate memory must carry quotes that exist character-for-character in the raw material, tagged with where they came from.

classwhat it iswhat it licenses
[USER]the human's own words"they decided", "they asked"
[TOOL]machine outputnumbers — the only source
[ACT]a tool that was invoked"this was done"
[SELF]the agent's own prosea judgement, in the first person, never a bare fact

A quote that is not found verbatim is discarded. A candidate with no surviving quote is thrown away. A number with no [TOOL] behind it is stripped. Text crediting the human with a decision, when no [USER] quote survived, is refused at the last gate. Ideas are welcome — they go to a seed file, never to the store, and graduate only when later evidence confirms them.


Tier zero: recognition before intelligence

The recall above is the right tool for a question that shares no words with its memory. It is the wrong tool for a question that names what it wants — and in a working session, most questions do. A blind 40-question benchmark on a live 317-memory store split exactly along that line: the direct questions needed no intelligence at all, and everything else needed all of it.

So before the thinker runs, a deterministic recognizer gets one look. The design is transposed from the n-gram embedding table inside Qwen3.8-Flash-Next — many hash heads voting over one table, behind a gate — onto the index:

  • Five heads, each an independent recognition channel: exact name; IDF-weighted word tokens (identifiers, ports, katakana runs); character 3-grams with stop-grams (a gram present in over a fifth of the store drowns in its own collisions, so it is dropped); character 2-grams; and the head of each body.
  • Coverage scoring — each head's vote is normalized by what it could possibly have reached for this question, so one lucky rare gram cannot fake confidence.
  • An honesty gate — a hit is returned only when the top score clears an absolute bar and beats the runner-up by margin. Anything less and the fast path says nothing; the question falls through to the thinker, unchanged.

Blind-tested — the examiner wrote the 40 questions from the index alone, never seeing the implementation — against the live store over HTTP:

question typetier zerothinker tier
direct (14)14/14, median 2.3 ms14/14, ~900 ms
paraphrase (10)silent → falls through10/10
semantic bridge (10)silent → falls through10/10
not in the store (6)6/6 refused0/6 refused
wrong answers, whole set0—

What that buys:

  • The everyday case stops paying the intelligent price for a lookup. Direct recall drops from ~900 ms to ~2 ms, and the fall-through tax on every other question is about 2 ms.
  • It knows what it does not know. Zero wrong answers across the set is the gate working, not the heads being clever — everything uncertain goes to the model. On the six questions whose answers were not in the store, tier zero refused all six; the thinker tier answered something every time. Refusal is a feature this project keeps having to buy back.
  • A direct question now survives the thinker being down. Recall used to degrade straight to word overlap; the named memory comes back regardless.
  • Every reply says which tier answered — how: "fastpath", fastpath_verdict, fastpath_ms — so a slow answer is never a mystery.

Configured under [fastpath] (enabled, on by default; gate), per-store overridable like everything else. The row it will never win: a question that shares no surface with its memory. That is the thinker's job, and the gate exists to hand it over rather than guess.


Quick start

git clone https://github.com/lna-lab/distill-kura && cd distill-kura
pip install -e .                       # or just run: python3 -m distill_kura.cli

cp kura.example.toml kura.toml         # edit: one model endpoint is enough to start
kura init main --path ~/kura/main      # create an empty store
kura serve                             # http://127.0.0.1:8085
curl -s -X POST localhost:8085/recall -H 'content-type: application/json' \
     -d '{"question":"what did we decide about the archive disk?","hops":1}'

Wear the index, so the agent always knows what is known:

kura weave                             # build the three-layer cloth
kura prefill                           # the block to put in the system prompt

Feed it your agent transcripts:

kura distill run      # drink a batch → candidates → gate → drafts
kura distill drafts   # look at what it wants to write
kura distill drain    # the scribe re-reads each draft cold: pour / fix / toss
kura distill night    # stay resident and do it whenever things go quiet

Nothing enters the store until drain (or a hand-run pour). Drafts carry their evidence in an HTML comment, so you can always see why a memory exists.


The resident map

Recall-by-tool answers "what do you know about X?" — but only once the agent has decided to ask. It never answers the question the agent does not think to ask: is there anything here at all? An agent that cannot see the map does not know what it is missing, so it guesses, and a confident guess about your household is precisely the failure this project exists to prevent.

So the index is also worn: a standing block in the system prompt, on every turn.

kura weave      # re-weave the index into the three-layer cloth
kura prefill    # print the block a host should inject

Three layers, because detail only pays for recent things

A blind A/B test — 20 questions, fat index vs slimmed index, scored without knowing which was which — settled the shape:

bandfatslim
overall911
recent events41
doctrine14
cross-domain leaps14

The doctrine lines were byte-identical in both indexes, and the slim index still won that band: a lighter surround makes the standing lines work better. Detail is not the source of insight. It earns its place only where things are still moving.

layerruleline
pinnedfrontmatter type in pinned_typeskept in full
freshchanged within fresh_dayskept in full
triggereverything elsecompressed to ~trigger_tokens

Trigger lines are written by the scribe model and cached in a ledger keyed on the description and the budget, so a re-weave in the steady state costs nothing. With no model reachable the loom trims mechanically instead — a memory system must not go blank because a GPU is down.

Age is not mtime. cp -r, a restore or a checkout resets every timestamp, the whole index turns "fresh", nothing is trimmed, and the mechanism has silently switched itself off. So the loom prefers a date written inside the memory, and distrusts any mtime that a fifth of the store shares with one calendar day.

Where it goes, and why that is a cache decision

- id: kura
  name: distill-kura
  config: { store: eq, promptOrder: -50 }   # before the persona

A prefix cache is lost from the first changed byte onward — measured on one local server: an identical 4,029-token preamble reprices from 0.68 s to 0.14 s, appending at the end stays 0.14 s, and one word added at the front costs the whole cache (0.66 s). The persona commonly carries a clock, so it changes every minute; the map is the largest block in the prompt and changes a few times a day. The big stable thing goes in front of the thing that ticks.

The block itself therefore contains no date, no clock, no counter — and build() refuses a header that does, at build time rather than through mysteriously slow turns three weeks later.

It never hands over half a map

situationwhat the agent gets
all wellthe map, between <<<KURA-MAP>>> markers
over budget_fractionthe whole map, and a warning in the JSON (never in the text — a banner is volatile content)
over hard_fractiona stub with no index lines, saying the map is missing rather than empty
kura unreachablean explicit note that the map is missing, never an empty string

A truncated map is the worst artifact available: it looks complete, and every memory below the cut appears not to exist. weave will shorten the fresh window to fit, but it will never drop a line — and if no setting reaches the budget it says so, keeps the better map, and tells you where the weight is.

Getting it into a host

hostmechanism
DSHnative plugin — a systemPrompt.section, refreshed in the background
Claude Code, VS Code, GooseMCP instructions carries a short pointer (2KB cap); the map itself comes from the kura_map tool or a session hook running kura prefill
Claude Desktop, claude.aiignore instructions entirely — use kura_map
anything elseGET /prefill?format=text, or kura prefill in a shell hook

The MCP instructions field is a MAY in the spec, and a 9,000-token index cannot travel through a 2KB cap regardless, so this project does not pretend otherwise.

Reading imperfect memories

The full and sector resident maps, and MCP initialization instructions, tell the answering host to interpret clear prose typos, missing particles and foreign-language common words in context, then explain in the requested response language. Quotations stay verbatim; translations belong outside them. Names, numbers, units, code, paths, identifiers, negation, conditions and causality must not be guessed away. Ambiguity must be stated. Interpretation does not authorize correcting the stored memory.

This is reader guidance, not a new thinker/brain/scribe role or an automatic text repair pass. Hosts consuming raw HTTP/library results without a resident map must supply equivalent reader instructions themselves. Prompt guidance does not guarantee model compliance and does not replace the deterministic evidence gate.

Pay it forward

Byte-stability makes a prefix cache hold; it does not make the first turn cheap. After a re-weave changes the map, the next turn pays the whole cold prefill — and on a slow mouth that is minutes, not milliseconds. A llama.cpp server started with --slot-save-path can save a slot's KV to disk and load it back, so the cold turn can be paid once, in the quiet hours, and kept across restarts:

kura pay-forward      # every [[payforward.mouths]] entry; -s / --mouth narrow, --force re-bakes

Measured on one machine (a 320B pure-CPU llama.cpp mouth, 16,444-token map): the bake 796 s; the save 283 ms (1.5 GB on NVMe); the server killed, rebooted, and the restore 655 ms — after which the first turn reprocessed 18 prompt tokens. A 13-minute cold turn became a 0.7-second restore. The name is the film's: the cold turn is paid forward, so the next turn — whoever's it is — receives it warm.

The slot filename carries the map's etag (kura-<store>-<etag…>.bin), so the files are content-addressed: a fresh etag is proven, not assumed — a restore shows the file still exists, a one-token probe reads timings.prompt_n, small means warm — and exits 2, nothing to do; a changed etag tries the restore first anyway (a file left by a lost state or a parallel runner is still the right bytes) and only then bakes, saves, and records _still/payforward.json. A mouth that cannot be reached is a loud, labeled skip, never a crash and never a state advance. Old slot files are not pruned — the slots API can save and restore a filename but cannot list the directory — and they are not small (KV width × map length: that 16k-token map was 1.5 GB), so sweep the directory by hand. kura tend runs this as a track after each weave; the recipe, including the systemd shape for mouth restarts, is in docs/OPERATING.md.


The shortest cue that still recognises (M4, shadow)

A trigger line is paid on every turn. The question is not how short it can be cut but how short it can be while the reader still thinks "ah, THAT one" — and not its neighbour. With

[prefill]
trigger_tokens = 24            # the legacy budget, still what production wears
adaptive_triggers = true       # generate and judge shorter candidates (shadow)
adaptive_apply = false         # nothing enters the cloth until a benchmark earns it
trigger_steps = [8, 12, 16, 24]

kura weave also writes _still/adaptive.json: per memory, a candidate per rung, the shortest one that passes every floor the production trigger passes (plus the ones a shorter cue newly needs — a number tied to a different unit, a dropped negation or retirement word, a cut identifier) AND is recognised alone by the recognizer with the callsign pre-head and the body off, and why_not_shorter for each rung refused. A verified callsign is judged by its receipt, not by word overlap. Candidates are cached per memory; the verdicts are recomputed every time, because whether a cue is ambiguous depends on every neighbour. Falling back to 24, or to the line itself, is a measurement — that memory needs that many tokens.

Promotion is a benchmark's decision, not a flag's: kura bench worldline --resident canonical,woven --resident-file adaptive=<rendered map> under --routing agent-only, read per category, with no increase in wrong or obsolete branches and no rise in remembered_but_unreachable. Until then the shadow only watches.

Modes: more than one kura

A single memory that serves both "help me build this" and "help me think this through" serves neither well: the recall that helps you debug is noise in a conversation about what to do next. So a store is a directory, and a mode maps to a store.

[stores.maker]
path = "~/kura/maker"
label = "maker mode — building things"

[stores.eq]
path = "~/kura/eq"
label = "EQ mode — talking things through"

[modes]
maker = "maker"
eq    = "eq"

Every route takes a selector, so one process serves them all:

curl -s -X POST localhost:8085/recall -d '{"question":"...","mode":"eq"}'
curl -s localhost:8085/index?store=maker
curl -s localhost:8085/s/eq/doctor          # path form, for clients that only vary a base URL

The stores share no memories, no index, and no distiller watermark. Switching mode genuinely changes what is remembered — not the same memory in a different voice.

The room is chosen before the conversation. A mode is what the host sends — a DSH preset, KURA_STORE in an MCP environment, -s on the CLI — and it is the whole session's home. Nothing in this project reads a message and decides which store it belongs to; a conversation that drifts from building into feeling stays where it started, and the host may offer another room for the next session. An unknown selector is an error at the door, never a quiet fall to the default.

One room, many tags. A memory lives in exactly one store and may carry several tags that describe its character (decision, landmine, emotion-carried, …). Tags are words, not weights: nothing ranks by them, nothing counts them, and a Develop memory tagged emotion-carried is still a Develop memory. There is no command that moves or copies a memory to another store, and a mode change affects only future sessions. The same topic raised in two rooms yields two memories, each distilled from that room's own evidence — Research's "what we learned" and Develop's "what we did" are different facts, and nothing crosses the boundary to deduplicate them.

A wide room recalls a little softer. Narrow stores with fixed charters recognise sharply. A store that accepts anything — a USER room that follows the person rather than a purpose — is expected to be looser, and in exchange is the one whose understanding may grow: a profile.md beside its charter, in sentences, read after the charter, drafted from its own memories and applied by a person. Five such rooms, with their charters and a config, are in examples/rooms/.

Independent as routing, not as confidentiality. The server has no authentication, so any process that can reach its port can name any store it holds. Binding an agent keeps a model in its lane; it does not keep a process out. One trust level per process — docs/TRUST.md is short and worth reading before a private store goes in. It also covers the two boundaries that are easy to miss: two stores drinking from one journal root, and two stores behind one model endpoint.

With DeepSeek Harness

DSH switches persona and tools by agent preset. distill-kura switches memory by store. Bind them and one preset change moves the whole self:

# .agent-presets/eq/agent.cordis.yml
- id: kura-eq
  name: distill-kura
  config:
    url: http://127.0.0.1:8085
    store: eq            # this preset's memory
    readonly: true       # the CLIENT's own switch: do not even offer a write tool
    # (the store's own `write_policy` is the authority; this just keeps the tool
    #  out of the model's hands. Naming a store already binds the preset.)

One dependency, and why it is a peer. The plugin imports defineTool from @deepseek-ai/dsh-tools. A profile-local second copy can split the package's module-local Symbol identity — even at the same version — and make the first tool call fail on undefined.prepare. The plugin therefore declares the package as a "*" peer so the profile supplies its copy without a version mismatch. A stale physical duplicate can still require deduplication; see the install checks in examples/dsh-presets/.

allowSwitch has no fixed default: it follows store. A preset that names a store is bound to it — no kura_use, no drifting mid-conversation — and only an explicit allowSwitch: true reopens that door. Name no store and the session is free: every tool takes a store argument and kura_use switches for the session. Tools: kura_recall, kura_read, kura_doctor, and kura_map (while prefill is on — the default); kura_list unless the preset is bound; kura_use only when the session is switchable; and kura_remember only when the plugin's readonly is false — and even then the store's write_policy has the last word. Full wiring, including the MCP bridge and the isolate realm rule for service rows, is in examples/dsh-presets/.

Persona is the host's business, not ours. This project never renders or injects a persona; it only records, per store, which persona file belongs with it, readable at GET /profile?store=eq so the two halves can be kept in step by whoever owns the preset. Agent instructions likewise stay with the host's AGENTS.md mechanism — see AGENTS.md in this repo for the conventions an agent working on this codebase should follow.

With any MCP host

{ "mcpServers": { "kura": {
    "command": "python3", "args": ["-m", "distill_kura.mcp"],
    "env": { "KURA_URL": "http://127.0.0.1:8085", "KURA_STORE": "eq", "KURA_READONLY": "1" }
}}}

Leave KURA_STORE unset for free mode: the tools take an optional store argument and kura_use switches for the session.

The bridge reads five variables:

variabledefaultmeaning
KURA_URLhttp://127.0.0.1:8085where the kura's HTTP mouth is listening
KURA_STOREunsetbind to one kura; unset = free mode (whitespace-only exits)
KURA_LABELthe kurathe name the tool descriptions use for this memory
KURA_READONLY1anything but ""/0/false/no hides and refuses kura_remember
KURA_WRITE_LOGunsetpath to a JSONL file that gets one {ts, store, args, result} line per direct write; the directory is created, and a failure to log never fails the write

KURA_WRITE_LOG is a record, not a permission: it only sees writes that already happened, so it is only meaningful with KURA_READONLY: '0'.


Models: one by default, upgrade a role at a time

Three roles, not three machines:

rolewhen it runswants
thinkerevery recallsmall and fast; must judge relevance by meaning
braindistilling: reads a whole batch of journalcontext length and patience
scribedistilling: writes the memory, then judges draftsgood prose in your language, judgement

Declare only [models.thinker] and one model does all three — the model you talk to is also the editor that writes and judges your memories. That is the default and it is a fair one: a capable GPU model does the editor's work well enough in its idle minutes, and kura tend stops it the moment you come back (see "Unattended" below).

The upgrade path is to give the editor its own seat — a bigger model, an online API, or a CPU model that does not compete for the GPU at all, so maintenance can go on while you are talking. The house this was built in runs a 1-trillion-parameter MoE on CPU at about 3 tokens/second as the editor: slow, but it never touches the seat the conversation uses, and the memories it wrote over five days are a third of the store today. Upgrade either of the other roles independently — a bigger local model, or an online API (any OpenAI-compatible /chat/completions; the key is read from an environment variable you name, never stored in the config):

[models.thinker]                       # always-on, local, small
url = "http://127.0.0.1:8000/v1"
model = "local-small"

[models.scribe]                        # upgrade just the writing
url = "https://api.example.com/v1"
model = "big-model"
api_key_env = "EXAMPLE_API_KEY