Memory: what the office keeps
How the office writes facts down, who may write them, and why nothing is derived from a transcript.
Memory exists because of a specific failure. Everything a run learned used to die with the run: a convention discovered in one brief, a decision argued out in a debate, a trap that cost somebody twenty minutes — all of it vanished the moment the run settled, and the next run paid for it again. Memory is the office's answer to that, and it is a deliberately narrow answer.
A fact is a convention, a decision, a pitfall or a note
That is the whole vocabulary. MemoryKind is four values — convention, decision, pitfall, note — and there is no free-form category to invent. The four correspond to the four things a colleague would actually tell you across a desk: how we do it here, what we chose and why, what will bite you, and anything else worth remembering.
Alongside the text, a fact carries tags, an optional source, a scope and a kind. The limits are real and enforced at write time: 500 characters of text, 12 tags, 200 characters of source. A fact is a note, not a document. If you need a document, the workspace already has a filesystem.
Scope is the boundary
Every fact is written at one of three scopes, and the scope decides who can see it:
| Scope | Who sees it |
|---|---|
installation | Every employee in every project on this machine. Shown in the console as "everywhere". |
workspace | The employees working in one project — one floor of the building. |
role | One seat: a specific role's own accumulated experience. |
Scope is enforced in one place, which is the reason it holds. A recall for a role-scoped question is filtered by scope on the same code path that does everything else, so there is no second implementation that nearly agrees.
One consequence is worth stating as a rule rather than a hope: a correction cannot widen a fact's scope. If a floor's note turns out to be true everywhere, you write a new installation-scoped fact that supersedes it. You do not promote the floor's fact. The alternative — an edit that quietly changes who can see something — is how a workspace-local observation becomes installation-wide guidance nobody agreed to.
Nothing is derived from a transcript
There is no extraction step and no model in the write path. Every fact is written by a person, and MemoryOrigin has exactly two values — operator and operator-verified — both of them human. A run cannot write a fact. No stage, no turn and no tool summarises a transcript into the store.
This is the most important design decision on the page, and it is a decision rather than a gap. Automatic extraction is where memory systems are measured to fail: a model that reads a transcript and emits "facts" produces confident, plausible, subtly wrong statements with no way to tell them apart from the true ones, and once they are in the store they are indistinguishable from something a person wrote down. A memory store that can hallucinate into itself is worse than no memory store, because it launders the hallucination into authority.
So dev3d's memory is a ledger written by hand. That is slower and it means the store is only as useful as the operator's habit of writing to it. It also means every line in it is attributable to a person who decided it was true.
The project's own docs/memory-systems.md recommends deriving facts from transcripts. That document is a design document, not feature documentation, and the shipped product deliberately does not do what it recommends. Do not read it as a description of this subsystem.
A correction supersedes, and never overwrites
The one operation that changes what the office believes does not destroy what it believed before. Correcting a fact writes a replacement and marks the original as no-longer-true, keeping its exact wording and recording when it stopped being true. Three fields carry the link: supersedes, supersededBy and invalidFrom.
Both questions stay answerable afterwards. "What does the office believe about this?" gets the new fact. "What did it believe in March, and why?" gets the old wording, intact, with the date it was retired. That is what makes a run record from six months ago explicable: an employee that followed a convention which has since been corrected did not ignore the store, it read a different state of it.
It also means the store is append-only in the way that matters. Nothing is deleted for being old — there is no decay, no TTL and no sweep, and the store has no delete to call at all. A fact that has been true since the first week is exactly as present as one written this morning. The only thing that removes a fact from current belief is an explicit retraction, and even then the fact stays on record; it stops being a live answer and stops being offered to a prompt, and it does not disappear.
Why no expiry. A convention that is three years old is not stale by being old. Ageing out facts on a timer would silently delete the load-bearing ones — the architectural decisions and the traps — and keep whatever happened to be written recently. If a fact is wrong, retract it. If it is superseded, supersede it. Neither of those is a timer, and a timer cannot tell the difference between a stale fact and an important one.
Facts are asked for, not injected
A prompt does not carry the memory store. Every prompt carries a short index — at most 8 facts, each clipped to 220 characters — plus a count of how many more are on record and not shown, and the employee calls the recall tool to fetch the rest. recall defaults to 8 facts, accepts a limit up to 25, and trims its query to 300 characters.
The reason is that always-injected context is worse on every axis that matters. It spends the token budget on every turn whether or not the turn is relevant; it grows monotonically, because nothing is ever deleted; and the more of it there is, the less attention each line gets. An index plus a count is honest about what is being held back — the employee can see that eleven more facts exist and go and ask — and it costs the same whether the store holds twenty facts or two thousand.
An empty answer is a real answer rather than an error. recall returning nothing says "Nothing on record matches … Work from the brief and the code; do not assume a convention exists", which is a much better outcome than an employee inventing a convention because the store was silent.
Search is FTS5 with BM25, in the database the office already has
Nothing was added to make recall work. Search is SQLite's own FTS5 extension with BM25 ranking, and FTS5 is compiled into the SQLite that Node ships. There is no extension to install and no search dependency to audit. The store keeps memory_facts with a partial active index and a scope index, and memory_facts_fts as an FTS5 table.
That is not a guarantee, and the code treats it as one that can fail. If FTS5 is unavailable, the boot log says so in as many words — no full-text index for memory (…); recall will fall back to an unranked substring scan — recall falls back to an unranked scan, and the Memory page in the console shows the degradation rather than pretending the ranking is there. The same honesty applies when the whole store is missing: running without persistence (…); history will be lost on exit.
Semantic re-ranking is off by default, and only re-orders
Out of the box, recall is lexical. Turning on vector search takes both switches:
| Variable | Default | What it does |
|---|---|---|
DEV3D_MEMORY_VECTORS | false | Load the sqlite-vec index so recall can re-rank by meaning. Off is a complete configuration. |
DEV3D_MEMORY_EMBEDDING | empty | provider/model to embed with. Required for semantic recall; empty keeps it lexical. |
Both are needed. Vectors without an embedder has nothing to embed with; an embedder without vectors has nowhere to put the result.
When semantic recall is on, the division of labour is the part worth remembering: full text still selects the candidates, and vectors only order them. Vector search never introduces a fact that lexical search did not already find. That is what keeps the scope, activity and containment filters on the one code path that enforces them — the vector call receives the facts that survived those filters and returns the same set in a different order. If vectors could select, there would be a second retrieval path and the filters would have to be re-implemented on it, which is exactly how a role-scoped fact leaks into an installation-wide answer.
The vector table is memory_fact_vectors, a vec0 table of 768 dimensions. Four behaviours follow from that, and all four are about degrading rather than failing:
- A fact with no vector keeps its lexical position. Nothing is dropped for being unembedded; a partially backfilled store still answers.
- A failing embedder degrades to the lexical answer rather than to no answer. The recall still returns facts.
- A wrong-width vector is refused rather than truncated. The store logs a refusal naming both the width it was given and the 768 the index holds, then moves on — because silently truncating an embedding produces a vector that means something else and ranks accordingly.
- Embeddings are backfilled lazily, bounded by the limit the caller gives, and a partial backfill reports how many it stored rather than claiming success.
Vectors on but unavailable is its own named state: semantic memory is switched on but unavailable (…); recall stays lexical, which is a complete configuration.
The commands and the routes
Memory is driven from the console or over the wire, and both surfaces are small:
| Surface | What it is |
|---|---|
rememberFact | Socket command: write a fact. |
retractFact | Socket command: withdraw a fact from current belief, keeping it on record. |
GET /api/memory | Read the ledger. |
POST /api/memory | Write a fact. |
GET /api/memory/search?q=… | The same search recall uses, from outside a turn. |
POST /api/memory/:id/retract | Retract one fact by id. |
POST /api/memory/embed | Run the semantic backfill. |
The console's Memory tab is one of the twelve top-level tabs. It has two sub-tabs — Active · <n> and Ledger — with a fact composer, a correct/retract control, and a button to run the semantic backfill. "Correct" is the supersede path; "retract" is the withdraw path; they are different buttons because they are different operations.
The console receives the whole store as OfficeState.memory, typed MemoryState, and it is carried by two of the three declared memory events. memory.created and memory.retracted are emitted. The third — memory.updated — is declared in the protocol and never emitted by anything. It is dead wire surface; do not write a client that waits for it.
What memory does not do
These are open limits rather than bugs, and they are worth reading before you rely on the store:
- Nothing scores a fact by whether it helped. There is no usage feedback, no "this fact was cited in a turn that succeeded" signal, and no confidence that moves with outcomes. The ordering is text relevance, full stop.
- Semantic recall is unmeasured against a real workload. The vector path works and is tested; whether it retrieves better than BM25 on an office's actual facts has not been demonstrated.
- Embeddings are backfilled lazily, so a fact written a minute ago may not have one yet and will rank lexically until the backfill next runs.
- Nothing is derived from a run. If nobody wrote it down, the office does not know it — and it will not notice that it should have.
The honest summary is that memory here is a well-built store for facts a person chose to write, and it is not a system that learns. That is the trade the design makes, and it is the trade that keeps every line in the store attributable. Known gaps keeps the same list in the same voice, and Environment variables has the two switches above in context with the rest of the configuration.
Did this page answer your question?