Files
stack/packages/conversation/src/transcript.mjs
T
jason.woltjeandClaude Opus 5.5 243e153c8b 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]>
2026-10-04 15:47:53 -05:00

305 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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") : "…";
}