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
@@ -0,0 +1,218 @@
# CHAT-02 backend: review packet (#1507, row 5)
Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.1 and D3.
Base: `34777c56` on `refactor`. Nothing here is committed; the candidate is
the working tree, pinned by the hashes below.
Reviewers (Sage's order):
- Filbert: the code, all nine files.
- Darkwing: the two board routes, `serve.mjs` and `serve.test.mjs`.
The Console (`packages/webui`) is the next step and is not in this packet.
## 1. Candidate hashes
```
f9008c01c608ea9aacdd15459f03a4ca8f8b4fe4d5225f3f8337bc5f9282f955 packages/conversation/package.json
6f2cab5a31a26f71cf1cc00d6aed2a683e011b6cc2b52ebd4bb50b404af96d5c packages/conversation/README.md
da336ed2a2a149ff56ac12af2440a8353d4573c33e9069381be60f1a0ca70151 packages/conversation/src/parts.mjs
28e22501ab133af7dee1a38f0dc209d005ead4192b64dae6ef999ec4191db8e0 packages/conversation/src/pi.mjs
1e1db046c65292dbcfa2d59b6f0d5009dc48062cbd6a7ec123a18b6707ec8142 packages/conversation/src/reader.mjs
98835b171c1473de2ba242347b191773dfac1e50f383e43ec43b2ef4a5122a3a packages/conversation/src/safe-fs.mjs
3f89f5e7a3cdd5bb31623c799105c538fd5b5eacae368af0170e43f1130267e6 packages/conversation/tests/reader.test.mjs
afc95bdb850e4b533516984d2dd7f3ff852c90b46b384f42bc550adf9f2c540d packages/control-board/src/serve.mjs
e60aa14b77350a2807e9e9b887acb9daec1c0468d94baaf9f77548dc54fcecbc packages/control-board/tests/serve.test.mjs
```
The first seven files are new. `serve.mjs` and `serve.test.mjs` are diffs
against `34777c56`: `git diff 34777c56 -- packages/control-board`.
`scan.mjs` is unchanged. D3 allowed edits there, but none were needed.
## 2. What it does
`packages/conversation/README.md` is the reference: API, sources, safe open,
parser rules, page limits, snapshot, epoch and cursor rules, follow, the
refusal table and costs. In short:
- `rootsFromSpecs` turns the board's repository specs into roots, using
registrations only as hints. `createReader` serves `catalogue`, `open` and
`next`.
- Pages and cursors are CHAT-01 `page` and `cursor` records. Everything the
Console needs beyond CHAT-01 goes in `view`.
- The board serves `GET /api/conversations` (no parameters) and
`GET /api/conversation?id=&branch=&cursor=`. Both run after the existing
Host/Origin guard (`d1629d61`) and its GET/HEAD check.
- Responses are `application/json` with `no-store` and `nosniff`, and no CORS
headers.
- Status map:
- 404 for an unknown conversation or branch;
- 409 for cursor refusals, `source-replaced` and `incomplete-header`;
- 403 for `unsafe-path`, `foreign-project` and `unreadable`;
- 422 for the rest;
- 400 for any query parameter other than `id`, `branch` or `cursor`, a
repeated one, or a value that fails the id pattern;
- 500 with a fixed message for an exception, with details only on stderr.
## 3. Choices and deviations to review
1. **Catalogue rows are a summary shape, not CHAT-01 `catalogueItem`.** The
row is: conversation, seat, project, harness, history, title, readOnly,
`controlMode: "unavailable"`, the three separate time fields,
availability, `unsupportedReason` and refusal. `catalogueItem` carries
binding and control fields that belong to CHAT-03. I did not want to fill
them with placeholders that look authoritative.
2. **Registrations match on `sessionsDir`, not `sessionFile`.** Registrations
have no `sessionFile` field. The brief §2.1 rule is applied to the
directory instead: seat, layout `repo`, project and `samePath(sessionsDir)`
must all match. A registration never adds a root or names a file (F9).
3. **Placeholder rows for non-Pi seats.** A seat on another harness has no Pi
directory, so the board has no spec for it. A registration adds one
`unsupported-harness` row when its `sessionsDir` is the standard
directory under a project root that is already approved. Its directory is
never read. Rocko (`claude-code`) is the live case (F15).
4. **An epoch id is comparable only within one cursor chain.**
`sourceEpoch = "e-" + sha256(dev:ino:digest)`, taken at open and carried
forward while the prefix verifies. Two opens of an unchanged file give the
same value. After growth, a new open gets a new value for the same epoch,
while the old chain keeps its own. Only a new inode, a shorter file or a
changed prefix is a new epoch, and that is refused.
5. **Follow semantics.** On the last page, `follow` replaces `cursor`.
- `next` with it re-verifies the pinned prefix and takes a fresh snapshot.
- If the view's leaf is still on the new default branch, the ids of the
parts already served must be unchanged. It then returns only the new
parts.
- If the conversation went on down another branch, it returns an empty
page on the old branch and reports the new default in `view`.
- Silently switching branches would break "nothing switches silently".
6. **Missing parent next to malformed lines is bridged.** The history
continues from the previous valid entry, with an "assumed link" notice.
Pi itself would drop the history before that point. Without adjacent
malformed lines, the history stops with a notice. A follow refuses if a
line that was malformed now reads differently, which changes meaning
(F1 follow test).
7. **`unreadable` refusal.** `EACCES` or `EPERM` on a root listing or file
open becomes a per-row 403, so one unreadable file cannot fail the whole
catalogue.
8. **256 MiB file cap** (`too-large`, 422). The largest real file today is
18.6 MB.
9. **Every page re-hashes the whole pinned prefix.** This makes detection
simple and total, at a cost measured in §4.3.
10. **Refusal code names** not already in CHAT-01 are CHAT-02 values, like
`unsupported-harness` (D2): `unsafe-path`, `foreign-project`,
`unreadable`, `not-a-pi-session`, `incomplete-header`, `too-large`,
`unknown-branch`, `unknown-actor` and `unsupported-purpose`.
## 4. Evidence
All runs are on the candidate hashes above.
### 4.1 Suites and contract checks
- `node --test --test-concurrency=1 packages/conversation/tests/
packages/control-board/tests/ packages/webui/tests/ packages/seat/tests/`:
175 tests, 175 pass.
- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
`chat-01c/check.mjs` all exit 0.
### 4.2 Fixture map (brief §2.1)
| # | Test |
|---|---|
| F1 | three tests in `reader.test.mjs`: malformed notice in place (with a hostile id), bridge and missing parent, and the follow that refuses when history changes meaning |
| F2–F15 | one named test each in `reader.test.mjs` |
| F16 | `serve.test.mjs` §12: foreign Host and cross-origin Origin on both routes give 403; a spy reader records zero calls, and no `access-control-*` header is sent |
| F17 | every reader call in `reader.test.mjs` runs inside a fingerprint of the whole fixture tree: size, SHA-256, mtime (ns), (dev, ino), mode and every directory listing. The route test in `serve.test.mjs` does the same. Files no read may touch are mode 000. |
Additional coverage: mapping of every Pi entry type and role, pagination
at 100 parts and at the byte cap, surrogate-safe fragments, unknown and
empty and non-Pi files, `unreadable`, and CHAT-01 schema validation of
every page and cursor the tests produce (Python `jsonschema`).
### 4.3 Real data (read-only, this checkout)
- Catalogue: 19 rows. There are 18 available Pi conversations across darkwing,
dewey, filbert, researcher and sage, plus rocko as `unsupported-harness`.
- Reading every page of every conversation gave 102 pages and 8693 entries,
with no refusals. The slowest conversation took 662 ms; the largest file is
18.6 MB.
- 186 pages and cursors sampled from that run: 0 schema-invalid.
- Over HTTP, through a temporary board on port 0 with a temporary `boardDir`
(the live board was not touched): 19 rows and 102 pages in 1517 ms. Rocko
returns 422 `unsupported-harness`.
- Scripts and output: `evidence/backend/smoke.mjs`, `smoke-stats.txt`,
`http-real.mjs`, `http-out.txt`.
### 4.4 Mutation testing
Mutations were run in scratch copies under `/tmp/dewey-chat02/`, never in
the served tree. Scripts and full output: `evidence/backend/mutate.py`,
`mutate-board.py`, `mutate-backend.txt` and `mutate-routes.txt`.
Reader: 27 of 28 caught.
| Mutant | Caught by |
|---|---|
| no O_NOFOLLOW and no lstat symlink check | F7, F8 |
| directory components unchecked | F7 |
| listing follows symlinks | F7, F8 |
| no prefix digest check | F4 |
| no dev/ino check | F3 |
| no shorter check | **not caught, equivalent** (below) |
| cwd always in project | F10 |
| no character cap | F14, schema |
| no page byte cap | F14 |
| no block cap | F14, schema |
| no foreign check | F6 |
| no branch binding | F6 |
| no expiry | F6 |
| no parentSession notice | F11 |
| branch parameter ignored | F12 |
| registration harness ignored | F15 |
| no placeholder roots | F15 |
| malformed notices dropped | F1 |
| no bridge | F1 |
| `incomplete` never set | F2, F5 |
| `next` re-reads fresh instead of pinned | F2, F5, F12, unknown/empty |
| follow skips history check | F1 follow |
| follow ignores branching | F12 |
| catalogue writes a file | F17 in six fixtures |
| wrong thinking visibility | mapping |
| raw tool ids | mapping, schema |
| id namespaces dropped | F1 |
| surrogate pair split | fragments |
The equivalent mutant: removing the "file is shorter" check changes nothing
observable. A shorter file cannot supply the pinned length, so the prefix
digest differs and `source-replaced` still follows. The check stays because
it gives the refusal without hashing a truncated read.
Board routes: 9 of 9 caught. The mutants were: routes before the guard, no
`nosniff`, every refusal 422, no query validation, unknown parameters
allowed, `reconcile` dropped, cursor ignored, registrations ignored, and a
CORS header sent.
## 5. Known limits
- **No `openat` in Node.** A directory component swapped between the checks
and the open is caught by the re-check after the open, not prevented.
Nothing is read from a descriptor whose (dev, ino) does not match.
- **The `fstat` mismatch branch of F8** is covered by design, not by a
test. The F8 test swaps in a symlink, which `O_NOFOLLOW` refuses first. A
plain-file swap between `lstat` and `open` needs a race the suite cannot
schedule.
- **An in-place rewrite with the same inode** is detected by the digest of
the pinned prefix, as the brief requires. Growth after the pin is not a
change.
- **Cost.** Each page reads and hashes the whole pinned prefix; §4.3 has the
cost on real files.
- **One actor.** Cursors live in memory and are lost when the board restarts;
the refusal is `cursor-unknown` with reconcile. `local-operator` is the
only actor, which is not multi-actor safety (CHAT-04R).
- **Claude history** waits for B1 and CHAT-03 (D2).
## 6. After review
- Pushing goes through Sage with Jason's word. The live board needs a restart
after the commit to serve the routes.
- The WebUI proxy allowlist for the two routes is Console work and is not in
this packet.
+340
View File
@@ -0,0 +1,340 @@
# CHAT-02 backend: review packet, revision 2 (#1507, row 5)
Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.1 and D3.
Base: `1c5f6bc3` on `refactor`. Nothing here is committed; the candidate is
the working tree, pinned by the hashes below.
Revision 1 was `286c3ad5`, kept as `BACKEND-r1-286c3ad5.md`. Filbert
reviewed it and asked for a revision
(`agents/filbert/work/chat-02-backend-review-2026-09-26.md`, `27d64e14…`).
Darkwing approved the routes in it
(`agents/darkwing/work/chat-02-routes-review-2026-09-26.md`, `07b10ad1…`)
with two nonblocking notes. §0 answers both reviews.
Reviewers (Sage's order):
- Filbert: the code, all nine files. He asked to review the delta.
- Darkwing: the two board routes. `serve.mjs` and `serve.test.mjs` changed
for his notes, so the route delta needs his look.
The Console (`packages/webui`) is the next step and is not in this packet.
The four WebUI browser failures Filbert saw in the shared tree come from my
unpinned Console edits to `app.js` and `index.html`. They are Console work.
## 0. Revision 2 changes
### Filbert, required
1. **Branch ids are stable.**
- Roots are entries whose parent is null or absent, in file order. The
first root's line is `main`. `main` exists even in a file with no
entries, as an empty branch with a null leaf.
- At a fork the earliest child in file order continues its parent's
branch. Each later child starts a branch named `b.<its id>`. Each later
root (Pi's `resetLeaf`) starts `b.<its id>` too.
- Growth never renames a branch. `page.branch` always equals the cursor's
branch, and `open({branch})` with a name taken before growth still
works.
- The default branch is the one ending at Pi's default leaf, the last
entry in file order. `view.defaultBranch` names it; `view.branches`
flags it with `isDefault`.
- Follow rebuilds the cursor's own branch from a fresh snapshot. It
refuses `source-replaced` with reconcile if the branch is gone, is
shorter than the parts served, or the ids of those parts differ.
- Tests: F12 "two leaves" (branches `main` and `b.o4`, one branch value
across follows, the pre-growth name still opens, the `main` view picks
up its growth), F12 "a second root", F12 "a follow refuses when an
appended duplicate id changes the branch's earlier parts", F1 "a follow
stays on its branch".
- Your `branch.mjs`: open and follow both give `main`, every entry is
labelled `main`, and reopening with the first branch id works.
2. **The bridge is gone.**
- A missing parent stops the history with a `missing-parent` notice. When
malformed lines sit just before the entry, the notice names them: "Line
N could not be read and may have held it." Those lines are not repeated
as separate notices.
- The history before the gap is its own leaf branch and reads normally.
- Tests: F1 "a missing parent stops the history with a notice that names
the unreadable lines", F1 "an unreadable fork is never merged into
another branch's history" (your case).
- Your `bridge.mjs`: `b.y` shows only the notice and `leaf`.
3. **`EACCES` and `EPERM` on a directory component become `unreadable`.**
- `lstatOrNull` maps them through `denied()`. The root is refused on its
own row; the catalogue and opens under other roots are unaffected.
- Test: "a seat directory without search permission refuses that root,
not the catalogue", with the bad root first and then last.
- Your `perm.mjs`: the catalogue returns the good root's row and lists
`bad` in `refusedRoots` as `unreadable`. The open of an unknown id walks
both roots and returns an `unknown-conversation` refusal, not a throw.
### Filbert, small
- Redacted thinking (`redacted: true`) is `unavailable` with empty text,
whatever the `thinking` field says. Test: the mapping test.
- A relative header `cwd` is refused as `foreign-project`. Test: F10
`relative.jsonl`.
- Malformed notices appear when a file has no valid entry. Each malformed
line keeps its own notice at its file position, on every branch, including
after the leaf. Test: F1 "a file whose entries are all unreadable".
### Darkwing, nonblocking
1. A cursor call without `branch` is now 400 ("a cursor call repeats the
page's branch"), and the header comment says so.
2. `REFUSAL_STATUS` is exported. A new test scans the reader's three source
files for every `new Refusal("<code>"` plus `UNSUPPORTED_HARNESS` and
fails on a missing or stale entry. Its first run found `unavailable`
falling to 422; it is now 404. `unknown-actor` (403) and
`unsupported-purpose` (422) are now explicit.
Route delta against revision 1: `git diff 34777c56 -- packages/control-board`
shows the whole change; against Darkwing's pins it is the header comment,
`export` on `REFUSAL_STATUS` with three new entries, one line in
`conversationQuery`, the new test and branch names in expectations
(`main`, `b.e5`, `b.e1`).
## 1. Candidate hashes
```
f9008c01c608ea9aacdd15459f03a4ca8f8b4fe4d5225f3f8337bc5f9282f955 packages/conversation/package.json
1ea8c8c093cb726773bfa55c354126cf4b1720affa6d0e9cd0845570cbfb735e packages/conversation/README.md
da336ed2a2a149ff56ac12af2440a8353d4573c33e9069381be60f1a0ca70151 packages/conversation/src/parts.mjs
20e781240846298fa65f9a8226b5e1ee1e81338b81229334666d845c17c99ac1 packages/conversation/src/pi.mjs
72c3255bca7a894f6484b3224aabf054d40a0db78cbe46a6c492ed385333b8ad packages/conversation/src/reader.mjs
10a1ff9e91c1f1e4684fc38c5bca83c50d79bb1f459d269517396545a269203a packages/conversation/src/safe-fs.mjs
78b7719b0c183cc1d9fedf009fb8a5e4dae34a30d1b39f931824016cc07fa86b packages/conversation/tests/reader.test.mjs
d62720dcf1bd43ce9412356a04b5248a85aec59dd78194887e3c38010c0aa2f3 packages/control-board/src/serve.mjs
d38aa2b209e0fb22ab3c52c3cb26a8f05e9e269e567d40b7e6ad88430c593f4a packages/control-board/tests/serve.test.mjs
```
Unchanged from revision 1: `package.json` and `parts.mjs`. The first seven
files are new. `serve.mjs` and `serve.test.mjs` are diffs against the base.
`scan.mjs` is unchanged. D3 allowed edits there, but none were needed.
## 2. What it does
`packages/conversation/README.md` is the reference: API, sources, safe open,
parser rules, branch names, page limits, snapshot, epoch and cursor rules,
follow, the refusal table and costs. In short:
- `rootsFromSpecs` turns the board's repository specs into roots, using
registrations only as hints. `createReader` serves `catalogue`, `open` and
`next`.
- Pages and cursors are CHAT-01 `page` and `cursor` records. Everything the
Console needs beyond CHAT-01 goes in `view`.
- The board serves `GET /api/conversations` (no parameters) and
`GET /api/conversation?id=&branch=&cursor=`. Both run after the existing
Host/Origin guard (`d1629d61`) and its GET/HEAD check.
- Responses are `application/json` with `no-store` and `nosniff`, and no CORS
headers.
- Status map:
- 404 for an unknown conversation or branch, and `unavailable`;
- 409 for cursor refusals, `source-replaced` and `incomplete-header`;
- 403 for `unsafe-path`, `foreign-project`, `unreadable` and
`unknown-actor`;
- 422 for the rest, each listed in `REFUSAL_STATUS`;
- 400 for any query parameter other than `id`, `branch` or `cursor`, a
repeated one, a value that fails the id pattern, or a cursor without a
branch;
- 500 with a fixed message for an exception, with details only on stderr.
## 3. Choices and deviations to review
1. **Catalogue rows are a summary shape, not CHAT-01 `catalogueItem`.** The
row is: conversation, seat, project, harness, history, title, readOnly,
`controlMode: "unavailable"`, the three separate time fields,
availability, `unsupportedReason` and refusal. `catalogueItem` carries
binding and control fields that belong to CHAT-03. I did not want to fill
them with placeholders that look authoritative.
2. **Registrations match on `sessionsDir`, not `sessionFile`.** Registrations
have no `sessionFile` field. The brief §2.1 rule is applied to the
directory instead: seat, layout `repo`, project and `samePath(sessionsDir)`
must all match. A registration never adds a root or names a file (F9).
3. **Placeholder rows for non-Pi seats.** A seat on another harness has no Pi
directory, so the board has no spec for it. A registration adds one
`unsupported-harness` row when its `sessionsDir` is the standard
directory under a project root that is already approved. Its directory is
never read. Rocko (`claude-code`) is the live case (F15).
4. **An epoch id is comparable only within one cursor chain.**
`sourceEpoch = "e-" + sha256(dev:ino:digest)`, taken at open and carried
forward while the prefix verifies. Two opens of an unchanged file give the
same value. After growth, a new open gets a new value for the same epoch,
while the old chain keeps its own. Only a new inode, a shorter file or a
changed prefix is a new epoch, and that is refused.
5. **Follow stays on its branch.** On the last page, `follow` replaces
`cursor`.
- `next` with it re-verifies the pinned prefix, takes a fresh snapshot and
rebuilds the cursor's branch by name.
- It returns the parts after the ones already served. The branch must
still exist and the ids of the served parts must be unchanged;
otherwise `source-replaced` with reconcile.
- If Pi's default moved to another branch, the page stays on the old
branch and `view.defaultBranch` names the new one. The Console decides
what to show; nothing switches silently.
6. **A missing parent stops the history.** There is no bridge. The notice
names any malformed lines just before the entry, since one of them may
have held the parent. The history before the gap is its own branch. A
malformed line keeps its notice at its file position on every branch, so
a branch's served prefix does not change as the file grows.
7. **`unreadable` refusal.** `EACCES` or `EPERM` on a root listing, a
directory component or a file open becomes a per-row 403, so one
unreadable path cannot fail the whole catalogue.
8. **256 MiB file cap** (`too-large`, 422). The largest real file today is
18.6 MB.
9. **Every page re-hashes the whole pinned prefix.** This makes detection
simple and total, at a cost measured in §4.3.
10. **Refusal code names** not already in CHAT-01 are CHAT-02 values, like
`unsupported-harness` (D2): `unsafe-path`, `foreign-project`,
`unreadable`, `not-a-pi-session`, `incomplete-header`, `too-large`,
`unknown-branch`, `unavailable`, `unknown-actor` and
`unsupported-purpose`.
## 4. Evidence
All runs are on the candidate hashes above.
### 4.1 Suites and contract checks
- In `/tmp/dewey-chat02/overlay`, a `git archive` of `1c5f6bc3` with only
the nine files overlaid: `node --test --test-concurrency=1` over the
conversation, control-board, webui and seat suites gives 181 tests, 181
pass (29, 124, 9 and 19).
- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
`chat-01c/check.mjs` all exit 0.
### 4.2 Fixture map (brief §2.1)
| # | Test |
|---|---|
| F1 | five tests in `reader.test.mjs`: malformed notice in place (with a hostile id); missing parent names the unreadable lines; an unreadable fork is never merged; a follow stays on its branch; an all-malformed file shows a notice per line |
| F12 | three tests: two leaves with stable names across growth; a follow that refuses when an appended duplicate id changes served parts; a second root |
| F2–F11, F13–F15 | one named test each in `reader.test.mjs` |
| F16 | `serve.test.mjs` §12: foreign Host and cross-origin Origin on both routes give 403; a spy reader records zero calls, and no `access-control-*` header is sent |
| F17 | every reader call in `reader.test.mjs` runs inside a fingerprint of the whole fixture tree: size, SHA-256, mtime (ns), (dev, ino), mode and every directory listing. The route test in `serve.test.mjs` does the same. Files no read may touch are mode 000. |
Additional coverage: mapping of every Pi entry type and role (including
redacted thinking), pagination at 100 parts and at the byte cap,
surrogate-safe fragments, unknown and empty and non-Pi files, an unreadable
file or root, a seat directory at mode 000, every refusal code having an
HTTP status, and CHAT-01 schema validation of every page and cursor the
tests produce (Python `jsonschema`).
### 4.3 Real data (read-only, this checkout)
- Catalogue: 19 rows in 75 ms, no refused roots. There are 18 available Pi
conversations across darkwing, dewey, filbert, researcher and sage, plus
rocko as `unsupported-harness`.
- Reading every page of every conversation gave 102 pages and 8693 entries,
with no refusals. Every page is on branch `main`. The slowest conversation
took 798 ms (662 ms in revision 1, on files that have grown since; I did
not isolate the difference). The largest file is 18.6 MB.
- 186 pages and cursors sampled from that run: 0 schema-invalid.
- Over HTTP, through a temporary board on port 0 with a temporary `boardDir`
(the live board was not touched): 19 rows and 102 pages in 1487 ms. Rocko
returns 422 `unsupported-harness`.
- Scripts and output: `evidence/backend/smoke.mjs`, `smoke-stats.txt`,
`http-real.mjs`, `http-out.txt`.
### 4.4 Mutation testing
Mutations were run in scratch copies under `/tmp/dewey-chat02/`, never in
the served tree. The route scratch is a full copy of the checkout (without
`.git`, `agents`, `v1` and `skills/aws-*`), so each mutant's failures are
real test failures, not import errors. Scripts and full output:
`evidence/backend/mutate.py`, `mutate-board.py`, `mutate-backend.txt` and
`mutate-routes.txt`.
Reader: 38 of 39 caught.
| Mutant | Caught by |
|---|---|
| no O_NOFOLLOW and no lstat symlink check | F7, F8 |
| directory components unchecked | F7 |
| listing follows symlinks | F7, F8 |
| no prefix digest check | F4 |
| no dev/ino check | F3 |
| no shorter check | **not caught, equivalent** (below) |
| cwd always in project | F10 |
| relative cwd accepted | F10 |
| no character cap | F14, schema |
| no page byte cap | F14 |
| no block cap | F14, schema |
| no foreign check | F6 |
| no branch binding | F6 |
| no expiry | F6 |
| no parentSession notice | F11 |
| branch parameter ignored | F12 two leaves, F12 second root, F1 missing parent |
| unknown branch served | F12 two leaves |
| registration harness ignored | F15 |
| no placeholder roots | F15 |
| malformed notices dropped | four F1 tests |
| bridge restored | F1 missing parent, F1 unreadable fork, F1 follow |
| missing-parent lines not named | F1 missing parent, F1 unreadable fork, F1 follow |
| trailing malformed notices only on the default branch | F1 missing parent, F1 follow |
| branch named by its leaf | all three F12 tests, two F1 tests |
| later child continues the branch | F12 two leaves |
| first root not `main` | all three F12 tests, two F1 tests |
| no empty `main` | F1 all malformed, unknown/empty |
| redacted thinking shown | mapping |
| `EACCES` on a component rethrown | seat directory at mode 000 |
| `incomplete` never set | F2, F5 |
| `next` re-reads fresh instead of pinned | F2, F5, F12, F1 follow |
| follow skips the history check | F12 duplicate id |
| follow skips the id digest | F12 duplicate id |
| follow reads the default branch | F12 two leaves, F1 follow |
| catalogue writes a file | F17 in six fixtures |
| wrong thinking visibility | mapping |
| raw tool ids | mapping, schema |
| id namespaces dropped | F1 |
| surrogate pair split | fragments |
Removed since revision 1: "no bridge" and "follow ignores branching", whose
behaviour no longer exists.
The equivalent mutant: removing the "file is shorter" check changes nothing
observable. A shorter file cannot supply the pinned length, so the prefix
digest differs and `source-replaced` still follows. The check stays because
it gives the refusal without hashing a truncated read.
Board routes: 11 of 11 caught. The mutants were: routes before the guard, no
`nosniff`, every refusal 422, no query validation, unknown parameters
allowed, `reconcile` dropped, cursor ignored, registrations ignored, a
cursor without branch served, `unavailable` falling to 422, and a CORS
header sent.
## 5. Known limits
- **No `openat` in Node.** A directory component swapped between the checks
and the open is caught by the re-check after the open, not prevented.
Nothing is read from a descriptor whose (dev, ino) does not match.
- **The `fstat` mismatch branch of F8** is covered by design, not by a
test. The F8 test swaps in a symlink, which `O_NOFOLLOW` refuses first. A
plain-file swap between `lstat` and `open` needs a race the suite cannot
schedule.
- **An in-place rewrite with the same inode** is detected by the digest of
the pinned prefix, as the brief requires. Growth after the pin is not a
change.
- **Follow compares entry ids, not content, for the parts already served.**
The pinned byte prefix is still re-hashed, so a rewrite of served bytes is
caught. An appended entry that reuses a served id is caught when it moves
that entry off the branch or puts another id in its place (F12 duplicate
id, both cases). If it keeps the same id in the same position with new
content, follow does not notice: the parts already served keep the old
text, while a fresh open shows the new one. Pi never writes duplicate
ids.
- **Cost.** Each page reads and hashes the whole pinned prefix; §4.3 has the
cost on real files.
- **One actor.** Cursors live in memory and are lost when the board restarts;
the refusal is `cursor-unknown` with reconcile. `local-operator` is the
only actor, which is not multi-actor safety (CHAT-04R).
- **Claude history** waits for B1 and CHAT-03 (D2).
## 6. After review
- Commit condition (Darkwing): `serve.mjs` imports
`../../conversation/src/reader.mjs` at load, so `packages/conversation/`
lands in the same commit as the routes or an earlier one, after Filbert's
approval.
- Pushing goes through Sage with Jason's word. The live board needs a restart
after the commit to serve the routes.
- The WebUI proxy allowlist for the two routes is Console work and is not in
this packet.
@@ -0,0 +1,3 @@
rows 19 researcher:available dewey:available sage:available darkwing:available filbert:available darkwing:available filbert:available filbert:available filbert:available filbert:available filbert:available darkwing:available filbert:available dewey:available darkwing:available darkwing:available darkwing:available darkwing:available rocko:unsupported
pages 102 ms 1487
unsupported 422 {"error":"this harness has no history reader yet","refusal":{"code":"unsupported-harness","reconcile":false}}
@@ -0,0 +1,20 @@
import { startServer } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/serve.mjs";
import { discoverRepoAgents } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs";
import { homedir } from "node:os";
import { mkdtempSync } from "node:fs";
const specs = discoverRepoAgents("/mnt/storage/src/mosaic-stack");
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: mkdtempSync("/tmp/dewey-chat02/board-"), seatsDir: homedir() + "/.mosaic-dev/seats", isAlive: () => true, page: "x" });
const base = `http://127.0.0.1:${server.address().port}`;
const cat = await (await fetch(base + "/api/conversations")).json();
console.log("rows", cat.conversations.length, cat.conversations.map(c => `${c.seat}:${c.availability}`).join(" "));
const big = cat.conversations.filter(c => c.availability === "available");
let pages = 0, t = Date.now();
for (const c of big) {
let r = await (await fetch(`${base}/api/conversation?id=${c.conversation}`)).json();
pages++;
while (r.page?.hasMore) { r = await (await fetch(`${base}/api/conversation?id=${c.conversation}&branch=${r.page.branch}&cursor=${r.page.nextCursor}`)).json(); pages++; if (!r.ok) { console.log("refused", r); break; } }
}
console.log("pages", pages, "ms", Date.now() - t);
const u = cat.conversations.find(c => c.availability === "unsupported");
const ur = await fetch(`${base}/api/conversation?id=${u.conversation}`); console.log("unsupported", ur.status, JSON.stringify(await ur.json()));
server.close();
@@ -0,0 +1,39 @@
CAUGHT no O_NOFOLLOW + no lstat symlink -> F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlink after the catalogue is refused
CAUGHT dir components unchecked -> F7: a symlinked file and a symlinked directory component are refused,
CAUGHT listing follows symlinks -> F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlink after the catalogue is refused
CAUGHT no prefix digest check -> F4: a same-inode rewrite of the prefix refuses old cursors with reconc
CAUGHT no dev/ino check -> F3: a replaced file
MISSED no shorter check ->
CAUGHT cwd always in project -> F10: a header cwd naming another project is refused
CAUGHT relative cwd accepted -> F10: a header cwd naming another project is refused
CAUGHT no char cap -> F14: long strings split into fragments and parts, reassemble exactly, ; every page and cursor is a valid CHAT-01 record
CAUGHT no page byte cap -> F14: long strings split into fragments and parts, reassemble exactly,
CAUGHT no block cap -> F14: long strings split into fragments and parts, reassemble exactly, ; every page and cursor is a valid CHAT-01 record
CAUGHT no foreign check -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
CAUGHT no branch binding -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
CAUGHT no expiry -> F6: unknown, foreign and expired cursors refuse and leave the cursor u
CAUGHT no parentSession notice -> F11: parentSession renders with a marker and the parent is never opene
CAUGHT branch param ignored -> F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a missing parent stops the history with a notice that names the un
CAUGHT unknown branch served -> F12: two leaves: the default leaf is shown and the other branch reads
CAUGHT registration harness ignored -> F15: a Claude seat is an unsupported-harness placeholder whose directo
CAUGHT no placeholder roots -> F15: a Claude seat is an unsupported-harness placeholder whose directo
CAUGHT malformed notices dropped -> F1: a file whose entries are all unreadable shows a notice per line; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a malformed line is an unavailable part at its position, and readi; F1: a missing parent stops the history with a notice that names the un
CAUGHT bridge restored -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un; F1: an unreadable fork is never merged into another branch's history
CAUGHT missing-parent lines not named -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un; F1: an unreadable fork is never merged into another branch's history
CAUGHT trailing malformed only on default -> F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names the un
CAUGHT branch named by leaf -> F12: a follow refuses when an appended duplicate id changes the branch; F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names
CAUGHT later child continues branch -> F12: two leaves: the default leaf is shown and the other branch reads
CAUGHT first root not main -> F12: a follow refuses when an appended duplicate id changes the branch; F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F1: a missing parent stops the history with a notice that names
CAUGHT no empty main -> F1: a file whose entries are all unreadable shows a notice per line; unknown conversations, empty files and non-Pi files refuse
CAUGHT redacted thinking shown -> native entries map to blocks: tools, thinking, bash, notices, ids that
CAUGHT EACCES on a component rethrown -> a seat directory without search permission refuses that root, not the
CAUGHT incomplete never set -> F2: a truncated trailing line marks the view incomplete, not an error; F5: growth between pages keeps the epoch and the page stops at the pin
CAUGHT next re-reads fresh -> F12: a second root; F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea; F2: a truncated trailing line marks the view incomplete, not an error; F5: growth between pages keeps the epoch and the page stops at th
CAUGHT follow skips history check -> F12: a follow refuses when an appended duplicate id changes the branch
CAUGHT follow skips the id digest -> F12: a follow refuses when an appended duplicate id changes the branch
CAUGHT follow reads the default branch -> F12: two leaves: the default leaf is shown and the other branch reads ; F1: a follow stays on its branch when the next entry's parent is unrea
CAUGHT catalogue writes a file -> F10: a header cwd naming another project is refused; F15: a Claude seat is an unsupported-harness placeholder whose directo; F1: a malformed line is an unavailable part at its position, and readi; F7: a symlinked file and a symlinked directory component are refused, ; F8: a file swapped for a symlin
CAUGHT thinking visibility wrong -> native entries map to blocks: tools, thinking, bash, notices, ids that
CAUGHT raw tool ids -> every page and cursor is a valid CHAT-01 record; native entries map to blocks: tools, thinking, bash, notices, ids that
CAUGHT id namespaces dropped -> F1: a malformed line is an unavailable part at its position, and readi
CAUGHT surrogate split -> fragments never cut a surrogate pair and keep an empty string
@@ -0,0 +1,30 @@
import subprocess, os
S="/tmp/dewey-chat02/scratch-board"
f=os.path.join(S,"packages/control-board/src/serve.mjs")
orig=open(f).read()
M=[
("conversation routes before the guard",[(""" const refused = foreignRequest(req);""",""" { const u0 = new URL(req.url, "http://localhost"); if (u0.pathname.startsWith("/api/conversation")) { let o; try { o = conversationResponse(reader, u0); } catch { o = { status: 500, body: {} }; } return sendConversationJson(res, o.status, o.body); } }
const refused = foreignRequest(req);""")]),
("no nosniff",[(', "x-content-type-options": "nosniff"','')]),
("all refusals 422",[("status: REFUSAL_STATUS[out.refusal.code] ?? 422","status: 422")]),
("no query validation",[(" if (values.length !== 1 || !QUERY_VALUE.test(values[0])) return { error: `invalid ${key}` };\n","")]),
("unknown params allowed",[(""" if (!["id", "branch", "cursor"].includes(key)) return { error: `unknown parameter: ${key}` };\n""","")]),
("reconcile dropped",[("refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile }","refusal: { code: out.refusal.code }")]),
("cursor ignored",[(" const out = query.cursor\n"," const out = false\n")]),
("registrations ignored",[("rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations)","rootsFromSpecs(specs, [])")]),
("cursor without branch served",[(" if (out.cursor && !out.branch) return"," if (false) return")]),
("unavailable falls to 422",[(" unavailable: 404,\n","")]),
("CORS header sent",[('"x-content-type-options": "nosniff" });','"x-content-type-options": "nosniff", "access-control-allow-origin": "*" });')]),
]
for name,reps in M:
src=orig
ok=True
for a,b in reps:
if a not in src: print("NOT APPLIED",name); ok=False; break
src=src.replace(a,b,1)
if not ok: continue
open(f,"w").write(src)
r=subprocess.run(["node","--test","--test-concurrency=1","tests/serve.test.mjs"],cwd=os.path.join(S,"packages/control-board"),capture_output=True,text=True,timeout=600)
fails=sorted(set(l.strip()[2:].split(" (")[0][:60] for l in r.stdout.splitlines() if l.lstrip().startswith("✖") and "failing tests" not in l))
print(("CAUGHT " if r.returncode else "MISSED ")+name+" -> "+"; ".join(fails))
open(f,"w").write(orig)
@@ -0,0 +1,11 @@
CAUGHT conversation routes before the guard -> conversation routes
CAUGHT no nosniff -> conversation routes: catalogue, first page, next page and fo
CAUGHT all refusals 422 -> conversation routes: catalogue, first page, next page and fo
CAUGHT no query validation -> conversation routes: catalogue, first page, next page and fo
CAUGHT unknown params allowed -> conversation routes: catalogue, first page, next page and fo
CAUGHT reconcile dropped -> conversation routes: catalogue, first page, next page and fo
CAUGHT cursor ignored -> conversation routes: catalogue, first page, next page and fo
CAUGHT registrations ignored -> conversation routes: catalogue, first page, next page and fo
CAUGHT cursor without branch served -> conversation routes: catalogue, first page, next page and fo
CAUGHT unavailable falls to 422 -> every refusal code the reader can raise has an HTTP status
CAUGHT CORS header sent -> conversation routes: catalogue, first page, next page and fo
@@ -0,0 +1,59 @@
import subprocess, sys, shutil, os
S="/tmp/dewey-chat02/scratch/packages/conversation"
M=[
("no O_NOFOLLOW + no lstat symlink","src/safe-fs.mjs",[("constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK","constants.O_RDONLY"),(' if (before.isSymbolicLink()) throw new Refusal("unsafe-path", "session file is a symlink");\n',''),(' if (!before.isFile()) throw',' if (false) throw'),("if (!st.isFile() || st.dev !== before.dev","if (st.dev !== before.dev")]),
("dir components unchecked","src/safe-fs.mjs",[(' if (st.isSymbolicLink()) throw new Refusal("unsafe-path", "session root contains a symlink");\n',''),(' if (!st.isDirectory()) throw',' if (false) throw')]),
("listing follows symlinks","src/safe-fs.mjs",[(' else if (dirent.isSymbolicLink()) refused.push({ name: dirent.name, code: "unsafe-path" });\n','')]),
("no prefix digest check","src/reader.mjs",[('if (pinned.digest !== state.digest) throw','if (false) throw')]),
("no dev/ino check","src/reader.mjs",[('if (pinned.file.dev !== state.dev || pinned.file.ino !== state.ino) throw','if (false) throw'),('if (fresh.file.dev !== state.dev || fresh.file.ino !== state.ino || fresh.length < state.length) throw','if (fresh.length < state.length) throw')]),
("no shorter check","src/reader.mjs",[('if (pinned.shorter) throw','if (false) throw')]),
("cwd always in project","src/reader.mjs",[(' if (typeof cwd !== "string" || !isAbsolute(cwd)) return false;',' return true;')]),
("relative cwd accepted","src/reader.mjs",[(' if (typeof cwd !== "string" || !isAbsolute(cwd)) return false;',' if (typeof cwd !== "string" || !cwd) return false;')]),
("no char cap","src/parts.mjs",[("chars + 1 > LIMITS.chars ||","false ||")]),
("no page byte cap","src/parts.mjs",[("if (bytes + add > LIMITS.pageBytes) break;","")]),
("no block cap","src/parts.mjs",[("current.length === LIMITS.blocks ||","false ||")]),
("no foreign check","src/reader.mjs",[("if (actor !== record.actor || purpose !== record.purpose || conversation !== record.conversation || branch !== record.branch) {","if (false) {")]),
("no branch binding","src/reader.mjs",[("|| branch !== record.branch) {",") {")]),
("no expiry","src/reader.mjs",[("if (Date.parse(record.expiresAt) <= now()) {","if (false) {")]),
("no parentSession notice","src/pi.mjs",[(" if (parsed.header.parentSession !== undefined && parsed.header.parentSession !== null) {\n units.push"," if (false) {\n units.push")]),
("branch param ignored","src/reader.mjs",[("const built = build(snap, found.root, branch ?? null, conversation);","const built = build(snap, found.root, null, conversation);")]),
("unknown branch served","src/reader.mjs",[("if (!parsed.branches.has(branch)) return null;","if (false) return null;")]),
("registration harness ignored","src/reader.mjs",[("unsupportedReason: harness !== null && harness !== HISTORY ? UNSUPPORTED_HARNESS : null,","unsupportedReason: null,")]),
("no placeholder roots","src/reader.mjs",[(" if (!reg || reg.layout !== \"repo\""," if (true || reg.layout !== \"repo\"")]),
("malformed notices dropped","src/pi.mjs",[('out.push({ notice: "malformed", lines: [pending[mi++].line] });','mi++;')]),
("bridge restored","src/pi.mjs",[(' path.push({ notice: "missing-parent", lines: lost });\n break;',' const prev = r.index > 0 ? parsed.byId.get(parsed.entries[r.index - 1].entry.id) : null;\n if (lost.length && prev) { path.push({ notice: "missing-parent", lines: lost }); r = prev; continue; }\n path.push({ notice: "missing-parent", lines: lost });\n break;')]),
("missing-parent lines not named","src/pi.mjs",[("const lost = parsed.malformed.filter((m) => m.after === r.index - 1).map((m) => m.line);","const lost = [];")]),
("trailing malformed only on default","src/pi.mjs",[(" flushBefore(Infinity);\n return out;"," if (leaf === parsed.defaultLeaf) flushBefore(Infinity);\n return out;")]),
("branch named by leaf","src/pi.mjs",[(" const branchOf = (leaf) => {"," const branchOf = (leaf) => branchName(leaf);\n const unused = (leaf) => {")]),
("later child continues branch","src/pi.mjs",[("if (children.get(p.entry.id)[0] !== r) return branchName(r);","if (children.get(p.entry.id).at(-1) !== r) return branchName(r);")]),
("first root not main","src/pi.mjs",[("return r === firstRoot ? MAIN : branchName(r);","return branchName(r);")]),
("no empty main","src/pi.mjs",[(" if (!branches.size) branches.set(MAIN, null);\n","")]),
("redacted thinking shown","src/pi.mjs",[('const t = b.redacted === true ? "" : typeof b.thinking','const t = typeof b.thinking')]),
("EACCES on a component rethrown","src/safe-fs.mjs",[('throw denied(err, "a session path component");','throw err;')]),
("incomplete never set","src/reader.mjs",[("incomplete: buf.length > length };","incomplete: false };")]),
("next re-reads fresh","src/reader.mjs",[("const pinned = snapshot(state.root, state.name, state.length);","const pinned = { ...snapshot(state.root, state.name), length: state.length };")]),
("follow skips history check","src/reader.mjs",[("if (!built || built.entries.length < state.offset || idsDigest(built.entries, state.offset) !== state.prefix) {","if (!built) {")]),
("follow skips the id digest","src/reader.mjs",[("|| idsDigest(built.entries, state.offset) !== state.prefix) {",") {")]),
("follow reads the default branch","src/reader.mjs",[("const built = build(fresh, state.root, state.branch, conversation);","const built = build(fresh, state.root, null, conversation);")]),
("catalogue writes a file","src/reader.mjs",[(" function catalogue() {"," function catalogue() {\n for (const r of listRoots()) { try { require_(r); } catch {} }")]),
("thinking visibility wrong","src/pi.mjs",[('visibility: t ? "permitted-visible" : "unavailable"','visibility: "permitted-visible"')]),
("raw tool ids","src/pi.mjs",[("call: safeId(b.id), name: safeId(b.name)","call: String(b.id), name: String(b.name)")]),
("id namespaces dropped","src/pi.mjs",[("id: `n.${e.id}`","id: e.id")]),
("surrogate split","src/parts.mjs",[("const width = cp > 0xffff ? 2 : 1;","const width = 1;")]),
]
orig={}
for f in ["src/safe-fs.mjs","src/reader.mjs","src/parts.mjs","src/pi.mjs"]:
orig[f]=open(os.path.join(S,f)).read()
for name,f,reps in M:
src=orig[f]
for a,b in reps:
if a not in src: print("NOT APPLIED",name,repr(a[:60])); break
src=src.replace(a,b)
else:
if name=="catalogue writes a file":
src=src.replace("require_(r);",'(await_import => 0)(); (require_fs => 0)(); import_fs.writeFileSync(r.dir + "/x.tmp", "")').replace('import { createHash, randomBytes } from "node:crypto";','import { createHash, randomBytes } from "node:crypto";\nimport * as import_fs from "node:fs";')
open(os.path.join(S,f),"w").write(src)
r=subprocess.run(["node","--test","tests/"],cwd=S,capture_output=True,text=True,timeout=600)
fails=sorted(set(l.strip()[2:].split(" (")[0] for l in r.stdout.splitlines() if l.lstrip().startswith("✖") and "failing tests" not in l))
print(("CAUGHT " if r.returncode else "MISSED ")+name+" -> "+"; ".join(x[:70] for x in fails)[:300])
open(os.path.join(S,f),"w").write(orig[f])
@@ -0,0 +1,2 @@
{ convs: 18, pages: 102, entries: 8693, refusals: {}, maxMs: 798 }
catalogue 19 rows in 75 ms, refusedRoots []; 186 sampled pages and cursors: 0 schema-invalid; every page branch: main (102)
@@ -0,0 +1,37 @@
import { discoverRepoAgents } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs";
import { rootsFromSpecs, createReader } from "/mnt/storage/src/mosaic-stack/packages/conversation/src/reader.mjs";
import { readRegistration, LAYOUTS } from "/mnt/storage/src/mosaic-stack/packages/seat/src/seat.mjs";
import { readdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
const seats = homedir() + "/.mosaic-dev/seats";
const regs = [];
import { loadRegistrations } from "/mnt/storage/src/mosaic-stack/packages/control-board/src/scan.mjs"; regs.push(...loadRegistrations(seats).registrations);
const roots = rootsFromSpecs(discoverRepoAgents("/mnt/storage/src/mosaic-stack"), regs);
console.log(roots.map(r => `${r.seat} ${r.harness} ${r.unsupportedReason} ${r.engineStartedAt}`).join("\n"));
const reader = createReader({ roots });
let t = Date.now();
const cat = reader.catalogue();
console.log("catalogue", cat.conversations.length, "ms", Date.now() - t, "refusedRoots", JSON.stringify(cat.refusedRoots));
const byAvail = {}; for (const c of cat.conversations) byAvail[c.availability + ":" + c.refusal] = (byAvail[c.availability + ":" + c.refusal] ?? 0) + 1;
console.log(byAvail);
console.log(cat.conversations.slice(0, 3));
const pages = [];
let stats = { convs: 0, pages: 0, entries: 0, refusals: {} , maxMs: 0};
for (const c of cat.conversations.filter(c => c.availability === "available")) {
t = Date.now();
let r = reader.open({ conversation: c.conversation });
if (!r.ok) { stats.refusals[r.refusal.code] = (stats.refusals[r.refusal.code] ?? 0) + 1; continue; }
stats.convs++;
let n = 0;
while (true) {
stats.pages++; stats.entries += r.page.entries.length; n++;
if (pages.length < 400) pages.push(r.page);
if (r.cursor) pages.length < 400 && pages.push(r.cursor);
if (!r.page.hasMore) break;
r = reader.next({ cursor: r.page.nextCursor, conversation: c.conversation, branch: r.page.branch });
if (!r.ok) { console.log("next refused", r.refusal); break; }
}
stats.maxMs = Math.max(stats.maxMs, Date.now() - t);
}
console.log(stats);
writeFileSync("/tmp/dewey-chat02/pages.json", JSON.stringify(pages));