Files
Codeman/src/story-mode.ts
T
Codeman maintainer ed6c89ff98 fix(story-mode): a failed check that Claude fixes in its next command says so
Flagged by four readers across rounds 10 to 12: after a push, "but a
check after that fails" never said that Claude repaired what the check
found (the new remote pointed at a branch with no commits) and checked
again. When Claude's next command runs that same check again after a
change (`git -C remote.git symbolic-ref HEAD refs/heads/main && git -C
remote.git log …`) and passes, the caption adds "; Claude fixes it, and
the check passes next." A check re-run with no change before it, or one
that fails again, claims nothing.

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

3256 lines
147 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. Across all 1,309 local transcripts (3,856
* turns, 44,008 tool calls) it is 87%, and 91% of those calls carry a `description`.
*
* 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. Sized on
* 3,855 local turns: 94% never reach it, and the longest live page went from 157 KB to 74 KB
* gzipped (it is refetched every few seconds while it changes).
*/
pageTextBudget: 120_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 the command makes (`git commit` segments), when any. */
commits?: number;
/** The first commit's message, when the command spells it out. */
subject?: string;
/** Files a Bash call writes through here-documents. */
writes?: StoryEdit[];
description?: string;
file?: string;
offset?: number;
old?: string;
new?: string;
pattern?: string;
path?: string;
url?: string;
query?: string;
type?: string;
name?: string;
prompt?: string;
todos?: StoryTodo[];
question?: string;
options?: string[];
plan?: string;
to?: string;
summary?: string;
text?: string;
}
export type StoryEntry =
| {
k: 'prompt';
id: string;
at: string;
text: string;
origin: StoryTurnOrigin;
queued: boolean;
/** A slash command (`/compact`), not a prompt to Claude. */
slash?: boolean;
/** Claude Code's id for the prompt; one prompt can be written more than once (typed, then as a command). */
promptId?: string;
/** 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; keyPoints?: string[]; citesSaveRule?: boolean }
| { 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;
/** The plan an ExitPlanMode call presented. */
plan?: string;
/** Files Claude Code says a Bash call changed ("This command modified 1 file you've previously read: a.py"). */
modified?: string[];
/** A Bash call sent to the background: its result only says it started. */
background?: boolean;
}
| { k: 'end'; at: string; durationMs?: number }
| { k: 'compact'; at: string; trigger?: string; preTokens?: number; postTokens?: number; durationMs?: number }
/** What a local slash command printed (`<local-command-stdout>`): its whole answer. */
| { k: 'local'; at: string; text: 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')
// A heading ends at its line. A short one (`## Summary`) is a label and goes; a longer one
// (`# Plan: add wrap to mathx.py`) is a sentence of its own.
.replace(/^[ \t]{0,3}#{1,6}[ \t]+(.*?)[ \t]*$/gm, (_, h: string) =>
h.split(/\s+/).length <= 3 ? '' : /[.!?:]$/.test(h) ? h : `${h}.`
)
.replace(/^[ \t]{0,3}(>|[-*+]|\d+[.)])[ \t]+/gm, '')
);
}
const restoreSpans = (text: string, spans: string[]): string =>
// NUL marks a kept span: no text Claude writes contains one.
// eslint-disable-next-line no-control-regex
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 two sentences of a message, without markdown (the second may be empty). */
function firstTwoSentences(text: string): [string, string] {
const spans: string[] = [];
const plain = plainMarkdown(text, spans).replace(/\s+/g, ' ').trim();
const one = SENTENCE_END.exec(plain);
if (!one) return [restoreSpans(plain, spans), ''];
const rest = plain.slice(one[1].length).trim();
const two = SENTENCE_END.exec(rest);
return [restoreSpans(one[1], spans), two ? restoreSpans(two[1], spans) : ''];
}
/** A sentence that reports a problem: its next sentence (usually the fix) belongs with it. */
const REPORTS_PROBLEM = /\b(left|stray|broke|broken|fail(s|ed)?|error|missing|wrong|bug|oops|mistake|forgot)\b/i;
/** A sentence that carries a caveat a reader must not lose. */
const CAVEAT =
/^(but|however|note|nothing|no\b|none|one (caveat|gap|thing)|heads up|caveat|warning|i (didn't|did not|couldn't|could not|haven't|have not|wasn't|left)|it (isn't|is not|wasn't|was not)|this (isn't|is not)|the (only|one) )/i;
/** A sentence that says how it ended: the tests, the save. Kept in Claude's last words after the first. */
const OUTCOME =
/\b(all \d+ (tests? )?(now )?pass|tests? (now )?pass|(is|are|was) (now )?committed|committed (it|them|the)|pushed|nothing is (lost|committed))\b/i;
/** What a speech balloon says: the first sentence, and the next when the first reports a problem. */
export function balloonText(text: string): string {
const [one, two] = firstTwoSentences(text);
return clip(two && REPORTS_PROBLEM.test(one) && two.length <= 140 ? `${one} ${two}` : one, 260);
}
/**
* A sentence a reader of Claude's last words must not lose. STRONG: an open problem or quirk,
* something not yet done, checked or confirmed, work still out, a word on what you asked for or
* expected, a correction of an earlier warning. WEAK: a bare negation ("I didn't push"), kept only when
* nothing strong is there.
*/
/**
* What a sentence in Claude's last words is, for a reader who must not lose it (round 8 and 9 of the
* cold-reader reviews): kinds in the order they are kept when there is no room for all.
* - `problem`: something wrong or left broken ("it won't work if you double-click", "doesn't fix it").
* - `offer`: Claude asks you or offers more ("Want me to add that?", "I can fix it whenever you want").
* - `howto`: how to use what was built ("serve the folder with python3 -m http.server").
* - `pending`: not done, checked or confirmed yet ("I haven't opened it in a browser").
* - `failed`: a step that failed, reported ("git describe --tags failed: no tags").
* - `asked`: a word on what you asked for or expected ("--csv is part of the CLI commit").
* - `correction`: an earlier warning that turned out wrong ("That's out of date").
* A bare negation ("I didn't push") is kept only when nothing else is there.
*/
const KEY_KINDS: Array<[StoryKeyKind, RegExp]> = [
[
'problem',
// "bug-fixing" and "cron-broken-network" are names, and a bare "warning" is often one too.
/\b(problems?|bugs?(?!-| fix)|(?<!-)broken(?!-)|known (issue|quirk)s?|quirks?|caveats?|(?<![#/])limitations?|heads up|warning sign|be warned|watch out|worth flagging|i want to flag|unfixed|left unfixed|still (fails?|failing|broken)|(won't|doesn't|does not|will not) (work|fix)|(isn't|is not|not) fixed|never (moves|runs|loads|works|starts|shows))\b/i,
],
[
'offer',
/\b(want me to|should i|shall i|do you want|if you want|whenever you want|let me know|say if you want|i can (add|fix|change|do|update|make|run|commit)|you (may|might) want to (change|revisit|undo|review)|decisions? (you|for you)|worth a second look)\b/i,
],
[
'howto',
/\b(to (use|run|open|try) it|serve (the|this) folder|http\.server|open https?:\/\/\S+|you('ll)? need to (run|open|serve|install|set)|instead,? (run|open|serve|use))\b/i,
],
[
'pending',
// "Left out" is open work, unless Claude chose it ("left out on purpose", "rather than guessed").
/(?<!(deliberately|intentionally|purposely) )\bleft (out|open)\b(?! (on purpose|deliberately|intentionally|rather))|\b(not yet|haven't|have not|(hasn't|has not) (answered|replied|finished|started|run|been (run|tested|checked|merged|pushed))|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened|run)|still (running|working|open(?!\s+(the|a|an|in|on|it|them)\b)|missing)|didn't (open|try|check|test|run|verify) it)\b/i,
],
// A step that failed, not a description of failure ("1 for a failed operation").
['failed', /(?<!\b(a|the|any|on) )(?<!\d )\b(failed|fails)\b/i],
[
'asked',
/\b(as you (asked|expected|said|wanted|suggested)|you asked|your (--?[\w-]+|request|option|flag|change)|(didn't|did not|couldn't|could not) (follow|use|do) (your|exactly what you))\b/i,
],
// A warning or report that turned out wrong: what resolves a worry an earlier panel raised.
[
'correction',
/\b(out of date|looked too early|(had )?looked before|turned out|was wrong|were wrong|is correct after all)\b/i,
],
];
const KEY_WEAK = /\b(didn't|did not|couldn't|could not|wasn't able|won't|doesn't)\b/i;
/** A problem sentence that is history: "I found and fixed a bug", "is fixed by …", "the caveat is gone". */
const PROBLEM_CLOSED =
/\b(?:(?:i|we|claude|and|is|are|was|were|been|be|now|got|gets?|it's|that's|already|also)\s+(?:\w+\s+)?(?:fixed|resolved)|(?:bugs?|problems?|issues?)\s+fixed|fixed (?:it|that|this|them|by|in|with|at|before|during)|(?:was|were|had been) broken|(?:was|were) the (?:\w+ )?(?:problem|bug|cause)|fixes|(?:is|are) gone|no longer|went away|works now|now (?:works|passes))\b/i;
/** …unless it says the fix is NOT there: "is not fixed", "wasn't fixed yet". */
const NOT_FIXED = /\b(?:not|never)\s+(?:yet\s+)?(?:fixed|resolved)\b|n't\s+(?:\w+\s+)?(?:fixed|resolved)\b/i;
/** A negation: "found no broken links", "not a problem", "without any memory problem", "couldn't find a bug". */
const NO_PROBLEM =
/\b(?:no|not an?|(?:isn't|is not|wasn't|was not|aren't) an?|without (?:any|an?)|found no|(?:couldn't|could not|didn't|did not) find (?:any|an?)|none of the)\s+(?:\S+\s+){0,2}?(?:problems?|bugs?|issues?|quirks?|caveats?|limitations?|broken)\b|\b(?:never|not|n't)\s+(?:been\s+)?broken\b/i;
const openProblem = (s: string): boolean => (NOT_FIXED.test(s) || !PROBLEM_CLOSED.test(s)) && !NO_PROBLEM.test(s);
/** A sentence's kind: the first that matches, where a problem must still be open. */
/** A sentence about how things were before the change ("Before, an early idle could end a wait"). */
const HISTORY = /^(before|previously|until now|in the past|originally)\b,?|\bused to\b/i;
const sentenceKind = (s: string): StoryKeyKind | undefined =>
KEY_KINDS.find(
([kind, re]) =>
re.test(s) &&
(kind !== 'problem' || openProblem(s)) &&
((kind !== 'problem' && kind !== 'pending') || !HISTORY.test(s))
)?.[0];
/** A sentence whose "it" or "that" is further back: "would fix it", "That's the cause". */
const POINTS_BACK = /^(that|this|it)\b|\b(fix|solve|change|handle|avoid|prevent|add) (it|that|this|them)\b/i;
/** A sentence that only points elsewhere ("described below"): nothing a balloon can show. */
const POINTS_ELSEWHERE = /\b(described|listed|shown|details?) below\b|\bsee below\b|\bas follows\b/i;
/** The fix that follows a problem sentence: then the problem is not open. */
const FIX_FOLLOWS =
/^(so i (changed|fixed|made|updated)|i (fixed|changed|corrected|updated) (it|that|this|the)|fixed\b|now it\b)/i;
export type StoryKeyKind = 'problem' | 'offer' | 'howto' | 'pending' | 'failed' | 'asked' | 'correction';
/** A key point that waits on you: a question, or an offer to do more (not an optional tip). */
const ASKS_YOU =
/\?$|\b(want me to|should i|shall i|say if you want|let me know|tell me)\b|\bi can\b.{0,100}\b(if|whenever) you want\b/i;
/** The kind of a key point already chosen (its words say it), for the final panel's title. */
export function keyKindOf(point: string): StoryKeyKind | undefined {
// A problem with its fix is history, not an open problem.
if (/\b(so i (changed|fixed)|i fixed|fixed it)\b/i.test(point)) return undefined;
return sentenceKind(point);
}
/**
* Sentences from further into a long message that a reader must not lose, at most three, picked by
* kind (one of each, in KEY_KINDS order) and shown in the order Claude wrote them; a bare negation
* only when none of those is there. Read on the whole message (the event's label is clipped), its
* first 8,000 characters. A table row is skipped; a list intro ending in `:` brings its first item; a
* sentence starting with "that"/"this"/"it" brings the one before it; a problem brings the fix that
* follows it.
*/
function keyPointsOf(text: string): string[] {
if (text.length <= 40) return [];
const spans: string[] = [];
const plain = plainMarkdown(text.slice(0, 8000), spans);
// Every sentence in order, with the line it came from (list items are lines).
const sentences: string[] = [];
const lineOf: number[] = [];
let lineNo = 0;
for (const line of plain.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
lineNo++;
if (trimmed.startsWith('|') || (trimmed.match(/\|/g)?.length ?? 0) >= 2) continue; // a table row
let rest = trimmed.replace(/^[-*•]\s+/, '').replace(/\s+/g, ' ');
while (rest) {
const m = SENTENCE_END.exec(rest);
sentences.push((m ? m[1] : rest).trim());
lineOf.push(lineNo);
rest = m ? rest.slice(m[1].length).trim() : '';
}
}
type Pick = { at: number; text: string; kind?: StoryKeyKind };
const candidates: Pick[] = [];
let weak: Pick | undefined;
for (let i = 1; i < sentences.length && candidates.length < 24; i++) {
let sentence = sentences[i];
if (sentence.length < 12 || POINTS_ELSEWHERE.test(sentence)) continue;
// Judged on its own words (a list intro too: "Known quirks:", "Two places where I didn't…:").
const kind = sentenceKind(sentence);
if (!kind && (weak || !KEY_WEAK.test(sentence))) continue;
// A list intro brings its first items, while short, never one that only points elsewhere. One that
// announces how many ("Two places where I didn't follow your wording:") brings that many lines.
let wide = false;
if (/:$/.test(sentence)) {
const count =
/\b(two|three|2|3)\s+(places|things|points|changes|issues|differences|deviations|items|notes|decisions|quirks|caveats)\b/i.exec(
sentence
)?.[1];
const lines = count ? (/^(three|3)$/i.test(count) ? 3 : 2) : 0;
wide = lines > 0;
const room = lines ? 480 : 360;
for (
let k = i + 1;
k < sentences.length &&
(lines ? lineOf[k] <= lineOf[i] + lines : k <= i + 2) &&
sentence.length + sentences[k].length < room;
k++
)
if (!POINTS_ELSEWHERE.test(sentences[k])) sentence = `${sentence} ${sentences[k]}`;
}
// "That's out of date" needs what "that" was, and so does "Want me to add that?".
// ("It doesn't mention…" is a sentence of its own: only "that"/"this" point back.)
const refersBack =
/^(that|this)\b/i.test(sentence) ||
(kind === 'offer' && sentence.length < 80 && /\b(that|this|it|them)\b/i.test(sentence));
let before = refersBack && i > 1 ? `${sentences[i - 1]} ` : '';
// "…would fix it for up to 10 files. Want me to add that?": what "it" is sits further back, at the
// start of that paragraph ("Suffix swap: the two files … would trade their _1 suffix.").
if (before && kind === 'offer' && i > 2 && POINTS_BACK.test(sentences[i - 1])) {
let start = i - 1;
while (start > 1 && lineOf[start - 1] === lineOf[i - 1]) start--;
if (start < i - 1) before = `${sentences[start]}${start < i - 2 ? ' …' : ''} ${before}`;
}
// A problem reported with its fix right after it: both, so it never reads as still open.
const fix =
kind === 'problem' && sentences[i + 1] && FIX_FOLLOWS.test(sentences[i + 1]) ? ` ${sentences[i + 1]}` : '';
const shown = clip(
restoreSpans(before + sentence + fix, spans),
before.includes(' …') || wide ? 480 : before || fix ? 360 : 300
);
if (kind) candidates.push({ at: i, text: shown, kind });
else weak = { at: i, text: shown };
}
const picked: Pick[] = [];
for (const [kind] of KEY_KINDS) {
const c = candidates.find((p) => p.kind === kind);
if (c && picked.length < 3) picked.push(c);
}
if (!picked.length && weak) picked.push(weak);
return picked.sort((a, b) => a.at - b.at).map((p) => p.text);
}
/**
* Claude's last words: the first sentence, the next when it carries a caveat, and the key points from
* further in (an open problem, what was or was not done), so the final panel never reads as a clean
* finish when Claude said otherwise.
*/
export function closingText(text: string, keyPoints: string[] = [], gap = ' '): string {
const [one, two] = firstTwoSentences(text);
// A very short first sentence ("Not done.") says too little alone.
const head = [one];
if (two && (CAVEAT.test(two) || OUTCOME.test(two) || one.length < 24) && two.length <= 200) head.push(two);
const more: string[] = [];
for (const point of keyPoints)
if (![...head, ...more].some((p) => p.includes(point) || point.includes(p))) more.push(point);
// `gap` marks where the balloon skips ahead (" … " for a helper's report quoted in a notice): only
// where text was left out between two kept sentences, found in the message itself.
const flat = (t: string) =>
t
.replace(/[`*_#>|-]/g, '')
.replace(/\s+/g, ' ')
.trim();
const whole = gap === ' ' ? '' : flat(text.slice(0, 8000));
let out = head.join(' ');
let end = whole ? whole.indexOf(flat(out).slice(0, 60)) + flat(out).length : -1;
for (const point of more) {
const at = whole ? whole.indexOf(flat(point).slice(0, 60)) : -1;
out += at >= 0 && end >= 0 && at - end <= 2 ? ` ${point}` : `${gap}${point}`;
end = at >= 0 ? at + flat(point).length : -1;
}
return clip(out, head.length + more.length > 1 ? 560 : 300);
}
/** The first sentence of a message, without markdown, for a speech balloon. */
export function firstSentence(text: string, max = 160): string {
const spans: string[] = [];
// Only the start can hold the first sentence; the rest (a helper's whole report) is never scanned.
const plain = plainMarkdown(text.slice(0, 4000), 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.slice(0, 20_000).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].slice(0, 400), 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 ${s % 60} s`;
}
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;
}
/**
* One-line writes: `printf 'a\nb\n' > f`, `echo "x" >> f`. The quoted text is the content (printf
* and `echo -e` turn `\n` into line breaks). Same target rules as here-documents.
*/
function echoWrites(command: string, cwd?: string): StoryEdit[] {
const out: StoryEdit[] = [];
let moved = false;
for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 20_000)))) {
const head = segmentHead(segment);
if (/^(cd|pushd)\b/.test(head)) moved = true;
const m =
/^(printf|echo)((?:\s+-[neE]+)*)\s+(['"])((?:(?!\3)[\s\S])*)\3\s*(>>?)\s*(["']?)([^\s"'|;&<>()]+)\6\s*$/.exec(
head
);
if (!m || SCRATCH_TARGET.test(m[7])) continue;
const escapes = m[1] === 'printf' || /e/.test(m[2]);
const text = escapes ? m[4].replace(/\\n/g, '\n').replace(/\\t/g, '\t') : m[4];
const lines = text.replace(/\n$/, '').split('\n');
const file =
m[7].startsWith('/') || !cwd || moved ? m[7] : `${cwd.replace(/\/+$/, '')}/${m[7].replace(/^\.\//, '')}`;
out.push({
file,
added: lines.length,
removed: 0,
created: false,
hunks: [
{
oldStart: 0,
oldLines: 0,
newStart: m[5] === '>>' ? 0 : 1,
newLines: lines.length,
lines: lines.slice(0, 40).map((l) => `+${clip(l, STORY_LIMITS.lineChars)}`),
...(m[5] === '>>' ? { atEnd: true } : {}),
},
],
truncated: lines.length > 40,
});
if (out.length >= 6) break;
}
return out;
}
/**
* The first commit's message in a command: `-m "..."` / `-m '...'`, or the first line of the
* here-document a `git commit -F -` reads.
*/
function commitSubject(command: string): string | undefined {
// Line by line with plain searches, never one regex over the command: a command is agent-written,
// and `[^;]*commit[^;]*?-m` over many `git commit` tokens backtracks (seconds on a long one-liner).
const lines = command.slice(0, 8000).split('\n');
for (let i = 0; i < lines.length && i < 400; i++) {
const line = lines[i];
const g = line.search(/\bgit\b/);
if (g < 0) continue;
const c = line.slice(g).search(/\bcommit\b/);
if (c < 0) continue;
const rest = line.slice(g + c + 'commit'.length);
// Its own options only: the part before the next `;`, `&` or `|`.
const stop = rest.search(/[;&|]/);
const end = stop < 0 ? rest.length : stop;
// `-m`, a combined `-am` / `-qm`, or `--message`; a trailer (`Co-Authored-By:`) is never the subject.
const m = /\s-(?:[a-zA-Z]{0,8}m|-message=?)\s*(["'])/.exec(rest);
if (m && m.index < end) {
const from = m.index + m[0].length;
const close = rest.indexOf(m[1], from);
const first = (close < 0 ? rest.slice(from) : rest.slice(from, close)).trim();
if (first && !/^[\w-]+-By:/i.test(first)) return clip(first, 100);
continue;
}
// `git commit -F - <<'EOF'`: the here-document's first line.
const f = /\s-F\s*-(?=\s|$)/.exec(rest);
const h = /<<-?\s*(['"]?)(\w+)\1/.exec(rest);
if (f && f.index < end && h) {
const body = lines[i + 1]?.trim();
if (body && body !== h[2]) return clip(body, 100);
}
}
return undefined;
}
/** 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 [];
// `sed -n '5,25p' README.md` prints part of a file, like head and tail.
const sed = /^sed\s+-n\s+(['"]?)[0-9,$]+p\1\s+(.+)$/.exec(head);
const m = sed ? [head, 'sed', sed[2]] : /^(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 script fed to python or node
* (`python3 - <<EOF`) is read for the files it opens for writing; those are edits without lines,
* since what the script changed is not in the command.
*/
export function heredocWrites(command: string, cwd?: string): StoryEdit[] {
const out: StoryEdit[] = [...echoWrites(command, cwd)];
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 && /\b(python3?|node)\b/.test(before)) {
for (const written of scriptWrites(body)) {
if (SCRATCH_TARGET.test(written) || out.some((d) => d.file.endsWith(written))) continue;
const file =
written.startsWith('/') || !cwd || moved
? written
: `${cwd.replace(/\/+$/, '')}/${written.replace(/^\.\//, '')}`;
out.push({ file, added: 0, removed: 0, created: false, hunks: [], truncated: true });
if (out.length >= 6) break;
}
continue;
}
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: [
append
? { oldStart: 0, oldLines: 0, newStart: 0, newLines: body.length, lines: kept, atEnd: true }
: { oldStart: 0, oldLines: 0, newStart: 1, newLines: body.length, lines: kept },
],
truncated: kept.length < body.length,
});
}
return out;
}
/**
* Files a python or node script opens for writing: `open('a.py', 'w')`, `open(p, "w")` after
* `p = "a.py"`, `Path('a.py').write_text(...)`, `fs.writeFileSync('a.js', ...)`. Only string literals
* and names bound to one; anything computed is left out rather than guessed.
*/
function scriptWrites(body: string[]): string[] {
const names = new Map<string, string>();
for (const line of body) {
const m = /^\s*(?:(?:const|let|var)\s+)?([A-Za-z_]\w*)\s*=\s*(?:Path\(\s*)?(['"])([^'"\n]+)\2\s*\)?\s*;?\s*$/.exec(
line
);
if (m) names.set(m[1], m[3]);
}
const target = (lit: string | undefined, name: string | undefined) => lit ?? (name ? names.get(name) : undefined);
const found: Array<{ at: number; file: string }> = [];
const text = body.join('\n');
const patterns = [
// open(target, 'w') / open(target, mode="a+")
/\bopen\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))\s*,\s*(?:mode\s*=\s*)?(['"])([rwaxbt+]*)\4/g,
// Path(target).write_text( / name.write_text(
/(?:\bPath\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))\s*\)|\b([A-Za-z_]\w*))\.write_(?:text|bytes)\(/g,
// fs.writeFileSync(target / appendFileSync(target
/\b(?:write|append)File(?:Sync)?\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))/g,
];
for (const [k, re] of patterns.entries()) {
for (const m of text.matchAll(re)) {
if (k === 0 && !/[wax+]/.test(m[5])) continue;
const file = k === 1 ? target(m[2], m[3] ?? m[4]) : target(m[2], m[3]);
if (file && !/[*?$`{}]/.test(file)) found.push({ at: m.index ?? 0, file });
}
}
// In the order the script writes them.
return unique(found.sort((x, y) => x.at - y.at).map((f) => f.file)).slice(0, 6);
}
/**
* 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 {
// The last verdict wins: a formatter's "1 file would be reformatted" preview, then "1 file
// reformatted" and a clean check, is clean.
const last = (patterns: RegExp[]) =>
Math.max(-1, ...patterns.map((re) => [...output.matchAll(new RegExp(re.source, 'gm'))].pop()?.index ?? -1));
const problem = last([
/\bFound [1-9]\d* errors?\b/,
/\b[1-9]\d* files? would be reformatted\b/,
/^would reformat /,
/✖ [1-9]\d* problems?\b/,
/\[warn\] Code style issues found/,
/\berror TS\d+:/,
]);
if (problem < 0) return false;
const clean = last([
/\bAll checks passed!/,
/\bFound 0 errors\b/,
/^(?!.*would be).*\b\d+ files? reformatted\b/,
/^(?!.*would be).*\b\d+ files? (left unchanged|already formatted)\b/,
]);
return problem > clean;
}
/**
* 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 {
// The newest summary wins; a long output is searched from its end (the last 2,000 lines).
const lines = output.split('\n').slice(-2000).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 ==========
/*
* Tagged blocks in prompt text are found with indexOf, never a lazy `<a>[\s\S]*?</a>` regex: that
* one rescans to the end for every opener without a closer (seconds on a crafted megabyte, and a
* teammate's or a helper's message is another agent's writing).
*/
/** The text with every `<open>…<close>` block taken out; an opener never closed stays. */
function withoutBlocks(text: string, open: string, close: string): string {
let out = '';
let from = 0;
for (;;) {
const at = text.indexOf(open, from);
if (at < 0) break;
const end = text.indexOf(close, at + open.length);
if (end < 0) break;
out += text.slice(from, at);
from = end + close.length;
}
return out + text.slice(from);
}
/** `<name attrs>inner</name>` blocks in order (`name` followed by a space or `>`), at most `limit`. */
function blocks(text: string, name: string, limit = 50): Array<{ attrs: string; inner: string }> {
const out: Array<{ attrs: string; inner: string }> = [];
const open = `<${name}`;
const close = `</${name}>`;
let from = 0;
while (out.length < limit) {
const at = text.indexOf(open, from);
if (at < 0) break;
const next = text[at + open.length];
if (next !== '>' && next !== undefined && !/\s/.test(next)) {
from = at + open.length;
continue;
}
const gt = text.indexOf('>', at + open.length);
if (gt < 0) break;
const end = text.indexOf(close, gt + 1);
if (end < 0) break;
out.push({ attrs: text.slice(at + open.length, gt), inner: text.slice(gt + 1, end) });
from = end + close.length;
}
return out;
}
/** The inner text of the first `<name>…</name>`. */
function blockText1(text: string, name: string): string | undefined {
const at = text.indexOf(`<${name}>`);
if (at < 0) return undefined;
const end = text.indexOf(`</${name}>`, at);
return end < 0 ? undefined : text.slice(at + name.length + 2, end);
}
/** 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 blocks(text, 'teammate-message')) {
const from = /teammate_id="([^"]*)"/.exec(m.attrs)?.[1]?.split('@')[0];
if (!from) continue;
const rawSummary = /summary="([^"]*)"/.exec(m.attrs)?.[1];
const summary = rawSummary === undefined ? undefined : unescapeXml(rawSummary);
let body: string | undefined = unescapeXml(m.inner.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;
}
/** Claude Code escapes a notification's text (`&lt;noreply@anthropic.com&gt;`); undo the five it uses. */
const unescapeXml = (s: string): string =>
s.replace(
/&(lt|gt|quot|apos|amp);/g,
(_, e: string) => ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[e] ?? _
);
function readPrompt(raw: string): {
text: string;
kind: 'prompt' | 'task' | 'peer' | 'interrupt';
/** A slash command (`/compact`, `/model`), from its `<command-name>`. */
slash?: boolean;
task?: StoryTaskReport;
teammates?: StoryTeammateReport[];
} | null {
const text = withoutBlocks(raw, '<system-reminder>', '</system-reminder>')
.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', slash: true };
}
if (/^<task-notification>/.test(text)) {
const tag = (name: string) => {
const value = blockText1(text, name)?.trim();
return value === undefined ? undefined : unescapeXml(value);
};
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 };
}
// A command typed as such (`/compact`, `/model opus`); a path (`/home/x`) is not one.
if (/^\/[a-z][\w:-]*(\s|$)/i.test(text) && !text.includes('\n')) return { text, kind: 'prompt', slash: true };
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 (turnOrigin === 'scheduled') return 'scheduled';
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;
// A script or command that writes project files is an edit, whatever it was run as.
if (writes.length && (intent === 'run' || intent === 'look')) input.intent = 'edit';
// Files it prints, whatever else it does (`cat a.py && pytest`): the drawer offers them. A
// relative name resolves against the line's cwd (printedFiles gives none after a `cd`).
const reads = printedFiles(full).map((f) =>
f.startsWith('/') || f.startsWith('~') || !cwd ? f : `${cwd.replace(/\/+$/, '')}/${f.replace(/^\.\//, '')}`
);
if (reads.length) input.reads = reads;
{
// Counted whatever wins: a test run that ends in a commit still saves the work.
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;
// Counted on every command that commits: a save can also be a push or a tag that makes none.
if (commits) input.commits = commits;
if (commits) {
const subject = commitSubject(full);
if (subject) input.subject = subject;
}
}
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': {
const file = s(i.file_path, 1000);
// A plan-mode plan file keeps its text: it becomes the plan panel's list.
return file && PLAN_FILE.test(file) ? { file, plan: s(i.content, 4000) } : { file };
}
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 === 'ExitPlanMode') {
// The plan rides on the result (the call's input is empty in current builds), with your answer.
if (typeof extra.plan === 'string') result.plan = clip(extra.plan, 4000);
if (!isError && /\bapproved\b/i.test(text)) result.answer = 'Approved';
else if (text) result.answer = 'Sent back';
} 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' && /^\s*Command running in background with ID:/.test(text)) {
result.background = true;
// The launch note tells Claude where to read later; the reader gets what it means.
result.output = 'Started in the background. Its result comes back as a report of its own.';
result.outputTruncated = undefined;
} else if (name === 'Bash') {
// Counted on the whole output: a later command's lines can push the summary out of the tail.
const tests = parseTestCounts(text);
if (tests) result.tests = tests;
const intent = call?.input.intent;
if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true;
// Claude Code appends this note: one anchored try at its last occurrence, never a scan
// of the whole output (a lazy match per repeat of the phrase is quadratic).
const noteAt = text.lastIndexOf('This command modified ');
const note =
noteAt < 0
? null
: /^This command modified \d+ files? you've previously read: ([^\]\n]{1,2000}?)\.? Call Read/.exec(
text.slice(noteAt, noteAt + 2400)
);
if (note) {
const cwd = str(entry.cwd);
result.modified = note[1]
.split(/,\s*/)
.filter((f) => f && !/\s/.test(f))
.slice(0, 8)
.map((f) => (f.startsWith('/') || !cwd ? f : `${cwd.replace(/\/+$/, '')}/${f}`));
}
}
}
out.push(result);
calls.delete(id);
}
if (results.length) return out;
}
// A scheduled task's prompt is written as a meta line; it still starts a turn of its own.
const scheduled = entry.turnOrigin === 'scheduled' || typeof entry.scheduledTaskId === 'string';
if ((entry.isMeta === true && !scheduled) || entry.turnCompanion === true) return out;
const local = /^\s*<local-command-(stdout|stderr)>([\s\S]*?)<\/local-command-\1>/.exec(blockText(content));
if (local) {
const text = local[2]
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
.replace(/`([^`\n]+)`/g, '$1')
.trim();
return [{ k: 'local', at, text: clip(text, 2000) }];
}
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'
),
promptId: str(entry.promptId),
},
];
}
/**
* 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.slash) reduced.slash = true;
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;
const textEntry = (text: string): StoryEntry => {
const keyPoints = keyPointsOf(text);
// Read on the whole message: the label keeps only its start.
const rule = RULE_SAVE.test(text.slice(0, 8000));
return {
k: 'text',
at,
text: clip(text, STORY_LIMITS.textChars),
...(keyPoints.length ? { keyPoints } : {}),
...(rule ? { citesSaveRule: true } : {}),
};
};
if (typeof content === 'string') return content.trim() ? [textEntry(content)] : [];
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(textEntry(block.text));
} 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') {
const meta = isRecord(line.compactMetadata) ? line.compactMetadata : {};
return [
{
k: 'compact',
at,
trigger: str(meta.trigger),
preTokens: num(meta.preTokens),
postTokens: num(meta.postTokens),
durationMs: num(meta.durationMs),
},
];
}
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;
/** Claude Code's id for the prompt that started the turn. */
promptId?: string;
/** What ended the turn, for the Director's cut. */
endedBy?: 'turn_duration' | 'stop_hook_summary' | 'local command' | 'interrupted' | 'next prompt';
}
/** How long after a turn's end a line may still belong to it (Claude Code flushes some lines late). */
const LATE_FLUSH_MS = 5000;
/** Plan mode keeps its plan in a file under `~/.claude/plans/`: writing it is planning, not changing code. */
const PLAN_FILE = /\/\.claude\/plans\/[^/]+\.md$/;
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 'ToolSearch':
return 'look';
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 input.file && PLAN_FILE.test(input.file) ? 'plan' : '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 'EnterPlanMode':
return 'plan mode on';
case 'ToolSearch':
return `tools: ${(i.text ?? '').replace(/^select:/, '')}`;
case 'Write':
case 'Edit':
return i.file && PLAN_FILE.test(i.file) ? `the plan, ${basename(i.file)}` : (i.file ?? '');
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.subject) ev.subject = i.subject;
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 (i.plan) {
const todos = planFromText(i.plan);
if (todos) ev.todos = 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.intent !== 'plan') {
ev.edit = r.edit;
ev.file = r.edit.file;
}
if (r.plan) {
ev.output = r.plan;
if (ev.question) ev.question = { ...ev.question, text: firstSentence(r.plan, 240) };
const todos = planFromText(r.plan);
if (todos) ev.todos = todos;
}
if (r.problems) ev.ok = false;
if (r.background) {
// Started, not done: its task notification says how it ended.
ev.running = true;
ev.background = true;
ev.ok = undefined;
}
// Reading a background job's output file that reports tests is a look at test results.
if (
r.tests &&
ev.tool === 'Bash' &&
(ev.intent === 'look' || ev.intent === 'run') &&
/\.(output|log)\b|\/tasks\//.test(ev.label)
)
ev.intent = 'test';
if (r.modified?.length) {
// Changed, but no diff to show: the file is listed, its lines unknown. A plain command that did
// it (a script, a formatter) edited files.
if (ev.intent === 'run' || ev.intent === 'look') ev.intent = 'edit';
const known = new Set((ev.edits ?? []).map((d) => d.file));
const more = r.modified
.filter((f) => !known.has(f))
.map((file) => ({ file, added: 0, removed: 0, created: false, hunks: [], truncated: true }));
if (more.length) ev.edits = [...(ev.edits ?? []), ...more];
}
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>();
/** A compaction between turns, held for the turn that comes next. */
let pendingCompact: StoryEvent | null = null;
/** The same helpers by name: a teammate reports back by name, not by call. */
const helpersByName = new Map<string, StoryEvent>();
let say = 0;
/** The turn each helper or command call belongs to: a report after that turn ended came back later. */
const turnOfCall = new WeakMap<StoryEvent, StoryTurn>();
const cameBackLater = (ev: StoryEvent) => {
const own = turnOfCall.get(ev);
return !!own && (own !== cur || own.ended);
};
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 (helper.background && cameBackLater(ev)) ev.finishedLater = true;
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, by: NonNullable<StoryTurn['endedBy']>) => {
t.ended = true;
t.endedAt = at;
t.endedBy = by;
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?.running && !ev.helper) {
// A background command: done now, and its summary carries the exit code.
ev.running = undefined;
if (cameBackLater(ev)) ev.finishedLater = true;
const code = /\(exit code (\d+)\)/.exec(prompt.label)?.[1];
ev.ok =
code !== undefined ? code === '0' : !report.status || /^(completed|done|finished|success)/i.test(report.status);
ev.output = `Finished in the background${code !== undefined ? `, exit code ${code}` : ''}. Its report is the turn it came back in.`;
return;
}
if (!ev?.helper) return;
const helper: StoryHelper = { ...ev.helper, running: undefined };
if (helper.background && cameBackLater(ev)) ev.finishedLater = true;
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;
// The report's own turn links back to the helper: its drawer shows what the helper did.
prompt.helper = { ...helper };
if (report.status && !/^(completed|done|finished|success)/i.test(report.status)) ev.ok = false;
};
const ensure = (at: string): StoryTurn => {
// Work that comes well after a turn ended (no prompt of its own) is a turn of its own; a line
// Claude Code flushed a moment late still joins the turn it belongs to.
const after = cur?.ended ? Date.parse(at) - Date.parse(cur.endedAt ?? at) : 0;
if (!cur || (cur.ended && Number.isFinite(after) && after > LATE_FLUSH_MS)) {
cur = {
id: cur ? `continued-${at}` : `partial-${at}`,
at,
prompt: '',
origin: cur ? 'system' : 'human',
partial: !cur,
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: e.slash ? 'Command' : '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.promptId && open.promptId === e.promptId) {
// The same prompt written again (`/compact` typed, then recorded as a command): one turn.
const first = open.events[0];
if (e.slash && first?.intent === 'prompt') {
first.tool = 'Command';
first.label = e.text;
open.prompt = e.text;
}
break;
}
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, 'next prompt');
cur = {
id: e.id,
at: e.at,
prompt: e.text,
origin: e.origin,
partial: false,
events: [ev],
ended: false,
interrupted: false,
};
if (e.promptId) cur.promptId = e.promptId;
turns.push(cur);
byId.clear();
if (pendingCompact) {
cur.events.push(pendingCompact);
pendingCompact = null;
}
}
break;
}
case 'text':
ensure(e.at).events.push({
id: `say-${say++}`,
at: e.at,
actor: 'claude',
tool: 'Text',
label: e.text,
intent: 'say',
...(e.keyPoints ? { keyPoints: e.keyPoints } : {}),
...(e.citesSaveRule ? { citesSaveRule: true } : {}),
});
break;
case 'tool': {
const ev = toolEvent(e, 'claude');
ensure(e.at).events.push(ev);
byId.set(e.id, ev);
if (ev.helper || ev.tool === 'Bash') {
// A helper, or a command that may turn out to run in the background: both report back later.
helpers.set(e.id, ev);
turnOfCall.set(ev, ensure(e.at));
if (helpers.size > 2000) helpers.delete(helpers.keys().next().value as string);
if (ev.helper) {
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
t.endedBy = e.durationMs !== undefined ? 'turn_duration' : (t.endedBy ?? 'stop_hook_summary');
}
break;
}
case 'compact': {
const ev: StoryEvent = {
id: `compact-${say++}`,
at: e.at,
actor: 'system',
tool: 'Compact',
label: 'Context compacted',
intent: 'compact',
};
const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
const facts = [
e.trigger === 'auto' ? 'automatic' : e.trigger,
e.preTokens !== undefined && e.postTokens !== undefined
? `${k(e.preTokens)} → ${k(e.postTokens)} tokens`
: '',
e.durationMs !== undefined ? exactDuration(e.durationMs) : '',
].filter(Boolean);
if (facts.length) ev.description = facts.join(' · ');
if (e.trigger === 'auto') ev.label = 'Context compacted: memory was full';
// Between turns (`/compact` writes its boundary before the command line itself), it belongs to
// the turn that comes next; mid-turn (an automatic compaction), to the running one.
const t = cur as StoryTurn | null;
if (t && !t.ended) t.events.push(ev);
else pendingCompact = ev;
break;
}
case 'local': {
// A local slash command answers itself: its turn ends with what it printed.
const t = cur as StoryTurn | null;
if (t && !t.ended && t.events[0]?.tool === 'Command' && countToolCalls(t.events) === 0) {
if (e.text) t.events[0].output = e.text;
closeTurn(t, e.at, 'local command');
}
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, 'interrupted');
break;
}
}
}
// A compaction after the last turn (no turn came after it to take it) closes that turn.
if (pendingCompact && turns.length) turns[turns.length - 1].events.push(pendingCompact);
return turns;
}
const NOT_TOOL_CALLS = new Set<StoryIntent>(['prompt', 'say', 'interrupt', 'compact', 'notice']);
/** The turn's own tool calls: a second opinion (the advisor) is no step of the work, so it is not counted. */
const countToolCalls = (events: StoryEvent[]) =>
events.filter((e) => !NOT_TOOL_CALLS.has(e.intent) && e.tool !== 'Advisor').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,
...(turn.endedBy === 'next prompt' ? { superseded: true } : {}),
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' | 'note'; text: string };
rounds?: number;
}
/** 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 ?? []));
/** Keeps the newest outputs, hunks and helper texts within the page budget; true when it cut any. */
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|red|clean|errors?)\b/i;
/** Claude reporting a save ("Commits are in.", "Pushed to origin."). */
const TALKS_SAVES = /\b(commit(s|ted)?|pushed|merged|tagged)\b/i;
/** The remark that reports on a panel of this kind, if any. */
const REPORT_TALK: Partial<Record<StoryPanelKind, RegExp>> = {
check: TALKS_RESULTS,
save: TALKS_SAVES,
run: TALKS_RESULTS,
};
const ANNOUNCES = /\b(I'll|I will|let me|let's|I'm going to|next,? I|now I)\b/i;
/** What Claude's last words leave open: a catch (a problem), a note (something pending), a question. */
/** Claude did something differently from what you asked: worth a note on the final panel. */
const DEVIATED =
/\b(didn't|did not|couldn't|could not) (follow|use|do) (your|exactly what you)|\bnot exactly as you\b/i;
function closingKinds(points: string[] = []): { openNote?: 'catch' | 'note'; asks?: boolean } {
const kinds = new Set(points.map(keyKindOf));
return {
...(kinds.has('problem')
? { openNote: 'catch' as const }
: kinds.has('pending') || points.some((p) => DEVIATED.test(p))
? { openNote: 'note' as const }
: {}),
...(points.some((p) => keyKindOf(p) === 'offer' && ASKS_YOU.test(p)) ? { asks: true } : {}),
};
}
function groupPanels(events: StoryEvent[]): {
panels: DraftPanel[];
trailingSay?: string;
openNote?: 'catch' | 'note';
asks?: boolean;
} {
const panels: DraftPanel[] = [];
let pendingSay: string | undefined;
let lastSaid: StoryEvent | undefined;
// A second opinion (the advisor) is not a step of its own (its answer is not readable anyway): it
// rides on the panel of the step after it, or the last one when nothing follows.
let opinions: number[] = [];
const takeOpinions = (panel: DraftPanel) => {
if (!opinions.length) return;
panel.events = [...panel.events, ...opinions].sort((a, b) => a - b);
opinions = [];
};
events.forEach((ev, idx) => {
if (ev.tool === 'Advisor') {
opinions.push(idx);
return;
}
if (ev.intent === 'say' && !ev.todos) {
pendingSay ??= balloonText(ev.label);
lastSaid ??= ev;
return;
}
lastSaid = undefined;
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);
takeOpinions(last);
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.
// A check that also committed reports on both: "All 8 pass." and "Committed."
const saves = last?.kind === 'check' && last.events.some((i) => events[i].commits);
const talk = last
? saves
? new RegExp(`${TALKS_RESULTS.source}|${TALKS_SAVES.source}`, 'i')
: REPORT_TALK[last.kind]
: undefined;
const reportsBack =
pendingSay &&
talk &&
last &&
(!last.balloon || (last.kind === 'check' && !talk.test(last.balloon.text))) &&
talk.test(pendingSay) &&
!ANNOUNCES.test(pendingSay);
if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 400) };
else if (reportsBack && last && pendingSay) last.balloon = { who: 'claude', text: pendingSay };
else if (pendingSay) panel.balloon = { who: 'claude', text: pendingSay };
takeOpinions(panel);
panels.push(panel);
}
pendingSay = undefined;
});
if (opinions.length) {
const last = panels[panels.length - 1];
if (last) takeOpinions(last);
else panels.push({ kind: 'backup', events: opinions });
}
// The closing words keep a caveat that follows them ("Nothing is committed: this is no git repo.").
return {
panels,
trailingSay: lastSaid !== undefined ? closingText(lastSaid.label, lastSaid.keyPoints) : pendingSay,
// Claude's last words name an open problem, or something not done or checked: no celebration.
...closingKinds(lastSaid?.keyPoints),
};
}
/** 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;
// Four passes: quiet kinds; then anything but a prompt or a question; then a prompt you added
// mid-turn; then, only so the page keeps its 12 panels, a question too (the turn's own prompt
// is never folded).
search: for (const pass of [0, 1, 2, 3]) {
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 =
pass === 0
? MINOR.has(a.kind) && MINOR.has(b.kind)
: pass === 1
? ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask')
: ![a, b].some(
(d) =>
(pass === 2 && d.kind === 'ask') || (d.kind === 'mission' && d.events[0] === out[0]?.events[0])
);
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] ?? '').replace(/\s*<<-?\s*(['"]?)\w+\1/g, ''), 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 ?? 0), 0);
if (commits || evs.some((e) => e.intent === 'save'))
parts.push(commits > 1 ? `saves the work in ${commits} commits` : 'saves the work');
return parts.length ? parts.join(', ') : 'a few more steps';
}
/**
* One output line as the terminal card may show it. The card is drawn on the canvas, so it goes into a
* saved image someone may share: a line that looks like a secret is left out, addresses and long
* token-like strings are masked, and Claude Code's own markers go.
*/
function printableLine(line: string): string | null {
const text = line.replace(/<\/?[a-z_]+>/g, '').trim();
if (!text || /^\(Bash completed with no output\)$/.test(text)) return null;
// Anywhere in the line, inside identifiers too (`API_TOKEN=`, `db_password:`): losing a harmless line is cheap.
if (/token|secret|passw(or)?d|api[_-]?key|authorization|bearer|private[_ -]?key|credential/i.test(text)) return null;
return clip(text.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '…@…').replace(/[A-Za-z0-9+/=_-]{32,}/g, '…'), 90);
}
/** What a check other than the tests is, by its tool, for a reader: "a formatting check", "a style check". */
function checkName(e: StoryEvent | undefined): string {
const cmd = e?.label ?? '';
if (/\b(ruff format|black|prettier|gofmt|rustfmt|isort|autopep8|biome format)\b/.test(cmd))
return 'a formatting check';
if (/\b(ruff|eslint|flake8|pylint|clippy|shellcheck|golangci-lint|stylelint|biome)\b/.test(cmd))
return 'a style check';
if (/\b(tsc|mypy|pyright|vue-tsc)\b/.test(cmd)) return 'a type check';
if (/\b(build|make|cargo build|go build|webpack|vite build|esbuild)\b/.test(cmd)) return 'the build';
return 'another check';
}
/**
* A command whose LAST step answers "no" with exit code 1: grep finds nothing, a git setting is unset,
* `test` is false, a program is not installed. What it printed before that is the answer Claude was
* after, so the panel says so instead of "it fails". Anchored on a separator, so linear.
*/
const EMPTY_ANSWER_STEP = /(?:^|[;&|]\s*)(?:e?grep|fgrep|rg|git config|test|\[|which|command -v)\s[^;&|]*$/;
const emptyAnswer = (e: StoryEvent): boolean => {
const out = e.output ?? '';
// A usage message or an error is a step that went wrong, not an empty answer.
return (
e.ok === false &&
/^Exit code 1\n\s*\S/.test(out) &&
!/^\s*(usage|error|fatal|grep):/im.test(out) &&
EMPTY_ANSWER_STEP.test(e.label.trim())
);
};
/** The first save of a folder Claude made a repository (`git init`, not `--bare`) with nothing committed before. */
function startsRepoAt(indices: number[], events: StoryEvent[]): boolean {
const earlier = events.slice(0, Math.min(...indices));
return (
!earlier.some((e) => (e.commits ?? 0) > 0) &&
[...earlier, ...indices.map((i) => events[i])].some(
(e) => e.tool === 'Bash' && /\bgit init\b(?![^;&|\n]*\s--bare\b)/.test(e.label)
)
);
}
/** What the last lookup of an empty answer came back without, in a reader's words. */
function emptyAnswerWhat(e: StoryEvent): string {
const step = EMPTY_ANSWER_STEP.exec(e.label.trim())?.[0].replace(/^[;&|\s]+/, '') ?? '';
return /^git config/.test(step)
? 'the last one finds a git setting that is not set'
: /^(which|command -v)/.test(step)
? 'the last one finds a program that is not installed'
: /^(test|\[)/.test(step)
? 'the last test it makes comes out false'
: 'the last search finds no match';
}
/** Words in a prompt that ask for (or mention) saving the work: then a save is no surprise. */
const ASKS_TO_SAVE =
/\b(commit|commits|committed|git|push|pushed|pull request|check(ed)? in|ship|release|merge|version control)\b|\bpr\b|\b(save|saving|saved) (the |your |all |this |my |our )?(work|changes|progress|code|project)\b/i;
/** Claude saying a project rule asks for the commits ("CLAUDE.md says to commit after every change"). */
const RULE_SAVE =
/\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\b[^.\n]{0,120}\bcommit|\bcommit[^.\n]{0,120}\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\b/i;
/** The branch Claude created for this save (`git checkout -b x`, `git switch -c x`), when it is the first save on it. */
function newBranchAt(indices: number[], events: StoryEvent[]): string | undefined {
const firstAt = Math.min(...indices);
const lastAt = Math.max(...indices);
for (let i = lastAt; i >= 0; i--) {
const m =
events[i].tool === 'Bash' &&
/\bgit (?:checkout|switch)(?:\s+-q)?\s+(?:-b|-c|--create)\s+([\w./-]+)/.exec(events[i].label);
if (!m) continue;
// Nothing committed between the branch and this panel: this is its first save.
return events.slice(i + 1, firstAt).some((e) => (e.commits ?? 0) > 0) ? undefined : m[1];
}
return undefined;
}
/**
* Lines added and removed, per file summed: a file created in these edits counts what it ends with
* (its later edits in the same span are its own drafting), so a new file never shows removed lines.
*/
function netCounts(edits: StoryEdit[]): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const file of unique(edits.map((d) => d.file))) {
const mine = edits.filter((d) => d.file === file);
const a = mine.reduce((n, d) => n + d.added, 0);
const r = mine.reduce((n, d) => n + d.removed, 0);
if (mine.some((d) => d.created)) added += Math.max(0, a - r);
else {
added += a;
removed += r;
}
}
return { added, removed };
}
/** A file an earlier panel of the turn already wrote or changed: writing it again is changing it. */
function touchedBefore(file: string, before: StoryEvent[]): boolean {
return before.some((e) => editsOf(e).some((d) => d.file === file));
}
/** A command made only of git lookups ("git log --oneline; git status -sb", "git -C origin.git log"). */
const GIT_LOOK =
/^(?:(?:git (?:-C \S+ |--git-dir=\S+ )?(?:log|status|show|ls-remote|branch|remote|describe|rev-parse|diff)\b[^;&|\n]*|(?:echo|head|tail|wc|sort)\b[^;&|\n]*)\s*(?:&&|\|\||;|\||\n|$)\s*)+$/;
/** A dry run: a command that shows what it would do without doing it. */
const DRY_RUN = /\bdry[- ]?run\b|--dry-run\b|--what-if\b/i;
/** One or two files by name ("calc.py and test_calc.py"), more by count ("3 files"). */
const fileWords = (files: string[], noun = 'file'): string =>
files.length && files.length <= 2 ? files.map((f) => basename(f)).join(' and ') : plural(files.length, noun);
/** You asked for a failing run first (test first), or Claude said the failure was expected. */
const PLANNED_IN_PROMPT =
/\b(test[- ]first|tdd|fail(s|ing)? (first|on purpose)|failing tests? first|on purpose|red first|before (the )?(code|module|function|implementation) exists|should fail because|(watch|see) (them|it|the tests?) fail|must (error|fail) (at|on|because|while))\b/i;
const EXPECTED_BY_CLAUDE =
/\b(as expected|as planned|as intended|expected (failure|to fail|red)|deliberately fail\w*|on purpose|test[- ]first|the red state)\b/i;
function plannedFailure(turn: StoryTurn, events: StoryEvent[]): false | 'asked' | 'planned' {
const prompt = events.find((e) => e.intent === 'prompt')?.label ?? turn.prompt;
if (PLANNED_IN_PROMPT.test(prompt)) return 'asked';
return events.some((e) => e.intent === 'say' && EXPECTED_BY_CLAUDE.test(e.label)) ? 'planned' : false;
}
function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, index: number): StoryPanel {
const all = draft.events.map((i) => events[i]);
// Second opinions riding on this step are counted, never part of its story.
const opinions = all.filter((e) => e.tool === 'Advisor').length;
const evs = opinions && opinions < all.length ? all.filter((e) => e.tool !== 'Advisor') : all;
const panel: StoryPanel = { kind: draft.kind, events: draft.events, caption: { story: '', director: '' } };
if (evs !== all) panel.secondOpinion = opinions;
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.',
scheduled: 'A scheduled task starts this turn.',
system: 'Something automatic starts this turn.',
};
const origin = evs[0]?.origin ?? turn.origin;
const command = evs[0]?.tool === 'Command' ? evs[0].label.split(/\s/)[0] : '';
story = command
? `You run ${command}.`
: index === 0
? lines[turn.origin]
: origin === 'human'
? 'You add something while Claude works.'
: origin === 'peer'
? 'Another session chimes in while Claude works.'
: lines[origin];
// Words that started the turn but are not yours are a note, not your balloon.
if (panel.balloon && !command && origin !== 'human') panel.balloon = { ...panel.balloon, who: 'note' };
// A helper reporting back: its own words, not only the line saying it finished, so what it
// did not do ("I haven't run them against the real file") is on the page.
const report = origin === 'task' && evs[0]?.helper?.result?.trim();
if (panel.balloon && report) {
// A report that is only a path says which file it wrote: its name is enough.
const pathOnly = /^\S+$/.test(report) && report.includes('/');
const said = (pathOnly ? basename(report) : closingText(report, keyPointsOf(report), ' … '))
// An absolute path is mostly room: its last two parts ("tests/test_extra.py") say which file.
// Run on the clipped balloon (560 characters at most), so the scan stays short.
.replace(/(?<![\w.~-])\/(?:[^\s/]+\/)+([^\s/]+\/[^\s/]+)/g, '$1');
const name = /^Agent "(.+)" (?:finished|completed)\.?$/.exec(panel.balloon.text)?.[1];
if (said)
panel.balloon = {
who: 'note',
text:
name && pathOnly
? `"${name}" reports back with the file it wrote: ${said}.`
: name
? `"${name}" reports: ${said}`
: `${panel.balloon.text.replace(/[.]?$/, '.')} Its report: ${said}`,
};
}
director = command
? `Command · ${clip(evs[0].label, 60)}`
: `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);
// Codeman's own case file is plumbing, not part of the story.
panel.files = panel.files.filter((f) => !/(^|\/)\.codeman[-.]/.test(f));
panel.notes = evs
.filter((e) => e.intent !== 'read')
.map((e) => e.description || shortCommand(e))
.slice(0, 4);
if (evs.every((e) => e.tool === 'Bash')) director = `${tools} · ${shortCommand(evs[0])}`;
if (errors) director += ` · ${plural(errors, 'error')}`;
const shown = files.filter((f) => !/(^|\/)\.codeman[-.]/.test(f));
// A file or two by name; in a turn a report started, reading is checking what came back.
const checking = turn.origin === 'task' || turn.origin === 'peer';
// Only git lookups (log, status, ls-remote) after a save in this turn: Claude checks its save.
const savedBefore = events.slice(0, Math.min(...draft.events)).some((e) => (e.commits ?? 0) > 0 || !!e.ships);
const named = shown.map((f) => basename(f));
story = evs.every((e) => e.tool === 'ToolSearch')
? evs.some((e) => /PlanMode/.test(e.label))
? 'Claude gets ready to plan.'
: 'Claude gets the tools it needs ready.'
: shown.length && shown.length <= 2
? `Claude ${checking ? 'checks' : 'reads'} ${named.join(' and ')}.`
: shown.length
? `Claude reads ${plural(shown.length, 'file')} to find its way around.`
: evs.every((e) => e.intent === 'search')
? 'Claude searches the code.'
: savedBefore && evs.every((e) => e.tool === 'Bash' && GIT_LOOK.test(e.label.trim()))
? 'Claude checks what it saved.'
: checking
? 'Claude checks the work that came back.'
: 'Claude looks around the project.';
// A tool lookup's query (`select:EnterPlanMode,ExitPlanMode`) means nothing to a reader.
if (evs.every((e) => e.tool === 'ToolSearch')) panel.notes = [];
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')}.`
: evs.every((e) => e.tool === 'EnterPlanMode')
? 'Claude switches to plan mode to think it through.'
: '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 && evs.some((e) => e.file && PLAN_FILE.test(e.file)))
director = `${tools} · plan file · ${plural(todos.length, 'step')}`;
else if (todos) director = `${tools} · ${todos.filter((t) => t.done).length}/${todos.length} done`;
break;
}
case 'backup': {
// A second opinion riding on this panel is no helper of its own.
const helpers = draft.events
.filter((i) => events[i].helper && (!panel.secondOpinion || events[i].tool !== 'Advisor'))
.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
? evs.every((e) => e.tool !== 'Advisor' || e.ok === false)
? 'Claude asks a stronger AI model for a second opinion, but gets no answer.'
: 'Claude asks a stronger AI model 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);
const types = unique(helpers.map((h) => h.type).filter((t) => t !== 'advisor'));
director = `${tools}${advisor ? ' · a second opinion' : ''}${types.length ? ` · ${types.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);
({ added: panel.added, removed: panel.removed } = netCounts(edits));
if (draft.kind === 'fix') {
const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length;
// Some files written whole (new ones), others patched: "writes README.md and changes CLAUDE.md".
// A file created here is written, even when Claude then edited it in the same panel; one an
// earlier panel already wrote is changed, even when this panel rewrites it whole.
const before = events.slice(0, Math.min(...draft.events));
const wholeOf = (f: string) => {
const mine = edits.filter((d) => d.file === f);
return (
mine.some((d) => d.created) ||
(!touchedBefore(f, before) &&
mine.every((d) => d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines)))
);
};
const wholeList = files.filter(wholeOf);
const mixed =
wholeList.length && wholeList.length < files.length
? { whole: wholeList, patched: files.filter((f) => !wholeList.includes(f)) }
: undefined;
const whole =
edits.length > 0 &&
edits.every((d) => d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines)));
story =
created && created === files.length
? files.length <= 2
? `Claude writes the new ${files.length === 1 ? 'file' : 'files'} ${fileWords(files)}.`
: `Claude writes ${plural(created, 'new file')}.`
: whole && files.length && wholeList.length === files.length
? `Claude writes ${fileWords(files)}.`
: mixed
? `Claude writes ${fileWords(mixed.whole)} and changes ${fileWords(mixed.patched)}.`
: files.length
? `Claude changes ${fileWords(files)}.`
: 'Claude changes a file.';
const perFile = files.slice(0, 3).map((f) => {
const mine = netCounts(edits.filter((e) => e.file === f));
return `${basename(f)} ${fmtCounts(mine.added, mine.removed)}`;
});
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 test run's verdict, so a run that failed (often the one you asked for) is not lost,
// even inside a round that passed in the end.
const runs = checks.map(passed);
// A run still going is null, so a live loop already shows the runs that finished.
if (runs.some((r) => r !== undefined)) panel.roundsOk = runs.map((r) => r ?? null);
const failed = checks.slice(0, -1).filter((e) => passed(e) === false);
const fails = failed.length;
// A failure where the tests passed and only a linter or formatter complained.
const lintFails = failed.filter(
(e) => !!e.tests && e.tests.passed > 0 && !e.tests.failed && !e.tests.errors
).length;
const n = panel.rounds;
const testFails = fails - lintFails;
// A run where not one test loaded (a collection error) "doesn't even load", as a check panel says it.
const loadsNot = (e: StoryEvent) => !!e.tests && !e.tests.passed && !e.tests.failed && !!e.tests.errors;
// A failure you asked for (test first), or that Claude said it expected, is the plan working.
const planned = passed(checks[0]) === false ? plannedFailure(turn, events) : false;
// The check that failed while the tests passed, by name: "a formatting check", not "another check".
const otherCheck = checkName(
failed.find((e) => !!e.tests && e.tests.passed > 0 && !e.tests.failed && !e.tests.errors)
);
story =
panel.ok === true && !fails
? `Build and test, ${plural(n, 'round')}. Every check passes.`
: panel.ok === true && fails === lintFails
? `Build and test, ${plural(n, 'round')}. The tests pass every time; ${otherCheck} fails ${fails === 1 ? 'once' : `${fails} times`}, then passes.`
: panel.ok === true && planned === 'asked'
? checks.length === 2
? loadsNot(checks[0])
? "At first the tests don't even load, as you asked; the second run passes."
: 'The first run fails on purpose, as you asked; the second passes.'
: `${plural(n, 'round')}: the first run fails on purpose, as you asked, then it passes.`
: panel.ok === true && planned && checks.length === 2
? 'Test first: the first run fails as planned, the second passes.'
: panel.ok === true && planned
? `Test first, ${plural(n, 'round')}: the first run fails as planned, then it passes.`
: panel.ok === true && checks.length === 2 && fails === 1
? 'Fix and test: the first run fails, the second passes.'
: panel.ok === true && testFails && lintFails
? `Fix and test, ${plural(n, 'round')}: ${testFails === 1 ? 'a test fails' : `tests fail ${testFails} times`} and Claude fixes it, then ${otherCheck} fails and Claude fixes that; everything passes.`
: panel.ok === true
? `Fix and test, ${plural(n, 'round')}: ${fails === 1 ? 'one run fails' : `${fails} runs fail`}, then it passes.`
: panel.ok === false && tests && tests.passed && !tests.failed && !tests.errors
? `Fix and test, ${plural(n, 'round')}. The tests pass, but another check fails.`
: panel.ok === false
? `Fix and test, ${plural(n, 'round')}. Still failing.`
: `Fix and test, ${plural(n, 'round')}.`;
// Commits made inside the loop (a test one-liner that ends in one) are part of its story.
const saved = evs.reduce((k, e) => k + (e.commits ?? 0), 0);
if (saved)
story += ` Claude ${startsRepoAt(draft.events, events) ? 'starts a git repository and ' : ''}saves the work${
saved > 1 ? ` in ${saved} commits` : ''
} along the way.`;
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 (lastCheck?.running)
story = isTest ? 'Claude starts the tests in the background.' : 'Claude starts the checks in the background.';
// Finished in the background: not tests that ran during this turn, and said when it was later.
else if (lastCheck?.background && ok !== undefined)
story = lastCheck.finishedLater
? `Claude starts the ${isTest ? 'tests' : 'checks'} in the background; later they ${ok ? 'pass' : 'fail'}.`
: `The ${isTest ? 'tests' : 'checks'} run in the background and ${ok ? 'pass' : 'fail'}.`;
else 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.';
const plannedCheck = ok === false ? plannedFailure(turn, events) : false;
if (plannedCheck)
story = `${story.replace(/[.]$/, '')}, ${plannedCheck === 'asked' ? 'as you asked' : 'as planned'}.`;
// The same command with the same outcome twice in a row reads once, with a count.
const runs: Array<{ text: string; n: number }> = [];
for (const e of checks) {
const text = `${shortCommand(e)} → ${outcome(e)}`;
const prev = runs[runs.length - 1];
if (prev?.text === text) prev.n++;
else runs.push({ text, n: 1 });
}
director = runs
.slice(-3)
.map((r) => (r.n > 1 ? `${r.text} ×${r.n}` : r.text))
.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')}`;
// Tests run in a fresh copy (`git clone remote.git "$C" && (cd "$C" && pytest)`): the copy is what passed.
const cloned = /\bgit clone\b(?:\s+-{1,2}[\w-]+)*\s+["']?([^\s"';&|]+)/.exec(lastCheck?.label ?? '')?.[1];
if (cloned && /^The (tests|checks) (pass|fail)\w*/.test(story))
story = story.replace(
/^The (tests|checks) (pass|fail)(\w*)/,
`The $1 $2$3 in a fresh copy made from ${basename(cloned.replace(/\/+$/, ''))}`
);
const written = unique(edits.map((d) => d.file));
// Whole files are written; a file it patched is changed (a later panel often patches one written earlier).
const isWhole = (d: StoryEdit) =>
d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines));
const earlier = events.slice(0, Math.min(...draft.events));
const wholeFiles = written.filter(
(f) =>
edits.filter((d) => d.file === f).some((d) => d.created) ||
(!touchedBefore(f, earlier) && edits.filter((d) => d.file === f).every(isWhole))
);
const patched = written.filter((f) => !wholeFiles.includes(f));
const did = [
wholeFiles.length ? `writes ${fileWords(wholeFiles)}` : '',
patched.length ? `changes ${fileWords(patched)}` : '',
].filter(Boolean);
if (did.length) story = `Claude ${did.join(' and ')}; ${story[0].toLowerCase()}${story.slice(1)}`;
const saved = checks.reduce((n, e) => n + (e.commits ?? 0), 0);
if (saved) {
director += ` · ${plural(saved, 'commit')}`;
if (ok)
story = `${story.slice(0, -1)}, ${startsRepoAt(draft.events, events) ? 'then Claude starts a git repository and' : 'and Claude'} saves the work${
newBranchAt(draft.events, events) ? ` on a new branch (${newBranchAt(draft.events, events)})` : ''
}${checks.some((e) => e.ships === 'push') ? ' and sends it up' : ''}.`;
}
break;
}
case 'run': {
panel.notes = notesOf(evs);
// What it printed, so a dry run or a listing you asked to see is on the page, not only in the drawer.
// A dry run in the panel is what the card shows, whatever ran after it (edge cases, a clean-up).
const dryRun = evs.find(
(e) => e.output?.trim() && !e.running && e.ok !== false && DRY_RUN.test(`${e.description ?? ''} ${e.label}`)
);
const lastOut = dryRun ?? [...evs].reverse().find((e) => e.output?.trim() && !e.running);
if (lastOut?.output) {
const lines = lastOut.output
.split('\n')
.map(printableLine)
.filter((l): l is string => !!l);
// Of a dry run, the lines that show what it would do ("a.jpg -> holiday_a.jpg").
const shows = dryRun ? lines.filter((l) => /->|→|\bwould\b|dry[- ]?run\]/i.test(l)) : [];
const picked = shows.length ? shows.slice(0, 3) : lastOut.ok === false ? lines.slice(-2) : lines.slice(0, 3);
if (picked.length) panel.printed = picked;
}
story = evs.every((e) => e.running)
? `Claude starts ${evs.length === 1 ? 'a command' : `${evs.length} commands`} in the background.`
: errors && errors === evs.length && evs.every(emptyAnswer)
? `Claude looks a few things up; ${emptyAnswerWhat(evs[evs.length - 1])}, which is no error.`
: errors && errors === evs.length
? `Claude tries ${evs.length === 1 ? 'a command' : `${evs.length} commands`}, and ${
evs.some((e) => /\bblocked\b|PreToolUse|denied/i.test(e.output ?? ''))
? 'it is blocked'
: evs.length === 1
? 'it fails'
: 'they fail'
}.`
: evs.every((e) => e.tool !== 'Bash')
? 'Claude uses a tool.'
: dryRun
? events.some((e) => e.intent === 'prompt' && DRY_RUN.test(e.label))
? 'Claude does the dry run you asked for: it shows what would happen, changing nothing.'
: 'Claude does a dry run: it shows what would happen, changing nothing.'
: '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' : '';
// The command failed, but its own output shows the push (or the commit) went through: a step
// chained after it failed, not the save.
const out = last?.output ?? '';
const savedAnyway =
last?.ok === false &&
(ships === 'push'
? /\[new branch\]|\S+\.\.\S+\s+\S+\s+->|\bset up to track\b|Everything up-to-date/.test(out)
: /^\[[^\]\n]+ [0-9a-f]{7,}\]/m.test(out));
// `git push && git describe; echo "exit=$?"` exits 0, but printed the failure after the push.
const pushedAt = ships === 'push' ? out.search(/\[new branch\]|\S+\.\.\S+\s+\S+\s+->|Everything up-to-date/) : -1;
const hiddenFail =
last?.ok === true && pushedAt >= 0 && /^(fatal|error): |\bexit[= ][1-9]\d*\b/m.test(out.slice(pushedAt));
if (savedAnyway) panel.ok = true;
const commits = evs.reduce((n, e) => n + (e.commits ?? 0), 0);
const inCommits = commits > 1 ? ` in ${commits} commits` : '';
// A push of commits made earlier: nothing is saved here, only uploaded.
const uploadOnly = ships === 'push' && !commits;
// A push to a folder on this computer (a repository beside the project, `To ./origin.git`) is a
// copy, not an upload: nothing left the machine.
const target = /^To (\S+)/m.exec(evs.map((e) => e.output ?? '').join('\n'))?.[1];
const localTarget =
target && (/^file:\/\//.test(target) || !/:/.test(target)) ? basename(target.replace(/\/+$/, '')) : '';
const uploads = (what: string) =>
localTarget ? `copies ${what} to ${localTarget}, a folder on this computer` : `uploads ${what}`;
// One commit: its message says what was saved.
const subject = commits === 1 ? evs.find((e) => e.subject)?.subject : undefined;
// Its conventional prefix (`feat:`, `chore(deps):`) is for git, not for the reader.
const plainSubject = subject?.replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, '');
const said = plainSubject ? `: "${clip(plainSubject, 60).replace(/[.]$/, '')}"` : '';
// What came after the save in that command: only looking ("git log", "git status") makes it a check.
const ship = (last?.label ?? '').search(ships === 'push' ? /\bgit push\b/ : /\bgit commit\b/);
const after =
ship < 0
? []
: (last?.label ?? '')
.slice(ship)
.split(/&&|\|\||;|\n/)
.slice(1)
.map((s) => s.trim())
.filter(Boolean);
const looksOnly =
after.length > 0 &&
after.every((s) =>
/^(echo|printf|ls|cat|head|tail|wc|git (-C \S+ )?(log|status|remote|ls-remote|show|diff|branch|rev-parse|describe))\b/.test(
s
)
);
// The prompt named that step and said it may fail ("git describe may fail, that is fine").
const asked = events
.filter((e) => e.intent === 'prompt')
.map((e) => e.label)
.join('\n');
const allowed =
after.some((s) => {
const head = s.split(/\s+/).slice(0, 2).join(' ');
return head.length > 3 && asked.includes(head);
}) &&
/\b(may|might|can|could|will) (fail|error)\b|\bexpected to fail\b|\b(fine|ok|okay) if it fails\b|\b(that is|that's) fine\b/i.test(
asked
);
// `git describe` with no tags: a lookup of a version name, not a check of the work.
const noTags = /No names found, cannot describe anything|No tags can describe/.test(out);
// The failed check is run again in Claude's next command, after a change, and passes: it was fixed
// (`git -C remote.git symbolic-ref HEAD refs/heads/main && git -C remote.git log …`).
const lookSegs = after.filter((seg) => !/^(echo|printf)\b/.test(seg));
const nextRun = events.slice(Math.max(...draft.events) + 1).find((e) => e.tool === 'Bash');
let fixedNext = false;
if (looksOnly && !noTags && nextRun?.ok === true) {
const segs = nextRun.label
.split(/&&|\|\||;|\n/)
.map((seg) => seg.trim())
.filter(Boolean);
const at = segs.findIndex((seg) => lookSegs.includes(seg));
fixedNext = at > 0 && segs.slice(0, at).some((seg) => !GIT_LOOK.test(seg) && !/^(echo|printf|cd)\b/.test(seg));
}
const stepAfter = `${
noTags
? 'the version-tag lookup after that fails (there are no tags yet)'
: looksOnly
? 'a check after that fails'
: 'a step after that fails'
}${allowed ? ', as you said it might' : ''}${fixedNext ? '; Claude fixes it, and the check passes next' : ''}`;
story =
savedAnyway || hiddenFail
? uploadOnly
? `Claude ${uploads('the work')}, but ${stepAfter}.`
: ships === 'push'
? `Claude saves the work${inCommits}${said} and ${uploads('it')}, but ${stepAfter}.`
: `Claude saves the work${inCommits}${said}, but ${stepAfter}.`
: panel.ok === false
? ships === 'pr'
? 'Claude tries to open a pull request, and the command fails.'
: ships === 'push'
? 'Claude tries to upload the work, and the command fails.'
: 'Claude tries to save the work, and the command fails.'
: ships === 'pr'
? 'Claude opens a pull request.'
: uploadOnly
? `Claude ${uploads('the work')}.`
: ships === 'push'
? localTarget && said
? `Claude saves the work${said}, then ${uploads('it')}.`
: `Claude saves the work${inCommits} and ${uploads('it')}${said}.`
: `Claude saves the work${inCommits}${said}.`;
const wrote = unique(edits.map((d) => d.file));
const startsRepo = startsRepoAt(draft.events, events);
const wroteWhole = wrote.filter(
(f) =>
edits.filter((d) => d.file === f).some((d) => d.created) ||
(!touchedBefore(f, events.slice(0, Math.min(...draft.events))) &&
edits
.filter((d) => d.file === f)
.every((d) => d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines)))
);
const wrotePatched = wrote.filter((f) => !wroteWhole.includes(f));
const lead = [
startsRepo ? 'starts a git repository' : '',
wroteWhole.length ? `writes ${fileWords(wroteWhole)}` : '',
wrotePatched.length ? `changes ${fileWords(wrotePatched)}` : '',
].filter(Boolean);
if (lead.length && story.startsWith('Claude ')) {
// "starts a git repository, saves the work and uploads it": never "and … and".
const rest = story.slice(7);
story =
lead.length >= 2
? `Claude ${lead.join(', ')}, and ${rest}`
: `Claude ${lead[0]}${rest.replace(/"[^"]*"/g, '').includes(' and ') ? ',' : ' and'} ${rest}`;
}
const branch = newBranchAt(draft.events, events);
if (branch && commits) story = story.replace('saves the work', `saves the work on a new branch (${branch})`);
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
? q?.answer === 'Approved'
? 'Claude shows you its plan, and you approve it.'
: q?.answer
? 'Claude shows you its plan, and you send it back.'
: '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 = evs.some((e) => e.label.includes('memory was full'))
? "Claude's memory is full: it packs up its notes."
: 'Claude packs up its notes to free memory.';
director = evs.map((e) => e.description).find(Boolean) ?? 'compact_boundary';
break;
case 'montage': {
panel.notes = notesOf(evs.filter((e) => e.tool === 'Bash'));
const added = evs.filter((e) => e.intent === 'prompt').length;
story = `Claude ${index <= 1 ? 'gets started' : 'keeps going'}${added ? ` (you add ${plural(added, 'thing')})` : ''}: ${montageSummary(evs)}.`;
break;
}
case 'complete':
break;
}
const savesHere = draft.events.some((i) => (events[i].commits ?? 0) > 0);
// A plan you approved that has a commit step asked for the save too ("Verify and commit").
const planAsked = events.some(
(e) =>
e.tool === 'ExitPlanMode' &&
e.question?.answer === 'Approved' &&
(ASKS_TO_SAVE.test(e.label) || !!e.todos?.some((t) => ASKS_TO_SAVE.test(t.text)))
);
if (
savesHere &&
turn.origin === 'human' &&
!planAsked &&
!events.slice(0, Math.min(...draft.events)).some((e) => (e.commits ?? 0) > 0) &&
!events.some((e) => e.intent === 'prompt' && ASKS_TO_SAVE.test(e.label))
)
panel.unaskedSave = events.some((e) => e.intent === 'say' && e.citesSaveRule) ? 'rule' : true;
if (panel.secondOpinion) {
// Asked before the step, after it (nothing following), or in the middle of a step's calls.
const work = draft.events.filter((i) => events[i].tool !== 'Advisor');
const asked = draft.events.filter((i) => events[i].tool === 'Advisor');
const when = asked.every((i) => i < Math.min(...work))
? 'after'
: asked.every((i) => i > Math.max(...work))
? 'then'
: 'during';
const unanswered = asked.every((i) => events[i].ok === false);
const count = plural(panel.secondOpinion, 'second opinion');
director += ` · ${when === 'during' ? `${count} along the way` : `${when} ${count}`}${unanswered ? ' (no answer)' : ''}`;
}
panel.caption = { story, director };
return panel;
}
function completePanel(turn: StoryTurn, page: Omit<StoryPage, 'panels'>, trailingSay?: string): StoryPanel {
const running = page.helpersRunning;
const commands = page.events.filter((e) => e.running && !e.helper).length;
const later = page.events.filter((e) => e.finishedLater && e.intent !== 'prompt');
const laterHelpers = later.filter((e) => e.helper).length;
const laterCommands = later.length - laterHelpers;
const done = turn.durationMs !== undefined ? `Done in ${roughDuration(turn.durationMs)}.` : 'Done.';
// Where a stop landed: the step Claude last announced ("Step 3: …"), and what it had saved by then.
const announced = [...page.events].reverse().find((e) => e.intent === 'say' && /^\W{0,4}step \d+\b/i.test(e.label));
const step = announced ? /step (\d+)/i.exec(announced.label)?.[1] : undefined;
// What that step was, in Claude's words: "Step 3: new tests under test/." brings "new tests under test/".
const stepWhat = announced
? /^\W{0,4}step \d+\W{0,3}[:.-]\s*([^.!?\n]{3,80}?)\s*(?:[.!?](?:\s|$)|\n|$)/i
.exec(announced.label.replace(/[`*]/g, ''))?.[1]
?.replace(/[:;,]$/, '')
: undefined;
const savedBefore = page.events.reduce((n, e) => n + (e.commits ?? 0), 0);
// What was still open on Claude's own to-do list when you stopped it.
const todos = [...page.events].reverse().find((e) => e.todos?.length)?.todos;
const open = todos?.filter((t) => !t.done).map((t) => clip(t.text, 60)) ?? [];
const notDone = open.length
? ` Not done yet: ${open.slice(0, 2).join('; ')}${open.length > 2 ? ` and ${open.length - 2} more` : ''}.`
: '';
const story = turn.interrupted
? `You stop Claude here${step ? `, during its step ${step}${stepWhat ? ` (${stepWhat})` : ''}` : ''}.${
savedBefore ? ` Saved before the stop: ${plural(savedBefore, 'commit')}.` : ''
}${notDone}`
: page.noneWorked
? 'Nothing Claude tried worked, so the job is not done.'
: turn.endedBy === 'next prompt'
? 'The trail stops here: the next prompt starts a new turn.'
: running || commands
? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${[
running ? `${running === 1 ? 'A helper is' : `${running} helpers are`} still working.` : '',
commands
? `${commands === 1 ? 'A command is' : `${commands} commands are`} still running in the background.`
: '',
]
.filter(Boolean)
.join(' ')}`
: later.length
? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${[
laterHelpers
? `${laterHelpers === 1 ? 'Its helper' : `Its ${laterHelpers} helpers`} finished later and reported back.`
: '',
laterCommands
? `${laterCommands === 1 ? 'Its background command' : `${laterCommands} background commands`} finished later.`
: '',
]
.filter(Boolean)
.join(' ')}`
: done;
const director = [
turn.endedBy === 'next prompt' ? 'superseded by the next prompt' : (turn.endedBy ?? '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: turn.durationMs !== undefined && turn.durationMs < 1000 && story === done ? 'Done.' : story,
director,
},
};
// A local command answers itself: its own first line is the last word.
const said = turn.endedBy === 'local command' ? turn.events[0]?.output?.split('\n')[0] : undefined;
if (trailingSay) panel.balloon = { who: 'claude', text: trailingSay };
// A local command's output (/compact's "Compacted") is the CLI's own, not Claude's words: a note.
else if (said) panel.balloon = { who: 'note', text: clip(said, 160) };
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, openNote, asks } = 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,
...netCounts(edits),
// The advisor is a second opinion, not a helper that does work.
helpers: events.filter((e) => e.helper && e.tool !== 'Advisor' && e.intent !== 'prompt').length,
helpersRunning: events.filter((e) => e.helper?.running).length,
...(openNote && turn.ended && !turn.interrupted ? { openNote } : {}),
...(asks && turn.ended && !turn.interrupted ? { asks: true } : {}),
...(events.some((e) => e.finishedLater && e.intent !== 'prompt')
? { finishedLater: events.filter((e) => e.finishedLater && e.intent !== 'prompt').length }
: {}),
};
const calls = events.filter((e) => !NOT_TOOL_CALLS.has(e.intent) && e.tool !== 'Advisor');
// A check that found nothing answered its question: it did not fail to work.
if (calls.length && calls.every((e) => e.ok === false && !emptyAnswer(e))) base.noneWorked = true;
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];
// One helper's whole run as one page: how it ended is the LAST turn's (the first one "ended" only
// because the helper was messaged again, which would read as superseded).
return buildStoryPage({
...first,
events: turns.flatMap((t) => t.events),
ended: finished ?? last.ended,
endedBy: last.endedBy === 'next prompt' ? undefined : last.endedBy,
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);
}