feat(conversation): CHAT-03 I1, mediated control of a sealed headless Pi (#1507)

Controller, claim store, live-session guard, engine link and seal,
turn tracker, cohort force stop and recovery, client library,
transcript and mediated terminal, with the fake engine and tests.
Fixtures only; no live cutover.

Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694)
approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files).
Suites on an export: conversation 152/152, control-board 124, webui 14,
seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green.
Follow-ups for I3 are in DEFERRED. Gate E stays with Jason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 15:47:53 -05:00
co-authored by Claude Opus 5.5
parent ddd9cf3635
commit 243e153c8b
35 changed files with 10317 additions and 11 deletions
+304
View File
@@ -0,0 +1,304 @@
// The transcript view shared by the library and the terminal (#1507, CHAT-03
// §9, E1–E7).
//
// It joins a history page with the live event stream. Replay is unavailable
// in CHAT-03, and page entries and events carry different IDs, so overlap at
// the seam can't be deduplicated (CHAT-01 lines 104–110). The rule is
// reconciliation instead:
//
// - The controller's seam says where live events start (`fromSequence`) and
// whether the cut was quiet: no run visible and no prompt in the slot, so
// every earlier message had settled and was persisted.
// - A cut that wasn't quiet puts a reconcile marker at the seam. Near it a
// message may show twice or be missing. The view re-reads the page after
// the next `run-settled` and clears the marker once a read is quiet.
// - A sequence gap, a new stream epoch or a repeated event ID with different
// bytes also marks the seam and re-reads at once. A gap is never
// concatenated across.
// - A repeated event ID with identical bytes is dropped (E3).
//
// Tool progress (`tool-start`, `tool-update`, `tool-end`) shows a call while
// it runs. Once a finished tool message carries that call's result, from the
// stream or the page, the progress item is hidden, so each message appears
// once.
export const RECONCILE_MARKER = "-- reconciling: messages near here may repeat or be missing until the run settles --";
const MESSAGE_EVENTS = new Set(["message-start", "text-delta", "thinking-delta", "message-end"]);
const TOOL_EVENTS = new Set(["tool-start", "tool-update", "tool-end"]);
export class Transcript {
// With a client, the view follows it: it reads the page on every welcome
// and re-reads when the stream asks for it.
constructor({ client = null } = {}) {
this.client = client;
this.entries = [];
this.events = [];
this.held = [];
this.seen = new Map();
this.seam = null;
this.expected = null;
this.reconcile = false;
this.reason = null;
this.refusal = null;
this.loads = 0;
this.loading = null;
this.again = false;
this.listeners = new Set();
if (client) client.on((m) => this.#onClient(m));
// Attached to a client that is already connected: no welcome will come,
// so read the page now.
if (client && !client.closed && client.connection) void this.reload("attach");
}
on(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
#changed() {
for (const fn of this.listeners) fn();
}
#onClient(m) {
if (m.type === "welcome") {
void this.reload("connect");
return;
}
if (m.type !== "push" || m.kind !== "event") return;
const why = this.event(m.event);
if (why) void this.reload(why);
this.#changed();
}
// Installs a full read of the page (every entry, in order) and its seam.
load(entries, seam) {
this.entries = entries.slice();
this.seam = { ...seam };
this.reconcile = seam.quiet !== true;
this.reason = this.reconcile ? "cut" : null;
this.loads += 1;
const kept = [...this.events, ...this.held].filter((e) => e.streamEpoch === seam.streamEpoch && e.sequence >= seam.fromSequence).sort((a, b) => a.sequence - b.sequence);
this.events = [];
this.held = [];
this.expected = seam.fromSequence;
for (const e of kept) {
if (e.sequence < this.expected) continue;
if (e.sequence > this.expected) {
this.#mark("gap");
this.held = kept.filter((x) => x.sequence > this.expected);
// A run settled past the gap: the next read starts after it.
if (this.held.some((x) => x.type === "run-settled")) this.again = true;
break;
}
this.events.push(e);
this.expected = e.sequence + 1;
}
}
// Takes one live event. Returns why the page must be re-read, or null.
event(e) {
const bytes = JSON.stringify(e);
const prior = this.seen.get(e.id);
if (prior !== undefined) return prior === bytes ? null : this.#mark("conflict");
this.seen.set(e.id, bytes);
// Events past a gap or in another epoch are held, not shown, until the
// next read; the read keeps those after its seam.
if (!this.seam) {
this.held.push(e);
return null;
}
// Once something is held a read is already due; a later held event asks
// again only when its run settles, when the page has caught up.
if (this.held.length) {
this.held.push(e);
return e.type === "run-settled" ? "settled" : null;
}
if (e.streamEpoch !== this.seam.streamEpoch) {
this.held.push(e);
return this.#mark("epoch");
}
if (e.sequence < this.expected) return null;
if (e.sequence > this.expected) {
this.held.push(e);
return this.#mark("gap");
}
this.events.push(e);
this.expected = e.sequence + 1;
return e.type === "run-settled" && this.reconcile ? "settled" : null;
}
#mark(why) {
this.reconcile = true;
this.reason = why;
return why;
}
// Reads every page and installs it. Overlapping calls coalesce; a call made
// while a read is in flight runs one more read after it.
reload(why = "manual") {
if (!this.client) return Promise.resolve();
if (this.loading) {
this.again = true;
return this.loading;
}
this.loading = (async () => {
do {
this.again = false;
const r = await this.#readAll();
if (!r.ok) {
this.refusal = r.refusal;
break;
}
this.refusal = null;
this.load(r.entries, r.seam);
} while (this.again);
})().finally(() => {
this.loading = null;
this.#changed();
});
this.lastReload = why;
return this.loading;
}
async #readAll() {
let r = await this.client.observe();
if (r.outcome !== "observing") return { ok: false, refusal: r.refusal ?? r.outcome };
const seam = r.data.seam;
const entries = [...r.data.page.entries];
while (r.data.page.hasMore) {
r = await this.client.observe({ cursor: r.data.page.nextCursor });
if (r.outcome !== "observing") return { ok: false, refusal: r.refusal ?? r.outcome };
entries.push(...r.data.page.entries);
}
return { ok: true, entries, seam };
}
// The view: page messages, the marker when the seam is unreconciled, then
// live messages. Each item is {key, role, source, final, text} or
// {marker: true, text, reason}.
messages() {
const items = [];
for (const en of this.entries) {
const last = items.at(-1);
const it = last && last.key === en.message ? last : null;
if (it) it.parts.set(en.part, en.content);
else items.push({ key: en.message, role: en.role, source: "page", parts: new Map([[en.part, en.content]]), lastPart: null, exec: false });
if (en.lastPart) (it ?? items.at(-1)).lastPart = en.part;
}
const pageCount = items.length;
const live = new Map();
const epoch = this.seam?.streamEpoch;
for (const e of this.events) {
if (!this.seam || e.streamEpoch !== epoch) continue;
if (!MESSAGE_EVENTS.has(e.type) && !TOOL_EVENTS.has(e.type)) continue;
let it = live.get(e.message);
if (!it) {
it = { key: e.message, role: e.role, source: "live", parts: new Map(), lastPart: null, stream: new Map(), exec: TOOL_EVENTS.has(e.type), status: null, call: null };
live.set(e.message, it);
items.push(it);
}
if (e.type === "text-delta" || e.type === "thinking-delta") {
for (const b of e.content) {
const s = it.stream.get(b.block) ?? { type: b.type, text: "", visibility: b.visibility };
s.text += b.text ?? "";
it.stream.set(b.block, s);
}
} else if (e.type === "message-end") {
it.role = e.role;
it.parts.set(e.part, e.content);
if (e.lastPart) it.lastPart = e.part;
} else if (TOOL_EVENTS.has(e.type)) {
it.call = e.content[0]?.call ?? it.call;
if (e.type === "tool-start") {
it.status = "running";
it.name = e.content[0]?.name ?? null;
} else {
it.status = e.type === "tool-end" ? "done" : "running";
it.result = e.content;
}
}
}
const finals = new Set();
for (const it of items) {
if (!isFinal(it) || it.exec) continue;
for (const b of blocksOf(it)) if (b.type === "tool-result") finals.add(b.call);
}
const out = [];
items.forEach((it, i) => {
if (i === pageCount && this.reconcile) out.push(this.#marker());
if (it.exec && finals.has(it.call)) return;
out.push({ key: it.key, role: it.role, source: it.source, final: isFinal(it), text: textOf(it) });
});
if (items.length === pageCount && this.reconcile) out.push(this.#marker());
return out;
}
#marker() {
return { marker: true, text: RECONCILE_MARKER, reason: this.reason };
}
// One line per message (a message may span several lines of text).
lines() {
return this.messages().map((m) => (m.marker ? m.text : `${m.role}: ${m.text}`));
}
}
function isFinal(it) {
if (it.lastPart === null) return false;
for (let p = 0; p <= it.lastPart; p++) if (!it.parts.has(p)) return false;
return true;
}
function blocksOf(it) {
const out = [];
const n = it.lastPart ?? Math.max(-1, ...it.parts.keys());
for (let p = 0; p <= n; p++) out.push(...(it.parts.get(p) ?? []));
return out;
}
// Joins fragments by block and renders each block.
export function renderBlocks(blocks) {
const joined = [];
for (const b of blocks) {
const last = joined.at(-1);
if (last && last.block === b.block && last.type === b.type && (b.fragment ?? 0) > 0 && (last.call ?? null) === (b.call ?? null)) {
last.text = (last.text ?? "") + (b.text ?? "");
last.argumentsText = (last.argumentsText ?? "") + (b.argumentsText ?? "");
last.summary = (last.summary ?? "") + (b.summary ?? "");
} else joined.push({ ...b });
}
return joined.map(renderBlock).join("\n");
}
function renderBlock(b) {
switch (b.type) {
case "text":
return b.text ?? "";
case "thinking":
return b.visibility === "unavailable" ? "(thinking not shown)" : `(thinking) ${b.text ?? ""}`;
case "tool-call":
return `[tool-call ${b.name} ${b.argumentsText ?? ""}]`;
case "tool-result":
return `[tool-result${b.isError ? " error" : ""}] ${b.text ?? ""}`;
case "compaction":
return `[compaction] ${b.summary ?? ""}`;
case "attachment":
return `[attachment ${b.attachment}]`;
default:
return `[${b.type}]`;
}
}
function textOf(it) {
if (isFinal(it)) {
const blocks = blocksOf(it);
return blocks.length ? renderBlocks(blocks) : "(empty)";
}
if (it.exec) {
const head = `[tool ${it.name ?? it.call} ${it.status}]`;
return it.result?.length ? `${head} ${renderBlocks(it.result)}` : head;
}
const parts = [...it.stream.entries()].sort(([a], [b]) => a - b).map(([, s]) => renderBlock(s));
return parts.length ? parts.join("\n") : "…";
}