/** * @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; /** How many commits a save makes, when more than one. */ commits?: number; /** 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; /** A check whose output reports problems (lint, format, types). */ problems?: boolean; } | { k: 'end'; at: string; durationMs?: number } | { k: 'compact'; at: string } | { k: 'interrupt'; at: string }; /** What a `` says about the background call it answers. */ interface StoryTaskReport { toolUseId?: string; status?: string; result?: string; toolUses?: number; durationMs?: number; } /** One ``: 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 => 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 = (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\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 = /^(.+?(?= 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]+/, ''); // Shell structure is not a command: `for x in a b`, `done`, `fi`; `do`/`then`/`if` only wrap one. if (/^(for\s+\w+(\s+in\b.*)?|done|fi|esac|else|[{}])\s*$/.test(s)) return ''; if (/^command\s+-[vV]\b/.test(s)) return 'which'; for (let k = 0; k < 6; k++) { const next = s .replace(/^(do|then|else|if|elif|while|until|!)\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|config (--get|-l|--list))|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(/<<]/.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 < f`, `tee f <>` / `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 - <&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; } /** * Whether a linter, formatter or type checker reported problems (`Found 3 errors.`, `1 file would be * reformatted`, `✖ 5 problems`, `error TS2322:`). Read when a trailing command (`; ls`) hid the exit code. */ export function reportsProblems(output: string): boolean { return [ /\bFound [1-9]\d* errors?\b/, /\b[1-9]\d* files? would be reformatted\b/, /^would reformat /m, /✖ [1-9]\d* problems?\b/, /\[warn\] Code style issues found/, /\berror TS\d+:/, ].some((re) => re.test(output)); } /** * 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 = /[\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: `body`, * 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(/]*)>([\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 (/^ 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 = /^]*)>([\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>/.exec(text); if (command) { const args = /([^<]*)<\/command-args>/.exec(text)?.[1] ?? ''; return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt' }; } if (/^/.test(text)) { const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)`).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 heads = commandSegments(withoutHeredocBodies(full.slice(0, 4000))).map(segmentHead); if (heads.some((h) => /^gh pr create\b/.test(h))) input.ships = 'pr'; else if (heads.some((h) => /^git push\b/.test(h))) input.ships = 'push'; const commits = heads.filter((h) => /^git commit\b/.test(h)).length; if (commits > 1) input.commits = commits; } 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, 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 = { 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(); 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, 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 => 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 = { 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; const intent = call?.input.intent; if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true; } } 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, 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>, id: string, at: string, origin: StoryTurnOrigin, queued: boolean ): Extract { const reduced: Extract = { 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, 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 = { 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, 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.commits) ev.commits = i.commits; 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): 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.problems) ev.ok = false; 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(); /** Helper calls by tool id, across turns: a background helper reports back in a later turn. */ const helpers = new Map(); /** The same helpers by name: a teammate reports back by name, not by call. */ const helpersByName = new Map(); 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(['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 = { 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([ '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(['investigate', 'research', 'plan', 'run', 'compact', '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 neighbouring panels into montages until the page fits. Quiet kinds fold first, and the * oldest pair first, growing a montage already there when it can: so on a live page, the panels * the reader is watching keep their place, and the newest panel is never the one folded while * another choice exists. */ 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; search: for (const strict of [true, false]) { for (const lastStart of [out.length - 3, out.length - 2]) { let oldest = -1; for (let k = firstMergeable; k <= lastStart; 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'); if (!ok) continue; if (a.kind === 'montage' || b.kind === 'montage') { best = k; break search; } if (oldest < 0) oldest = k; } if (oldest >= 0) { best = oldest; break search; } } } 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(); 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'); const commits = evs.reduce((n, e) => n + (e.commits ?? (e.intent === 'save' ? 1 : 0)), 0); if (commits) parts.push(commits > 1 ? `saves the work in ${commits} commits` : 'saves the work'); 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 = { 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 && tests && tests.passed && !tests.failed && !tests.errors ? ' The tests pass, but another check fails.' : 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 && tests && tests.passed && !tests.failed && !tests.errors) story = 'The tests pass, but another check fails.'; 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' : ''; const commits = evs.reduce((n, e) => n + (e.commits ?? (e.intent === 'save' ? 1 : 0)), 0); const inCommits = commits > 1 ? ` in ${commits} commits` : ''; 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${inCommits} and sends it up.` : `Claude saves the work${inCommits}.`; 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, 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 = { ...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); }