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
The date a daily log is named after — and “today” everywhere in memory — is the brain’s
day: locale.timezone, the zone your schedules and the turn’s current date are in. So a memory_write at 00:30 local lands in the new day’s log,
today’s ## Memory (YYYY-MM-DD) section and the recent-days window open on that day, and a
week or month counts as closed once its last day has passed on that calendar.
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. A name in a role’s
memory: block may also be a template — jesus-{person}, club-{chat} —
that the engine resolves to one line per person or per chat on every turn
(§ Memory per person and per room).
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 lines (one per write slot, below), 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 lines, always — each slot’s |
memory_search / memory_read | its own lines plus every corpus read or recall opens |
| recall (the hits injected into every turn) | its own lines 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.
Both lists are read in full only in a session owners alone see. In a chat other people see, an entry outside the role’s own lines and feature directory is dropped and the chat’s grants apply instead (§ Memory per person and per room).
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 lines, 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. An agent curates one line per write slot, and
nothing at runtime adds a slot. 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 lines: 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 its lines, never the report set. - It stays writable for every audience and never opens a read: in a chat
other people see, a
reporttarget still takes deposits, but naming it inread/recalltoo does not keep it readable there (§ Memory per person and per room). - 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 at runtime — not through
readorrecall, not through a tool argument, not through a config key. The lines an agent curates are itswriteslots, declared in its file and resolved by the engine: oneMEMORY.mdtarget per slot, one budget per line, and nothing a turn does adds a slot.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 — one of its slots, or a linereportopened — and any other name is refused with the allowed lines spelled out. AmemoryScopestyped 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. - A literal line is shared by every chat its role serves. A role belongs to
the CHAT (chat-access.md), and
write: supportis one line for every DM and group that role serves, yours included. To give each person or each group a line of its own, name the line with a template (write: support-{person}); to keep your own documents and lines out of a chat other people read, you do nothing: they reach only an owners’ session, and the chat’s rule grants back what that chat may see (§ Memory per person and per room).
Which line an agent ends up in
Most specific wins, no merging (the tiers pick the write slots; a template in
them is then resolved for the turn’s audience, § Memory per person and per room):
- the engine, for a turn that works on another agent’s behalf (see the flush note under How much memory an agent carries) — it hands down lines already resolved, never templates;
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 — its first slot, resolved as for an owner (jesus-{person} opens jesus-owner). 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.
Memory per person and per room
A role belongs to the chat, and one role often serves several people: your persona bot talks to you and to a friend, a group bot to twenty members. Two questions then need an answer per chat, not per role: whose notebook does the agent write in, and what of yours may it reach. The first is a line template; the second is the session’s audience.
Line templates
Wherever a role’s (or a beat’s) memory: block names a line — write, report,
read, recall — the name may carry a variable from a closed set:
| Variable | Resolves to |
|---|---|
{person} | the person the session is with: tg-<user id> in a DM, the counterpart’s tg-<id> in a Business chat, the speaker’s tg-<id> in a group (tgc-<digits> for a post made on behalf of a chat) |
{chat} | the chat: tgc-<digits> for a group (tg:chat:-100555 → tgc-100555, sign dropped), the peer’s tg-<id> in a DM or Business chat |
In a session only owners see (below) every variable resolves to owner:
write: jesus-{person} is jesus-owner in your DM, on the dashboard, in a
heartbeat, a hook and a workflow alike, and shared by every owner, exactly like a
literal line was.
write takes one name or a list of slots: write: [club-{chat}, club-{chat}-{person}] keeps a room line and a line per member of that room, in
one conversation. The first slot is home — where a bare memory_write lands,
the line the dashboard opens on and whose budget the defaults speak of. Every
slot is curated by the turn: its MEMORY.md, its period ladder and its recent
window each ride the prompt; memory_write({ line }) picks a slot,
memory_edit / memory_delete work in any of them, and memory_move stays
within one. A slot whose variable has no value this turn is skipped — a group
turn nobody speaks in (the dashboard composer) has no {person}.
The rules that keep a family enumerable:
- only
{person}and{chat}, each at most once, and at least one literal character beside them ({person}alone is refused); - the longest instance must fit a line name (≤ 64 chars), counting each variable at 20; the load fails naming the rule broken;
- lines stay flat:
club-tgc-100555-tg-42/sits besideclub-tgc-100555/, never inside it, so reading the room line never opens a member’s; - two roles may resolve to the same line (two persona bots sharing
friends-{person}), like two roles writingmain; owneris reserved as a value: no person resolves to it.
A memory.lines.<key> budget and a consolidation.lines.<key> entry may be a
template too (§ MEMORY.md, § Period levels); omnislash doctor → Memory
sources counts each family’s lines on disk (jesus-{person}: 3 lines), and
memory_overview lists them.
The audience — who can see the session
A CLI session keeps everything it was ever sent: a recall hit, a memory_read
result, the daily window. So what a turn may reach is decided by who can see
the session, never by who is speaking. Every turn resolves one of three
audiences from the chat its agent is bound to:
| Audience | Sessions | Variables |
|---|---|---|
| owner | no chat (dashboard, MCP, heartbeat, hook, workflow, pipeline); an owner’s DM; a Business chat with another owner; a group whose rule is members: owners and whose bot declares neither policy.chat nor policy.sender | every variable = owner |
| person | a DM or a Business chat with one person who is not an owner | {person} = {chat} = tg-<their id> |
| room | any other group | {chat} = tgc-<digits>; {person} = the speaker, or none |
An owner speaking in a room gets the room’s reach: the reply is read by the
room. members: owners limits who may speak, not who reads — a channel’s
discussion group is read by its subscribers — so use it for an owner audience
only where only owners read the chat (omnislash doctor → Chat access warns
when such a group’s role reaches your lines).
Own reach and foreign reach
In an owner audience a role reaches everything its block names, as always. In a person or room audience it keeps only its own reach:
- its
writeslots, resolved for that audience; - its
reporttargets — writes only, they never keep a read open; - every templated
read/recallentry, resolved for that audience (in a room, a template with{person}and no{chat}is dropped: the speaker’s DM-family line would ride a session the whole room reads); - authored corpora inside the role’s own feature directory
(
features/personas/jesus/voice).
Everything else it names — another literal line (main), '*', an authored
corpus outside its feature (content, people/maria) — is foreign: yours,
reached only by an owners’ session. In its place the chat rule’s own grants
apply: memory: { read, recall } on a rule in constitution/access.yaml, on
the DM, the group, or one person’s own rule
(bots.<bot>.people."tg:user:<id>", chat-access.md § Memory
in shared chats). A grant names literal lines and declared sources; adding one is
a widening change, which an owner approves.
And memory is on or off per session: in a person or room audience, it is on
only when the rule’s ceiling in access.yaml holds memory:read (or '*') —
for every turn of that session, an owner’s included. Off means no injection, no
memory tools, no flush.
The binding
An agent’s audience comes from its binding: the chat it serves, or, for an
agent created from a chat turn (an agent_task / agent_create child, the
flush curator), the chat it was created from. It is re-read from access.yaml
on every turn, so a grant you revoke is gone on the next turn — a scheduled
prompt, a HITL resume and a Retry included. Agents bound to different chats
cannot reach each other (agent_call, agent_send, agent_history, … are
refused across them), and a turn bound to a chat other people see cannot start
a run (scenario_start, an eval): the run would work with the whole brain’s
memory and report back into that chat.
The memory wall
The memory tools apply the reach by themselves, but a CLI turn can also read
files with the harness’s own tools. On every turn of a person or room session
the engine walls them: the Claude harness may not read anything under memory/
— not even the turn’s own lines, which it reads and writes through the memory
tools; walling the whole directory is what keeps a line first written while the
turn runs, and the search index (.memory-index.db, which holds every line’s
text), out of reach — nor a declared source the turn does not reach;
Codex and OpenCode steps, which have no per-path read wall, are dropped from the
model chain (claude,codex runs as claude; a chain with no Claude step fails
the turn naming the role); the shell (engineering:execute) is taken away, and
an API turn also loses file:read / file:write. The wall covers memory only:
other brain files stay as readable as before.
Three worked configs
A persona bot shared with friends.
# features/personas/jesus/jesus.agent.md
memory:
write: jesus-{person}
recall: [main, features/personas/jesus/voice]
# constitution/access.yaml
identities: # `members: listed` admits these
"tg:user:42": { tier: member, note: Anna }
"tg:user:77": { tier: member, note: Boris }
bots:
jesus:
dm: { role: jesus, members: listed, ceiling: [memory:read, memory:write] }
people:
"tg:user:42": { memory: { read: [main] } } # Anna may also read your diary
A people entry admits nobody by itself: who may talk is identities plus the
rule’s members.
| Session | Writes | Reaches |
|---|---|---|
| your DM | jesus-owner | jesus-owner, main, the voice corpus |
| Anna’s DM | jesus-tg-42 | jesus-tg-42, the voice corpus; main (her grant) by search |
| Boris’s DM | jesus-tg-77 | jesus-tg-77, the voice corpus — never main |
A group bot with a line per member.
# features/club/club.agent.md
memory:
write: [club-{chat}, club-{chat}-{person}]
recall: [main]
# constitution/access.yaml — bots.club.chats
"tg:chat:-100555": { role: club, members: everyone, ceiling: [memory:read, memory:write], memory: { recall: [club-handbook] } }
When Anna speaks, the turn writes club-tgc-100555 and club-tgc-100555-tg-42,
recalls both plus club-handbook, and never main — not even when you speak
there. The dashboard composer’s turn has no speaker and keeps the room slot.
A secretary bot with a role per counterpart.
# constitution/access.yaml
bots:
my-secretary:
dm: { ceiling: [memory:read, memory:write, messaging:read] }
people:
"tg:user:111111111": { role: secretary-maria, memory: { read: [people/maria] } }
secretary-maria declares write: maria and no foreign reach of its own: the
person’s folder is a grant on her rule, so no other counterpart’s chat can reach
it. A counterpart without an entry runs the bot’s own role, resolved for them
(write: secretary-{person} gives each one a line of their own).
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. The key
may be a template (jesus-{person}), which sets the budget of every line of that
family; a line’s exact key wins over a template that matches it, and two template
keys that could match the same line fail the load:
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. A key may be a
template (jesus-{person}: { periods: [week] }): each line of the family on disk
is consolidated, unless it has an exact key of its own. The consolidator reads
the line it summarizes and nothing else (role-consolidator.md declares
read: [], recall: []), so a person’s ladder is never written from your
corpora.
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 lines, 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.
A turn with several write slots flushes into all of them: the curator is told
which slot is the person’s (the one whose name uses {person}) and files facts
about the person there and everything else in the first slot. The curator runs
in the flushed session’s audience, so in a chat other people see it reaches
what that chat may reach, never your other lines.
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 |
An agent with several write slots gets one <agent-memory>, one
<agent-memory-periods> and one <agent-memory-recent> per slot, in slot order,
each labelled with its line and each under the role’s own budget (priorityChars,
periodChars, recentChars apply per slot); recall stays one search over every
slot plus what recall opens, under one recallChars.
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(s) agents curate unless their own file says otherwise — one name, or a list of slots
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
lines, not its own: the post-run flush curator files what your researcher agent
learned under the researcher’s lines, and a throwaway
agent_tasksub-agent curates the lines of the agent that spawned it — when the sub-agent’s own role declares memory; lines handed down never turn memory on. It READS those lines too — theirMEMORY.md, daily window and recall all come from the borrowed lines, 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 lines, resolved, with the dispatch; they are not something a model can ask for. A sub-agent spawned from a chat other people see stays in that chat’s audience for its whole life, and so does the flush of a turn there. - 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.