feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)

packages/conversation is a library with no server: safe-fs, the Pi session
parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control
board adds GET /api/conversations and /api/conversation behind the Host
and Origin guard. Both are read-only, their queries are validated, and
each refusal code maps to a status.

Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code:
R1 revise (branch ids moving on append, the assumed-link bridge merging
branches, one unreadable seat directory turning the catalogue into a 500),
then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve
(07b10ad1), R2 approve (b9d92003). The package lands with the routes,
because serve.mjs imports the reader at load.

On an index export: the eight suites 24/90/43/17/14/15/63/18,
conversation and control-board 153/153, webui 9/9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 16:36:20 -05:00
co-authored by Claude Opus 5.5
parent c5db8c8819
commit a5beb6d97d
22 changed files with 3247 additions and 3 deletions
+207
View File
@@ -0,0 +1,207 @@
# conversation
Read-only Pi conversation histories for the Console: a catalogue of approved
session files, full branch history in CHAT-01 pages, and cursors. Opening a
conversation never resumes, forks, launches or controls anything, and nothing
here writes a file.
Issue #1507 (CHAT-02, row 5). Brief: `agents/dewey/work/chat-02/BRIEF.md` R4.
Plain ESM, no dependencies, Node 24 or newer. A library with no server: the
control board serves it on two GET routes (D3).
## API
```js
import { rootsFromSpecs, createReader } from "@mosaic/conversation";
const reader = createReader({ roots: () => rootsFromSpecs(specs, registrations) });
reader.catalogue(); // { ok, conversations, refusedRoots, generatedAt }
reader.open({ conversation, branch? }); // first page
reader.next({ cursor, conversation, branch }); // next page, or a follow
```
`open` and `next` return `{ ok: true, page, cursor, follow, view }` or
`{ ok: false, refusal: { code, reconcile, message } }`.
- `page` is a CHAT-01 `page`, and `cursor` and `follow` are CHAT-01 `cursor`
records. `page.nextCursor` is `cursor.id` when more parts remain in the
snapshot.
- On the last page, `follow` replaces `cursor`. Calling `next` with it takes
a fresh snapshot and returns only what was appended since (see Follow).
- `view` holds what the Console needs beyond CHAT-01: `defaultBranch`,
`branches`, `incomplete` (a truncated trailing line),
`forkedFromEarlierSession` and `unreadableLines`.
- `actor` defaults to `local-operator` and `purpose` to `history`. Any other
value is refused.
## Sources
Roots come from the board's repository specs only:
`<projectRoot>/.pi/state/<seat>/sessions`, with the project named after the
root directory. Fleet and connector specs are not roots, and there is no
global scan. A conversation id is `pi-` plus a hash of project root, seat and
file name. It is resolved by listing the roots again, so no path comes from
the caller.
A seat registration is seat-written, so it is a hint, not authority:
- It counts for a root only when seat, layout `repo`, project and
`sessionsDir` all match (`samePath`). Anything else is ignored.
- It supplies `engineStartedAt` for conversations created at or after its
`startedAt`.
- A non-Pi `harness` makes the root unsupported. The root appears as one
catalogue row with `unsupportedReason: "unsupported-harness"` (D2), and its
directory is never read.
- A seat on another harness has no Pi sessions directory, so the board has no
spec for it. Its registration adds that placeholder row when it names the
standard directory under a project root that is already approved. This is
Rocko's case today: `claude-code`, no `sessions`.
Deviation from brief §2.1: the brief speaks of a registration `sessionFile`.
Registrations have no such field, only `sessionsDir`, so the rule above
applies to the directory. A registration never adds a root or names a file.
## Opening a file
`src/safe-fs.mjs`:
- Every component from the project root down to the sessions directory must
be a real directory (lstat).
- A session file must be a regular `*.jsonl` directly in the root.
- It is opened `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, and the descriptor's
(dev, ino) must equal the lstat taken before the open.
- The components are checked again after the open. Node has no `openat`, so
a directory swapped between the checks and the open is detected afterwards,
not prevented.
The project root itself may be a symlink (the compatibility path to this
checkout). The header `cwd` must be the project or inside it. Real paths are
compared, and a `cwd` that no longer exists is compared as written. That
comparison calls `realpath` on the recorded `cwd`, which resolves the path but
opens nothing.
`SessionManager.open` is never used.
## Parser
`src/pi.mjs`, pinned against `@earendil-works/pi-coding-agent` 0.85.1
(`docs/session-format.md`, `dist/core/session-manager.js`):
- Line 1 must be the session header.
- Entries form an `id`/`parentId` tree. Where an id repeats, the later entry
wins, as in Pi's index.
- The default leaf is the last valid entry in file order, as Pi loads it.
Each leaf ends one branch; other branches are read-only and opened by
`branch`.
- Branch names do not change while the file grows. The first root's line is
`main`, even before the file has entries. Each later root (Pi's
`resetLeaf`, or an entry whose parent is missing) and each later child at a
fork starts a branch named `b.<entry id>`; the earliest child in file order
continues its parent's branch. An inner entry is not a branch.
- A malformed line becomes a notice at its file position on every branch,
after the leaf too, and reading continues. The same placement on every
branch keeps a branch's earlier parts unchanged while the file grows.
- A missing parent stops the history with a notice. When unreadable lines sit
just before the entry, the notice names them as the likely place of the
parent. The history is never joined across the gap: the entries before it
may belong to another branch, and they read as their own branch. A loop
stops with a notice.
- `parentSession` gives a "forked from an earlier session" notice and is
never opened.
- Redacted reasoning (`redacted: true`) is `unavailable` with empty text.
`thinkingSignature` is never read.
- A header `cwd` must be absolute and inside the project; a relative one is
refused as `foreign-project`.
- A compaction is a marker in place, and the full history stays on the path.
`retainedTail` entries are already on the path, so they are not rendered
twice.
- Model and thinking-level changes, labels, session names and extension state
are not shown. `custom_message` shows only with `display: true`. An unknown
entry type or role becomes a notice.
- Ids that do not fit the CHAT-01 id pattern are hashed (`h-` plus 40 hex).
OpenAI tool call ids such as `call_x|fc_y` are the real case. Entry ids are
namespaced (`n.` for native, `x.` for notices), so no native id can collide
with a notice.
- `get_entries` order is not used.
## Pages
`src/parts.mjs` applies the CHAT-01 limits:
- at most 100 parts and 8 MiB of serialized UTF-8 per page;
- at most 64 blocks per part;
- at most 262144 characters per string.
Strings split into fragments. A fragment is also cut at 1 MiB of JSON, and
blocks group into parts of at most 4 MiB, so one part always fits a page.
Content is never clipped, and a surrogate pair is never cut.
## Snapshots, epochs, cursors
- A snapshot is the file up to its last newline when the descriptor was read.
A trailing partial line is left out and sets `view.incomplete`.
`snapshotDigest` is the SHA-256 of those bytes.
- `sourceEpoch` is a hash of (dev, ino) and the digest at open. Follows carry
it forward while the prefix verifies, so growth keeps the epoch. A new open
after growth hashes the longer prefix and gets a different id for the same
epoch, so an epoch id is comparable only within one cursor chain.
- Every `next` re-reads the pinned prefix and refuses `source-replaced` (with
reconcile) when dev or ino changed, the file is shorter, or the prefix
digest differs (an in-place rewrite with the same inode).
- Cursors live in memory, with an LRU cap (1000) and a 10-minute TTL. They are
bound to actor, purpose, conversation, branch, snapshot, epoch and expiry.
- Refusals: `cursor-unknown` (never issued or evicted), `cursor-expired`,
`cursor-foreign` (any binding differs) and `source-replaced`. All carry
`reconcile: true`. A foreign attempt does not consume the cursor, so the old
view keeps working.
- `local-operator` is the only actor on this unauthenticated loopback route.
That is not multi-actor safety. Authenticated actors come with CHAT-04R.
### Follow
A follow cursor verifies the old prefix, then takes a fresh snapshot and
reads the same branch in it. `page.branch` is always the cursor's branch.
- The parts before the cursor must be unchanged (compared as a digest of
entry ids). If they are, the page holds only the parts appended to this
branch, which is empty when the conversation continued on another branch.
`view.defaultBranch` shows where Pi's default leaf is now.
- If the branch is gone or its earlier parts changed (a duplicate id that
replaces an entry, or a missing parent that turns up later), it refuses
`source-replaced`.
## Refusal codes
| Code | Meaning | Reconcile |
|---|---|---|
| `unknown-conversation` | not in the approved roots now | yes |
| `unknown-branch` | not a branch of this conversation | yes |
| `unavailable` | the session root no longer exists | no |
| `unsupported-harness` | non-Pi seat (D2) | no |
| `unsafe-path` | symlink, bad name, not a regular file, swapped | no |
| `foreign-project` | header `cwd` outside the project | no |
| `unreadable` | permission denied on a file, a root or a directory above it inside the approved roots | no |
| `not-a-pi-session`, `incomplete-header` | first line is not a complete Pi header | incomplete: yes |
| `too-large` | over 256 MiB | no |
| `cursor-unknown`, `cursor-expired`, `cursor-foreign`, `source-replaced` | see above | yes |
| `unknown-actor`, `unsupported-purpose` | not `local-operator` / `history` | no |
## Costs
Each page reads and hashes the whole pinned prefix. A parse cache and a
branch-entries cache (two snapshots each) avoid re-parsing. On this checkout's
18 roots (largest file 18.6 MB), reading every page of every conversation took
at most 680 ms per conversation.
## Tests
`node --test packages/conversation/tests/` covers fixtures F1–F15 and F17 of
the brief (F16 is in the control-board suite, which serves the routes).
- Every reader call runs inside a fingerprint of the fixture tree: size,
SHA-256, mtime, (dev, ino), mode and every directory listing, before and
after.
- Files that no read may touch are mode 000, so an attempted open would throw
`EACCES` rather than refuse.
- Every page and cursor is validated against
`docs/plans/chat-01/contracts.schema.json` with Python `jsonschema`.