P21 and the M5a spec: identity and experience; the archive becomes notes, and note is a memory action

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 13:49:28 -07:00
co-authored by Claude Opus 5.5
parent 20bb2ff6fc
commit 64a5931354
2 changed files with 56 additions and 42 deletions
+54 -40
View File
@@ -28,8 +28,8 @@ Facts this rests on, checked on 2026-09-24:
2. An entry moved out of `memory.md` by `consolidate` is found by `recall`.
3. A memory write reaches the prompt only in the next session; a session already running keeps
the bytes it started with. No write asks the owner anything.
4. A write from a session that has seen untrusted content lands in the archive, labelled
untrusted, and never in the prompt.
4. What a session that has seen untrusted content wants to remember becomes a note, labelled
untrusted: remembered and found by `recall`, never in the prompt.
5. The baseline with the starter `soul.md` stays at 3,000 tokens or less, measured on the device.
6. A hit from a session that had seen untrusted content comes back marked untrusted.
7. The owner can list every write (`bxctl memory log`), and undo the latest one of a file.
@@ -57,7 +57,7 @@ any of them with an editor; `soul.md` only ever that way.
| `soul.md` | the owner | 4,000 characters | a copy of the starter (`[memory] soul_starter`, default `soul.md` next to the config file) |
| `memory.md` | `memoryd`, applying the agent's writes | 2,200 characters | empty |
| `user.md` | `memoryd`, applying the agent's writes | 1,375 characters | empty |
| `archive.md` | `memoryd`, appending moved and dropped entries, and writes from untrusted sessions | none | empty |
| `notes.md` | `memoryd`, appending notes (things seen) and entries moved or dropped from the files above | none | empty |
| `edits/YYYY-MM.jsonl` | `memoryd`, appending one line per applied edit | none | absent |
| `pending.jsonl` | `memoryd`, when review is on (section 5) | none | absent |
| `recalls/YYYY-MM.jsonl` | `memoryd`, appending one line per `recall` | none | absent |
@@ -69,23 +69,31 @@ counted with `chars()`. Limits are on the file's text.
(as Hermes does). An entry is trimmed, is not empty, and may span lines, but may not contain a
line holding only `§`. Two identical entries in one file are refused.
**`archive.md`.** Append-only. Each entry starts with one header line saying how it got there:
**Two kinds of memory** (owner, 2026-09-24). **Identity** is who the agent is and what it holds
true about its work and its owner: `soul.md`, `memory.md`, `user.md`, in every prompt, written
only by the owner or by sessions that have not read untrusted content. **Experience** is what it
has seen or been told: `notes.md` and the session logs, never in the prompt, found with `recall`,
each item with its own label. Not everything remembered is believed, but it is remembered.
**`notes.md`.** Append-only. Each entry starts with one header line saying how it got there:
```text
§ noted 2026-09-24T21:02Z in session mm-dh45qg… · untrusted
<the entry>
§ moved 2026-09-24T20:14Z from memory.md in session mm-y73sxj3popgwumyccptb56p1ah
<the entry>
§ dropped 2026-09-24T20:14Z from memory.md in session mm-y73sxj3popgwumyccptb56p1ah
<the entry>
§ written 2026-09-24T21:02Z for user.md in session mm-dh45qg… · untrusted
<the entry>
```
Nothing a write removes is lost: moved and dropped entries are archived, and the journal keeps
A `noted` entry is labelled by the session that wrote it: untrusted if it had read untrusted
content, trusted otherwise; its class is the session's. `moved` and `dropped` entries were
identity, so they are trusted. Nothing a write removes is lost: moved and dropped entries go to `notes.md`, and the journal keeps
every before and after.
**The edit journal** (`edits/`) records every applied edit: its id (`YYYY-MM-DD-NNNN`), time,
session, call, action, file, where it went (`memory.md`, `user.md` or the archive), the entries
before and after, and whether the session had seen untrusted content. It is the history `archive.md` alone cannot give (when
session, call, action, file, where it went (`memory.md`, `user.md` or `notes.md`), the entries
before and after, and whether the session had seen untrusted content. It is the history `notes.md` alone cannot give (when
an entry first appeared), and the record an owner reads when a memory looks wrong. **The recall
log** (`recalls/`) records time, session, query, and the source of every hit, and the queries that
found nothing: hit counts for M5c's tiering job are worked out from it, so they survive a reindex.
@@ -132,11 +140,13 @@ its source and label, and each snippet at most 600 characters:
```text
[1] session mm-8ass9m…, 2026-09-20 14:02 UTC, the owner said · private
…the launch window moved to Thursday…
[2] archive.md, moved 2026-09-24 from memory.md · private
[2] you knew this: moved 2026-09-24 from memory.md · private
Owner prefers answers in UTC.
[3] session chat-1789…, 2026-09-21 09:15 UTC, the assistant said · private · untrusted
…
[4] session mm-dh45qg…, 2026-09-22 18:17 UTC, the assistant called · private
[4] you noted this, 2026-09-22 18:20 UTC, in a session that had read untrusted content · private · untrusted
example.org says the release moved to March.
[5] session mm-dh45qg…, 2026-09-22 18:17 UTC, the assistant called · private
http_fetch {"url":"https://example.org/notes"}
```
@@ -151,12 +161,14 @@ stricter of each (section 8).
### `memory`
Arguments, one of (`file` is `"memory"` or `"user"`):
Arguments, one of (`file` is `"memory"` or `"user"`). `note` records something seen or told
(experience); the others edit identity:
```json
{"action": "add", "file": "memory", "text": "…"}
{"action": "replace", "file": "memory", "old": "<an entry, exactly>", "text": "…"}
{"action": "remove", "file": "user", "old": "<an entry, exactly>"}
{"action": "note", "text": "…"}
{"action": "consolidate", "file": "memory", "entries": [
{"old": "<an entry>", "keep": true},
{"old": "<an entry>", "text": "<its rewrite>"},
@@ -172,25 +184,27 @@ the file fails safely: every `old` must match an entry of the file as it is now,
**Where a write goes.** `brokerd` sends each call with the session's state (section 7):
- In a session that has **not** seen untrusted content, the write applies to `memory.md` or
`user.md`.
- In a session that **has**, only `add` is possible, and the entry is appended to `archive.md`
with the header `§ written … · untrusted`, never to the file named. `recall` finds it, labelled
untrusted; it never reaches the prompt on its own. The answer says so: `saved to the archive,
not memory.md: this session has read untrusted content`. `replace`, `remove` and `consolidate`
are refused with `this session has read untrusted content; it can add to the archive but not
change memory.md`. The owner can move an archived entry into a file by editing it.
- `note` is appended to `notes.md` in any session, labelled by the session (above). The answer:
`noted (untrusted)` or `noted`.
- In a session that has **not** seen untrusted content, the identity actions apply to `memory.md`
or `user.md`.
- In a session that **has**, an identity `add` becomes a `note`, and the answer says so: `saved as
a note (untrusted): this session has read untrusted content`. It is remembered and `recall`
finds it; it never reaches the prompt on its own. `replace`, `remove` and `consolidate` are
refused with `this session has read untrusted content; it can take notes but not change
memory.md`. The owner can move a note into a file by editing it.
**Rules**, checked by `brokerd` when parsing (shape) and by `memoryd` when applying (content):
- `add`: the new file must fit its limit; a duplicate is refused.
- `replace`, `remove`: `old` must match exactly one entry. A removed entry is archived as dropped.
- `replace`, `remove`: `old` must match exactly one entry. A removed entry goes to `notes.md` as dropped.
- `note`: 1 to 1,000 characters; the check applies.
- `consolidate`: every current entry of the file must appear exactly once as an `old`, each with
exactly one of `keep`, `text`, `move`, `drop`; entries with only `text` are new. The resulting
file must fit its limit. Moved and dropped entries are appended to `archive.md` in the order
file must fit its limit. Moved and dropped entries are appended to `notes.md` in the order
given, with their headers.
- An edit that would go over the limit is refused with: `memory.md would be 2,350 of 2,200
characters; use consolidate to move entries to the archive`. That sentence is fixed apart from
characters; use consolidate to move entries to notes`. That sentence is fixed apart from
the numbers and the file.
**The check.** Every new or rewritten entry is refused, with a fixed sentence naming which rule,
@@ -204,13 +218,13 @@ if it contains:
`ignore all previous`, `disregard previous instructions`, `disregard all previous`,
`new instructions:`, `system prompt:`.
The list is short and fixed on purpose: it is defence in depth, as Hermes' scan is. The archive
routing above is the real defence, because a pattern cannot tell a plausible planted fact from a
The list is short and fixed on purpose: it is defence in depth, as Hermes' scan is. Turning an untrusted
session's writes into notes is the real defence, because a pattern cannot tell a plausible planted fact from a
true one.
**Applying.** The file is written to a temporary file and renamed, then the journal line is
appended. The answer to the model: `memory.md updated (1,510 of 2,200 characters); it takes
effect in the next session`, or the archive sentence above.
effect in the next session`, or the note sentences above.
**Seeing writes.** Writes are not posted anywhere (owner, 2026-09-24): the journal is the
record, and `bxctl memory log` shows it.
@@ -218,34 +232,33 @@ record, and `bxctl memory log` shows it.
**Undo.** `bxctl memory log [N]` prints the last N edits (default 10) from the journal: id, time,
session, action, file. `bxctl memory undo <id>` reverts an edit if it is the **latest** edit of
its file and the file still holds exactly what that edit left: the file gets its entries back from
the journal, entries the edit archived stay in the archive (append-only), and the undo is itself
the journal, entries the edit moved to `notes.md` stay there (append-only), and the undo is itself
journalled. Anything else is refused with the reason, so an undo never overwrites a later change.
**Review (off by default).** With `[memory] review = true`, a write that would change `memory.md`
or `user.md` is appended to `pending.jsonl` instead, and the answer says it is waiting for the
owner. `bxctl memory pending` lists them; `bxctl memory accept <id>` applies one (with the same
checks, against the file as it is then); `bxctl memory reject <id>` discards it (journalled).
Writes to the archive are never staged.
Notes are never staged.
## 6. The index
`{home}/index/memory.sqlite3`, directory 0700, file 0600, owned by `memoryd`. Disposable: deleting
it and restarting `memoryd`, or `bxctl reindex`, rebuilds it from `sessions/` and `archive.md`.
it and restarting `memoryd`, or `bxctl reindex`, rebuilds it from `sessions/` and `notes.md`.
**What is indexed.** From session logs: the `content` of `User` records, of `Assistant` records
that have content, and each tool call the assistant made, as its tool name and arguments
(`read_file {"path":"/srv/notes/q3.txt"}`), so `recall` can answer "did we read that, and when".
Not tool results (the bulk of every log, and the way injected text arrives) and not
`reasoning_content`. A summary of a tool's output is M5c's (owner, 2026-09-24): it needs a model
to write it. From `archive.md`: every entry.
to write it. From `notes.md`: every entry.
`memory.md` and `user.md` are not indexed: they are already in every prompt.
**Labels.** Walking a session log in order, `memoryd` keeps the session's label so far: the
highest `class` of any `ToolResult` before this record (at least `private`), and whether any was
untrusted. Each indexed record gets the label in force when it was written. A record whose class
is above `[index] max_class` (default `private`) is not indexed at all. Archive entries are
labelled `private`, and untrusted when their header says so; moved and dropped entries came from
`memory.md` or `user.md`, which only clean sessions write, so they are trusted.
is above `[index] max_class` (default `private`) is not indexed at all. `notes.md` entries carry
the label their header gives (section 3).
**Keeping up.** The index stores, per session log, how many records it has read. After each
finished turn `loopd` sends `memoryd` `session_updated` with the session id (section 7), and
@@ -281,7 +294,7 @@ connection, the existing frames (`proto::frame`, `MAX_FRAME`). New message kinds
| `session_updated` | `{"session": SessionId}` |
| `memory_call` | `{"request": ToolRequest, "taint": DataClass, "untrusted": bool}`: the decided call, with the session's state when `brokerd` decided it |
| `reindex` | `{}` |
| `reindexed` | `{"logs": u64, "records": u64, "archive": u64}` |
| `reindexed` | `{"logs": u64, "records": u64, "notes": u64}` |
| `memory_admin` | `{"command": "log" \| "undo" \| "pending" \| "accept" \| "reject", "arg": string}` |
| `memory_admin_reply` | `{"text": string}` |
@@ -347,7 +360,7 @@ connection, the existing frames (`proto::frame`, `MAX_FRAME`). New message kinds
## 10. `bxctl`
- `bxctl reindex [--admin-socket PATH]`: sends `reindex`, waits, and prints `reindexed: 42 logs,
1,208 records, 7 archive entries`. `memoryd` rebuilds into a new file and renames it over the
1,208 records, 7 notes`. `memoryd` rebuilds into a new file and renames it over the
old one, so `recall` keeps working meanwhile.
- `bxctl memory log [N]`, `bxctl memory undo <id>`, and with review on, `bxctl memory pending`,
`accept <id>`, `reject <id>` (section 5). Entries are printed escaped, as approvals are.
@@ -375,8 +388,8 @@ max_class = "private" # records above this class are not indexed
- `memoryd` has no network in M5a and never writes `sessions/`, grants or the audit log.
- What reaches the prompt: `soul.md`, the owner's file; and `memory.md` and `user.md`, which only
sessions that have not read untrusted content write, through the check, frozen per session.
Everything a session that read untrusted content writes goes to the archive, labelled
untrusted, where it can be recalled but never lands in the prompt by itself.
Everything a session that read untrusted content wants remembered becomes a note, labelled
untrusted, which can be recalled but never lands in the prompt by itself.
- Memory writes are the one tool with no approval anywhere. The trade is deliberate (owner,
2026-09-24): the risk it leaves is a clean session writing something false, which is the
agent's judgement of what the owner said; every such write is journalled, listed by `bxctl
@@ -402,7 +415,8 @@ Offline, in `make gate`:
- `memoryd`: entries parsing and rendering; each edit's rules, including a stale `old`, a
duplicate, the limit sentence, and `consolidate` missing an entry or naming one twice; every
rule of the check, on write and on building the block; routing by the session's state (an
untrusted `add` to the archive, the other actions refused); the archive headers and journal
untrusted `add` becoming a note, the other identity actions refused; `note` in both kinds of
session); the `notes.md` headers and labels, and the journal
lines; undo of the latest edit, and its refusal when the file changed; review on and off; the block; the quoted-terms query builder against FTS5 operators
(`NEAR`, `*`, `^`, quotes, column filters); labels walked from a log; `max_class`; catching up
from `progress`; a reindex equal to the incremental index; the recall log.
@@ -416,7 +430,7 @@ Offline, in `make gate`:
On the device (the owner's Mattermost, as for M4b): the baseline token count with the starter
soul; a fact told in one thread and recalled in another; an `add`, made with no question and listed by
`bxctl memory log`, that appears in a new thread and not in the thread that wrote it; a `consolidate` that
moves an entry, which `recall` then finds; a write after a fetched page that lands in the archive,
moves an entry, which `recall` then finds; a write after a fetched page that becomes an untrusted note,
untrusted; `bxctl memory undo` of the latest edit.
## 15. For the owner's review
@@ -426,7 +440,7 @@ untrusted; `bxctl memory undo` of the latest edit.
summariser, labelled like the output they summarise.
2. Decided: memory writes need no approval (P21); the policy of `recall` and `memory` is built in
and no grant file can change it (section 8). A session that read untrusted content writes only
to the archive (section 5).
to `notes.md`, as notes (section 5). Identity and experience are separate (section 3).
3. Decided: entries are named by their full text, not by number (section 5).
4. Decided: the starter `soul.md` is generic (section 3).
5. Decided: M5a starts with the check as written, the short fixed phrase list and rules 1 to 3