Host-written notes that outlive a session: one markdown file per fact, an index file, and a usage
counter per store. The model requests a write or a read through kernel verbs; the host validates,
renders and writes every file. The only part that reaches a prompt is one untrusted yard block
ranked by the first typed prompt, with the bodies that prompt earns. §N refers to
YI_DESIGN.md.
Invariants:
- Only the root session keeps memory. A child session (depth > 0) gets no focused block, and every
verb refuses with
only the root session keeps memory. - At most 3
memory.savecalls succeed per session. A refused save does not count against the cap. - The host writes every note file in one canonical form: frontmatter
name,description,type, then unknown keys as their raw lines, then the body. - A save is refused if the text carries a yard fence (
<<<yi-external) or a key-shaped secret (PRIVATE KEY-----, or ansk-,ghp_,github_pat_orAKIArun that starts at a token edge and holds a digit, sodisk-backedis a word and not a key). Other refusals, each naming the field and bound: a missing or placeholderdescription, one over 240 characters, a missing or unknowntype, a body over 8 KiB. - A note file that does not parse is never refused on disk. It is indexed by its first body line and counted as unparsed.
- Every write holds the store's
.lockfile lock and replaces files by staged rename. - Each store keeps
ops.jsonl, a journal ofsave,edit,adopt,forgetandreadrecords appended under that lock in the same critical section as the file change, each carryingsha256(previous digest ‖ canonical record)as the plan journal does. It names versions by hash and never holds a body; append order is the one order across lanes. An append reads only the last record for the previous digest, and the whole journal when the file does not end in one whole record;yi memory rebuildreplays the whole chain and names a break. - Every version's text is
objects/<sha256 hex>.memory.forgetdeletes all of a note's objects, so no copy stays in the store. The save call's own arguments stay in the transcript of the session that made it, wherehistory.grepstill finds them. - Reconcile at session start journals a note that predates the journal (
adopt), a hand edit (edit) and a hand deletion (forgetwithby: hand, its objects deleted too). - Each verb also appends a
custom{memory}entry{op, name, scope, hash}to the root session. It never carries the body. - Replies never carry a filesystem path; the kernel sandbox cannot read
~/.yi.
Owner: crates/runtime/src/memory/ (mod.rs verbs,
doc.rs note format, store.rs files, ext.rs session start and focused block),
python/skills/memory/ (kernel verbs),
crates/cli/src/memory.rs (CLI).
State:
MemoryType { User, Feedback, Project, Reference }andScope { Repo, Global }(doc.rs).scopeselects the store at save time and defaults torepo. It is not written into the note.usage.json:{ sessions, notes: { <name>: { saves, reads, last } } }, wherelastis Unix seconds (store.rs).
Shapes (on disk):
- Repo store:
~/.yi/projects/<encoded canonical repo>/memory/. The canonical repo is the parent of the git common dir, so every worktree and lane of one repository shares it (§14). Outside a repository the key is the canonical cwd. - Global store:
~/.yi/memory/. Each store holds<name>.mdper note,MEMORY.md(the index, one- [name](name.md) — descriptionline per note, hand-editable),usage.json,.lock, the journalops.jsonl(MemoryRecord) andobjects/.
The kernel imports the memory package at startup (§9). Each function is a host request.
memory.save(markdown, **fields):fieldsoverlay the frontmatter.nameis slugged to kebab-case (80 characters max;memoryis reserved). Without a name, the first five words of the description are used. The same name updates the note and keeps its unknown keys. A key within two edits of a known one comes back as a warning. The reply isname,description,type,scope,updated,warnings.memory.read(name, scope=None): matches a note by file name or slug. Failing that, it matches by index label or description, case-insensitive. It searches repo first, then global, and counts a read inusage.json. Failing both, it opens the topmemory.searchhit and warns[no note has that name or hook · opened <name>, the closest of N by memory.search · memory.search("…", limit=N) for the ranking];forgetnever does. The reply carries the body astext.memory.search(query, limit=5, scope=None): ranks every note of both stores (or the given one) by BM25 (k1 1.2, b 0.75, Lucene's form) over its name, description and body, tokenized as lowercase ASCII words with.,/and-joining a path or identifier into one token and 61 function words dropped. The reply ishits(name,description,scope), best first, andtotal; a cut list carries[5 of N notes · limit 5 · memory.search("…", limit=N) for all].memory.forget(name, scope=None): deletes the note, its index line and its usage entry.
Claude Code keeps its own memories per working directory. memory.importClaude (on unless the
config sets it false) imports them at every session start, fresh or resumed, and
yi memory import-claude runs it by hand whether the switch is on or not.
- The directory is
~/.claude/projects/<the working directory, every byte but an alphanumeric a dash>/memory;~/.claude/memoryis the user-level one. The first lands in the repository store, the second in the global store. A directory that does not exist is not a failure and says nothing. - Every note is copied once, its frontmatter tagged
source: claudeas its first key, so an imported note is told from a note Yi wrote. A note that carries no block gains one; a note whose block never closes still imports and is countedunparsed, as any other hand-written note. Yi never writes into~/.claude. - Idempotent by content: the note file is compared byte for byte with the tagged source text, so
a second start imports nothing and a changed source note updates its Yi memory. A note deleted
at the source is kept and marked: its index line ends
⚠ stale, andyi doctor'sclaude-memoryrow counts what was found, imported and stale. The note is stale only when no Claude Code directory of this home still holds it, since one store serves every worktree of a repository. - A note forgotten from the Yi store stays forgotten at later automatic starts while its Claude
source remains.
yi memory import-claudeis the explicit act that adopts it again. - The line a start commits reads
claude · N notes in ~/.claude/projects/…/memory · N imported · N updated · N stale, and an import that leaves the store past the block's load cap appends the[… K of T notes loaded (LOAD_LINES 200); the rest: await memory.search("words")]row. Counts and paths only: no note text reaches a log, a summary or a reply.
history.search(query, limit=8, offset=0) (kernel compact.search) ranks user messages, assistant
text turns and compaction summaries by the same BM25, across every root session whose directory
under the sessions dir ran in this repository: the header's cwd resolves to the same canonical
repository (the git common dir), or sits inside it once its checkout is gone. Tool calls, their
arguments and their results are never indexed. A hit carries session, entryId, type, a
snippet in its text's own case (at most 160 characters; a cut one adds cut: true and a notice
row naming the cap) and url, history://<session>/<entry>, which fetch resolves across
lanes. A cut page names the call for the next one; a directory whose checkout is gone and whose
repository is therefore unknown is counted in a second notice line, never searched. The parsed
units are cached per file and re-read when a file's length changes. Root session only.
MemoryExt is a built-in extension interested in SessionStart (§6). On every start, fresh or
resumed, it imports the Claude Code memories (above) while memory.importClaude is on, then
reconciles each store's MEMORY.md: drops lines whose note is gone or duplicated,
appends a line per unindexed note, keeps every other line and the order, and journals hand edits.
It emits the summary to the activity feed, and a fresh start increments the repo store's
sessions. It does not attach a memory block: the first typed prompt renders the only block a
request carries. A resumed session keeps a ranked block restored from its snapshot; one whose
snapshot has no memory block earns it at the first prompt, as a fresh session does.
The first typed prompt decides which notes earn rows and bodies. The prompt reaches the host
before the first request, so MemoryExt renders and attaches the block once (focused); every
later prompt leaves the frozen prefix alone. The pass is a composition of what the store already
had:
- Every note is scored by
memory::rank's BM25 over its name, hook and body, weighted by type:feedback1.5,project1.4,user1.25,reference1.0, untyped 1.1. - A note whose hook restates a higher-ranked note's hook (token Jaccard ≥ 0.7) keeps its index row but earns no body, so near-identical saves earn one body.
- The bodies of at most 3 scored notes, each cut at 1,200 bytes, come first; the notes left over print as index lines in rank order, the unscored ones after them, at most 8.
- The budget is the unranked index's row bytes, never under two body caps and never past 6,000, so one real body fits a small store; rows, then bodies, are dropped until the text fits it.
- A cut names what it dropped and how to get it:
[… K of T notes (the index budget N, or FOCUS_ROWS 8); the rest: await memory.search("words")], a[… body cut at 1200 B of N · the rest: await memory.read("name")]row per truncated body, and[no note shares a word with this request · …]when nothing matched. A store that failed to load and a store past its load cap are named. A failed store also counts as one not-loaded entry in the focused cut's total.
A blank first prompt (a host or custom message carries no typed words) spends nothing and leaves the first real prompt free to rank the block. A resumed session with a restored block never re-ranks, so "continue" cannot replace the block the original task earned. A subagent's brief is its first prompt and ranks the block like any other.
The eval that holds it is evals/memory_inject.py over evals/fixtures/memory-inject: eleven
real first messages, hand-labelled against a sixteen-note fixture store, scored on both arms
(crates/runtime/tests/memory_inject.rs renders this build's blocks and replay scores them
against before/block.txt, what the pre-change build rendered). The paid judge run is
label --model openrouter/… --cap-usd 10; it writes only the cases it judged to
labels-judge.json, leaves the hand-labelled fixture untouched, refuses uncountable costs, and
writes its ledger row separately.
Score that arm with replay --labels evals/fixtures/memory-inject/labels-judge.json --blocks …,
or validate its note names with check --labels evals/fixtures/memory-inject/labels-judge.json.
The TUI commits the start summary and one line per save or forget as footer cells; the HUD shows
saved N.
yi memory [list | show <name> | search <words> | forget <name> | import <dir> | import-claude | stats | check | rebuild] works on both stores of the cwd; search prints every ranked hit.
import copies a directory of notes into the repo store untagged, skips identical
files, merges index lines and reconciles; import-claude is the section above, printing one
line per store. stats prints saves and reads per session per store.
check prints path:line: reason per unparsed note and exits 1 if there is any. rebuild
restores every note the journal holds and has not forgotten, recounts usage.json from the
journal, names a file that differs from its journal head and a version with no object, and
exits 1 on a missing object or a broken chain.
Settled by: D169, D277, D278, D279.