Memory: lines, authored sources & curation
The engine gives your brain a searchable memory with two zones and, inside the machine-written one, as many separate lines as you declare. Knowing both boundaries lets you keep a large curated corpus searchable while the memory automation stays out of it, give a role a memory that does not cross with anyone else’s, and (optionally) run an agent that declutters the machine-written zone.
State is not memory
Before anything else, the boundary that saves the most grief: memory is prose, state is
Postgres. memory.write names a line of markdown files an agent writes sentences into —
what was decided, what was learned, what happened. state.namespace names a prefix of
Postgres views an agent models with db_execute — rows it counts, filters and updates:
“paused until 14:00”, a streak per direction, today’s challenge and its status. Prose that
is really a row (“third morning in a row”, “blocked until the 12th”) is the thing that
grows MEMORY.md without ever becoming queryable, and a row that is really a conclusion is
a table nobody reads.
Neither is derived from the other. They are separate declarations with separate
budgets: a role may use the same word for both, may declare one and not the other, and an
agent with memory switched off can still carry state. Nothing in the engine reads
memory.write to decide a namespace, or the reverse. The full model — the <namespace>_*
convention, the budget tiers, when the block is re-sent — is in state.md.
Two zones
| Zone | Where | Who writes it | Memory automation |
|---|---|---|---|
| Managed | <brain>/memory/<line>/ | the engine (memory_write → that line’s daily logs YYYY/MM/YYYY-MM-DD.md + its MEMORY.md) | may edit / delete / compact |
| Authored | dirs you declare in memory.sources | you or your agent, as ordinary files (editor, git, an agent’s file-edit tools) | never — no memory-tool write path exists |
The boundary is structural, not a flag: every memory mutator resolves paths
relative to <brain>/memory/, so anything you declare outside it is physically
unaddressable by memory_edit / memory_delete / compaction. There is nothing
to “freeze” and no allowlist to maintain — it holds at any corpus size.
The loop never writes memory — either zone. memory/ and every
memory.sources entry are on its write-denied floor, so no loop PR can
edit a line or a source (see self-evolving.md § What the loop may not touch).
Both zones are read the same way: by being named. A role’s read / recall
lists take memory lines and authored corpora — content, people/maria —
so who may see the document tree is the brain author’s decision per role, the
same way a neighbour’s line is. Declaring a source in memory.sources indexes
it; naming it in a role or in the manifest is what makes it reachable.
This is a boundary on the memory tools, not a write-ban. An authored source is a normal directory: you edit it in your editor, and so can an agent with file-edit tools — in a live-checkout brain those edits are committed and pushed by brain-sync like any other G-path. If a corpus must stay human-only, that is a rule you put in the agent’s role prompt; the two-zone boundary won’t enforce it. Say what you actually mean there — “read-only” reads as “you may not write this” and will stop the agent from curating a corpus you wanted it to keep.
Memory lines
Memory is declared. A role has memory only when its file carries a top-level
memory: block — memory: {} is enough, and takes your manifest’s budget — or
when the heartbeat / hook running it declares agent.memory. A role without one
gets no memory at all: no MEMORY.md or daily window in its prompt, no recall,
no memory tools, no post-turn flush, and it never reads or writes any line,
main included. The engine’s own broad roles (role-general.md,
role-chief.md, role-coder.md, …) declare memory: {}; the loop’s
roles and the browser agent (system:browser) declare none on purpose. omnislash doctor →
Role declarations lists every role that runs without memory.
Taking memory: away from a live role withdraws what it was shown, once. A
CLI session keeps every block it was ever sent, so the next turn carries
<agent-memory path="main/MEMORY.md">(this memory line is no longer part of your context — the copy above is withdrawn)</agent-memory>
(and the same for the period ladder). One turn, and an agent that never received
memory never receives a withdrawal.
Every managed file lives in a line: a directory under <brain>/memory/. Unless
you say otherwise, all your memory-declaring agents share one, main. Give a
role its own and its memory stops mixing with everyone else’s:
# features/research/researcher.agent.md
memory: { write: research }
That is the entire declaration — no registry, no CRUD, no migration to run. The line exists as soon as something writes into it:
<brain>/memory/
├── main/ ← MEMORY.md + YYYY/MM/YYYY-MM-DD.md
├── research/ ← its own MEMORY.md, its own daily logs
└── .memory-index.db ← ONE index, covering every line
A line name is one lowercase segment ([a-z0-9][a-z0-9_-]*, ≤ 64 chars, and not
four digits — memory/2026/ means something else). Research, a/b and ../x
fail the role load rather than becoming a path; that is what makes the isolation
structural instead of a rule the engine has to remember.
The flip side: any directory you create under memory/ with a valid name is a
line, declared or not. Park your own notes in memory/notes/ and they become a
line no agent reads until some role names it. A corpus that should be readable
from everywhere belongs in memory.sources (below), not in a directory under
memory/.
What a role with its own line sees and does:
memory_edit / memory_delete, and memory_write unless a heartbeat was given report | its own line, always — nothing widens curation; a report grant adds signed daily-log deposits elsewhere, nothing more (§ Reporting into another line). A replay of the cycle’s check writes nothing at all (below) |
MEMORY.md + the recent daily notes in its prompt | its own line, always |
memory_search / memory_read | its own line plus every corpus read or recall opens |
| recall (the hits injected into every turn) | its own line plus the corpora recall opens |
authored sources (memory.sources) | when the role (or the manifest) names them in read / recall — the declared entry itself, or a path inside it |
So an isolated agent is isolated from other agents’ machine memory and from
whatever of your curated corpus it was not pointed at. Set the brain-wide default
once in constitution/MANIFEST.yaml (memory.recall: [content, people]) and let
the roles that need something else say so. And main is not special — it is an
ordinary line that happens to be the default.
Reading other corpora
Reading beyond your own line is your call, per role — and it comes in two strengths, because the two cost different things:
# features/research/researcher.agent.md — writes to `research`, may go and look at the
# `ops` line and at your `content/` corpus, and is shown `journal` on every turn
memory:
write: research
read: [ops, content]
recall: [journal]
An entry is either a memory line (one lowercase segment) or an authored
corpus — a brain-root-relative path that is a declared memory.sources entry
or something inside one (content, people/maria, handbook.md). One list,
both zones: from a role’s side “what may this agent see” is one question.
read opens a corpus for memory_search and memory_read and stops there:
nothing from it rides a turn the agent did not ask for, so it costs nothing
until the agent decides it needs something. recall puts the corpus in the
active-recall sweep that runs on every turn — the agent is shown what matches
without asking, and you pay for the search whether it hits or not.
Reach for recall when the corpus is part of how this agent thinks, and
for read when it is a reference the agent should be able to consult.
recall implies read: whatever recall can put in the prompt, memory_read can
fetch back, so a shared corpus goes in one list, never both.
Matching is by whole directory, so people/maria opens people/maria/
and never the people/maria-old/ beside it.
# features/team/memory-curator.agent.md — curates the research line
memory:
write: research
read: ['*']
'*' is read-only oversight, not a master key: that curator sees every line and
still edits and deletes only in research. Curation is therefore per line — ship the
curator once per line you want curated (a heartbeat each), rather than expecting one
agent to tidy them all.
Note which verb that is: read, not recall. A curator goes looking on purpose,
with the tools, for the line it was pointed at — putting '*' in recall instead
would sweep every line on every single turn and pay for it whether or not the turn
was about curation. Widening for a job is read; widening for context is recall.
'*' is every LINE and no authored source. A curator of the machine-written
zone is what the wildcard is for, and it does not silently hand out the document
corpus as well — those are reached by name, always. So a role that replaces the
manifest’s list with read: ['*'] loses your documents from its search: name them
alongside it (read: ['*', content, people]).
Both default to [] (isolated), so sharing is something you ask for rather than
something you forget to turn off. Neither widens what the prompt carries by
itself: the MEMORY.md and daily notes always come from the agent’s own line, so
opening a neighbour never doubles what every turn pays for. Recall is the door.
Reporting into another line
A line is a curation domain: it is defined by who curates it — its MEMORY.md,
its budget, its consolidation. One agent curates exactly one line, and that stays
true. But the class of a fact is a property of the fact, not of the agent. The
chief’s daily-briefing beat legitimately learns two things in one tick: “she moved
the review to Friday” (hers — the personal line) and “the calendar sync failed, second
morning” (the ops team’s). Filed in the personal line, the second fact sits where
its curator prunes run recaps and takes a recall slot from the material that line
exists for.
report opens other lines for deposits. Put it on the heartbeat or hook whose
prompt names the line:
# features/personal/briefing.heartbeat.md
agent:
role: assistant
memory: { report: [ops] }
Then say, in the beat’s own prompt, which facts go where — the routing rule lives three lines below the grant, not in the model’s judgement:
When the sync mechanism itself misbehaved: memory_write({ line: 'ops', content })
— name the mechanism, never the person. Everything about her stays in your own line.
What a deposit is, and is not:
- It lands in the destination’s daily log only — already that curator’s inbox.
prioritytogether with a foreignlineis refused before anything is written, andmemory_edit/memory_deletenever leave your own line: the destination’sMEMORY.md, its budget and its curated sections belong to its curator. - It is signed. The engine appends a trailing
— <role> · <heartbeat>to the entry (the role alone outside a heartbeat), so the curator sees at a glance that an entry came from outside and from whom — and can promote it or drop it on its next pass. Tell that curator’s role what to do with foreign deposits. - It is not permission to look.
reportwidens writes only; what the agent canreadandrecallis unchanged. - It is not inherited. A flush curator or an
agent_tasksub-agent working on this agent’s behalf gets the line, never the report set. - Lines only — no authored corpus, no
'*'— and every entry must be a line somebody curates (some role’s or beat’swrite, or an existingmemory/<line>/). A misspelling fails the boot andomnislash doctor→ Memory sources, rather than birthing a line with its first deposit.
The same key parses in a role file and in MANIFEST.yaml too, but there it opens the
door for every turn that role (or every agent) runs, chat included. omnislash doctor
→ Agent memory warns on a role-tier or brain-wide report, so that it is a decision
you made and not a default you inherited.
What you cannot do, and why
-
No curation fan-out, ever — not through
readorrecall, not through a tool argument, not through a config key.writeis a single line by TYPE, not by rule: oneMEMORY.mdtarget, one durable curator per line.reportdoes not bend this — a deposit is a signed append to a daily log and never touches the destination’sMEMORY.md(§ Reporting into another line). That is integrity, not policy, so there is no knob for it. -
The model never widens its lines. The SET of lines an agent may write into rides the execution context, not the tool schema;
lineinmemory_writeonly selects within that set — its own line, or onereportopened — and any other name is refused with the allowed lines spelled out. AmemoryScopetyped into tool arguments is dropped, not honoured. -
A replay writes no memory. The loop’s pre-merge check replays your workflow at a pinned commit (
loop.check.mode: replay). Memory is an input of what is being measured, so the replay — and every sub-agent it spawns — READS your lines live but may write none of them:memory_write/memory_edit/memory_deleteanswer “memory is read-only for this turn; nothing was written; do not retry”, and the end-of-turn flush does not run. The tools stay listed, so the agent behaves as it would live. A workflow whose job IS writing memory is therefore measured without its writes. -
Granularity is the corpus, not the file. In the managed zone that unit is the line; in the authored zone it is the directory —
people/mariais addressable,people/maria/style.mdis not. Shape the corpus you want shared as a folder; there is no per-file sharing on either side. -
All chats served by one role share its line. Lines separate roles, not conversations or end users of the same role. A role belongs to the CHAT (chat-access.md): your DM with the bot runs the bot’s role, a group runs only the role its rule in
constitution/access.yamlnames — so a group handed a narrow role writes that role’s line and recalls only what that role may recall, never yours. The one way to get memory per PERSON is therefore to give that person their own role: a secretary bot routes a role per counterpart (bots[].secretary.contacts, see secretary-mode.md), and each of those roles brings its own line. A line per conversation still does not exist, and neither does one per member of a shared DM role.That role is also where you decide which of your documents this person’s chat may reach. A secretary’s counterpart role typically runs
recall: []— the turn is a conversation, not a research task, so nothing should ride it unasked — and puts the person’s own folder inreadinstead:memory: { write: maria, read: [main, people/maria], recall: [] }. The stranger’s role (the bot’s ownrole) names neither, so an unknown chat searches its own empty line and nothing else.
Which line an agent ends up in
Most specific wins, no merging:
- the engine, for a turn that works on another agent’s behalf (see the flush note under How much memory an agent carries);
agent.memory.writein the heartbeat or hook file — the beat’s own tier, on top of the role it names inagent:. A scenario’sagent:block is run-scope, so amemorykey there is inert (omnislash doctor→ Agent memory says so);memory.writein the role file (system/role-*.mdor*.agent.md);memory.writeinconstitution/MANIFEST.yaml— your brain-wide default;main.
An agent that declares memory and ends up with no line of its own is not an
error case: it works in the default line, exactly like every agent did before
lines existed. An agent that declares NO memory has no line at all — none of the
tiers above applies to it, and a line handed down by a dispatch (a parent’s, for
an agent_task child) never turns memory on.
Tier 4 has one more consumer: the dashboard’s memory page (Knowledge, /knowledge) opens on that
line. Point memory.write at the line your brain actually works in and the page
lands there instead of on whichever line sorts first alphabetically — it falls
back to that only when nothing has written to your default line yet. The page
browses the managed zone with no role policy applied — it is you at the
keyboard, not an agent — so what you can see there says nothing about what any
role reaches.
An existing brain migrates itself
A brain whose memory predates lines has MEMORY.md and YYYY/ sitting at the
root of memory/. The first worker startup folds them into main/ — once,
idempotently, leaving the index where it is — and brain-sync commits the moves as
ordinary renames on its next tick. Nothing is re-embedded. Until that startup
happens, a reader process sees an empty line, so run the worker before judging a
freshly-upgraded brain’s memory.
Only the root’s own managed files move: MEMORY.md, loose root *.md, and
the dated YYYY/ tree. A folder of notes you kept under memory/ — say
memory/knowledge/ — stays where it is and becomes a line named after the
folder, and a line no role declares is a line no agent reads: it is still
indexed, and returned by no recall, no memory_search and no memory_read. The
migration names each such directory in a startup warning. Pick one:
- point a role at it (
memory.write: knowledge, ormemory.read: [knowledge]on the roles that should be able to consult it); - move its files into a line that is already read (
memory/main/…); - move the folder out of
memory/and declare it as an authored source (below) — then name it in the manifest’smemory.recallso every role reaches it, or in just the roles that should.
A directory whose name is not a valid line name ([a-z0-9][a-z0-9_-]*, and not
four digits) cannot be declared at all, so rename it before declaring it.
Declaring authored sources
In omnislash.config.ts, list brain-root-relative dirs (or .md files):
export default defineProject({
memory: {
embeddingModel: 'text-embedding-3-small',
sources: ['lifelog', 'research/notes'],
},
});
Point them outside memory/: a source inside the memory workspace is not
authored at all — it is a memory line like any other, readable only by an agent
whose role declares it. A source may not share its name with an existing line
either — one name cannot mean two corpora, and the boot refuses that whether or
not anybody names it.
Each entry is a brain-root-relative path, spelled exactly as it sits on
disk (content, research/notes, handbook.md, content/Заметки/архив):
every segment starts with a letter or a digit — any script, either case — and
continues with letters, digits, ., _ or -. No spaces, no .., no leading
/, no absolute path.
That is the same alphabet a read / recall ref uses, and deliberately so: a
source you could not spell in a role’s list would be a corpus the engine indexes
and no agent can reach.
Declaring indexes; naming reaches. memory.sources decides what gets
embedded and searchable; a role’s read / recall (or the manifest’s, as the
brain-wide default) decides who can actually retrieve it. A source nobody names
is indexed and returned by no search — the startup log says so by name, and
omnislash doctor (Memory sources) reports the same thing while you are still
editing. The brain-wide default is one line in constitution/MANIFEST.yaml:
memory:
write: main
recall: [lifelog, research/notes] # every role, unless it declares its own list
Name a source by the entry itself (lifelog, handbook.md) or by a path inside
it (people/maria, and a file under a directory source works the same way).
What does NOT work is naming a parent of what you declared: with
sources: ['people/maria', 'people/alex'], read: [people] fails the boot
— it would open both while naming neither. Declare the parent, or name the two.
A role’s memory: block replaces the list it names wholesale, so a role that
writes its own read or recall gets exactly what it wrote — that is how a
counterpart role narrows to one folder, and also why a curator on read: ['*']
has to re-name any corpus it still wants.
Every declared path is indexed; the ones an agent named surface in its
memory_search / memory_read exactly like managed files — addressed by their
brain path (cases/acme/brief.md), the same in a recall label, a search hit and
memory_read — and no memory automation can change any of them. Curate them as documents (your editor, git, or
the agent’s file-edit tools). Dated files inside an authored vault
(lifelog/2026/01/2026-01-01.md) are not treated as machine daily logs, so
temporal decay leaves them alone.
Languages your memory is written in
Memory search has two legs: one compares meaning (embeddings), the other
compares words. The word leg matches by stem, so report finds reports
and встреча finds «о встрече» — but only for the languages you list:
export default defineProject({
memory: {
languages: ['english', 'russian'],
},
});
- Default
['english']. If your agents write in another language, add it — without it, words of that language still match, lowercased and withёread asе, but only in the exact form:Иваномwill not findИван. - One language per script, and order matters. A word goes to the first
listed language written in its alphabet: Latin words to
english, Cyrillic torussianabove. A language that could never get a word — every script it uses is already taken by an earlier entry, likeukrainianafterrussian— fails the config load with a message saying so. Pick the one your memory is mostly written in. - Supported: arabic, armenian, bulgarian, danish, dutch, english, finnish, french, german, greek, hindi, hungarian, indonesian, irish, italian, lithuanian, nepali, norwegian, portuguese, romanian, russian, serbian, spanish, swedish, tamil, turkish, ukrainian. Text written without spaces (Chinese, Japanese, Thai) gets no word splitting, so it is found mainly by meaning.
- Changing the list rebuilds the search index on the next sync after the
deploy (
omnislash.config.tsis baked into the image). The index is rebuilt from your files, but embeddings come from its cache — no embedding calls, no API cost — so it takes seconds for a typical brain. - The list is brain-wide: there is one index, so a role cannot have its own.
Curating the managed zone
Decluttering managed memory (dedupe, drop superseded facts, prune stale daily logs) is your concern — the engine ships the tools, not a curator. The managed-zone mutators:
| Tool | Capability | HITL (default) | Notes |
|---|---|---|---|
memory_edit | memory:edit | off | exact-string replace, fail-loud (oldString must be unique) |
memory_delete | memory:delete | on | removes file + index + recall; git is the only undo |
Both work inside the curator’s own line only, whatever it may read — so if you run several lines, run a curator in each (§ Memory lines).
Every write BLOCKS on the worker’s verdict, up to 90 seconds. The tools do
not answer “queued”: a write goes through one durable writer, and the answer is
that writer’s own — the file it wrote, its error, or an explicit UNCONFIRMED
when the worker died mid-job or the lane is genuinely backed up. UNCONFIRMED is
not “not written”: the job is durable and may still land, so an agent told this
should re-read with memory_read rather than retry blindly. If nothing is
consuming the queue at all (the worker is down mid-deploy), the write is still
QUEUED — it runs when a worker comes back — and the agent is told the memory is
not yet updated. Nothing is thrown away. Every answer is an object
(memory_write → { outcome, paths, message }, memory_read → { path, content, chars, modifiedAt, budget }), and every memory write — an agent’s or one made on
the dashboard — is a row of the action log (audit_list), so “who last wrote this
file” has an answer.
Ship a curator as a role + heartbeat (see
features/team/memory-curator.agent.md and
features/memory-care/memory-curator.heartbeat.md in brain-example):
- The role (
*.agent.md) carriescapabilities: [memory:read, memory:edit, memory:delete]and the curation instructions. - The heartbeat (
*.heartbeat.md) schedules it withagent: memory-curator— capabilities live on the role, not the heartbeat.
Guidance that travels well for a curator/dreamer prompt:
- Distill from episodic sources. Derive durable facts from the daily logs
and the dated
## Memory (…)sections — they carry provenance (a date) you can cite. Consolidate several dated entries into a curated section, then retire the entries you distilled. - Removal needs a superseding source, not judgment. Delete or rewrite a fact only when a newer entry contradicts or replaces it — “seems stale” is not a reason. Git is the preimage: have the curator’s runs committed with a marked message so memory loss stays reviewable.
- Route durability explicitly. What must survive into every future session
belongs in
MEMORY.md(viapriority: truewrites, or your own curated sections); everything else stays episodic in the daily logs, reachable through recall. - Keep the file inside
fileMaxChars. Once the engine has no dated section left to drop, it refuses the write instead of letting the file grow — so shrinking the curated sections is part of the curator’s job, not an optimization. Never have the curator measure the file itself. The engine hands it the reading —8794 of 12000 fileMaxChars (73%)on the block’s label, on amemory_readof the file, and in the result of every write or edit that touched it. A shellwccounts BYTES, which is ~1.6x the budget’s unit on a Cyrillic file: a curator told to measure with one distils a file that was never over budget, and a distillation pass costs content every time. - WHAT to remember is your role’s text to write, not the engine’s. The
memory prompt module (
Agents/system/with-memory.md) says what each memory block is and how to read it (coarsest summary first; a partial block means the rest exists; never measure the file yourself), and stops there. It names blocks, not tools: the path grammar and “how to fetch the rest” live in each block’s own footer and in the memory tools’ descriptions, which arrive only with the block or tool they describe. An engine that also shipped a default “save reusable knowledge after every task” would be policy every brain inherits and no brain wrote. Put the triggers — and, more importantly, what must NEVER be written — in the role’s own body, where the agent’s job is described. A role that says nothing about it has no policy, not a safe default.
MEMORY.md: two kinds of section, one hard budget
MEMORY.md (the long-term file carried into every agent session) is MANAGED
like the rest of memory/<line>/ — the engine may edit, delete and compact it.
Inside it there are two kinds of section, and the split is about who can
decide, not about who typed them:
- Dated engine-appended sections.
memory_write({ priority: true })appends the content as a list entry under today’s## Memory (YYYY-MM-DD)header — addressable, dated units the engine can retire by date alone, and the only thing it drops by itself. - Curated sections. Every other
##block, plus the preamble. Choosing which of these to lose takes judgement, so the engine never does it: that is your curator’s job, with a model.
One budget, memory.fileMaxChars (default 10000, 0 = unbounded), applies to
the whole file and is enforced the same way on every writer — memory_write,
memory_edit and the dashboard’s whole-file save alike. Two steps, in order:
- Drop. The oldest dated sections go until the file fits — loudly, via
a
memory:compactedevent and a note in the tool result naming the dropped dates. Editing inside a dated section makes it curated: a section whose body no longer matches the emitted shape (list entries under the dated header) is never dropped, because the engine cannot know what it means now. - Refuse. When every droppable dated section is gone and the file would
STILL be over the budget, the write is refused — nothing is written, the
tool result (or the thrown error, or the dashboard’s message) names the
numbers, and
memory:budget-refusedcarries{ file, scope, chars, budget }for your hooks.
A refusal is the signal that says the curated sections have outgrown the line’s
budget; bringing them back under it is the curator’s move. Over budget, a write
that makes the file smaller is still accepted — that is how a curator digs
out. Smaller means strictly smaller: a same-length rewrite leaves the line as
far over its budget as it was, so it is refused like any other non-shrinking
write. On a write, only the MEMORY.md half is refused: the daily-log entry
still lands and is reported.
Lines are not all the same size, so the budget takes a per-line tier —
memory.lines.<line>.fileMaxChars, the narrower half of the same number:
memory:
fileMaxChars: 10000 # every line, unless it says otherwise
lines:
main:
fileMaxChars: 60000 # a line whose curated core is genuinely that big
ops:
fileMaxChars: 0 # this one is unbounded
fileMaxChars is a budget on the FILE, not a ceiling on the prompt — they
are two numbers and confusing them is the classic mistake here. What one agent’s
turn pays for MEMORY.md is memory.priorityChars, which belongs to the READER
(per role, like recentChars), where fileMaxChars belongs to the line. Keep
priorityChars at or above fileMaxChars for any agent that must hold the
whole file: over priorityChars the block arrives with its oldest dated
sections dropped, then clipped if what is left still overflows, and it always
says out loud that it is partial and names the path that reads the rest.
fileMaxChars counts UTF-16 chars (.length) — the unit every number the
engine reports about a MEMORY.md is in, including the memory:written,
memory:edited, memory:deleted and memory:compacted events a hook may read.
Two doors stay open around the write budget, and omnislash doctor’s Memory
budget check is what names a line that walked through one: a direct file
Write by an agent holding file tools (an “an agent can break its own brain”
hole, not a hole in the budget), and lowering fileMaxChars under a file that
was already legal. Either way the finding tells you before the next priority
write is refused — and on a passing brain it reports each line’s reading.
That check is a HUMAN’s channel (it needs runtime:read, which a curator
role typically does not hold). A curator gets the same reading handed to it
without asking, on all three doors into the file: the <agent-memory> block’s
label, a memory_read of it, and the result of every memory_write /
memory_edit that touched it. Never write a role that measures the file
itself — see § Curating the managed zone above.
Period levels: weekly, monthly, yearly summaries
A memory line has two altitudes out of the box — MEMORY.md (“forever”) and the
daily log (“today”). Long-horizon commitments live at neither: “this month we
decided X” scrolls out of the daily flow within a week, and forcing it into
MEMORY.md turns that file into a landfill of mixed altitudes.
Turn on consolidation and the engine grows the missing rungs by itself, one file per closed calendar period, each derived from the level below:
<line>/2026/2026-W34.md weekly ← from that week's daily logs
<line>/2026/2026-08.md monthly ← from the weeks ending in August (+ its last days' logs)
<line>/2026/2026-Q3.md quarterly ← from its months
<line>/2026/2026.md yearly ← from its quarters
They are ordinary files in your line: searchable, memory_read-able, editable by
hand or by a curator, committed by brain-sync. And the latest of each level is
carried into the agent’s context automatically (<agent-memory-periods>) — which
is the part you cannot build yourself, since the engine assembles the envelope.
# constitution/MANIFEST.yaml
memory:
consolidation:
lines:
main: { periods: [week, month, year] }
research: { periods: [week], modelRef: fast }
# optional:
schedule: '0 4 * * *' # when the tick runs, in `locale.timezone`
maxRunsPerTick: 4 # summaries per line per night; the rest wait a night
maxSourceChars: 120000 # how much source text goes into one prompt
modelRef: cli:smart # what consolidation runs on, brain-wide
That is the whole setup. No heartbeat to write, no role to create — and with no
lines block at all, nothing runs and nothing costs anything.
You own the judgment. The summary text is written by a
system:consolidator turn. The engine ships a role file with sensible criteria
(decisions and their reasons, commitments with a horizon, open threads,
repeating patterns, corrections); to change the voice or the criteria, drop your
own constitution/Agents/system/role-consolidator.md in place — the same
override seam as every other system role.
The model is a separate decision from the voice, so it has its own seam and you
should not need the override for it. consolidation.modelRef says what this operation
runs on brain-wide; a line may override it; absent both, the role file decides. Reach for
the role override when the criteria are wrong for you — not to change a model, since that
copy replaces the engine’s file whole and stops receiving its updates.
Worth knowing:
- A level is summarized from the levels you enabled.
[week, year]means the year is written from its weeks, with no phantom monthly step. Belowweek, sources are the daily logs. - A period is summarized once, and the file is the record. Want it redone?
memory_deletethe summary — git holds the preimage and the next tick regenerates it. Nothing else to reset. Your edits to a summary are safe: the engine only ever CREATES one, so a night’s run that finishes after you saved is discarded rather than written over your version. - A period you did not write in produces nothing — no file, no model run. Dueness asks whether you wrote something INSIDE the period, which is not the same question as which files feed it: the ISO week of 27 Jul – 2 Aug feeds August’s summary, but it does not make August due for a brain that went quiet on the 1st.
- Nothing is too old to catch up. There is no lookback window: turn
consolidation on after a year of logs and you get that year’s whole ladder,
built at
maxRunsPerTicka night. Addyeartwo years in and you get the years you actually lived. The tick says how much is left each night (Consolidated 4/4 due period(s), 9 more queued for the next tick), so you can see the backlog on the first night and raise the rate if you are impatient. - A week belongs to the month it ends in. ISO weeks do not nest in months, so each straddling week is given to exactly one month: 27 Jul – 2 Aug is August’s (seam included), 31 Aug – 6 Sep is September’s. The days a month’s last week would have covered — 31 Aug here — come straight from the daily logs, so a month never waits on a week that ends in the next one and never carries the next month’s days. Quarters and years nest, so for them this is plain containment.
- The cascade waits for the level below. A month whose last week lands tonight is summarized tomorrow night — August’s summary appears on 1 or 2 September. The engine will not build a month on weeks that do not exist yet, and it has no escape hatch that would let it: an upper level is built from complete lower material or it waits. (A week you wrote nothing in never blocks anything: it produces no file, ever.)
- A late write does not reopen a period. If the flush appends to Sunday’s log after the weekly summary was written, the entry stays reachable through recall — delete the summary if you want it folded in.
- A summary is written to fit the ladder you carry. Each run is told a
length: an even share of your
memory.periodCharsacross a full ladder (memory.periodCountsummaries of every level the line enables, minus their labels and the pointer) — so every level arrives whole instead of a long week crowding the month out. There is no knob for it: raiseperiodCharsand the next summaries are written longer. It is a target, not a cut — an answer over it is filed as written, andmemory:consolidatedreportschars,targetCharsandoverTarget. WithperiodCharsorperiodCountat0brain-wide, no length is asked. A role that narrows its ownperiodCharsreads the same files, in part where they do not fit. - Summaries age on their own timescale. Recall decay scales the half-life by the period’s length, so a fresh weekly ranks like a fresh daily and then fades seven times slower.
- This does not touch
MEMORY.md. Evergreen distillation stays yours; the engine never runs a model over that file. - Narrow agents can opt out with
periodCount: 0in their own frontmatter.
Consolidation composes with a curator rather than replacing it: a curator cleans level 0 (dedupe, retire superseded facts), and cleaner daily logs make for honester weeklies.
The automatic flush, and how to tell when it stops writing
Separate from your own curator, the engine runs a memory flush after a turn
whose agent asks for one (memoryFlush in the role/heartbeat frontmatter; the
brain-wide kill switch is memory.flush.enabled in MANIFEST.yaml) — and whose
role DECLARES memory: a flush writes into the running role’s line, so a role
without a memory: block flushes nothing, whoever asked (omnislash doctor →
Role declarations names such a memoryFlush: true). The curator role
system:memory declares memory: {} itself; a copy of role-memory.md without
it makes every flush a no-op, which doctor fails. It replays
the turn to a strict curator running as the system:memory role, whose standing
instruction is to answer "done" unless something is genuinely worth keeping —
so most flushes saving nothing is the designed behavior, not a fault. That
instruction is the body of that role’s own file,
constitution/Agents/system/role-memory.md — override it by shipping a
same-path copy in your own constitution/ when your brain’s idea of “worth
keeping” differs from the engine’s default.
One curator reads every agent’s transcript, so that body can only state a criterion that fits your whole brain. Where an agent’s idea of worth is its own, it says so in its own frontmatter:
memoryFlush: true
memoryKeep: |
Budget cycles, who signs off, and objections that come back a second time.
Not call logs, not who said hello.
memoryKeep resolves agent frontmatter → role-general.md frontmatter (your
brain-wide default) → nothing. It reaches the curator as this agent’s stated
definition of worth, above the transcript. It narrows what the curator
looks for and cannot lower the bar: when the turn matches nothing in the
brief, the answer is still "done". So it is a brief, not a replacement
prompt — to change the discipline itself, replace the role body.
That makes the failure mode a quiet one: “the curator judged there was nothing
to save” and “the curator has stopped saving anything at all” look identical on
any single run. Which is why every flush reports its outcome, as the
agent:memory_flushed event and a Memory flush finished log line:
status | Meaning |
|---|---|
wrote | the curator called a memory mutation — tools names which |
nothing_saved | it ran to a clean finish and mutated nothing |
skipped | no curator ran at all — reason says why |
failed | it errored, or was stopped before deciding |
The event carries the usual agent routing, heartbeatId included, so you can
scope an alert to one lineage. The recipe that catches a dead pipeline: alert
on a streak of nothing_saved for one agent or heartbeat, sized to how
talkative that lineage is — a handful in a row is ordinary for a quiet
heartbeat, a day of them is not. failed deserves its own, tighter threshold.
What to check when the alert fires, in the order that pays off: the model behind
the memory role’s chain (a model that reads the “default to done” instruction
literally will answer done to everything), then any recent edit to the
curator’s prompt, then the provider’s health. If a lineage genuinely produces
nothing worth remembering, turn its flush off rather than letting it flap — and
let the agent write what matters through memory_write itself.
Note the boundary: this says the curator asked for a write. Whether the write
then landed is the write path’s own story — memory:written / memory:edited /
memory:deleted — since queued content that adds nothing new is deduplicated
away on purpose. The dedup only keeps or drops: an entry that adds anything lands
whole and word for word, so a daily log can hold a partial repeat — folding it
is the curator’s level-0 work — but never a fragment of an entry.
How memory reaches the agent
Memory is delivered outside the system prompt, and each part arrives on its own schedule. CLI agents (a harness session with its own transcript) get it in the conversation; API agents (whose history is replayed as text on every turn) get the same tags placed by how fast they change:
| Tag | Carries | CLI agent | API agent |
|---|---|---|---|
<agent-memory path="<line>/MEMORY.md"> | MEMORY.md, labelled with its path and budget reading, within memory.priorityChars | when a session opens, and again whenever the file (or its reading) changes — alone, not with the ladder or the environment | a standing system message after the system prompt, every request |
<agent-memory-periods line="<line>"> | the period ladder — the latest weekly / monthly / quarterly / yearly summaries (§ Period levels) | when a session opens, and again when a summary changes (at most once a week) — alone | with <agent-memory> |
<agent-memory-recent line="<line>"> | a window of recent daily logs (see below) | when a session opens (afterwards the agent’s own writes are already in the conversation) | every turn, in the turn’s message |
<turn-context> | the clock + this message’s recall hits | every turn | every turn |
On a CLI session each block is re-sent on its own: a priority write re-sends
<agent-memory>, a week rolling over re-sends the ladder, and neither drags the
other (or the <agent-environment> block) along. Each re-sent block carries its
path / line, which is how the agent knows it replaces the earlier copy. After
a harness compaction the next turn opens with an <agent-context-snapshot> line
withdrawing everything above and re-sends all of it, daily window included.
This is a cache decision, not a cosmetic one: the system prompt is written to the model provider’s cache once and read back at a fraction of the price, so anything that moves between turns is kept out of it. Two consequences worth knowing when you author prompts:
- Only a window of daily logs is injected, and it says so out loud: the
block ends with a pointer telling the agent older entries exist and how to
reach them (
memory_search/memory_read). If your role prompt promises “you can see the last N days”, say “you can search them” instead. MEMORY.mdis bounded too (memory.priorityChars), and over that budget it arrives in part — with the same kind of pointer, for the same reason. The agent’s own file is then also left in the recall set — minus the part already on screen — so what the budget cut can still come back on a turn that needs it, and the visible sections do not spend the recall slots to repeat themselves.- A summary or a day that does not fit whole arrives in part, and its label
says so:
[main/2026/2026-09.md] (in part — memory_read for the rest). The cut keeps whole lines from the top (a summary) or the bottom (a daily log), and never leaves a heading standing alone — a month crowded out by its week arrives as its opening words with…, or not at all. - A role file edit does invalidate the cache, exactly once, on the next turn. That is intended — the content really did change.
- A recall that found nothing usually says nothing — and occasionally says
### Memory recall — NOT CHECKED. The difference is whether the search was a fair test of absence. A healthy search that simply found no match is silent, including on a brand-new brain whose memory is still empty: there is nothing to find and “nothing” is the true answer. The block appears only when recall could not look properly — no embedding provider (so nothing is indexed at all), a failed index sync, a retrieval leg down, or the search itself failing outright — because an agent that reads a blind spot as proof will confidently tell your user it has no record. If you see it on every turn, that is a deployment problem to fix (usually a missingOPENAI_API_KEY), not prompt noise to suppress. - What recall does NOT say is that a match fell just under
recallScore. That threshold is YOUR definition of relevant, so a candidate below it is a fair miss by your own rule, and almost every query has one — a note about it would land on nearly every turn. An agent that wants to look under the bar hasmemory_search, which reports both the count and the score that would surface them. - What
recallScoremeasures. Each note gets one relevance score from 0 to 1:0.85 × how close its meaning is to the message + 0.15 × the share of the message's words it contains(words compared by stem — § Languages your memory is written in). Recall injects the notes at or aboverecallScore(default0.41);memory_searchusessearchScore(default0.3) unless the agent passes its ownminScore. Two numbers on purpose: recall rides every turn and wants precision, a search the agent chose to run wants coverage. A role that searches to deduplicate (is this already written down?) lowers its ownsearchScorein itsmemory:block rather than telling the model to pass a number. The scale with the defaulttext-embedding-3-small: relevant notes land at ~0.3–0.75, small talk reaches ~0.40, so a floor above ~0.6 silences recall almost entirely. Age never filters: an old note that clears the floor is still recalled, recency only decides the order. In practice a note holding every word of a short message clears0.41on a loose topical match, and a note sharing none of its words needs a close one. Recall is same-language: a message finds notes written in its own language, and a note in another language scores like small talk and stays out. A long message shares only a few words with any note, so meaning decides. Lower the floor and more borderline notes ride every turn (each one costs tokens); raise it and recall stays quiet more often. A floor you tuned on an engine before stemming was tuned against a different formula — re-check it. - A recall that found something can carry the same warning, as a one-line
_INCOMPLETE — …_footer under the hits. It means the search returned real memories but could not see everything — one retrieval leg down, or an index sync that failed — so the list is a sample rather than the record. This is the more dangerous half: an empty answer at least looks empty. It is charged torecallCharsahead of the hits, so it costs you a snippet, never the caveat.
How much memory an agent carries
The lever is not on/off but how much, and it is per-agent. An hourly heartbeat that decides whether to send one nudge does not need what your chat agent needs — and before the budget existed, it got it anyway.
One flat block answers both questions at once — WHICH lines (the four verbs) and HOW MUCH of them (everything else):
# constitution/MANIFEST.yaml — your brain's defaults
memory:
write: main # the line agents curate unless their own file says otherwise
report: [] # extra lines they may DEPOSIT into (daily log only, signed) — meant for a beat, not here
read: [] # extra lines they may REACH with the tools ('*' = all)
recall: [] # extra lines active recall searches every turn ('*' = all)
priorityChars: 12000 # ceiling for MEMORY.md in the session envelope (0 = don't carry it)
recentDays: 1 # days of daily log carried when a session opens (0 = none)
recentChars: 4000 # ceiling for that window; the newest entries survive
recallResults: 4 # search hits injected per turn (0 = no recall at all)
recallScore: 0.41 # relevance floor, 0..1 — see below
recallChars: 3000 # ceiling for the formatted recall block (0 = no recall block)
searchScore: 0.3 # memory_search floor when the agent passes no minScore — see below
periodCount: 1 # period summaries carried PER LEVEL (0 = no ladder)
periodChars: 6000 # ceiling for the whole ladder block
A role narrows (or widens) those in its own frontmatter — the same block, at
the top level of the file, next to skills: and capabilities::
# features/team/nova.agent.md — your main chat agent wants more
memory: { recentDays: 2, recentChars: 8000 }
# features/research/researcher.agent.md — a scheduled beat wants almost nothing
memory: { priorityChars: 0, recentDays: 0, recallResults: 2, periodCount: 0 }
A heartbeat or hook may patch it once more, in its own agent: block — the
same sparse keys, merged one level over the role it names:
# features/research/digest.heartbeat.md — researcher's role, an even smaller window for this beat
agent:
role: researcher
memory: { recentDays: 0, recallResults: 1 }
This is the tier report is meant for (§ Reporting into another line), and it is
also where a beat that needs a notebook of its own gets one (memory: { write: circles }) without a second role file. A scenario’s agent: block is
run-scope: a memory key there goes nowhere, and omnislash doctor → Agent
memory names it.
The block’s PRESENCE is the switch: whether this agent has memory at all. There
is no promptFlags.memory — the old key is ignored (stripped at load) and
omnislash doctor → Role declarations asks you to delete it.
Rules worth knowing:
- Every numeric knob is off at
0. There is no secondenabledboolean to keep in sync. - A zeroed budget is not the same as no block: the module still runs (and says how to use memory), the memory tools your grant holds are offered, and the agent keeps its line. No block means none of that.
- Which memory tools a declaring role gets is still its grant:
memory:read(memory_overview,memory_list,memory_search,memory_read),memory:write(memory_write),memory:edit(memory_edit,memory_write_file,memory_move),memory:delete(memory_delete). A block without any of them is ambient memory and no tools — legal; doctor names it so it is a choice. - Some engine roles ship with no block:
role-browser.md(page text is hostile input) and the loop’sloop/role-proposer.md,loop/role-implementer.mdandrole-evaluator.md(a round must be reproducible, and the judge’s rubric cannot include what memory happens to recall). A copy of one of those files replaces it whole — add a block only knowingly:omnislash doctor→ Loop role memory warns (proposer, implementer) or fails (judge). - Your block is merged one level over the manifest, so narrowing the window
does not silently reset your recall tuning. Each key is replaced whole — a
role that declares
recallstates its whole recall set, it does not add to the brain’s. - An agent that works on another agent’s behalf writes into that agent’s
line, not its own: the post-run flush curator files what your researcher agent
learned under the researcher’s line, and a throwaway
agent_tasksub-agent curates the line of the agent that spawned it — when the sub-agent’s own role declares memory; a line handed down never turns memory on. It READS that line too — itsMEMORY.md, its daily window and its recall all come from the borrowed line, so the curator sees what it is summarizing. Onlyreadandrecallstay its own: borrowing a line to write in is not permission to browse the corpus. The engine passes the line with the dispatch; it is not something a model can ask for. - The manifest block and every frontmatter block (role, heartbeat, hook) are
strict: a typo’d knob fails the load instead of sitting in YAML doing nothing.
omnislash doctorchecks this for you — a manifest the engine cannot load fails the Constitution structure check and names the offending key. - The three char ceilings bound the rendered block, not just the text read from disk: the window’s day labels and its “older entries are not shown” pointer, the ladder’s path labels and pointer, and the recall block’s heading, cost budget exactly like content does. Set a ceiling too small to hold that framing and the block is dropped entirely rather than reduced to a disclaimer.
periodCountcosts nothing until you turn consolidation on: no summaries on disk means no block, whatever the budget says.
Tune these hot — MANIFEST.yaml is a brain-sync path, and the dashboard’s
Memory Settings drawer edits the same file.
memory.priorityDefault: true is the write side, not part of the budget: it
makes every memory_write go to priority memory when the caller omits
priority. Note the trade — it also means almost every write moves MEMORY.md,
and each move re-sends the whole file to every running session.
Your responsibility (the disarm note)
The memory:delete HITL gate and the capability list are your guardrails. If
you relax the gate ("hitl": false in constitution/engine-capabilities.json)
and grant delete to a curator, you have disarmed that guardrail by choice — the
framework cannot stop a CLI agent from using a tool it has the capability for.
For how per-capability overrides work, see the capability section of the runtime
contract.md.