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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user