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