Files
Codeman/src/story-mode.ts
T

1872 lines
74 KiB
TypeScript
Raw Normal View History

/**
* @fileoverview Story Mode core (pure): a Claude Code transcript becomes comic pages, one page per turn.
*
* Three stages, each pure so the tests exercise the shipped code:
*
* 1. `createStoryReducer()` turns ONE parsed transcript line into compact `StoryEntry`s. This is where
* size is shed: a Read result's file content, a Write/Edit result's `originalFile` and image data
* never leave this function, outputs keep only a bounded tail, and an edit keeps its
* `structuredPatch` hunks up to `STORY_LIMITS.hunkLines`. The reader (story-reader.ts) caches
* these entries, so what is dropped here is never held in memory.
* 2. `buildStoryTurns()` joins each tool call with its result and splits the entries into turns.
* 3. `buildStoryPage()` groups one turn's events into panels and writes two captions per panel:
* a plain-language "story" line for anyone and a "director" line naming the tools.
*
* What the format rests on (measured 2026-10-18 on 40 local transcripts, counts and key names only):
* - Interactive transcripts carry NO `type: 'result'` entries. A turn ends with
* `{ type: 'system', subtype: 'turn_duration', durationMs }`; a compaction is `compact_boundary`.
* - A prompt is a `user` entry with text and no `tool_result`. `isMeta` and `isCompactSummary`
* entries are plumbing, `turnCompanion` rides along with a prompt, and `turnOrigin` says who
* started the turn (`human`, `task_notification`, `peer`, `system`, `scheduled`, `sdk`).
* - Tool results carry `toolUseResult` beside the `tool_result` block: every Edit result had a
* `structuredPatch` with line numbers (125 of 125), Write results the new `content`, Agent
* results `totalToolUseCount`/`totalDurationMs`, AskUserQuestion results `answers`.
* - Bash results carry no exit code; a failed call's `tool_result` has `is_error: true`
* (72 of 3,334, most starting "Exit code"). 92% of Bash calls carry Claude's own
* `description` ("Run the test suite"), which is what the plain captions use.
* - Bash is 78% of all tool calls, so a Bash call is sorted by what its command does
* (`classifyBashCommand`), not by its tool name.
*
* Everything here is untrusted text written by the agent. Nothing is interpreted: no path is
* opened, no command run, no regex built from transcript content.
*/
import type {
StoryActor,
StoryEdit,
StoryEvent,
StoryHelper,
StoryHunk,
StoryIntent,
StoryPage,
StoryPanel,
StoryPanelKind,
StoryTests,
StoryTodo,
StoryTurnOrigin,
StoryTurnSummary,
} from './types/story.js';
/** Size caps. A page stays small enough to fetch on every live refresh. */
export const STORY_LIMITS = Object.freeze({
promptChars: 4000,
textChars: 600,
labelChars: 400,
inputChars: 2000,
outputChars: 8000,
outputLines: 160,
hunkLines: 160,
lineChars: 400,
helperChars: 4000,
todoItems: 12,
eventsPerPage: 800,
panelsPerPage: 12,
/** Characters of output, hunk and helper text per page; older events give theirs up first. */
pageTextBudget: 400_000,
turnsListed: 60,
});
// ========== Entries (stage 1 output) ==========
interface ReducedInput {
command?: string;
/** A Bash call's intent, judged on the whole command before `command` is clipped for display. */
intent?: StoryIntent;
/** Files a Bash call prints (`cat`, `head`, `tail`), from the whole command. */
reads?: string[];
/** A save that ships: `gh pr create` opens a pull request, `git push` sends the work up. */
ships?: 'pr' | 'push';
/** The segment of a Bash one-liner that decided its intent. */
headline?: string;
/** Files a Bash call writes through here-documents. */
writes?: StoryEdit[];
description?: string;
file?: string;
offset?: number;
old?: string;
new?: string;
pattern?: string;
path?: string;
url?: string;
query?: string;
type?: string;
name?: string;
prompt?: string;
todos?: StoryTodo[];
question?: string;
options?: string[];
plan?: string;
to?: string;
summary?: string;
text?: string;
}
export type StoryEntry =
| {
k: 'prompt';
id: string;
at: string;
text: string;
origin: StoryTurnOrigin;
queued: boolean;
/** A background task reporting back: which call it answers, and how it went. */
task?: StoryTaskReport;
/** Teammates (helpers started as a team) reporting back, matched to their helper by name. */
teammates?: StoryTeammateReport[];
}
| { k: 'text'; at: string; text: string }
| { k: 'tool'; at: string; id: string; name: string; input: ReducedInput }
| {
k: 'result';
at: string;
id: string;
isError: boolean;
output?: string;
outputTruncated?: boolean;
edit?: StoryEdit;
helper?: { result?: string; toolUses?: number; durationMs?: number; agentId?: string; background?: boolean };
answer?: string;
tests?: StoryTests;
}
| { k: 'end'; at: string; durationMs?: number }
| { k: 'compact'; at: string }
| { k: 'interrupt'; at: string };
/** What a `<task-notification>` says about the background call it answers. */
interface StoryTaskReport {
toolUseId?: string;
status?: string;
result?: string;
toolUses?: number;
durationMs?: number;
}
/** One `<teammate-message>`: a helper started as a teammate, reporting back by name. */
interface StoryTeammateReport {
from: string;
summary?: string;
result?: string;
}
// ========== Small helpers ==========
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
export function clip(value: string, max: number): string {
const s = value.trim();
return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s;
}
/** The END of a long output: that is where the result and the error are. */
function clipTail(value: string, maxChars: number, maxLines: number): { text: string; truncated: boolean } {
let lines = value.replace(/\s+$/, '').split('\n');
let truncated = false;
if (lines.length > maxLines) {
lines = lines.slice(-maxLines);
truncated = true;
}
let text = lines.join('\n');
if (text.length > maxChars) {
text = text.slice(-maxChars);
truncated = true;
}
return { text, truncated };
}
/** Text of a message `content`: a string, or the text blocks of a block array. */
function blockText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.map((b) => (isRecord(b) && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
.filter(Boolean)
.join('\n');
}
const basename = (path: string): string => path.slice(path.replace(/\/+$/, '').lastIndexOf('/') + 1) || path;
const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`;
const unique = <T>(items: T[]): T[] => [...new Set(items)];
/**
* Markdown to plain text: only real markdown goes, paired emphasis and line-start markers. A bare `_`
* or `*` inside a word stays, or `test_wcount.py` would read `testwcount.py`. Code spans come back
* as placeholders (`\u0000<n>\u0000`, their text in `spans`) so nothing inside one is read as
* markdown or as the end of a sentence.
*/
function plainMarkdown(text: string, spans: string[]): string {
return text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`\n]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`)
.replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, '$1')
.replace(/(^|[\s(])(\*\*|__)(?=\S)([^\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
.replace(/(^|[\s(])([*_])(?=\S)([^*_\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
.replace(/^[ \t]{0,3}(#{1,6}|>|[-*+]|\d+[.)])[ \t]+/gm, '');
}
const restoreSpans = (text: string, spans: string[]): string =>
text.replace(/\u0000(\d+)\u0000/g, (_, n: string) => spans[Number(n)] ?? '');
/**
* Where a sentence ends: `.`, `!` or `?` before a space or the end, but not inside an ellipsis
* (`...`) and not after `e.g` or `i.e`.
*/
const SENTENCE_END = /^(.+?(?<!\.)(?<!\b(?:e\.g|i\.e|vs|cf))[.!?](?!\.))(?=\s|$)/;
/** The first sentence of a message, without markdown, for a speech balloon. */
export function firstSentence(text: string, max = 160): string {
const spans: string[] = [];
const plain = plainMarkdown(text, spans).replace(/\s+/g, ' ').trim();
const match = SENTENCE_END.exec(plain);
return clip(restoreSpans(match ? match[1] : plain, spans).replace(/\s+/g, ' '), max);
}
/**
* A plan Claude wrote as text (no plan tool): three numbered items or more, or two checkboxes or
* more. Plain bullets never count; a summary at the end of a turn is usually a bullet list.
*/
export function planFromText(text: string): StoryTodo[] | null {
const items: StoryTodo[] = [];
let numbered = 0;
let boxes = 0;
for (const line of text.split('\n')) {
const m = /^\s{0,3}(?:(\d+)[.)]|[-*+])\s+(\[[ xX]\]\s+)?(\S.*)$/.exec(line);
if (!m || (!m[1] && !m[2])) continue;
if (m[2]) boxes++;
else numbered++;
const spans: string[] = [];
const plain = restoreSpans(plainMarkdown(m[3], spans), spans).replace(/\s+/g, ' ').trim();
if (plain) items.push({ text: clip(plain, 120), done: /x/i.test(m[2] ?? '') });
}
return numbered >= 3 || boxes >= 2 ? items.slice(0, STORY_LIMITS.todoItems) : null;
}
export function roughDuration(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return plural(s, 'second');
const m = Math.round(s / 60);
if (m < 60) return plural(m, 'minute');
const h = Math.floor(m / 60);
const rest = m % 60;
return rest ? `${h} h ${rest} min` : plural(h, 'hour');
}
export function exactDuration(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} min ${s % 60} s`;
return `${Math.floor(m / 60)} h ${m % 60} min`;
}
const fmtCounts = (added: number, removed: number): string =>
[added ? `+${added}` : '', removed ? `−${removed}` : ''].filter(Boolean).join(' ') || '±0';
// ========== Bash commands and test counts ==========
/**
* Splits a command line into its simple commands at `&&`, `||`, `;`, `|` and newlines, outside quotes.
* Not a shell parser: just enough to see what each part of a one-liner starts with.
*/
function commandSegments(command: string): string[] {
const segments: string[] = [];
let cur = '';
let quote = '';
for (let i = 0; i < command.length; i++) {
const ch = command[i];
if (quote) {
if (ch === quote) quote = '';
else if (ch === '\\' && quote === '"') cur += command[++i] ?? '';
cur += ch;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
cur += ch;
continue;
}
if (ch === ';' || ch === '\n' || ch === '|' || (ch === '&' && command[i + 1] === '&')) {
if (ch === '&' || (ch === '|' && command[i + 1] === '|')) i++;
segments.push(cur);
cur = '';
continue;
}
cur += ch;
}
segments.push(cur);
return segments.map((x) => x.trim()).filter(Boolean);
}
/** A segment's command with what only wraps it removed: env assignments, `sudo`, `timeout 60`, `( ...`. */
function segmentHead(segment: string): string {
let s = segment.replace(/^[({\s]+/, '');
for (let k = 0; k < 6; k++) {
const next = s
.replace(/^[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\S*)\s+/, '')
.replace(/^(sudo|command|exec|time|nice|nohup|env|xargs)\s+/, '')
.replace(/^timeout\s+(-\S+\s+)*\S+\s+/, '');
if (next === s) break;
s = next;
}
// `git -c k=v -C dir commit`: drop git's own options so the subcommand comes next.
return s.replace(/^git((\s+-[cC]\s+\S+)|(\s+--\S+))*\s+/, 'git ');
}
const TEST_HEAD =
/^(npm (run )?test\b|npm run test:\S+|yarn (run )?test\b|pnpm (run )?test\b|bun test\b|npx (vitest|jest|mocha|playwright test)\b|vitest\b|jest\b|mocha\b|pytest\b|py\.test\b|python3? -m (pytest|unittest)\b|node --test\b|deno test\b|go test\b|cargo (test|nextest)\b|make (test|check)\b|rspec\b|phpunit\b|mix test\b|dotnet test\b|ctest\b|tox\b)/;
const BUILD_HEAD =
/^(npm run (build|typecheck|lint|check|format)\S*|yarn (run )?(build|lint|typecheck)\b|pnpm (run )?(build|lint|typecheck)\b|tsc\b|npx (tsc|eslint|prettier|biome)\b|eslint\b|prettier\b|biome\b|make\b|cargo (build|check|clippy)\b|go (build|vet)\b|gradlew?\b|\.\/gradlew\b|mvn\b|ruff\b|mypy\b|black\b|cmake\b|ninja\b|shellcheck\b|node --check\b)/;
const SAVE_HEAD = /^(git (commit|push|tag|merge|rebase|cherry-pick|revert|am)\b|gh (pr|release) (create|merge)\b)/;
/** Editors that change files in place. `tee` is not one: in a pipeline it keeps a log. */
const EDIT_HEAD = /^(sed (-i|--in-place)\b|perl -p?i\b|patch\b|git apply\b)/;
/** Where a redirect writes scratch, not the project: temp dirs, devices, and paths held in variables. */
const SCRATCH_TARGET = /^(\/tmp\/|\/var\/tmp\/|\/dev\/|\$|~\/\.cache\/)/;
const LOOK_HEAD =
/^(cd|cat|head|tail|sed -n|grep|egrep|rg|find|ls|wc|awk|jq|tree|stat|file|du|df|diff|less|more|xxd|od|which|type|pwd|echo|printf|sort|uniq|cut|basename|dirname|realpath|readlink|git (status|diff|log|show|branch|blame|ls-files|rev-parse|remote)|gh (pr|issue|run|api) (view|list|diff|checks))\b/;
/** A probe that asks a tool about itself without running anything: `pytest --version`, `npm test --help`. */
const PROBE = /\s--(version|help|collect-only)\b|\s-[Vh]\s*$/;
/**
* Whether a segment writes a project file through a redirect (`> notes.md`, `>> app.log`, `cat > a.py`).
* Quote-aware: a `>` inside a quoted string is text. A redirect into scratch (`/tmp/x`, `"$S/out.txt"`,
* `/dev/null`) or onto a stream (`2>&1`) is not an edit, and the target must look like a file.
*/
function redirectsIntoProject(segment: string): boolean {
let quote = '';
for (let i = 0; i < segment.length; i++) {
const ch = segment[i];
if (quote) {
if (ch === quote) quote = '';
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (ch !== '>') continue;
const before = segment[i - 1] ?? ' ';
if (/[0-9&>]/.test(before) && before !== ' ') continue; // `2>`, `&>`, the second `>` of `>>`
let j = i + 1;
if (segment[j] === '>') j++;
if (segment[j] === '&' || segment[j] === '(') continue; // `>&2`, `>(process)`
while (segment[j] === ' ' || segment[j] === '\t') j++;
const q = segment[j] === '"' || segment[j] === "'" ? segment[j++] : '';
let target = '';
while (j < segment.length && (q ? segment[j] !== q : !/[\s|;&<>()]/.test(segment[j]))) target += segment[j++];
if (target && !SCRATCH_TARGET.test(target) && /^[\w./~@+-]*\.\w+$/.test(target)) return true;
i = j;
}
return false;
}
/** A here-document's body is data, not commands: `cat > a.py <<'EOF' ... EOF` keeps only its first line. */
function withoutHeredocBodies(command: string): string {
const out: string[] = [];
let end: string | null = null;
for (const line of command.split('\n')) {
if (end !== null) {
if (line.trim() === end) end = null;
continue;
}
out.push(line);
const m = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line.replace(/<<</g, ' '));
if (m) end = m[2];
}
return out.join('\n');
}
/**
* Files a command prints with `cat`, `head` or `tail`, as named (maybe relative). Nothing when the
* command changes directory first (a relative name would point elsewhere) or names a glob or a variable.
*/
export function printedFiles(command: string): string[] {
const files: string[] = [];
for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 2000)))) {
const head = segmentHead(segment);
if (/^(cd|pushd)\b/.test(head)) return [];
const m = /^(cat|head|tail|nl|bat)\s+(.*)$/.exec(head);
if (!m || /[<>]/.test(m[2])) continue;
const words = m[2].match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
for (let k = 0; k < words.length; k++) {
const w = words[k];
if (w.startsWith('-')) {
if (/^-[nc]$/.test(w)) k++; // `head -n 20 file`
continue;
}
const name = w.replace(/^(["'])(.*)\1$/, '$2');
if (!name || /[*?$`{}[\]]/.test(name) || /^\d+$/.test(name)) continue;
files.push(name);
}
}
return unique(files).slice(0, 8);
}
/**
* Strongest first: one test in a one-liner makes the whole call a test run (its pass or fail is the
* news), and a commit outranks the build or lint that ran before it.
*/
const INTENT_RANK: StoryIntent[] = ['test', 'save', 'build', 'edit', 'run', 'look'];
/**
* What a Bash command does, from the command text alone. Never executes it, and never parses it as
* shell beyond splitting it into simple commands: each part is judged by what it starts with (after
* env assignments and wrappers like `timeout`), and the strongest intent of any part wins.
*/
export function classifyBashCommand(command: string): StoryIntent {
return judgeBashCommand(command).intent;
}
/** The intent of a Bash command and the segment that decided it. */
function judgeBashCommand(command: string): { intent: StoryIntent; segment: string } {
let best = INTENT_RANK.length - 1;
let deciding = '';
for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 4000)))) {
const head = segmentHead(segment);
if (!head) continue;
const intent: StoryIntent = PROBE.test(head)
? 'look'
: TEST_HEAD.test(head)
? 'test'
: BUILD_HEAD.test(head)
? 'build'
: SAVE_HEAD.test(head)
? 'save'
: EDIT_HEAD.test(head) || redirectsIntoProject(head)
? 'edit'
: LOOK_HEAD.test(head)
? 'look'
: 'run';
const rank = INTENT_RANK.indexOf(intent);
if (rank < best || !deciding) {
best = Math.min(best, rank);
deciding = segment.trim();
}
}
return { intent: INTENT_RANK[best], segment: deciding };
}
/**
* Files a command writes through here-documents: `cat > f <<'EOF'`, `cat <<EOF > f`, `tee f <<EOF`
* (and `>>` / `tee -a`, appends). Each becomes a whole-file edit of the body's lines. A relative
* target resolves against the transcript line's `cwd` unless the command changed directory first;
* scratch targets (`/tmp/x`, `"$S/x"`) are not project files. A body piped into a program
* (`python3 - <<EOF`) writes nothing.
*/
export function heredocWrites(command: string, cwd?: string): StoryEdit[] {
const out: StoryEdit[] = [];
const lines = command.slice(0, 200_000).split('\n');
let moved = false;
for (let i = 0; i < lines.length && out.length < 6; i++) {
const line = lines[i];
if (/(^|[;&|(]\s*)(cd|pushd)\s/.test(line.split('<<')[0])) moved = true;
const m = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line.replace(/<<</g, ' '));
if (!m) continue;
const body: string[] = [];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== m[2]) body.push(lines[j++]);
i = j;
const before = line.slice(0, m.index);
const after = line.slice(m.index + m[0].length);
const redirect = /(?:^|[^>&0-9])(>>?)\s*(["']?)([^\s"'|;&<>()]+)\2/;
let target: string | undefined;
let append = false;
if (/\bcat\s*(>|$|\s)/.test(before)) {
const r = redirect.exec(before) ?? redirect.exec(after);
if (r) {
target = r[3];
append = r[1] === '>>';
}
} else {
const tee = /\btee\s+(-a\s+)?(["']?)([^\s"'|;&<>()]+)\2/.exec(before);
if (tee) {
target = tee[3];
append = !!tee[1];
}
}
if (!target || SCRATCH_TARGET.test(target)) continue;
const file =
target.startsWith('/') || !cwd || moved ? target : `${cwd.replace(/\/+$/, '')}/${target.replace(/^\.\//, '')}`;
const kept = body
.slice(0, STORY_LIMITS.hunkLines)
.map((l) => (l.length >= STORY_LIMITS.lineChars ? `+${l.slice(0, STORY_LIMITS.lineChars - 2)}…` : `+${l}`));
out.push({
file,
added: body.length,
removed: 0,
created: false,
hunks: [{ oldStart: append ? 1 : 0, oldLines: 0, newStart: 1, newLines: body.length, lines: kept }],
truncated: kept.length < body.length,
});
}
return out;
}
/**
* Passed/failed counts from a test runner's summary line (vitest, jest, pytest, mocha, cargo...).
* Scans the last lines only; returns null when no summary is recognisable.
*/
export function parseTestCounts(output: string): StoryTests | null {
const lines = output.split('\n').slice(-40).reverse();
// node:test (TAP `# pass 7` / spec reporter `ℹ pass 7`), with its own fail line.
const nodePass = lines.map((l) => /^\s*[#ℹ]\s*pass\s+(\d+)\s*$/.exec(l)).find(Boolean);
if (nodePass) {
const nodeFail = lines.map((l) => /^\s*[#ℹ]\s*fail\s+(\d+)\s*$/.exec(l)).find(Boolean);
return { passed: Number(nodePass[1]), failed: nodeFail ? Number(nodeFail[1]) : 0 };
}
// Errors (pytest's collection errors) are counted apart: the run failed although no test did.
const errorsOn = (line: string) => Number(/\b(\d+) errors?\b/i.exec(line)?.[1] ?? 0);
const counts = (passed: number, failed: number, errors: number): StoryTests =>
errors ? { passed, failed, errors } : { passed, failed };
for (const line of lines) {
const passed = /\b(\d+) (passed|passing)\b/i.exec(line);
if (!passed) continue;
const failedSameLine = /\b(\d+) (failed|failing)\b/i.exec(line);
let failed = failedSameLine ? Number(failedSameLine[1]) : 0;
if (!failedSameLine) {
const failing = lines.map((l) => /^\s*(\d+) failing\b/i.exec(l)).find(Boolean);
if (failing) failed = Number(failing[1]);
}
return counts(Number(passed[1]), failed, errorsOn(line));
}
// Nothing passed: a summary line that only counts failures (`=== 3 failed in 0.12s ===`,
// `Tests 3 failed (3)`, `1 error in 0.05s`).
for (const line of lines) {
if (!/^\s*(=+ |Tests?:?\s|test result:)|\bin [\d.]+m?s\b/.test(line)) continue;
const failed = Number(/\b(\d+) (failed|failing)\b/i.exec(line)?.[1] ?? 0);
if (failed || errorsOn(line)) return counts(0, failed, errorsOn(line));
}
return null;
}
// ========== Stage 1: reduce one transcript line ==========
const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
/** Wrappers around things the user did locally (shell `!`, slash command output): never a turn. */
const LOCAL_WRAPPER_RE =
/^\s*<(local-command-caveat|local-command-stdout|local-command-stderr|bash-input|bash-stdout|bash-stderr)>/;
/**
* Messages from teammates: `<teammate-message teammate_id="x" summary="...">body</teammate-message>`,
* where the body is text or a JSON notification (`{"type":"idle_notification","result":"..."}`).
*/
function readTeammateMessages(text: string): StoryTeammateReport[] {
const reports: StoryTeammateReport[] = [];
for (const m of text.matchAll(/<teammate-message\b([^>]*)>([\s\S]*?)<\/teammate-message>/g)) {
const from = /teammate_id="([^"]*)"/.exec(m[1])?.[1]?.split('@')[0];
if (!from) continue;
const summary = /summary="([^"]*)"/.exec(m[1])?.[1];
let body: string | undefined = m[2].trim();
if (body.startsWith('{')) {
try {
const note: unknown = JSON.parse(body);
body = isRecord(note) ? (str(note.result) ?? str(note.summary) ?? str(note.message)) : undefined;
} catch {
// not JSON after all: keep it as text
}
}
reports.push({
from: clip(from, 80),
summary: summary ? clip(summary, 200) : undefined,
result: body || undefined,
});
}
return reports;
}
function readPrompt(raw: string): {
text: string;
kind: 'prompt' | 'task' | 'peer' | 'interrupt';
task?: StoryTaskReport;
teammates?: StoryTeammateReport[];
} | null {
const text = raw
.replace(SYSTEM_REMINDER_RE, '')
.replace(/<\/?pasted_content\b[^>]*>/g, '') // Claude Code's wrapper around pasted text
.replace(/^\s*Another Claude session sent a message:\s*/, '')
.trim();
if (!text) return null;
if (/^<teammate-message\b/.test(text)) {
const teammates = readTeammateMessages(text);
const first = teammates.find((t) => t.summary || t.result) ?? teammates[0];
if (first)
return {
text: `${first.from}: ${first.summary || (first.result ? firstSentence(first.result, 200) : 'reports back')}`,
kind: 'task',
teammates,
};
}
const peer = /^<cross-session-message\b([^>]*)>([\s\S]*?)(?:<\/cross-session-message>|$)/.exec(text);
if (peer) {
const name = /from-name="([^"]*)"/.exec(peer[1])?.[1];
return { text: `${name ? `${name}: ` : ''}${peer[2].trim()}`, kind: 'peer' };
}
if (/^\[Request interrupted/.test(text)) return { text, kind: 'interrupt' };
if (LOCAL_WRAPPER_RE.test(text)) return null;
const command = /<command-name>([^<]*)<\/command-name>/.exec(text);
if (command) {
const args = /<command-args>([^<]*)<\/command-args>/.exec(text)?.[1] ?? '';
return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt' };
}
if (/^<task-notification>/.test(text)) {
const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
const count = (name: string) => {
const n = Number(tag(name));
return tag(name) !== undefined && Number.isFinite(n) ? n : undefined;
};
const task: StoryTaskReport = {
toolUseId: tag('tool-use-id'),
status: tag('status'),
result: tag('result'),
toolUses: count('tool_uses'),
durationMs: count('duration_ms'),
};
return { text: tag('summary') || 'A background task finished.', kind: 'task', task };
}
return { text, kind: 'prompt' };
}
function originOf(turnOrigin: unknown, kind: 'prompt' | 'task' | 'peer'): StoryTurnOrigin {
if (turnOrigin === 'task_notification' || turnOrigin === 'task-notification' || kind === 'task') return 'task';
if (turnOrigin === 'peer' || turnOrigin === 'coordinator' || kind === 'peer') return 'peer';
if (turnOrigin === 'human') return 'human';
if (typeof turnOrigin === 'string') return 'system';
return 'human';
}
function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput {
const i = isRecord(input) ? input : {};
const s = (v: unknown, max: number) => {
const value = str(v);
return value === undefined ? undefined : clip(value, max);
};
switch (name) {
case 'Bash': {
// Judged on the whole command: a commit or a test run often sits past the displayed part.
const full = str(i.command) ?? '';
const { intent, segment } = judgeBashCommand(full);
const input: ReducedInput = {
// Shown without here-document bodies: the files they write are shown as diffs instead.
command: clip(withoutHeredocBodies(full), STORY_LIMITS.labelChars),
description: s(i.description, 160),
intent,
};
if (segment && segment !== full.trim() && (intent === 'test' || intent === 'build' || intent === 'save'))
input.headline = clip(segment, 200);
const writes = heredocWrites(full, cwd);
if (writes.length) input.writes = writes;
if (intent === 'look') {
const reads = printedFiles(full);
if (reads.length) input.reads = reads;
}
if (intent === 'save') {
const ships = commandSegments(full.slice(0, 4000)).map(segmentHead);
if (ships.some((h) => /^gh pr create\b/.test(h))) input.ships = 'pr';
else if (ships.some((h) => /^git push\b/.test(h))) input.ships = 'push';
}
return input;
}
case 'Read':
case 'NotebookRead':
return { file: s(i.file_path ?? i.notebook_path, 1000), offset: num(i.offset) };
case 'Edit':
case 'MultiEdit':
case 'NotebookEdit':
return {
file: s(i.file_path ?? i.notebook_path, 1000),
old: s(i.old_string ?? i.old_str, STORY_LIMITS.inputChars),
new: s(i.new_string ?? i.new_str, STORY_LIMITS.inputChars),
};
case 'Write':
return { file: s(i.file_path, 1000) };
case 'Grep':
case 'Glob':
return { pattern: s(i.pattern, 200), path: s(i.path, 1000) };
case 'WebFetch':
return { url: s(i.url, 400) };
case 'WebSearch':
return { query: s(i.query, 200) };
case 'Agent':
case 'Task':
return {
description: s(i.description, 160),
type: s(i.subagent_type, 60),
name: s(i.name, 60),
prompt: s(i.prompt, STORY_LIMITS.helperChars),
};
case 'TodoWrite': {
const todos = Array.isArray(i.todos) ? i.todos : [];
return {
todos: todos.slice(0, STORY_LIMITS.todoItems).map((t) => ({
text: clip(String((isRecord(t) && (str(t.content) ?? str(t.subject))) || ''), 160),
done: isRecord(t) && t.status === 'completed',
})),
};
}
case 'AskUserQuestion': {
const q = Array.isArray(i.questions) && isRecord(i.questions[0]) ? i.questions[0] : {};
const options = Array.isArray(q.options) ? q.options : [];
return {
question: s(q.question, 300),
options: options.slice(0, 6).map((o) => clip(String((isRecord(o) ? str(o.label) : str(o)) ?? ''), 80)),
};
}
case 'ExitPlanMode':
return { plan: s(i.plan, STORY_LIMITS.helperChars) };
case 'SendMessage':
return { to: s(i.to ?? i.recipient, 80), summary: s(i.summary ?? i.message, 200) };
default: {
const first = Object.values(i).find((v): v is string => typeof v === 'string');
return { text: first ? clip(first, 200) : undefined };
}
}
}
function editFromResult(name: string, result: Record<string, unknown>, input: ReducedInput): StoryEdit | undefined {
const file = str(result.filePath) ?? input.file;
if (!file) return undefined;
const clipLine = (l: string) =>
l.length > STORY_LIMITS.lineChars ? `${l.slice(0, STORY_LIMITS.lineChars - 1)}…` : l;
const hunks: StoryHunk[] = [];
let added = 0;
let removed = 0;
let truncated = false;
let budget: number = STORY_LIMITS.hunkLines;
const patch = Array.isArray(result.structuredPatch) ? result.structuredPatch : [];
for (const h of patch) {
if (!isRecord(h)) continue;
const lines = Array.isArray(h.lines) ? h.lines.filter((l): l is string => typeof l === 'string') : [];
for (const l of lines) {
if (l.startsWith('+')) added++;
else if (l.startsWith('-')) removed++;
}
const kept = lines.slice(0, Math.max(0, budget)).map(clipLine);
budget -= kept.length;
if (kept.length < lines.length) truncated = true;
if (kept.length) {
hunks.push({
oldStart: num(h.oldStart) ?? 0,
oldLines: num(h.oldLines) ?? 0,
newStart: num(h.newStart) ?? 0,
newLines: num(h.newLines) ?? 0,
lines: kept,
});
}
}
const content = str(result.content);
if (!patch.length && name === 'Write' && content !== undefined) {
// A new file has no patch: every line is an addition.
const lines = content.split('\n');
if (content.endsWith('\n')) lines.pop();
added = lines.length;
const kept = lines.slice(0, STORY_LIMITS.hunkLines).map((l) => clipLine(`+${l}`));
truncated = kept.length < lines.length;
hunks.push({ oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: kept });
} else if (!patch.length && input.old !== undefined && input.new !== undefined) {
// An older transcript without `structuredPatch`: show the replacement itself.
const oldLines = input.old.split('\n');
const newLines = input.new.split('\n');
removed = oldLines.length;
added = newLines.length;
const lines = [...oldLines.map((l) => `-${l}`), ...newLines.map((l) => `+${l}`)];
const kept = lines.slice(0, STORY_LIMITS.hunkLines).map(clipLine);
truncated = kept.length < lines.length;
hunks.push({ oldStart: 0, oldLines: removed, newStart: 0, newLines: added, lines: kept });
}
return { file, added, removed, created: result.type === 'create', hunks, truncated };
}
/** Tools whose result text is noise on a comic page (file contents, "edited successfully"). */
/** Server tools under the names the client tools of the same kind use. */
const SERVER_TOOLS: Record<string, string> = { advisor: 'Advisor', web_search: 'WebSearch', web_fetch: 'WebFetch' };
/** The advisor's answer reaches the transcript encrypted; this says so instead. */
const ADVISOR_ANSWER = 'The advice went to Claude only: the transcript keeps it encrypted.';
const QUIET_RESULTS = new Set(['Read', 'NotebookRead', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'TodoWrite']);
const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
export interface StoryReducer {
/** Compact entries for one parsed JSONL line (an object, or anything: non-objects reduce to nothing). */
reduce(line: unknown): StoryEntry[];
}
/**
* A reducer remembers each tool call's name and input until its result arrives, since the result line
* does not repeat them. Bounded: a call whose result never comes is forgotten after 4,000 newer calls.
* `sidechain` reads a helper's own transcript, where every line is a sidechain line; the main
* transcript skips them.
*/
export function createStoryReducer({ sidechain = false }: { sidechain?: boolean } = {}): StoryReducer {
const calls = new Map<string, { name: string; input: ReducedInput }>();
const remember = (id: string, name: string, input: ReducedInput) => {
calls.set(id, { name, input });
if (calls.size > 4000) {
const oldest = calls.keys().next().value;
if (oldest !== undefined) calls.delete(oldest);
}
};
function reduceUser(entry: Record<string, unknown>, at: string): StoryEntry[] {
if (entry.isCompactSummary === true || entry.isVisibleInTranscriptOnly === true) return [];
const message = isRecord(entry.message) ? entry.message : {};
const content = message.content;
const out: StoryEntry[] = [];
if (Array.isArray(content)) {
const results = content.filter((b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result');
for (const block of results) {
const id = str(block.tool_use_id);
if (!id) continue;
const call = calls.get(id);
const name = call?.name ?? '';
const isError = block.is_error === true;
// `toolUseResult` describes the entry's (single) result; only trust it for the first block.
const extra = block === results[0] && isRecord(entry.toolUseResult) ? entry.toolUseResult : {};
const text = blockText(block.content);
const result: Extract<StoryEntry, { k: 'result' }> = { k: 'result', at, id, isError };
if (EDIT_TOOLS.has(name) && !isError) {
const edit = editFromResult(name, extra, call?.input ?? {});
if (edit) result.edit = edit;
}
if (name === 'Agent' || name === 'Task') {
// A background start answers with launch notes meant for Claude only, never the helper's answer.
const background =
extra.isAsync === true || extra.status === 'async_launched' || extra.status === 'teammate_spawned';
const answer = background ? '' : blockText(extra.content) || text;
result.helper = {
background: background || undefined,
result: answer ? clip(answer, STORY_LIMITS.helperChars) : undefined,
toolUses: num(extra.totalToolUseCount),
durationMs: num(extra.totalDurationMs),
agentId:
typeof extra.agentId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(extra.agentId)
? extra.agentId
: undefined,
};
} else if (name === 'AskUserQuestion' && isRecord(extra.answers)) {
const first = Object.values(extra.answers).find((v): v is string => typeof v === 'string');
if (first) result.answer = clip(first, 300);
} else if (text && (!QUIET_RESULTS.has(name) || isError)) {
const tail = clipTail(text, STORY_LIMITS.outputChars, STORY_LIMITS.outputLines);
result.output = tail.text;
if (tail.truncated) result.outputTruncated = true;
if (name === 'Bash') {
const tests = parseTestCounts(tail.text);
if (tests) result.tests = tests;
}
}
out.push(result);
calls.delete(id);
}
if (results.length) return out;
}
if (entry.isMeta === true || entry.turnCompanion === true) return out;
const prompt = readPrompt(blockText(content));
if (!prompt) return out;
if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }];
return [
promptEntry(
prompt,
str(entry.uuid) ?? `prompt-${at}`,
at,
originOf(entry.turnOrigin, prompt.kind),
entry.promptSource === 'queued'
),
];
}
/**
* A prompt that arrived while Claude worked: current builds write no user line for it, only an
* `attachment` of type `queued_command` where Claude took it in (a typed prompt, a background
* task's notification, a message from another session).
*/
function reduceQueuedCommand(entry: Record<string, unknown>, at: string): StoryEntry[] {
const a = isRecord(entry.attachment) ? entry.attachment : {};
if (a.type !== 'queued_command') return [];
const prompt = readPrompt(typeof a.prompt === 'string' ? a.prompt : blockText(a.prompt));
if (!prompt || prompt.kind === 'interrupt') return [];
const from = isRecord(a.origin) ? a.origin.kind : undefined;
const kind = a.commandMode === 'task-notification' && prompt.kind === 'prompt' ? 'task' : prompt.kind;
const id = str(a.source_uuid) ?? str(entry.uuid) ?? `queued-${at}`;
return [promptEntry(prompt, id, at, originOf(from ?? 'human', kind), true)];
}
function promptEntry(
prompt: NonNullable<ReturnType<typeof readPrompt>>,
id: string,
at: string,
origin: StoryTurnOrigin,
queued: boolean
): Extract<StoryEntry, { k: 'prompt' }> {
const reduced: Extract<StoryEntry, { k: 'prompt' }> = {
k: 'prompt',
id,
at,
text: clip(prompt.text, STORY_LIMITS.promptChars),
origin,
queued,
};
if (prompt.task) {
const t = prompt.task;
reduced.task = {
toolUseId: t.toolUseId && /^[A-Za-z0-9_-]{1,128}$/.test(t.toolUseId) ? t.toolUseId : undefined,
status: t.status ? clip(t.status, 40) : undefined,
result: t.result ? clip(t.result, STORY_LIMITS.helperChars) : undefined,
toolUses: t.toolUses,
durationMs: t.durationMs,
};
}
if (prompt.teammates?.length)
reduced.teammates = prompt.teammates.slice(0, 8).map((t) => ({
from: t.from,
summary: t.summary,
result: t.result ? clip(t.result, STORY_LIMITS.helperChars) : undefined,
}));
return reduced;
}
function reduceAssistant(entry: Record<string, unknown>, at: string): StoryEntry[] {
const message = isRecord(entry.message) ? entry.message : {};
const content = message.content;
if (typeof content === 'string')
return content.trim() ? [{ k: 'text', at, text: clip(content, STORY_LIMITS.textChars) }] : [];
if (!Array.isArray(content)) return [];
const out: StoryEntry[] = [];
for (const block of content) {
if (!isRecord(block)) continue;
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
out.push({ k: 'text', at, text: clip(block.text, STORY_LIMITS.textChars) });
} else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
const input = reduceInput(block.name, block.input, str(entry.cwd));
remember(block.id, block.name, input);
out.push({ k: 'tool', at, id: block.id, name: block.name, input });
} else if (block.type === 'server_tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
// Tools the API runs itself (the advisor, web search): call and result share the message.
const name = SERVER_TOOLS[block.name] ?? block.name;
const input = reduceInput(name, block.input);
remember(block.id, name, input);
out.push({ k: 'tool', at, id: block.id, name, input });
} else if (
typeof block.type === 'string' &&
block.type.endsWith('_tool_result') &&
typeof block.tool_use_id === 'string' &&
calls.has(block.tool_use_id)
) {
const content = isRecord(block.content) ? block.content : {};
const isError = typeof content.type === 'string' && /error/.test(content.type);
const call = calls.get(block.tool_use_id);
const result: Extract<StoryEntry, { k: 'result' }> = { k: 'result', at, id: block.tool_use_id, isError };
if (call?.name === 'Advisor' && !isError) result.helper = { result: ADVISOR_ANSWER };
out.push(result);
calls.delete(block.tool_use_id);
}
}
return out;
}
return {
reduce(line: unknown): StoryEntry[] {
if (!isRecord(line) || (line.isSidechain === true && !sidechain)) return [];
const at = str(line.timestamp) ?? '';
switch (line.type) {
case 'user':
return reduceUser(line, at);
case 'assistant':
return reduceAssistant(line, at);
case 'attachment':
return reduceQueuedCommand(line, at);
case 'system':
if (line.subtype === 'turn_duration') return [{ k: 'end', at, durationMs: num(line.durationMs) }];
// Some sessions never write turn_duration; the Stop hook's summary marks the same end (unless
// the hook kept Claude going).
if (line.subtype === 'stop_hook_summary' && line.preventedContinuation !== true) return [{ k: 'end', at }];
if (line.subtype === 'compact_boundary') return [{ k: 'compact', at }];
return [];
default:
return [];
}
},
};
}
// ========== Stage 2: turns ==========
export interface StoryTurn {
id: string;
at: string;
prompt: string;
origin: StoryTurnOrigin;
/** Began before the part of the transcript that was read. */
partial: boolean;
events: StoryEvent[];
ended: boolean;
interrupted: boolean;
endedAt?: string;
durationMs?: number;
}
function intentOf(name: string, input: ReducedInput): StoryIntent {
switch (name) {
case 'Read':
case 'NotebookRead':
case 'LS':
return 'read';
case 'Grep':
case 'Glob':
return 'search';
case 'WebFetch':
case 'WebSearch':
return 'web';
case 'TodoWrite':
case 'TaskCreate':
case 'TaskUpdate':
case 'EnterPlanMode':
return 'plan';
case 'ExitPlanMode':
case 'AskUserQuestion':
return 'ask';
case 'Agent':
case 'Task':
case 'Workflow':
case 'Advisor':
return 'helper';
case 'SendMessage':
return 'message';
case 'Edit':
case 'MultiEdit':
case 'Write':
case 'NotebookEdit':
return 'edit';
case 'Bash':
return input.intent ?? classifyBashCommand(input.command ?? '');
default:
return 'tool';
}
}
function labelOf(name: string, i: ReducedInput): string {
switch (name) {
case 'Bash':
return i.command ?? '';
case 'Grep':
case 'Glob':
return `"${i.pattern ?? ''}"${i.path ? ` in ${i.path}` : ''}`;
case 'WebFetch':
return i.url ?? '';
case 'WebSearch':
return i.query ?? '';
case 'Agent':
case 'Task':
return `${i.name || i.type || 'helper'}: ${i.description ?? ''}`.trim();
case 'Advisor':
return 'a second opinion';
case 'TodoWrite':
return plural(i.todos?.length ?? 0, 'item');
case 'AskUserQuestion':
return i.question ?? '';
case 'ExitPlanMode':
return 'Plan ready for your OK';
case 'SendMessage':
return `to ${i.to ?? '?'}: ${i.summary ?? ''}`.trim();
default:
return i.file ?? i.text ?? '';
}
}
function toolEvent(e: Extract<StoryEntry, { k: 'tool' }>, actor: StoryActor): StoryEvent {
const i = e.input;
const ev: StoryEvent = {
id: e.id,
at: e.at,
actor,
tool: e.name,
label: clip(labelOf(e.name, i), STORY_LIMITS.labelChars),
intent: intentOf(e.name, i),
pending: true,
};
if (i.file) ev.file = i.file;
if (i.reads) ev.files = i.reads;
if (i.ships) ev.ships = i.ships;
if (i.headline) ev.headline = i.headline;
if (i.writes) ev.edits = i.writes;
if (i.offset) ev.line = i.offset;
if (e.name === 'Bash' && i.description) ev.description = i.description;
if (e.name === 'Agent' || e.name === 'Task') {
ev.helper = {
name: i.name || i.type || 'helper',
type: i.type || 'general-purpose',
task: i.description,
prompt: i.prompt || i.description || '',
};
}
if (e.name === 'Advisor')
ev.helper = {
name: 'advisor',
type: 'advisor',
task: 'A second opinion',
prompt: 'Claude shows a stronger model the whole conversation so far and asks what it thinks.',
};
if (i.todos) ev.todos = i.todos;
if (e.name === 'AskUserQuestion') ev.question = { text: i.question ?? '', options: i.options ?? [] };
if (e.name === 'ExitPlanMode')
ev.question = { text: firstSentence(i.plan ?? 'Claude has a plan.', 240), options: [] };
return ev;
}
function applyResult(ev: StoryEvent, r: Extract<StoryEntry, { k: 'result' }>): void {
ev.pending = undefined;
ev.ok = !r.isError;
if (r.output !== undefined) ev.output = r.output;
if (r.outputTruncated) ev.outputTruncated = true;
if (r.edit) {
ev.edit = r.edit;
ev.file = r.edit.file;
}
if (r.tests) {
ev.tests = r.tests;
// A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0).
if (r.tests.failed > 0 || (r.tests.errors ?? 0) > 0) ev.ok = false;
}
if (r.helper && ev.helper) {
const helper: StoryHelper = { ...ev.helper };
if (r.helper.background && !r.isError) {
helper.background = true;
helper.running = true;
}
if (r.helper.result) helper.result = r.helper.result;
if (r.helper.toolUses !== undefined) helper.toolUses = r.helper.toolUses;
if (r.helper.durationMs !== undefined) helper.durationMs = r.helper.durationMs;
if (r.helper.agentId) helper.agentId = r.helper.agentId;
ev.helper = helper;
}
if (r.answer && ev.question) ev.question = { ...ev.question, answer: r.answer };
}
/**
* Splits reduced entries into turns. A prompt starts a turn, except one queued while a turn is
* still running, which joins it ("you add something"). Entries before the first prompt in the
* window form a `partial` turn. `turn_duration` ends a turn; an interrupt ends it too.
*/
export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
const turns: StoryTurn[] = [];
let cur: StoryTurn | null = null;
const byId = new Map<string, StoryEvent>();
/** Helper calls by tool id, across turns: a background helper reports back in a later turn. */
const helpers = new Map<string, StoryEvent>();
/** The same helpers by name: a teammate reports back by name, not by call. */
const helpersByName = new Map<string, StoryEvent>();
let say = 0;
const teammatesBack = (reports: StoryTeammateReport[], prompt: StoryEvent) => {
const told = reports.filter((r) => r.result || r.summary);
if (told.length) {
prompt.output = clip(
told.map((r) => `${r.from}: ${r.result ?? r.summary}`).join('\n\n'),
STORY_LIMITS.helperChars
);
}
for (const r of reports) {
const ev = helpersByName.get(r.from);
if (!ev?.helper) continue;
const helper: StoryHelper = { ...ev.helper, running: undefined };
if (r.result) helper.result = r.result;
ev.helper = helper;
}
};
/** Ends a turn at `at` with no duration of its own: timed by its span, when the span is known. */
const closeTurn = (t: StoryTurn, at: string) => {
t.ended = true;
t.endedAt = at;
const span = Date.parse(at) - Date.parse(t.at);
if (!t.partial && Number.isFinite(span) && span >= 0) t.durationMs ??= span;
};
const reportBack = (report: StoryTaskReport, prompt: StoryEvent) => {
if (report.result) {
prompt.output = report.result;
if (report.result.length >= STORY_LIMITS.helperChars) prompt.outputTruncated = true;
}
const ev = report.toolUseId ? helpers.get(report.toolUseId) : undefined;
if (!ev?.helper) return;
const helper: StoryHelper = { ...ev.helper, running: undefined };
if (report.result) helper.result = report.result;
if (report.toolUses !== undefined) helper.toolUses = report.toolUses;
if (report.durationMs !== undefined) helper.durationMs = report.durationMs;
ev.helper = helper;
if (report.status && !/^(completed|done|finished|success)/i.test(report.status)) ev.ok = false;
};
const ensure = (at: string): StoryTurn => {
if (!cur) {
cur = {
id: `partial-${at}`,
at,
prompt: '',
origin: 'human',
partial: true,
events: [],
ended: false,
interrupted: false,
};
turns.push(cur);
}
return cur;
};
for (const e of entries) {
switch (e.k) {
case 'prompt': {
const ev: StoryEvent = {
id: e.id,
at: e.at,
actor: e.origin === 'human' ? 'you' : 'system',
tool: 'Prompt',
label: e.text,
intent: 'prompt',
origin: e.origin,
};
if (e.task) reportBack(e.task, ev);
if (e.teammates) teammatesBack(e.teammates, ev);
const open = cur as StoryTurn | null;
if (open && !open.ended && e.queued) {
// A report that lands while Claude works is a line on the wire, not a new mission.
if (e.origin === 'task') {
ev.intent = 'notice';
ev.tool = 'Report';
}
open.events.push(ev);
} else {
// Claude runs one turn at a time: a turn still open when the next one starts has ended.
if (open && !open.ended) closeTurn(open, e.at);
cur = {
id: e.id,
at: e.at,
prompt: e.text,
origin: e.origin,
partial: false,
events: [ev],
ended: false,
interrupted: false,
};
turns.push(cur);
byId.clear();
}
break;
}
case 'text':
ensure(e.at).events.push({
id: `say-${say++}`,
at: e.at,
actor: 'claude',
tool: 'Text',
label: e.text,
intent: 'say',
});
break;
case 'tool': {
const ev = toolEvent(e, 'claude');
ensure(e.at).events.push(ev);
byId.set(e.id, ev);
if (ev.helper) {
helpers.set(e.id, ev);
if (helpers.size > 500) helpers.delete(helpers.keys().next().value as string);
helpersByName.set(ev.helper.name, ev);
if (helpersByName.size > 500) helpersByName.delete(helpersByName.keys().next().value as string);
}
break;
}
case 'result': {
const ev = byId.get(e.id);
if (ev) applyResult(ev, e);
break;
}
case 'end': {
const t = cur as StoryTurn | null;
if (t) {
t.ended = true;
t.endedAt = e.at;
// Claude Code times a turn a background task started from the prompt that started the chain;
// a turn never takes longer than its own span, so that is the cap.
const span = Date.parse(e.at) - Date.parse(t.at);
const known = !t.partial && Number.isFinite(span) && span >= 0;
if (e.durationMs !== undefined) t.durationMs = known ? Math.min(e.durationMs, span) : e.durationMs;
else if (known && t.durationMs === undefined) t.durationMs = span; // ended by the Stop hook's summary
}
break;
}
case 'compact':
ensure(e.at).events.push({
id: `compact-${say++}`,
at: e.at,
actor: 'system',
tool: 'Compact',
label: 'Context compacted',
intent: 'compact',
});
break;
case 'interrupt': {
const t = ensure(e.at);
t.events.push({
id: `stop-${say++}`,
at: e.at,
actor: 'you',
tool: 'Interrupt',
label: 'You stopped Claude',
intent: 'interrupt',
});
t.interrupted = true;
closeTurn(t, e.at);
break;
}
}
}
return turns;
}
const NOT_TOOL_CALLS = new Set<StoryIntent>(['prompt', 'say', 'interrupt', 'compact', 'notice']);
const countToolCalls = (events: StoryEvent[]) => events.filter((e) => !NOT_TOOL_CALLS.has(e.intent)).length;
export function summarizeTurn(turn: StoryTurn): StoryTurnSummary {
return {
id: turn.id,
at: turn.at,
prompt: clip(turn.prompt.split('\n')[0] ?? '', 140),
origin: turn.origin,
complete: turn.ended && !turn.interrupted,
interrupted: turn.interrupted,
toolCalls: countToolCalls(turn.events),
};
}
// ========== Stage 3: panels ==========
const PANEL_FOR: Record<StoryIntent, StoryPanelKind | null> = {
prompt: 'mission',
read: 'investigate',
search: 'investigate',
look: 'investigate',
web: 'research',
plan: 'plan',
say: null,
helper: 'backup',
message: 'backup',
edit: 'fix',
test: 'check',
build: 'check',
run: 'run',
tool: 'run',
save: 'save',
ask: 'ask',
compact: 'compact',
interrupt: null,
notice: null,
};
/** Kinds whose consecutive events share one panel. A prompt or a question always gets its own. */
const MERGEABLE = new Set<StoryPanelKind>([
'investigate',
'research',
'plan',
'backup',
'fix',
'check',
'run',
'save',
'compact',
]);
/** Kinds that may be folded into a montage when a page has too many panels. */
const MINOR = new Set<StoryPanelKind>(['investigate', 'research', 'plan', 'run', 'compact', 'save', 'montage']);
interface DraftPanel {
kind: StoryPanelKind;
events: number[];
balloon?: { who: 'you' | 'claude'; text: string };
rounds?: number;
}
/** Keeps the newest outputs, hunks and helper texts within the page budget. */
/** Every edit an event made: an Edit/Write call's one, or the files a Bash call wrote. */
const editsOf = (e: StoryEvent): StoryEdit[] => (e.edit ? [e.edit] : (e.edits ?? []));
function applyTextBudget(events: StoryEvent[]): boolean {
let used = 0;
let cut = false;
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
const hunkChars = editsOf(e).reduce(
(n, d) => n + d.hunks.reduce((m, h) => m + h.lines.reduce((k, l) => k + l.length, 0), 0),
0
);
const size = (e.output?.length ?? 0) + hunkChars + (e.helper?.result?.length ?? 0) + (e.helper?.prompt.length ?? 0);
if (used + size <= STORY_LIMITS.pageTextBudget) {
used += size;
continue;
}
if (e.output !== undefined) {
delete e.output;
e.outputTruncated = true;
}
if (e.edit && e.edit.hunks.length) e.edit = { ...e.edit, hunks: [], truncated: true };
if (e.edits?.some((d) => d.hunks.length)) e.edits = e.edits.map((d) => ({ ...d, hunks: [], truncated: true }));
if (e.helper)
e.helper = {
...e.helper,
prompt: clip(e.helper.prompt, 200),
result: e.helper.result ? clip(e.helper.result, 200) : undefined,
};
cut = true;
}
return cut;
}
/** Claude reporting how a check went ("All 7 tests pass"), as opposed to announcing its next step. */
const TALKS_RESULTS = /\b(pass(es|ed|ing)?|fail(s|ed|ing|ures?)?|green|clean)\b/i;
const ANNOUNCES = /\b(I'll|I will|let me|let's|I'm going to|next,? I|now I)\b/i;
function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?: string } {
const panels: DraftPanel[] = [];
let pendingSay: string | undefined;
events.forEach((ev, idx) => {
if (ev.intent === 'say' && !ev.todos) {
pendingSay ??= firstSentence(ev.label);
return;
}
const kind = ev.todos && ev.intent === 'say' ? 'plan' : PANEL_FOR[ev.intent];
if (!kind) return;
const last = panels[panels.length - 1];
if (last && last.kind === kind && MERGEABLE.has(kind)) {
last.events.push(idx);
if (pendingSay && !last.balloon) last.balloon = { who: 'claude', text: pendingSay };
} else {
const panel: DraftPanel = { kind, events: [idx] };
// A sentence about how the check just before went belongs to that check, not to what comes next.
const reportsBack =
pendingSay &&
last?.kind === 'check' &&
(!last.balloon || !TALKS_RESULTS.test(last.balloon.text)) &&
TALKS_RESULTS.test(pendingSay) &&
!ANNOUNCES.test(pendingSay);
if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 220) };
else if (reportsBack && last && pendingSay) last.balloon = { who: 'claude', text: pendingSay };
else if (pendingSay) panel.balloon = { who: 'claude', text: pendingSay };
panels.push(panel);
}
pendingSay = undefined;
});
return { panels, trailingSay: pendingSay };
}
/** Fix → check → fix → check (4 panels or more) is one "loop" panel: try, test, repeat. */
function collapseLoops(panels: DraftPanel[]): DraftPanel[] {
const out: DraftPanel[] = [];
let i = 0;
while (i < panels.length) {
let j = i;
while (
j < panels.length &&
(panels[j].kind === 'fix' || panels[j].kind === 'check') &&
(j === i || panels[j].kind !== panels[j - 1].kind)
) {
j++;
}
// A loop ends at a check: writes after the last check (docs, config) are their own panel.
while (j > i && panels[j - 1].kind === 'fix') j--;
const run = panels.slice(i, j);
if (run.length >= 4) {
out.push({
kind: 'loop',
events: run.flatMap((p) => p.events),
// The last word of the rounds is how they ended.
balloon: [...run].reverse().find((p) => p.balloon)?.balloon,
rounds: run.filter((p) => p.kind === 'check').length,
});
i = j;
} else {
out.push(panels[i]);
i++;
}
}
return out;
}
/** Folds the smallest neighbouring pair into a montage until the page fits. */
function capPanels(panels: DraftPanel[], max: number): DraftPanel[] {
const out = [...panels];
const firstMergeable = out[0]?.kind === 'mission' ? 1 : 0;
while (out.length > max) {
let best = -1;
let bestSize = Infinity;
for (const strict of [true, false]) {
for (let k = firstMergeable; k < out.length - 1; k++) {
const a = out[k];
const b = out[k + 1];
const ok = strict
? MINOR.has(a.kind) && MINOR.has(b.kind)
: ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask');
const size = a.events.length + b.events.length;
if (ok && size < bestSize) {
best = k;
bestSize = size;
}
}
if (best >= 0) break;
}
if (best < 0) break;
const a = out[best];
const b = out[best + 1];
out.splice(best, 2, { kind: 'montage', events: [...a.events, ...b.events], balloon: a.balloon ?? b.balloon });
}
return out;
}
function countBy(events: StoryEvent[], key: (e: StoryEvent) => string): string {
const counts = new Map<string, number>();
for (const e of events) counts.set(key(e), (counts.get(key(e)) ?? 0) + 1);
return [...counts].map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)).join(' · ');
}
/** Lines of a script that say nothing about what it does: `set -e`, a bare `cd dir`, `S=/tmp/x`, comments. */
const NOISE_LINE = /^\s*(#.*|set\s+[-+][a-zA-Z]+(\s+\w+)?|cd\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)?\s*$/;
/** What a command does, for a caption: the part that decided it, else its first telling line. */
const shortCommand = (e: StoryEvent) => {
if (e.headline) return clip(e.headline.split('\n')[0] ?? '', 60);
const lines = e.label.split('\n');
return clip(lines.find((l) => !NOISE_LINE.test(l)) ?? lines[0] ?? '', 60);
};
function montageSummary(evs: StoryEvent[]): string {
const parts: string[] = [];
const files = unique(evs.filter((e) => e.intent === 'read' && e.file).map((e) => e.file as string)).length;
const commands = evs.filter((e) => e.intent === 'run' || e.intent === 'look' || e.intent === 'tool').length;
if (files) parts.push(`reads ${plural(files, 'file')}`);
if (commands) parts.push(`runs ${plural(commands, 'command')}`);
if (evs.some((e) => e.intent === 'plan')) parts.push('updates its plan');
if (evs.some((e) => e.intent === 'web')) parts.push('checks the web');
if (evs.some((e) => e.intent === 'edit')) parts.push('changes files');
if (evs.some((e) => e.intent === 'test' || e.intent === 'build')) parts.push('runs checks');
return parts.length ? parts.join(', ') : 'a few more steps';
}
function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, index: number): StoryPanel {
const evs = draft.events.map((i) => events[i]);
const panel: StoryPanel = { kind: draft.kind, events: draft.events, caption: { story: '', director: '' } };
if (draft.balloon) panel.balloon = draft.balloon;
const edits = evs.flatMap(editsOf);
const checks = evs.filter((e) => e.intent === 'test' || e.intent === 'build');
const lastCheck = checks[checks.length - 1];
// A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0).
const passed = (e: StoryEvent | undefined): boolean | undefined =>
e?.ok === undefined ? undefined : e.ok && !(e.tests && e.tests.failed > 0);
const outcome = (e: StoryEvent) => {
const ok = passed(e);
return ok === undefined ? 'running' : ok ? 'ok' : 'error';
};
const errors = evs.filter((e) => e.ok === false).length;
const notesOf = (list: StoryEvent[]) =>
list
.map((e) => clip(e.description || shortCommand(e), 80))
.filter(Boolean)
.slice(0, 4);
const tools = countBy(evs, (e) => e.tool);
let story = '';
let director = tools;
switch (draft.kind) {
case 'mission': {
const words = evs[0]?.label.split(/\s+/).filter(Boolean).length ?? 0;
const lines: Record<StoryTurnOrigin, string> = {
human: 'You give Claude a job.',
task: 'A background job reports back.',
peer: 'Another session sends a message.',
system: 'Something automatic starts this turn.',
};
const origin = evs[0]?.origin ?? turn.origin;
story =
index === 0
? lines[turn.origin]
: origin === 'human'
? 'You add something while Claude works.'
: origin === 'peer'
? 'Another session chimes in while Claude works.'
: lines[origin];
director = `Prompt · ${plural(words, 'word')}${origin === 'human' ? '' : ` · ${origin}`}`;
break;
}
case 'investigate': {
const files = unique(
evs.flatMap((e) => (e.intent === 'read' && e.file ? [e.file] : e.intent === 'look' ? (e.files ?? []) : []))
);
panel.files = files.slice(0, 12);
panel.notes = evs
.filter((e) => e.intent !== 'read')
.map(shortCommand)
.slice(0, 4);
if (evs.every((e) => e.tool === 'Bash')) director = `${tools} · ${shortCommand(evs[0])}`;
if (errors) director += ` · ${plural(errors, 'error')}`;
story = files.length
? `Claude reads ${plural(files.length, 'file')} to find its way around.`
: evs.every((e) => e.intent === 'search')
? 'Claude searches the code.'
: 'Claude looks around the project.';
break;
}
case 'research':
panel.notes = evs.map(shortCommand).slice(0, 4);
story = 'Claude looks things up on the web.';
break;
case 'plan': {
const todos = [...evs].reverse().find((e) => e.todos)?.todos;
if (todos) panel.todos = todos;
else panel.notes = evs.map(shortCommand).slice(0, 4);
story = todos ? `Claude writes a plan: ${plural(todos.length, 'step')}.` : 'Claude thinks it through.';
// A plan written as text has no progress to report; a plan tool's list does.
if (todos && evs.every((e) => e.tool === 'Text')) director = `written as text · ${plural(todos.length, 'step')}`;
else if (todos) director = `${tools} · ${todos.filter((t) => t.done).length}/${todos.length} done`;
break;
}
case 'backup': {
const helpers = draft.events
.filter((i) => events[i].helper)
.map((i) => ({
name: (events[i].helper as StoryHelper).name,
type: (events[i].helper as StoryHelper).type,
task: (events[i].helper as StoryHelper).task,
event: i,
}));
panel.helpers = helpers;
panel.notes = evs
.filter((e) => e.intent === 'message')
.map(shortCommand)
.slice(0, 4);
const behind = evs.filter((e) => e.helper?.background).length;
const advisor = evs.filter((e) => e.tool === 'Advisor').length;
story = helpers.length
? advisor === helpers.length
? 'Claude asks the advisor for a second opinion.'
: behind === helpers.length
? `Claude sends ${plural(helpers.length, 'helper')} off to work in the background.`
: `Claude calls in ${plural(helpers.length, 'helper')}.`
: 'Claude sends a message to another session.';
const inside = evs.reduce((n, e) => n + (e.helper?.toolUses ?? 0), 0);
director = `${tools}${helpers.length ? ` · ${unique(helpers.map((h) => h.type)).join(' + ')}` : ''}${inside ? ` · ${plural(inside, 'call')} inside` : ''}`;
break;
}
case 'fix':
case 'loop': {
// From the calls, not only the results: an edit still running already names its file.
const files = unique([
...evs.filter((e) => e.intent === 'edit' && e.file).map((e) => e.file as string),
...edits.map((d) => d.file),
]);
panel.files = files.slice(0, 12);
panel.added = edits.reduce((n, e) => n + e.added, 0);
panel.removed = edits.reduce((n, e) => n + e.removed, 0);
if (draft.kind === 'fix') {
const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length;
story =
created && created === files.length
? `Claude writes ${plural(created, 'new file')}.`
: files.length
? `Claude changes ${plural(files.length, 'file')}.`
: 'Claude changes a file.';
const perFile = files.slice(0, 3).map((f) => {
const mine = edits.filter((e) => e.file === f);
return `${basename(f)} ${fmtCounts(
mine.reduce((n, e) => n + e.added, 0),
mine.reduce((n, e) => n + e.removed, 0)
)}`;
});
director = `${tools}${perFile.length ? ` · ${perFile.join(' · ')}` : ''}`;
} else {
panel.rounds = draft.rounds ?? checks.length;
const ok = passed(lastCheck);
if (ok !== undefined) panel.ok = ok;
const tests = lastCheck?.tests;
if (tests) panel.tests = tests;
// Every check green from the start: Claude was building piece by piece, not fixing.
const neverFailed = checks.every((e) => passed(e) !== false);
story =
neverFailed && panel.ok === true
? `Build and test, ${plural(panel.rounds, 'round')}. Every check passes.`
: `Fix and test, ${plural(panel.rounds, 'round')}.${panel.ok === true ? ' It passes in the end.' : panel.ok === false ? ' Still failing.' : ''}`;
director = `${plural(checks.length, 'check')} · ${plural(edits.length, 'edit')} · ${fmtCounts(panel.added, panel.removed)} · last: ${
lastCheck ? outcome(lastCheck) : 'running'
}`;
}
break;
}
case 'check': {
const tests = [...checks].reverse().find((e) => e.tests)?.tests;
if (tests) panel.tests = tests;
const ok = passed(lastCheck);
if (ok !== undefined) panel.ok = ok;
panel.notes = notesOf(checks);
const isTest = checks.some((e) => e.intent === 'test');
const failedFirst = checks.slice(0, -1).some((e) => passed(e) === false);
if (ok === undefined) story = isTest ? 'Claude runs the tests.' : 'Claude runs the checks.';
else if (!ok && tests && !tests.passed && !tests.failed && tests.errors) story = "The tests don't even load.";
else if (ok)
story = failedFirst ? 'It fails at first, then passes.' : isTest ? 'The tests pass.' : 'The checks pass.';
else
story = isTest
? tests && tests.failed > 1
? `${tests.failed} tests fail.`
: 'A test fails.'
: 'A check fails.';
director = checks
.slice(-3)
.map((e) => `${shortCommand(e)} → ${outcome(e)}`)
.join(' · ');
if (tests)
director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}${
tests.errors ? `, ${plural(tests.errors, 'error')}` : ''
}`;
if (edits.length) director += ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}`;
break;
}
case 'run':
panel.notes = notesOf(evs);
story = evs.every((e) => e.tool !== 'Bash') ? 'Claude uses a tool.' : 'Claude works in the terminal.';
director = `${tools} · ${shortCommand(evs[0])}${errors ? ` · ${plural(errors, 'error')}` : ''}`;
break;
case 'save': {
panel.notes = notesOf(evs);
const last = evs[evs.length - 1];
if (last?.ok !== undefined) panel.ok = last.ok;
const ships = evs.some((e) => e.ships === 'pr') ? 'pr' : evs.some((e) => e.ships === 'push') ? 'push' : '';
story =
panel.ok === false
? ships === 'pr'
? 'Claude tries to open a pull request, and the command fails.'
: ships === 'push'
? 'Claude tries to send the work up, and the command fails.'
: 'Claude tries to save the work, and the command fails.'
: ships === 'pr'
? 'Claude opens a pull request.'
: ships === 'push'
? 'Claude saves the work and sends it up.'
: 'Claude saves the work.';
director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${
edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : ''
}`;
break;
}
case 'ask': {
const q = evs[0]?.question;
if (q) panel.question = { text: q.text, answer: q.answer };
const plan = evs[0]?.tool === 'ExitPlanMode';
story = plan
? 'Claude shows you its plan and waits for your OK.'
: q?.answer
? 'Claude asks you, and you answer.'
: 'Claude asks you something.';
director = `${tools}${q?.answer ? ` · answer: ${clip(q.answer, 60)}` : ''}`;
break;
}
case 'compact':
story = 'Claude packs up its notes to free memory.';
director = 'compact_boundary';
break;
case 'montage':
panel.notes = notesOf(evs.filter((e) => e.tool === 'Bash'));
story = `Claude keeps going: ${montageSummary(evs)}.`;
break;
case 'complete':
break;
}
panel.caption = { story, director };
return panel;
}
function completePanel(turn: StoryTurn, page: Omit<StoryPage, 'panels'>, trailingSay?: string): StoryPanel {
const running = page.helpersRunning;
const done = turn.durationMs !== undefined ? `Done in ${roughDuration(turn.durationMs)}.` : 'Done.';
const story = turn.interrupted
? 'You stop Claude here.'
: running
? `${done.slice(0, -1)} for now. ${running === 1 ? 'A helper is' : `${running} helpers are`} still working.`
: done;
const director = [
turn.interrupted ? 'interrupted' : 'turn_duration',
turn.durationMs !== undefined ? exactDuration(turn.durationMs) : '',
plural(page.toolCalls, 'tool call'),
page.filesChanged ? plural(page.filesChanged, 'file') + ' changed' : '',
page.helpers ? plural(page.helpers, 'helper') : '',
running ? `${running} still running` : '',
]
.filter(Boolean)
.join(' · ');
const panel: StoryPanel = { kind: 'complete', events: [], caption: { story, director } };
if (trailingSay) panel.balloon = { who: 'claude', text: trailingSay };
if (page.filesChanged) {
panel.added = page.added;
panel.removed = page.removed;
}
return panel;
}
/** One turn as one comic page. Pure: the same turn always draws the same page. */
export function buildStoryPage(turn: StoryTurn): StoryPage {
let events = turn.events.map((e) => ({ ...e }));
let truncated = false;
if (events.length > STORY_LIMITS.eventsPerPage) {
const head = events[0]?.intent === 'prompt' ? [events[0]] : [];
events = [...head, ...events.slice(-(STORY_LIMITS.eventsPerPage - head.length))];
truncated = true;
}
if (applyTextBudget(events)) truncated = true;
// Claude wrote its plan as a numbered list before starting work (no plan tool): draw it as one.
for (const e of events) {
if (e.intent === 'edit' || e.intent === 'test' || e.intent === 'build' || e.intent === 'save') break;
if (e.intent !== 'say') continue;
const todos = planFromText(e.label);
if (todos) e.todos = todos;
}
const { panels: grouped, trailingSay } = groupPanels(events);
const room = STORY_LIMITS.panelsPerPage - (turn.ended ? 1 : 0);
const drafts = capPanels(collapseLoops(grouped), room);
const panels = drafts.map((d, i) => decorate(d, events, turn, i));
const edits = events.flatMap(editsOf);
const base: Omit<StoryPage, 'panels'> = {
...summarizeTurn({ ...turn, events }),
partial: turn.partial,
truncated,
events,
filesChanged: unique(edits.map((e) => e.file)).length,
added: edits.reduce((n, e) => n + e.added, 0),
removed: edits.reduce((n, e) => n + e.removed, 0),
// The advisor is a second opinion, not a helper that does work.
helpers: events.filter((e) => e.helper && e.tool !== 'Advisor').length,
helpersRunning: events.filter((e) => e.helper?.running).length,
};
if (turn.endedAt) base.endedAt = turn.endedAt;
if (turn.durationMs !== undefined) base.durationMs = turn.durationMs;
if (turn.ended) {
panels.push(completePanel(turn, base, trailingSay));
} else if (panels.length) {
const last = panels[panels.length - 1];
last.live = true;
if (trailingSay && !last.balloon) last.balloon = { who: 'claude', text: trailingSay };
}
return { ...base, panels };
}
/**
* A helper's own transcript as one page. Its first prompt is the job Claude gave it; a later prompt
* (Claude messaging it again) continues the same page. `finished` comes from the helper's metadata,
* since a helper's transcript has no `turn_duration` of its own.
*/
export function buildHelperPage(turns: StoryTurn[], finished?: boolean): StoryPage | null {
if (!turns.length) return null;
const first = turns[0];
const last = turns[turns.length - 1];
return buildStoryPage({
...first,
events: turns.flatMap((t) => t.events),
ended: finished ?? last.ended,
interrupted: false,
endedAt: last.endedAt,
durationMs: undefined,
});
}
/** The turn list for the page picker, newest last, capped. */
export function summarizeTurns(turns: StoryTurn[]): StoryTurnSummary[] {
return turns.slice(-STORY_LIMITS.turnsListed).map(summarizeTurn);
}