Files
boxmaker/docs/specs/2026-09-27-m5c-honcho.md
T
kyleandClaude Opus 5.5 891f41374c docs: M5c spec: ask_memory at minimal only; Honcho hits must share a term with the query
From M5c-2 task 00's recordings on rift: a chat at reasoning low returned raw tool-call markup,
and a search with no match still returned the nearest messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:20:57 -07:00

371 lines
23 KiB
Markdown

# M5c design: Honcho
Status: approved by the owner, 2026-09-27, with section 15's answers. Designed with the owner the
same day (the decisions are marked "owner"). Supersedes P23 where they differ (section 14).
## 1. What M5c proves
The agent's experience outlives a session and is shared across the fleet, without anything a
session read from outside reaching memory the fleet treats as trusted. Finished turns go to
Honcho, whose own extraction replaces a flush job of ours; every trusted memory write is mirrored
there at once; `recall` searches the fleet as well as the local index; a new `ask_memory` tool asks
Honcho's dialectic.
The milestone is checked by:
- a turn from a session that read untrusted content never reaches a trusted workspace, and a turn
from a `secret` session reaches no workspace;
- a result from an untrusted workspace taints the session that reads it;
- `recall` still answers from the local index when Honcho is down, and says what it could not
reach;
- a memory write the agent is asked to keep ("remember that …") is in Honcho at once, and undoing
it removes it.
Honcho is not rebuilt from the logs (owner, 2026-09-27). It accumulates; what it holds wrongly is
corrected by writing a contrary fact, which Honcho's dreaming reconciles, or, for a conclusion
`memoryd` wrote itself, by deleting that conclusion.
## 2. Two halves
One spec, two plans, each run and device-checked on its own (owner):
1. **Write path (M5c-1)**: the `brokerd` session-state socket, the speaker on each turn, the
ledger, the sync worker, mirrored memory writes, configuration, `bxctl memory sync`, runbook.
2. **Read path (M5c-2)**: `recall` over the fleet, the `ask_memory` tool, trust labels on other
agents' workspaces.
Projects in memory stay out of M5c (open row in `docs/decisions.md`).
## 3. Workspaces and peers
**Workspaces** (owner, 2026-09-27). Each agent writes to three:
| Workspace | Holds | Read by | Trust | Class |
|---|---|---|---|---|
| its **trusted** one | turns from clean sessions; conclusions mirroring its trusted memory writes | the agent (through `ask_memory`); other agents, as the owner labels it | trusted | `private` |
| its **untrusted** one | turns from sessions that read untrusted content | the agent only (through `ask_memory`) | untrusted, fixed | `private` |
| the **fleet** one, shared by every agent | untrusted notes, as messages from each agent's own peer | every agent | untrusted, fixed in code | `private` |
A workspace's class is also its ceiling for writes: nothing above `private` is written to Honcho
(owner). A `public` turn is written as `private`, since `public` records where a result came
from, not a permission. A session that reached `secret` by a turn's end is never synced, to any
workspace (owner).
**Peers** (owner):
- the owner: one peer, the same name in every workspace of the fleet (`[honcho] owner`); observed;
- other people who speak in a channel the agent answers: a peer each, named from their
Mattermost username (a `.` becomes `_`: Honcho allows `[a-zA-Z0-9_-]`); observed;
- every peer in the fleet workspace is a member of the fleet, whether or not this agent's
configuration names it (owner);
- the agent: a peer under its own name (`[honcho] agent`), **not observed** (`observe_me = false`).
Its replies are written as messages and serve as context when Honcho reasons about the people
it talks to, but Honcho draws no conclusions about the agent from them: they are the agent's
own output and can restate what the session read. What the agent knows enters Honcho only as
conclusions `memoryd` writes (section 5).
A Honcho session's peers, and who observes whom, are fixed when it is created (`roncho`'s blocking
client cannot change them). A Boxmaker session maps to one Honcho session per workspace, named after
it (in the fleet workspace too, where it holds only the agent's peer and the session's untrusted
notes); when a speaker who is not yet in it speaks, `memoryd` starts the next Honcho session for it,
the same name with `-2`, `-3` and so on, holding every peer so far.
## 4. What goes where
**Turns.** A finished turn is two messages: the speaker's message (the turn's text as `loopd`
logged it) from the speaker's peer, and the agent's final reply from the agent's peer (P23).
Tool calls, tool results and reasoning are not written. A text over 25,000 characters (Honcho's
limit) is written as several messages of at most 25,000, in order.
| The turn's session at its end | Goes to |
|---|---|
| clean, class `public` or `private` | the trusted workspace |
| untrusted (read untrusted content), class `public` or `private` | the untrusted workspace |
| class `secret` | nowhere |
| no speaker, or incomplete speaker information (a burst joined from several people) | nowhere (owner) |
| the text holds a known-prefix secret (`sanitize::refuse`'s secret rules) | nowhere |
**Memory writes.** Every write is mirrored as a conclusion the moment it is journalled (owner),
so "remember this" reaches Honcho at once, through the memory tool and its checks, never by
spotting the phrase:
| Journalled edit | In Honcho |
|---|---|
| identity `add` (memory.md or user.md) | a conclusion in the trusted workspace |
| identity `replace` | the old entry's conclusion deleted, the new one created |
| an entry dropped from identity (`remove`, or dropped by `consolidate`) | its conclusion deleted |
| an entry moved out of identity by `consolidate` | nothing: its conclusion is already there |
| a trusted note | a conclusion in the trusted workspace |
| an untrusted note | a message in the fleet workspace, from the agent's peer |
| a flagged note (a sanitizer flag rule matched) | nothing: it stays local (owner) |
| `bxctl memory undo` of an edit whose conclusions were written | those conclusions deleted; any it had deleted, written again |
A conclusion's observer and observed are the same peer, which is Honcho's own picture of that
peer: a `user.md` entry is the owner's (it is about the owner); a `memory.md` entry or a note is
the agent's. Each goes into its Boxmaker session's memory session in Honcho, `<id>_memory`, which
holds the agent's peer and the owner's and is created on first use, whether or not any turn of the
session is synced (a write is made during a turn, before that turn's session exists; owner,
2026-09-28). An undo's conclusions carry the undone edit's session. Every conclusion has a session
(Honcho leaves session-less conclusions out of session-filtered queries, and its automatic
dreaming skips a peer whose newest conclusion has none). `memoryd` never deletes a Honcho session:
that would delete the conclusions stamped with it.
The session logs and `memory/` stay the source of truth for everything local; Honcho is not
rebuilt from them.
## 5. The write path
1. **A turn ends.** `loopd` already sends `SessionUpdated` to `memoryd`. It gains the turn's
number in the session (M5c-1 wire change, section 11).
2. **Decide.** `memoryd` asks `brokerd` for the session's state (section 7) and reads the turn's
records in `loopd`'s log. The turn is untrusted if either says so (`brokerd`'s `untrusted`, or
any `ToolResult` so far labelled untrusted); its class is `brokerd`'s. It decides by the table
of section 4 and appends the decision to the ledger **at once**, so a Honcho outage later
cannot change where the turn goes.
3. **If `brokerd` cannot answer**, nothing is decided. The turn is decided at the next
`SessionUpdated` of any session, or by the worker's retry, from the session's state then; a
late decision can only move a turn towards untrusted or unsynced (P23).
4. **Memory writes** are decided when journalled: the table of section 4, recorded in the same
ledger.
5. **Send.** One worker thread in `memoryd` sends what is decided and not yet sent, oldest first:
for a turn it creates the Honcho workspace, peers and session on first use (section 6), then
adds the two messages as one batch; for a memory write it creates or deletes the conclusion,
or, for an untrusted note, adds the message to the fleet workspace.
Each send is recorded in the ledger with Honcho's ids.
6. **Honcho down** (a connection error, a timeout, a 5xx): the worker waits and tries again,
doubling the wait from 5 s to 10 minutes. It logs the outage once, when it starts, and once
when it ends, not on every try (runbook `honcho-unreachable`).
7. **Honcho refuses** (a 4xx other than 409 on create, which means "exists"): the entry is marked
failed in the ledger, logged with the status and without the text (runbook
`honcho-send-refused`), and the worker moves on. `bxctl memory sync` lists failed entries.
**The ledger**, `memory/honcho.jsonl`, is append-only JSON lines, one per event: `decided` (the
item, and the workspace it goes to or `skip` and why), `sent` (the item and Honcho's ids), `failed`
(the item and the status). What is decided and not sent is the outbox; there is no separate queue.
The worker checks for an item's `sent` line before sending it, and on start `memoryd` reads the
ledger through, so an item is sent at least once and a second time only if a crash falls between
the send and its `sent` line, or the owner removes that line (Honcho cannot delete a message).
Items are a turn (`session`, `turn`) or an edit (its journal id).
**Sync starts when Honcho is turned on.** The ledger records when sync began, and a turn that
started before then is skipped, so turning on `[honcho]` never sends earlier conversations.
Everything written to Honcho is neutralised (`sanitize::neutralise`); a reply is the model's own
output and was logged as it came, so it is neutralised here, when it leaves.
**The guarantee.** Nothing reaches a trusted workspace unless `brokerd` and `loopd`'s labels both
say the session was clean, and `brokerd` says its class was at most `private`, when the turn
ended.
## 6. Honcho, through `roncho`
`memoryd` uses `roncho` 1.0.0 from crates.io with `default-features = false, features =
["tls"]`, which includes the blocking client, so an HTTPS URL works as well as plain HTTP (task 00). It adds one crate to the lock file; `cargo deny`
passes (checked 2026-09-27).
- **Every client is built with `.without_config_file()`.** `roncho` otherwise reads
`~/.config/roncho/roncho.toml` and takes an `api_key` from it when none is set.
- One client per workspace (a key is per workspace), shared by the worker and the read path.
- Timeouts: connect 5 s; read ("no bytes") 30 s for writes and searches, 180 s for chat (rift's
dialectic took up to 120 s, and a non-streamed chat sends nothing until it is done).
- `roncho`'s `Status` and `Decode` errors carry up to 4 KiB of the response, which can be memory
text: `memoryd` logs only the error's kind and status, never its `Display`.
- On creation, `memoryd` configures each workspace: the trusted and untrusted ones with reasoning,
summaries and dreaming on (Honcho's defaults); the fleet one with `reasoning`, `summary` and
`dream` all `enabled = false`, and every agent's peer in it unobserved, so no model reads the
fleet's notes to derive anything (Honcho enqueues summaries before it checks reasoning, and
schedules dreams by their own setting, so all three are set).
- Honcho ids allow `[a-zA-Z0-9_-]`, at most 512 characters. Boxmaker session ids fit; names from
configuration are checked at start.
## 7. `brokerd`'s session state
A new socket, `run/memory-broker/state.sock`, served by `brokerd`, connected to only by
`memoryd` (directory mode 0700, owned by the user both run as, as the other sockets). One message,
`SessionState { session }`, answered with `SessionStateReply { class, untrusted }`. It changes
nothing. A session `brokerd` has not seen answers `private`, trusted: it has made no tool call.
## 8. `recall`
One tool, as today, searching (owner):
| Source | Searched with | Tag on each hit | Class |
|---|---|---|---|
| the local index | FTS5, as today | as today (`identity`, `memory`, `memory-tainted`) | as recorded |
| each other agent's trusted workspace in `[[honcho.read]]` | message search, and a conclusion query for that agent's peer and the owner's | `memory` if the owner labelled it trusted, else `memory-tainted`; the agent's name | the workspace's `class` |
| the fleet workspace | message search over the whole workspace | `memory-tainted`; the writing peer's name | `private` |
The agent's own workspaces are not searched: the local index holds every turn and note of this
agent with exact labels, and would return the same text twice. Each source runs on its own thread
with its own limit (the local index as today; 5 s for each Honcho source); `recall` answers with
what arrived, and a source that did not answer is one line, `<source>: unavailable`. Hits are
ranked within their source and interleaved to `k`, the local index first. Honcho's search has no
relevance cutoff (a query with no match still returns the nearest messages, recorded 2026-09-28),
so a Honcho hit is kept only when it shares a search term with the query, as the local index's
full-text match requires. Text from Honcho is
neutralised on arrival and size-capped like every recall snippet. Any `memory-tainted` hit marks
the session untrusted, and every hit raises the session's class to its label, as local hits do.
Honcho's search endpoints find messages only, and the mirrored memory writes in another agent's
trusted workspace are conclusions: so for those `recall` also queries conclusions
(`POST /conclusions/query`, which needs the observer and the observed; for a conclusion `memoryd`
wrote, both are the same peer). The fleet workspace holds messages, so one search finds the notes
of every peer in it, including agents this one was never told about (owner).
**Queries from any session** (owner, 2026-09-28). `recall` searches Honcho, and `ask_memory` asks
it, from a session of any class, `secret` included: a query is not written to Honcho, which stores
neither searches nor dialectic questions, and on rift it reaches only the owner's own hosts (its
models run on dixie; its server sends no telemetry, `docs/egress.md`). This is an assumption about
rift's configuration, not a property of the code: if rift ever uses a hosted model or exports
telemetry, a `secret` session's queries must stop going to Honcho without the owner's approval
(the option set aside on 2026-09-28). A query or question that holds a known-prefix secret
(`sanitize::refuse`'s secret rules) is refused before anything is sent, in every session.
## 9. `ask_memory`
A new core tool, `ask_memory { question, workspaces? }`:
- `workspaces`: at most two names from `[honcho]`: `own` (default), `untrusted`, `fleet`, or
another agent's name from `[[honcho.read]]` (owner, 2026-09-28: two, so that both chats run at
once inside `brokerd`'s wait; the model asks again for more);
- Honcho's reasoning level is always `minimal` (one search, then the answer). A `low` chat,
recorded from rift on 2026-09-28, returned the dialectic model's raw tool-call markup as its
answer; the choice comes back when rift answers it;
- `question`: at most 2,000 characters.
One workspace chat (`POST /workspaces/{w}/chat`) per named workspace, both at once; workspace chat searches every conclusion and message of the workspace without the model
choosing a peer (it averaged 13 s on rift, over only three calls; peer chat, 32 s over 318). Each
has 150 s. The answer lists each workspace's reply, neutralised and capped, tagged like a `recall`
hit (`memory` for the agent's own trusted workspace and for others the owner labelled trusted,
`memory-tainted` otherwise), or `<workspace>: unavailable`. Labels taint the session as `recall`'s
do. `brokerd`'s wait for this tool is 170 s and `loopd`'s wait on `brokerd` for it 180 s (section
11).
Adding a tool changes the `tools` array, and the baseline is measured again against the
3,000-token budget. A session keeps the tools array it was created with (its baseline is saved at
its start and sent on every request), so sessions started before M5c-2 is deployed keep their
cache and never see `ask_memory`; sessions started after it have it.
## 10. Configuration and secrets
In `memoryd.toml`; without a `[honcho]` section, `memoryd` behaves exactly as after M5b:
```toml
[honcho]
url = "http://rift.scylla-hammerhead.ts.net:8000"
agent = "boxmaker0"
owner = "kyle"
trusted = "boxmaker0-trusted"
untrusted = "boxmaker0-untrusted"
fleet = "fleet"
[secrets.honcho_trusted] # optional, one per workspace: honcho_trusted, honcho_untrusted,
file = "…" # honcho_fleet, honcho_read_<agent>; sent only when configured
[[honcho.read]]
agent = "boxmaker1"
workspace = "boxmaker1-trusted"
trust = "untrusted" # the default; "trusted" must be written out
class = "private"
```
**API keys** (owner: the design must support them): each workspace this agent writes or reads has
its key, `honcho_trusted`, `honcho_untrusted`, `honcho_fleet` and `honcho_read_<agent>`, from
`bxcore::secrets` (M5b), and `memoryd` sends it as a bearer token on every call to that workspace
and never logs it. A workspace without a key configured is called without one; rift has auth off
today (2026-09-27), and turning it on needs only the keys. A refused key (401 or 403) stops the
sends to that workspace, is logged once with the workspace's name (runbook `honcho-key-refused`),
and is shown by `bxctl memory sync`; the others go on. An invalid `[honcho]` section stops
`memoryd` at start with the reason (runbook `honcho-config-invalid`). `docs/egress.md` gains rift:
`memoryd` → the configured URL, for writes, searches and chat.
## 11. Links between the roles
M5c's changes to the table of the M5b spec, section 10:
| Link | Serves | Connects | Path or address | Carries | When the server is down |
|---|---|---|---|---|---|
| Session state (new) | `brokerd` | `memoryd` | `run/memory-broker/state.sock` | `SessionState` → `SessionStateReply` | turns wait undecided (`broker-state-unavailable`) |
| Honcho (new) | Honcho | `memoryd` | `[honcho] url` | `roncho` calls | sends wait (`honcho-unreachable`); `recall` answers from the local index |
| Memory for turns | `memoryd` | `loopd` | as before | `SessionUpdated` gains `turn` | as before |
| Turns | `loopd` | `gatewayd`, `bxctl chat` | as before | `Turn` gains `speaker` | as before |
Wire changes (fixtures change): `Turn` gains `speaker: Option<String>`, which `gatewayd` sets to
the Mattermost username when every post of the burst is from one person, and `bxctl chat` sets
to the owner's name from its configuration; `loopd` logs it on the `User` record (a new optional
field: older logs read as `None`); `SessionUpdated` gains `turn: u64`. A turn whose speaker is
`None`, or not a valid peer name, is not synced (owner).
## 12. Runbook
New entries, given as fragments: `honcho-unreachable`, `honcho-send-refused`, `honcho-key-refused`,
`broker-state-unavailable`, `honcho-config-invalid`. Each names what `bxctl memory sync` shows.
## 13. Testing
Given tests, first, against a fake Honcho server (a small HTTP server in `memoryd`'s test support
that records requests and answers from fixtures taken from rift's OpenAPI document, like
`loopd`'s fake llama server):
- **Decisions**: clean → trusted; untrusted by `brokerd` or by the log → untrusted; `secret` →
none; no single speaker → none; a key in the text → none; `brokerd` down → undecided, then
decided from the later state, never towards trusted.
- **The ledger**: a restart sends nothing twice; an outage sends everything once it ends, in
order; a refused entry is marked failed and the rest go on.
- **Memory writes**: each row of section 4's table, including undo.
- **Peers and sessions**: the agent's peer not observed; a new speaker starts the next Honcho
session; names fit Honcho's pattern.
- **`roncho`**: a `roncho.toml` with an `api_key` in the test's `HOME` is not read.
- **Keys**: a configured key is sent as a bearer token to its workspace only, and appears in no
log line or error; without a key, no `Authorization` header; a 401 stops that workspace's sends
only, once logged.
- **The fleet**: an untrusted note is a message from the agent's peer; the fleet workspace is
created with reasoning, summaries and dreaming off; a note
from a peer this agent's configuration does not name is found by `recall`.
- **`recall`**: the fleet sources with their tags and classes; a source that times out is one
`unavailable` line and the rest still answer; the agent's own workspaces are not searched.
- **`ask_memory`**: at most two workspaces, both at once; the per-workspace limit; labels;
unknown workspace names.
- **Queries**: a query or question holding a known-prefix secret is refused and nothing is sent;
a `secret` session's query is sent.
- **No `[honcho]` section**: M5b's behaviour, every M5b test unchanged.
- **Attack tests (T37)**: a turn from a session that fetched a page never reaches the trusted
workspace, even when `brokerd` answers late; a fleet hit taints the reading session.
**On the device** (straylight, rift, the owner's Mattermost), for each half:
- M5c-1: a clean turn appears in the trusted workspace, an untrusted one in the untrusted
workspace, a `secret` session's in neither; "remember that …" appears as a conclusion at once,
and `bxctl memory undo` removes it; rift stopped (or unreachable) → the outbox waits and drains
once it is back, sending nothing twice; `bxctl memory sync` shows each state.
- M5c-2: `recall` returns a fleet note written by a second agent (a second `memoryd` with its own
name), tagged `memory-tainted`, and the session turns untrusted; `ask_memory` answers from the
agent's own trusted workspace within its limit; with rift down, `recall` answers from the local
index with `unavailable` lines.
## 14. Changes to the brief and decisions
With this spec, as their own commits:
- `docs/design.md`, Memory items 4 and 5: two workspaces per agent and the fleet workspace; no
rebuild ("corrected by writing a contrary fact"); mirrored memory writes; the peers.
- `docs/decisions.md`: P23 applied as amended here (the fleet workspace for untrusted notes; no
rebuild); rows for the class ceiling, the peers, mirroring, the ledger, and `recall`'s sources.
- `docs/milestones.md`, M5c: its check "Honcho's contents are rebuilt from the logs" goes.
## 15. The owner's answers (2026-09-27)
1. **The speaker**: a turn with no speaker, or incomplete speaker information, is not sent
(sections 4 and 11).
2. **The fleet**: every peer in the fleet workspace is a member of the fleet. Fleet notes are
therefore messages, which one search finds whoever wrote them; an undone note gets a
retraction message, since Honcho cannot delete a message (sections 3, 4, 8). (2026-09-28:
`bxctl memory undo` cannot undo a note, since notes are append-only, so no retraction is ever
sent and the row went from section 4; owner.)
3. **For `roncho`'s agent**, accepted (found 2026-09-27, none blocking): make the config file
opt-in (it supplies an `api_key` today; the README's "reads no environment variables" is
wrong); evidence decoding requires `observer_id` and `observed_id`, which rift's schema lacks;
a message's `configuration` (reasoning per message) is not exposed; the tag `v1.0.0` names
`b4e7864` on `origin` and `1f5ea42` on crates.io.
4. **API keys**: the design supports them per workspace (section 10); rift has auth off today.