Files
Codeman/src/story-mode.ts
T
Codeman maintainer a2db03cda5 fix(story-mode): commits in montages, lint verdicts, and a page that holds still
From review round 2 (a long turn, and a prompt added mid-turn):

- Commits fold last and a montage names them ("saves the work in 3
  commits"); a save counts its commits ("saves the work in 6 commits").
  Before, all three commits of a 16-step turn vanished into montages that
  read "runs 2 commands".
- Folding a full page starts at the oldest pair and grows a montage already
  there, and never takes the newest panel while another choice exists: a
  live page no longer reshuffles panels the reader is watching.
- A check whose output reports problems ("1 file would be reformatted",
  "Found 3 errors", "✖ 5 problems", `error TS…`) failed, though a trailing
  `; ls` hid the exit code: "The tests pass, but another check fails."
- Shell structure is not a command (`for x in`, `do`, `done`, `if`), so a
  `--version` probe in a loop reads as looking; `command -v` and
  `git config --get` too.
- The montage draws only the kinds of steps it folded, with a box for
  saves; the loop's arrow has a head and rests on a finished page; the
  "MISSION COMPLETE!" title has room for its tilt; drawer commands keep
  their line breaks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 11:10:14 +02:00

1927 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 `<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]+/, '');
// 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(/<<</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;
}
/**
* 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 = /<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 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<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;
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<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.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<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.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<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', '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<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');
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<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 && 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<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);
}