Found on the device (2026-09-25): the ledger raised a session's state by the grant's label, so a clean session that recalled a memory-tainted note stayed trusted and could add it to memory.md. The spec's "as today" was wrong; section 8 now says the state is raised by the stricter of the grant's label and the result's, and the recall section says the session becomes untrusted. The note answers (section 3) name notes.md and, for an untrusted add, the file that was not changed: the model read the old ones as success. Tasks 21 and 22. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
499 lines
30 KiB
Markdown
499 lines
30 KiB
Markdown
# M5a design: local memory
|
|
|
|
Status: approved by the owner, 2026-09-24, with P21 applied to the brief: memory writes need no
|
|
approval. It rests on the brief as changed by P17 to P20 (applied 2026-09-24,
|
|
`docs/decisions.md`) and P21: memory is three files read into the baseline, edited by the agent
|
|
through a `memory` tool without questions, as Hermes does; a new role, `memoryd`, owns the files and a full-text index; and
|
|
M5 is split into M5a (this), M5b (Honcho) and M5c (the scheduler). Owner's decisions of
|
|
2026-09-24, beyond those: `soul.md` starts from a generic file shipped in the repository, and
|
|
`memory.md` and `user.md` start empty; and compaction (new epochs) comes with the scheduler in
|
|
M5c. Until then every session has one epoch, so this spec says "the next session" where the
|
|
brief says "the next epoch". Over Mattermost every thread is a session, so a new thread is a new
|
|
session.
|
|
|
|
Facts this rests on, checked on 2026-09-24:
|
|
|
|
- `rusqlite` 0.40.2 (MIT, 2026-08-08; crates.io) with the `bundled` feature compiles SQLite from
|
|
source, and its build script always defines `SQLITE_ENABLE_FTS5` in that mode (the
|
|
`libsqlite3-sys` `build.rs`). No system SQLite is needed.
|
|
- `loopd` has no epochs yet: `Session::create` writes `0.baseline.json` and `0.jsonl`, and a full
|
|
context ends in `TurnError::SessionFull`. The baseline is saved once per session and replayed
|
|
from that file, so whatever goes into it is frozen for the session's life.
|
|
- Every tool result in a session log records its `class` and `untrusted` label
|
|
(`LogRecord::ToolResult`), so a session's taint at any record can be worked out from the log.
|
|
|
|
## 1. What M5a proves
|
|
|
|
1. A fact from one session is recalled in another.
|
|
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. 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;
|
|
so does the baseline with every memory file at its limit. (Measured while building the
|
|
reference, 2026-09-24: 881 and 2,432 tokens with Ornith's tokenizer.)
|
|
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.
|
|
|
|
## 2. Components
|
|
|
|
| Piece | Change |
|
|
|---|---|
|
|
| `memoryd` (new crate) | Owns `memory/` and `index/`. Serves the memory block to `loopd`, runs `recall` and `memory` calls for `brokerd`, indexes session logs, rebuilds the index for `bxctl`. No network. |
|
|
| `proto` | Eight new message kinds (section 7); the `memory` tool's argument structs. |
|
|
| `loopd` | The baseline takes the memory block from `memoryd` instead of reading `memory/core.md`. `recall` and `memory` are core tools. After each finished turn it tells `memoryd` the session has new records. |
|
|
| `brokerd` | `recall` and `memory` are decided under built-in grants (both `auto`); a decided call goes to `memoryd` with the session's state, instead of a container. A result's label can only be made stricter by the runtime. |
|
|
| `bxctl` | `bxctl reindex`; `bxctl memory log`, `undo`, and, when review is on, `pending`, `accept`, `reject`. |
|
|
| `config/soul.md` (new) | The generic starter persona. |
|
|
|
|
`memoryd` depends only on `proto` (and `rusqlite`), like every role.
|
|
|
|
## 3. The files
|
|
|
|
All under `{home}/memory/`, directory 0700, files 0600, owned by `memoryd`. The owner may edit
|
|
any of them with an editor; `soul.md` only ever that way.
|
|
|
|
| File | Written by | Limit | Starts as |
|
|
|---|---|---|---|
|
|
| `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 |
|
|
| `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 |
|
|
|
|
At startup `memoryd` creates what is missing and never overwrites anything. Characters are
|
|
counted with `chars()`. Limits are on the file's text.
|
|
|
|
**Entries.** `memory.md` and `user.md` are lists of entries separated by a line holding only `§`
|
|
(as Hermes does). An entry is trimmed, is not empty, and may span lines, but may not contain a
|
|
line that starts with `§`, leading spaces aside. A `§` inside a line is fine. The same rule holds
|
|
for a note's text: in `notes.md` such a line would read as a header, and a header says where an
|
|
entry came from (review of 2026-09-24: a note from an untrusted session could otherwise plant a
|
|
`moved` header, and `recall` would show what follows it as trusted identity). Two identical
|
|
entries in one file are refused.
|
|
|
|
**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… · private · 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>
|
|
```
|
|
|
|
A header is exactly one of these three forms; a line that differs in any part (an unknown word, a
|
|
fourth part, a last part other than `untrusted`) is not a header but text of the entry above.
|
|
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 `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.
|
|
|
|
## 4. The memory block
|
|
|
|
At the start of each session `loopd` asks `memoryd` for the block and appends it to the system
|
|
prompt, after `system.md` and a blank line. `memoryd` renders it:
|
|
|
|
```text
|
|
# soul.md
|
|
<soul.md, trimmed>
|
|
|
|
# memory.md (1,474 of 2,200 characters)
|
|
<entries, separated by a line holding only §>
|
|
|
|
# user.md (0 of 1,375 characters)
|
|
(empty)
|
|
```
|
|
|
|
The block goes into the session's saved baseline, so it is frozen for the session: a restarted
|
|
`loopd` replays the saved bytes whatever the files say by then (inference contract 2, "strict
|
|
extension"). Each entry of `memory.md` and `user.md` is checked again as the block is built
|
|
(section 5, "The check"); an entry that fails is left out of the block, and `memoryd` logs its
|
|
file and the reason. `soul.md` is the owner's and is not checked. `memoryd` refuses to serve the
|
|
block when a file is over its limit (the owner edited it) or unreadable; `loopd` then refuses to start the session, with the error and a pointer to the
|
|
runbook entry `memory-unavailable`. Existing sessions are unaffected. A starting session with no
|
|
memory, and no sign of it, is what this prevents; the M2b rule for `core.md` stays, moved.
|
|
|
|
`loopd` keeps no memory code: `memory/core.md` and the `core-memory-unreadable` entry go.
|
|
|
|
## 5. The tools
|
|
|
|
Both are core tools: declared in the `tools` array of every session, so the model never needs
|
|
`find_tool` for them. Their schemas count toward the baseline.
|
|
|
|
### `recall`
|
|
|
|
Arguments: `{"query": string, "k": integer}`; `query` 1 to 500 characters, `k` 1 to 10, default 5.
|
|
|
|
`memoryd` searches the index (section 6) and answers with at most `k` hits, best first, each
|
|
snippet at most 600 characters. Each hit starts with a **source tag** (owner, 2026-09-24), one of
|
|
three fixed words, so the model can weigh a hit and not only read it:
|
|
|
|
| Tag | Hits |
|
|
|---|---|
|
|
| `identity` | `notes.md` entries marked `moved`: identity that `consolidate` took out of the prompt, still held true |
|
|
| `memory` | notes and session records from sessions that had not read untrusted content, and `dropped` entries (the agent stopped holding them true, so they do not come back as identity) |
|
|
| `memory-tainted` | notes and session records from sessions that had read untrusted content |
|
|
|
|
M5b uses the same three words for Honcho: `memory` from a trusted workspace, `memory-tainted`
|
|
from an untrusted one. After the tag come where the hit is from and its class:
|
|
|
|
```text
|
|
[1] identity · moved 2026-09-24 from memory.md · private
|
|
Owner prefers answers in UTC.
|
|
[2] memory · session mm-8ass9m…, 2026-09-20 14:02 UTC, the owner said · private
|
|
…the launch window moved to Thursday…
|
|
[3] memory-tainted · session chat-1789…, 2026-09-21 09:15 UTC, the assistant said · private
|
|
…
|
|
[4] memory-tainted · note, 2026-09-22 18:20 UTC, session mm-dh45qg… · private
|
|
example.org says the release moved to March.
|
|
[5] memory · session mm-dh45qg…, 2026-09-22 18:17 UTC, the assistant called · private
|
|
http_fetch {"url":"https://example.org/notes"}
|
|
```
|
|
|
|
`(no matches for "…")` when nothing is found. The query goes into FTS5 only as quoted terms: it
|
|
is split on anything that is not a letter or digit, each term is wrapped in double quotes, and the
|
|
terms are joined with `OR`, so no FTS5 operator from the model ever reaches SQLite. Ranking is
|
|
FTS5's `bm25`.
|
|
|
|
**The label.** The result's class is the highest class among the hits (at least `private`), and
|
|
it is untrusted if any hit is tagged `memory-tainted`. `brokerd` combines that with the grant's label and keeps the
|
|
stricter of each (section 8), and the session's state follows the combined label: a session that
|
|
recalls a `memory-tainted` hit becomes untrusted, so its identity writes become notes from then on.
|
|
|
|
### `memory`
|
|
|
|
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>"},
|
|
{"old": "<an entry>", "move": true},
|
|
{"old": "<an entry>", "drop": true},
|
|
{"text": "<a new entry>"}
|
|
]}
|
|
```
|
|
|
|
No write asks the owner anything (P21). Entries are named by their **full text**, never by number,
|
|
so the journal reads on its own, and an edit made against an old view of
|
|
the file fails safely: every `old` must match an entry of the file as it is now, exactly.
|
|
|
|
**Where a write goes.** `brokerd` sends each call with the session's state (section 7):
|
|
|
|
- `note` is appended to `notes.md` in any session, labelled by the session (above). The answer:
|
|
`saved in notes.md`, or in a session that has read untrusted content `saved in notes.md, marked
|
|
untrusted: this session has read untrusted content`.
|
|
- 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, naming
|
|
the file it did not change: `not added to memory.md: this session has read untrusted content, so
|
|
it was saved in notes.md as an untrusted note` (or `user.md`). On the device (2026-09-25) the
|
|
model read the earlier, shorter answers as success and told the owner the file was changed.
|
|
The note 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 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 `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 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,
|
|
if it contains:
|
|
|
|
1. a control character other than newline and tab (C0, DEL, C1);
|
|
2. an invisible or direction-changing character: any code point with the Unicode property
|
|
`Default_Ignorable_Code_Point` (zero-width spaces and joiners, bidirectional controls,
|
|
variation selectors, tag characters U+E0000 to U+E007F, and the like), and U+034F, U+115F,
|
|
U+3164, U+2800, U+2028 and U+2029, which are invisible without being in that property or in
|
|
category `Cf` (`docs/research/2026-09-24-prompt-injection-memory.md`, section 2). The table
|
|
is fixed in the code, from Unicode 16 data;
|
|
3. chat-template markup: a less-than sign followed by a vertical bar, or a vertical bar followed
|
|
by a greater-than sign; or the opening or closing tags of `think` and `tool_call` (written out
|
|
only in `check.rs`, and there built from pieces: a model reading the literal tags could take them
|
|
for its own);
|
|
4. one of these phrases, ignoring case and runs of spaces: `ignore previous instructions`,
|
|
`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. 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 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.
|
|
|
|
**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 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).
|
|
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 `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 `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. `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
|
|
`memoryd` reads the records past that count. At startup it does the same for every log under
|
|
`sessions/`, so a turn missed while it was down is caught up. `memoryd` only ever reads
|
|
`sessions/`. A log it cannot parse is skipped with a log line naming it; it is `loopd`'s file, and
|
|
`loopd` reports damage itself.
|
|
|
|
Schema (one FTS5 table with the text, and a plain table for its metadata):
|
|
|
|
```sql
|
|
CREATE VIRTUAL TABLE hits USING fts5(text, tokenize = 'unicode61');
|
|
CREATE TABLE sources (rowid INTEGER PRIMARY KEY, kind TEXT, session TEXT, epoch INTEGER,
|
|
record INTEGER, time TEXT, role TEXT, class TEXT, untrusted INTEGER);
|
|
CREATE TABLE progress (log TEXT PRIMARY KEY, records INTEGER);
|
|
```
|
|
|
|
## 7. Sockets and messages
|
|
|
|
| Socket | Default path | Peer | Messages |
|
|
|---|---|---|---|
|
|
| `loopd` | `{home}/run/loop-memory/memory.sock` | `loopd` | `memory_block` → `memory_block_reply`; `session_updated` → `ok` |
|
|
| `brokerd` | `{home}/run/broker-memory/memory.sock` | `brokerd` | `memory_call` → `tool_response` (the existing kind) |
|
|
| owner | `{home}/run/owner-memory/admin.sock` | `bxctl` | `reindex` → `reindexed`; `memory_admin` → `memory_admin_reply` |
|
|
|
|
Directories 0700, sockets 0600, stale sockets removed, as `brokerd`'s. One request per
|
|
connection, the existing frames (`proto::frame`, `MAX_FRAME`). New message kinds in `proto`:
|
|
|
|
| Kind | Body |
|
|
|---|---|
|
|
| `memory_block` | `{}` |
|
|
| `memory_block_reply` | `{"text": string}` |
|
|
| `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, "notes": u64}` |
|
|
| `memory_admin` | `{"command": "log" \| "undo" \| "pending" \| "accept" \| "reject", "arg": string}` |
|
|
| `memory_admin_reply` | `{"text": string}` |
|
|
|
|
`brokerd`'s list of kinds grows from eighteen to twenty-six.
|
|
|
|
## 8. `brokerd`
|
|
|
|
- **Tools.** `ToolName` gains `Recall` and `Memory`; `ToolArgs` gains their parsed arguments
|
|
(`proto` holds the serde structs, as for the other tools). Parsing enforces the shapes and
|
|
limits in section 5. Neither tool has constraints (`paths`, `hosts`, `patterns` must be empty).
|
|
- **Grants: built in** (owner, 2026-09-24: reading and writing memory is what an agent is).
|
|
`brokerd` holds two fixed grants, always present whatever is in `grants/`:
|
|
|
|
```toml
|
|
# built in: recall
|
|
tool = "recall"
|
|
mode = "auto"
|
|
max_taint = "secret"
|
|
result_class = "private"
|
|
untrusted = false
|
|
```
|
|
|
|
```toml
|
|
# built in: memory
|
|
tool = "memory"
|
|
mode = "auto"
|
|
max_taint = "secret"
|
|
result_class = "private"
|
|
untrusted = false
|
|
```
|
|
|
|
Their names are `builtin-recall` and `builtin-memory`, and their `grant_sha256` is the hash of
|
|
that text, so the audit log names them like any other grant. A file in `grants/` whose `tool`
|
|
is `recall` or `memory` is a grant problem at load (`grants-invalid`), with the fixed sentence
|
|
`the policy of recall and memory is built in; remove this grant`. No grant file can turn memory
|
|
off or put questions in front of it. What protects the prompt is where untrusted sessions'
|
|
writes go (section 5), which `memoryd` decides from the state `brokerd` sends. The built-ins
|
|
are kept apart from the owner's grants (`GrantSet::grants` lists only the owner's; matching sees
|
|
both), and grant ids starting `builtin-` are reserved. An invalid `grants/` still denies every
|
|
call, the memory tools included: that is the fail-closed state `grants-invalid` describes.
|
|
- **Where a decided call runs.** A composite runtime sends `Recall` and `Memory` specs to
|
|
`memoryd`'s `brokerd` socket as a `memory_call`, with the session's taint and untrusted flag
|
|
as `brokerd` holds them when it decides, and everything else to the configured runtime
|
|
(Podman, or the refusing one). The socket is `{home}/run/broker-memory/memory.sock`, or
|
|
`[memory] socket` in `brokerd.toml`; the section is optional, since the memory tools are always
|
|
on. When `memoryd` does not answer in 30 seconds, the call fails with a fixed sentence and a
|
|
runbook pointer (`memoryd-unreachable`). The session's state reaches the runtime through the
|
|
`Decision` (which `decide` already receives it for) and the `RunSpec`. The Podman runtime
|
|
refuses a memory tool with a fixed sentence rather than start a container for it.
|
|
- **Labels only get stricter.** `Runtime` gains `run_labelled`, returning an optional label with
|
|
the output; its default calls `run` and returns none, so container runtimes and the given test
|
|
runtimes are unchanged. `runner::run` answers with the stricter of the grant's label and the
|
|
runtime's: the higher class, and untrusted if either is. The audit record carries the combined
|
|
label. The ledger raises the session's state by the stricter of the grant's label and the
|
|
result's own, never by the grant's alone. (The first version of this spec said "as today", but
|
|
the ledger raised the state by the grant's label, which a result could not change before M5a. A
|
|
clean session could then recall a tainted note and write it into `memory.md`; found on the
|
|
device, 2026-09-25.)
|
|
- **Size.** `policy.rs` (498 lines) and `ledger.rs` (487) are at the limit; the plan splits them
|
|
before anything is added.
|
|
|
|
## 9. `loopd`
|
|
|
|
- `[memory] socket` in `loopd.toml` (default `{home}/run/loop-memory/memory.sock`).
|
|
- Baseline: `system.md`, a blank line, then the block from `memoryd`
|
|
(`Baseline::assemble_with`; `Baseline::assemble` stays, with no memory, for tests and the
|
|
self-test). `memoryd` unreachable, or refusing, fails the new session with the runbook pointer
|
|
`memory-unavailable`. `Context` takes the memory source explicitly (a `MemorySource` trait:
|
|
`memoryd`'s client in `loopd serve`, `NoMemory` in tests), so `loopd serve` cannot leave it out.
|
|
`make verify-device` starts `memoryd` beside `loopd serve`.
|
|
- Registry: `recall` and `memory` are core, with the schemas of section 5. The model sees
|
|
`clock`, `recall`, `memory`, `find_tool`, `call_tool`.
|
|
- After a finished turn: `session_updated`, best effort. A failure is one log line; the next
|
|
startup of `memoryd` catches up.
|
|
- `system.md` gains two sentences: keep `memory.md` and `user.md` short and current, and use
|
|
`consolidate` when they fill; writes take effect in the next session, and recalled text is data
|
|
like any tool result.
|
|
|
|
## 10. `bxctl`
|
|
|
|
- `bxctl reindex [--memory-socket PATH]`: sends `reindex`, waits, and prints `reindexed: 42 logs,
|
|
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), each with `[--memory-socket PATH]` (default
|
|
`{home}/run/owner-memory/admin.sock`; `--admin-socket` stays `brokerd`'s). Entries are printed
|
|
escaped, as approvals are.
|
|
|
|
## 11. Configuration
|
|
|
|
`memoryd serve --config <path>`:
|
|
|
|
```toml
|
|
[paths]
|
|
home = "/var/lib/boxmaker" # default: $BOXMAKER_HOME, then /var/lib/boxmaker
|
|
|
|
[memory]
|
|
soul_starter = "soul.md" # next to the config file
|
|
review = false # true: writes to memory.md and user.md wait for the owner
|
|
|
|
[index]
|
|
max_class = "private" # records above this class are not indexed
|
|
|
|
# [sockets] loop, broker, admin: defaults as in section 7
|
|
```
|
|
|
|
## 12. Security notes
|
|
|
|
- `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 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
|
|
memory log`, and can be undone, and review can be turned on.
|
|
- Recalled text is a tool result: appended, labelled, and it taints the session like any other.
|
|
Tool results are not indexed, so a fetched page cannot come back through `recall`; what the
|
|
assistant said after reading one can, marked untrusted.
|
|
- The FTS5 query is built only from quoted terms; the model's text is never SQL.
|
|
- Entries shown by `bxctl` are escaped (`proto::escape`), so an entry
|
|
cannot render as markup or mention anyone.
|
|
|
|
## 13. Runbook
|
|
|
|
New entries: `memoryd-start-failed` (configuration, `memory/` or `index/` unusable),
|
|
`memory-unavailable` (a new session refused: `memoryd` down, a file unreadable or over its limit),
|
|
`memoryd-unreachable` (`brokerd` cannot reach it: memory calls fail), `memory-index-damaged`
|
|
(rebuild with `bxctl reindex`). `core-memory-unreadable` is removed.
|
|
|
|
## 14. Testing
|
|
|
|
Offline, in `make gate`:
|
|
|
|
- `memoryd`: entries parsing and rendering; the source tag of every kind of hit (moved, dropped,
|
|
noted clean and tainted, session records before and after the session read untrusted content); 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` 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.
|
|
- `brokerd`: parsing both tools; the built-in grants present with an empty `grants/`; a grant
|
|
file naming either tool refused at load; the session's state carried in `memory_call`; the composite runtime; the
|
|
stricter label, with a runtime that returns a stricter and a laxer one; the session's state
|
|
raised by a result stricter than its grant, and by the grant when a result is laxer.
|
|
- `loopd`: the baseline with a fake `memoryd`; the refusal and its pointer; `session_updated`
|
|
sent after a finished turn and not after a turn ending in tool calls.
|
|
- `bxctl`: `reindex` and each `memory` command against a fake socket.
|
|
|
|
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 becomes an untrusted note,
|
|
untrusted; `bxctl memory undo` of the latest edit.
|
|
|
|
## 15. For the owner's review
|
|
|
|
1. Decided: tool results are not indexed; tool calls are, by name and arguments (section 6).
|
|
Summaries of a tool's output, where the tool opts in (a web fetch, say), come with M5c's
|
|
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 `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
|
|
(section 5). A research thread on prompt-injection defences is running; its findings become a
|
|
sanitizer module for memory writes, and possibly other persisted text, as hardening in M5b.
|
|
6. Decided: writes are not announced; `bxctl memory log` lists them (section 5).
|