| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401 |
- #!/usr/bin/env node
- // Parse Claude Code stream-json run log(s): tool-call sequence, token usage, and
- // RESIDUAL CONTEXT OCCUPANCY — how many tokens of the context window each tool
- // family's responses still occupy when the run ends.
- //
- // Usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--brief] [--envelope] [--answer <glob>]...
- // Multiple files = one multi-turn session's segments, IN ORDER (run-all.sh
- // writes run-<label>.jsonl, run-<label>.t2.jsonl, … for a `Q1||Q2||Q3` set).
- // `--resume` does not replay prior messages, so the segments concatenate
- // cleanly and token accounting carries across the boundary.
- //
- // `--brief` drops the numbered tool-call transcript and keeps everything else,
- // for harnesses that print one of these blocks per run (ab-new-vs-baseline.sh
- // at RUNS>=2 is otherwise mostly call listings).
- //
- // Every run also reports EXPLORE SUFFICIENCY — each codegraph_explore call
- // bucketed by what the agent did next (see classifySufficiency) — and EXPLORE
- // ALLOCATION EFFICIENCY, the share of the bytes explore returned that belonged
- // to files the agent's final answer actually cited (see computeAllocation).
- //
- // `--envelope` additionally reports how the codegraph_explore responses were
- // DIVIDED across files — the per-file share of the source envelope (#1500).
- // `--answer <glob>` (repeatable, implies --envelope) marks the files that
- // actually answer the question and reports their combined share: bar 2 of the
- // CG-1/CG-22 allocation gate. See formatEnvelope for why it parses the
- // rendered markdown rather than the CG-4 diagnostic sidecar.
- //
- // ---------------------------------------------------------------------------
- // Why occupancy, and how it's measured
- // ---------------------------------------------------------------------------
- // A single-question A/B reports cost/tokens/time/tool-calls for ONE answer. It
- // cannot see what issue #1500 measured: a tool response stays in the window for
- // everything that follows, so it is charged against every later turn's headroom.
- // That is a per-session cost our single-question runs structurally miss.
- //
- // Tokens are MEASURED, not estimated at bytes/4. For assistant request k,
- // ctx_k = usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokens
- // is the exact token count of that request's whole prompt. So
- // gap_k = ctx_k - ctx_{k-1}
- // is exactly the tokens appended since the previous request: the previous
- // assistant output (thinking + text + tool_use JSON) plus the tool_results and
- // user text that followed it. We split gap_k across those blocks in proportion
- // to their characters, which attributes each tool_result its measured share.
- // (Measured on real runs, explore output lands near 2.3 chars/token — bytes/4
- // under-counts it by ~40%, which is why the estimate isn't good enough.)
- //
- // Two traps this file works around, both verified against real logs:
- // * Claude Code emits ONE assistant event PER CONTENT BLOCK, all carrying the
- // same message.id and the same `usage`. Summing usage per event double-counts
- // every turn that emits both thinking and a tool_use — dedupe by message.id.
- // * The streamed `output_tokens` is a partial snapshot (observed `out=2` on a
- // turn that really generated ~1100). Never trust it; the char-proportional
- // split doesn't need it.
- //
- // Residual ≠ contributed. Content leaves the window two ways, and both are
- // tracked: a `compact_boundary` system event (everything prior is replaced by a
- // summary) and micro-compaction (ctx drops mid-run — oldest tool results are
- // dropped first, so eviction is applied FIFO).
- import { readFileSync } from 'fs';
- import { pathToFileURL } from 'url';
- // Nominal window for the share-of-window column. Override for a [1m] context.
- const WINDOW_TOKENS = Number(process.env.CG_WINDOW_TOKENS || 200_000);
- const CHARS_PER_TOKEN_FALLBACK = 3.0;
- /** Which tool family a tool_use belongs to. */
- function familyOf(name) {
- if (/codegraph/.test(name)) return 'codegraph';
- if (name === 'Read' || name === 'NotebookRead') return 'read';
- if (name === 'Grep' || name === 'Glob') return 'search';
- if (name === 'Bash' || name === 'BashOutput') return 'bash';
- return 'other';
- }
- const FAMILIES = ['codegraph', 'read', 'search', 'bash', 'other'];
- // The without-arm's way of getting the same bytes: reading and searching files.
- const FILE_ACCESS = ['read', 'search', 'bash'];
- // A Bash command that INVOKES the codegraph CLI, in any command position and by
- // any path. Mentions are not invocations: `grep codegraph src/`, `ls .codegraph`
- // and `which codegraph` all pass. Kept in step with run-all.sh's blocking hook.
- const CG_CLI_RE = /(^|[;&|(]|&&|\|\||\$\(|`)\s*(?:[A-Za-z_]\w*=\S*\s+)*[\w./~-]*codegraph(\s|$)/;
- const textOf = (content) =>
- Array.isArray(content) ? content.map((c) => c.text ?? (typeof c === 'string' ? c : JSON.stringify(c))).join('')
- : typeof content === 'string' ? content
- : content == null ? '' : JSON.stringify(content);
- /** Characters an assistant content block occupies once it is back in the prompt. */
- function assistantBlockChars(b) {
- if (b.type === 'text') return (b.text || '').length;
- if (b.type === 'thinking') return (b.thinking || '').length;
- if (b.type === 'tool_use') return JSON.stringify(b.input ?? {}).length + (b.name || '').length;
- return JSON.stringify(b).length;
- }
- /**
- * Parse one session (its segment files, in order) into tool + occupancy stats.
- * Exported so parse-bench-readme.mjs can aggregate without duplicating any of
- * this — deliberately NOT a separate module file: a new scripts/agent-eval/*.mjs
- * scores into the self-query eval fixture's own corpus and moves its numbers.
- */
- export function parseSession(files) {
- const events = [];
- for (const f of files) {
- for (const line of readFileSync(f, 'utf8').split('\n')) {
- if (!line) continue;
- try { events.push(JSON.parse(line)); } catch { /* partial line */ }
- }
- }
- const toolCalls = []; // display sequence
- const nameById = new Map(); // tool_use_id -> tool name
- const cliById = new Set(); // tool_use_ids that tried to run the codegraph CLI
- const counts = {}; // tool name -> calls
- // Attempts vs successes: run-all.sh's hook DENIES CLI invocations, and a
- // denied attempt puts no codegraph output in the window. Only a call that
- // actually returned content contaminates the arm.
- let initTools = null, result = null, raced = false, cliCalls = 0, cliContaminated = 0;
- const results = []; // one `result` event per session segment (multi-turn)
- let compactions = 0;
- // Raw codegraph_explore response text, in call order. Feeds the envelope view
- // (see formatEnvelope) — kept here rather than re-parsed from the log later so
- // a multi-segment session's responses stay in one ordered list.
- const exploreTexts = [];
- // A timeline of everything appended to the context, in order. `req` entries
- // are assistant requests (carrying that request's ctx); `add` entries are
- // characters appended (assistant output blocks, tool results, user text).
- const timeline = [];
- const seenMsgIds = new Set();
- for (const ev of events) {
- if (ev.type === 'system' && ev.subtype === 'init') {
- initTools = (ev.tools || []).filter((t) => /codegraph/.test(t));
- }
- if (ev.type === 'system' && (ev.subtype === 'compact_boundary' || ev.subtype === 'compaction')) {
- compactions++;
- timeline.push({ kind: 'compact' });
- }
- if (ev.type === 'assistant' && ev.message) {
- const id = ev.message.id;
- // One event per content block, same id + same usage: count usage once,
- // but take the content blocks from every event that carries the id.
- if (id && !seenMsgIds.has(id)) {
- seenMsgIds.add(id);
- const u = ev.message.usage || {};
- const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
- timeline.push({ kind: 'req', ctx, out: u.output_tokens || 0 });
- }
- for (const b of ev.message.content || []) {
- timeline.push({ kind: 'add', family: null, chars: assistantBlockChars(b) });
- if (b.type === 'tool_use') {
- nameById.set(b.id, b.name);
- counts[b.name] = (counts[b.name] || 0) + 1;
- let detail = '';
- if (b.name === 'Task') detail = ` [subagent_type=${b.input?.subagent_type ?? '?'}] ${(b.input?.description ?? '').slice(0, 40)}`;
- else if (/codegraph/.test(b.name)) detail = ` ${JSON.stringify(b.input?.query ?? b.input?.task ?? b.input?.symbol ?? '').slice(0, 60)}`;
- else if (b.name === 'Bash') {
- detail = ` ${(b.input?.command ?? '').slice(0, 50)}`;
- // An arm with no codegraph MCP can still shell out to the CLI — the
- // target repo carries the .codegraph/ index and the binary is on
- // PATH. That silently turns a "without" arm into codegraph-over-CLI.
- if (CG_CLI_RE.test(b.input?.command ?? '')) { cliCalls++; cliById.add(b.id); }
- }
- else if (b.name === 'Read') detail = ` ${(b.input?.file_path ?? '').split('/').slice(-1)[0]}`;
- toolCalls.push(`${b.name}${detail}`);
- }
- }
- }
- if (ev.type === 'user' && ev.message) {
- const content = ev.message.content;
- if (Array.isArray(content)) {
- for (const b of content) {
- if (b.type === 'tool_result') {
- const t = textOf(b.content);
- // MCP cold-start race: the agent fired before `serve --mcp` had
- // registered its tools, so it floundered into grep/Read. That
- // measures startup latency, not steady-state value — flag it.
- if (/No such tool available/.test(t)) raced = true;
- // A CLI attempt that came back an error was blocked (by the hook, or
- // by the binary being genuinely absent) and put nothing in context.
- if (cliById.has(b.tool_use_id) && !b.is_error) cliContaminated++;
- const name = nameById.get(b.tool_use_id) || '';
- if (/codegraph_explore/.test(name) && !b.is_error) exploreTexts.push(t);
- timeline.push({ kind: 'add', family: familyOf(name), chars: t.length, tool: name });
- } else {
- timeline.push({ kind: 'add', family: null, chars: textOf([b]).length });
- }
- }
- } else if (typeof content === 'string') {
- timeline.push({ kind: 'add', family: null, chars: content.length });
- }
- }
- if (ev.type === 'result') { result = ev; results.push(ev); }
- }
- // ---- Pass 1: chars/token, calibrated on tool-result-dominated gaps. ------
- // Splitting a gap in proportion to characters over-attributes to tool results
- // whenever the assistant's own output is under-represented in the transcript
- // (redacted/empty thinking blocks are the common case — a gap whose only
- // visible chars were a 73-char tool_result charged it the whole 830-token
- // delta, 5.5 tok/char). So calibrate the ratio on gaps that are ≥80% tool
- // result by characters, then price every result at that ratio.
- const reqIdx = timeline.map((t, i) => (t.kind === 'req' ? i : -1)).filter((i) => i >= 0);
- const gaps = [];
- for (let k = 1; k < reqIdx.length; k++) {
- const prev = timeline[reqIdx[k - 1]], cur = timeline[reqIdx[k]];
- let chars = 0, toolChars = 0, compacted = false;
- const byFamily = {};
- for (let i = reqIdx[k - 1] + 1; i < reqIdx[k]; i++) {
- const t = timeline[i];
- if (t.kind === 'compact') { compacted = true; continue; }
- if (t.kind !== 'add') continue;
- chars += t.chars;
- if (t.family) { toolChars += t.chars; byFamily[t.family] = (byFamily[t.family] || 0) + t.chars; }
- }
- gaps.push({ delta: cur.ctx - prev.ctx, chars, toolChars, byFamily, compacted });
- }
- const clean = gaps.filter((g) => !g.compacted && g.delta > 0 && g.chars > 500 && g.toolChars / g.chars >= 0.8);
- // A gap where the window also SHED content has a delta far below what was
- // added, which reads as absurdly dense text and would drag the whole run's
- // ratio with it. Shedding can only push a gap's chars/token UP, so take the
- // lower median as the honest centre and drop anything well above it, then
- // pool the survivors. (On runs that never shed, every ratio is within a few
- // percent of the others and this changes nothing.)
- const ratios = clean.map((g) => g.toolChars / g.delta).sort((a, b) => a - b);
- const lowerMedian = ratios.length ? ratios[Math.floor((ratios.length - 1) / 2)] : 0;
- let sumD = 0, sumC = 0;
- for (const g of clean) {
- if (lowerMedian > 0 && g.toolChars / g.delta > lowerMedian * 1.5) continue; // shed
- sumD += g.delta; sumC += g.toolChars;
- }
- if (sumD === 0) { // no clean gap — fall back to every growing gap, all chars
- for (const g of gaps) if (!g.compacted && g.delta > 0 && g.chars > 0) { sumD += g.delta; sumC += g.chars; }
- }
- const charsPerToken = sumD > 0 ? sumC / sumD : CHARS_PER_TOKEN_FALLBACK;
- const calibrated = sumD > 0;
- // How far a single result's token density strays from the run-level ratio.
- // On a gap that is almost entirely one tool result, `delta` IS that result's
- // token count, so |chars/ratio - delta| / delta is the attribution error for
- // that result. The median over such gaps is the metric's real error bar.
- const errs = [];
- for (const g of gaps) {
- if (g.compacted || g.delta <= 0 || g.chars <= 500) continue;
- if (g.toolChars / g.chars < 0.95) continue;
- errs.push(Math.abs(g.toolChars / charsPerToken - g.delta) / g.delta);
- }
- errs.sort((a, b) => a - b);
- const dispersion = errs.length ? errs[(errs.length - 1) >> 1] : null;
- // ---- Pass 2: attribute gap tokens, then apply evictions FIFO. ------------
- const contributed = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
- const resultChars = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
- const resultCount = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
- for (const t of timeline) if (t.kind === 'add' && t.family) { resultChars[t.family] += t.chars; resultCount[t.family]++; }
- let queue = []; // resident contributions, oldest first
- let evicted = 0;
- const evict = (tokens) => {
- let left = tokens;
- while (left > 0 && queue.length) {
- const head = queue[0];
- if (head.tokens <= left) { left -= head.tokens; evicted += head.tokens; queue.shift(); }
- else { head.tokens -= left; evicted += left; left = 0; }
- }
- };
- for (const g of gaps) {
- if (g.compacted) {
- // Everything before the boundary is gone; the summary replaces it.
- evicted += queue.reduce((s, q) => s + q.tokens, 0);
- queue = [];
- }
- let toolTokens = 0;
- for (const [fam, ch] of Object.entries(g.byFamily)) {
- const tok = ch / charsPerToken;
- toolTokens += tok;
- contributed[fam] += tok;
- queue.push({ family: fam, tokens: tok });
- }
- // The gap grew by `delta`; the tool results account for `toolTokens` of it.
- // A shortfall means the window also shed content — micro-compaction drops
- // the OLDEST tool results first, so evict FIFO. The tolerance keeps
- // attribution noise (a run-level ratio priced against one gap's delta,
- // typically ±2%) from reading as an eviction; real shedding is thousands.
- const shortfall = toolTokens - g.delta;
- if (!g.compacted && shortfall > Math.max(200, toolTokens * 0.05)) evict(shortfall);
- }
- const residual = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
- for (const q of queue) residual[q.family] += q.tokens;
- const ctxFinal = reqIdx.length ? timeline[reqIdx[reqIdx.length - 1]].ctx : 0;
- // The FIRST request's prompt is system + tool schemas + the question, before
- // any tool has answered. Differencing the arms' ctxBase prices codegraph's
- // FIXED occupancy — its tool schema and MCP `initialize` instructions — which
- // it pays whether or not the agent ever calls it.
- const ctxBase = reqIdx.length ? timeline[reqIdx[0]].ctx : 0;
- // "Tokens processed" = the SUM of every assistant request's whole prompt. Every
- // request re-counts the prefix, which is what makes this a throughput figure and
- // why it cannot answer the occupancy question (that is `residual`, above).
- //
- // It must be summed PER TURN, not read off `result.usage`. The comment that used
- // to sit here asserted result.usage was "cumulative WITHIN a segment"; that was
- // true when the README figures were measured and is NOT true in current Claude
- // Code, where it reports the LAST turn only. Nothing in this repo changed — the
- // host did, silently, and the harness kept reporting the smaller number.
- //
- // The damage is one-sided and therefore worse than noise: it under-counts
- // whichever arm takes more turns, which is always the WITHOUT arm. Measured on
- // the 2026-08-05 campaign it turned a real 62% token saving into 19%, and
- // manufactured a token REGRESSION on tokio (-41%) and alamofire (-25%) that does
- // not exist. CLAUDE.md already warned about this field; the code did not follow.
- //
- // Dedupe by message.id: Claude Code emits one assistant event per content block,
- // each carrying the same `usage`, so summing per EVENT double-counts (~1.7x on a
- // real run). Same rule the occupancy timeline above uses.
- const seenUsageIds = new Set();
- let processed = 0;
- for (const ev of events) {
- if (ev.type !== 'assistant' || !ev.message?.id) continue;
- if (seenUsageIds.has(ev.message.id)) continue;
- seenUsageIds.add(ev.message.id);
- const u = ev.message.usage || {};
- processed += (u.input_tokens || 0) + (u.cache_read_input_tokens || 0)
- + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0);
- }
- return {
- files, toolCalls, counts, initTools, result, results, raced, cliCalls, cliContaminated,
- exploreTexts,
- // What the agent did after each explore — the free sufficiency signal (CG-8).
- sufficiency: classifySufficiency(events),
- // How much of what explore returned the answer actually drew on (CG-9).
- allocation: computeAllocation(exploreTexts, finalAnswerText(events)),
- ok: results.length > 0 && results.every((r) => r.subtype === 'success'),
- turns: reqIdx.length,
- tools: toolCalls.filter((t) => !t.startsWith('ToolSearch')).length,
- reads: counts.Read || 0,
- grep: (counts.Grep || 0) + (counts.Glob || 0),
- cg: Object.entries(counts).filter(([n]) => /codegraph/.test(n)).reduce((s, [, v]) => s + v, 0),
- dur: results.reduce((s, r) => s + (r.duration_ms || 0), 0) / 1000,
- cost: results.reduce((s, r) => s + (r.total_cost_usd || 0), 0),
- processed,
- occupancy: {
- ctxFinal, ctxBase, windowTokens: WINDOW_TOKENS,
- charsPerToken, calibrated, compactions, dispersion, evicted: Math.round(evicted),
- residual: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(residual[f])])),
- contributed: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(contributed[f])])),
- chars: resultChars, results: resultCount,
- residualFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + residual[f], 0)),
- contributedFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + contributed[f], 0)),
- charsFileAccess: FILE_ACCESS.reduce((s, f) => s + resultChars[f], 0),
- },
- };
- }
- /** The occupancy block, as printed under a run and reused by the aggregator. */
- export function formatOccupancy(s, indent = ' ') {
- const o = s.occupancy;
- const n = (x) => x.toLocaleString('en-US');
- const pctCtx = (t) => (o.ctxFinal > 0 ? ((t / o.ctxFinal) * 100).toFixed(1) : '0.0');
- const pctWin = (t) => ((t / o.windowTokens) * 100).toFixed(1);
- const rows = [];
- const row = (label, tok, chars, results) => rows.push(
- `${indent} ${label.padEnd(18)}${(n(tok) + ' tok').padStart(12)} ${(pctCtx(tok) + '%').padStart(6)} of ctx ` +
- `${(pctWin(tok) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k win` +
- (chars !== undefined ? ` (${n(chars)} chars, ${results} result${results === 1 ? '' : 's'})` : '')
- );
- const out = [`${indent}Residual context occupancy at end of run:`];
- out.push(`${indent} ${'final context'.padEnd(18)}${(n(o.ctxFinal) + ' tok').padStart(12)} ${(pctWin(o.ctxFinal) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k window`);
- row('codegraph', o.residual.codegraph, o.chars.codegraph, o.results.codegraph);
- row('Read', o.residual.read, o.chars.read, o.results.read);
- row('Grep/Glob', o.residual.search, o.chars.search, o.results.search);
- row('Bash', o.residual.bash, o.chars.bash, o.results.bash);
- row('→ file-access', o.residualFileAccess, o.charsFileAccess,
- o.results.read + o.results.search + o.results.bash);
- row('other tools', o.residual.other, o.chars.other, o.results.other);
- const toolTotal = Object.values(o.residual).reduce((a, b) => a + b, 0);
- row('base (prompt+prose)', Math.max(0, o.ctxFinal - toolTotal));
- out.push(`${indent} ${' of which fixed'.padEnd(18)}${(n(o.ctxBase) + ' tok').padStart(12)} system + tool schemas + question, before any tool answered`);
- out.push(...rows);
- const dropped = o.contributed.codegraph + o.contributedFileAccess + o.contributed.other
- - (o.residual.codegraph + o.residualFileAccess + o.residual.other);
- out.push(
- `${indent} measure: ${o.charsPerToken.toFixed(2)} chars/tok ${o.calibrated ? 'measured' : '(FALLBACK — no clean gap to calibrate on)'}` +
- (o.dispersion !== null ? ` ±${(o.dispersion * 100).toFixed(1)}%` : '') +
- ` · turns ${s.turns} · compactions ${o.compactions}` +
- (o.evicted > 0 || dropped > 1 ? ` · evicted ${n(o.evicted)} tok` : '')
- );
- return out.join('\n');
- }
- /**
- * One codegraph_explore response, split into the per-file source sections the
- * allocator divided its budget across.
- *
- * Parsed out of the RENDERED MARKDOWN, not the CG-4 diagnostic sidecar: the
- * sidecar only exists on a post-CG-4 build, so it cannot measure a baseline arm.
- * The markdown parse is the only instrument that measures both arms of a
- * new-vs-baseline A/B the same way. Both the envelope view and the allocation
- * metric key off this one parse.
- *
- * Returns `{ envelope, files: [{ path, chars, symbols }] }`, where `symbols` are
- * the names the section header lists — filtered to the ones the file DEFINES.
- * The header renders `name(kind)` for every node in the shipped clusters, and
- * that includes edge-kind pseudo-entries for call sites (`mutateElement(calls)`
- * on a file that merely calls it). Attributing a file from those would credit
- * every caller of a cited symbol — measured on a real excalidraw run, it marked
- * `dragElements.ts` used because the answer named `mutateElement`.
- */
- /** NodeKind values that mean "this file defines it" (src/types.ts, less file/import/export). */
- const DEFINING_KINDS = new Set([
- 'module', 'class', 'struct', 'interface', 'trait', 'protocol', 'function', 'method',
- 'property', 'field', 'variable', 'constant', 'enum', 'enum_member', 'type_alias',
- 'namespace', 'route', 'component',
- ]);
- export function parseExploreCall(text) {
- const src = String(text ?? '');
- const re = /^\*\*`([^`]+)`\*\*(.*)$/gm;
- const marks = [];
- let m;
- while ((m = re.exec(src)) !== null) marks.push({ path: m[1], header: m[2] || '', at: m.index });
- if (!marks.length) return { envelope: 0, files: [] };
- // Sections run to the next header, or to the trailing guidance quote.
- const tail = src.indexOf('\n> ', marks[marks.length - 1].at);
- const end = tail === -1 ? src.length : tail;
- const files = marks.map((mark, i) => ({
- path: mark.path,
- chars: (i + 1 < marks.length ? marks[i + 1].at : end) - mark.at,
- // `mutateElement(calls), onFinished(variable), +32 more` → the defined ones.
- symbols: [...mark.header.matchAll(/([A-Za-z_$][\w$.]*)\(([a-z_]+)\)/g)]
- .filter((s) => DEFINING_KINDS.has(s[2]))
- .map((s) => ({ name: s[1], kind: s[2] })),
- }));
- return { envelope: files.reduce((s, f) => s + f.chars, 0), files };
- }
- /**
- * How the codegraph_explore responses the agent received were DIVIDED across
- * files — the per-file share of the source envelope (#1500 / epic CG-1).
- *
- * `answerGlobs` marks the files that actually answer the question; the summary
- * reports their combined share, which is bar 2 of the CG-1/CG-22 gate. That is
- * HAND-SPECIFIED ground truth; the allocation metric below infers the same
- * intersection from the agent's own answer instead.
- */
- export function formatEnvelope(exploreTexts, answerGlobs = [], indent = ' ') {
- // `tools/cache/**` -> /^tools\/cache\/.*$/ . Same semantics as probe-allocation.
- // The `**` sentinel is written as an escape, never a literal NUL byte — a raw
- // one makes git treat this whole script as binary and costs every future diff.
- const glob2re = (glob) => {
- const S = '\\u0000';
- const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
- .replace(/\*\*/g, S).replace(/\*/g, '[^/]*').replaceAll(S, '.*');
- return new RegExp(`^${body}$`);
- };
- const answerRes = answerGlobs.map(glob2re);
- const isAnswer = (p) => answerRes.some((re) => re.test(p));
- // Share is over the sum of the per-file sections, i.e. of the source envelope
- // the allocator divides.
- const pooled = new Map();
- let envelope = 0;
- for (const text of exploreTexts) {
- for (const f of parseExploreCall(text).files) {
- pooled.set(f.path, (pooled.get(f.path) ?? 0) + f.chars);
- envelope += f.chars;
- }
- }
- const ranked = [...pooled.entries()]
- .map(([path, chars]) => ({ path, chars, share: envelope ? chars / envelope : 0, answer: isAnswer(path) }))
- .sort((a, b) => b.chars - a.chars);
- const answerChars = ranked.filter((r) => r.answer).reduce((s, r) => s + r.chars, 0);
- const pct = (f) => `${(f * 100).toFixed(1)}%`;
- const out = [];
- out.push(`${indent}Explore envelope: ${envelope.toLocaleString('en-US')} chars over ${exploreTexts.length} response(s)`);
- if (answerGlobs.length) {
- out.push(`${indent} answer-set share: ${pct(envelope ? answerChars / envelope : 0)} | top file answers: ${ranked[0]?.answer ?? false}`);
- }
- for (const f of ranked.slice(0, 12)) {
- out.push(`${indent} ${f.answer ? '*' : ' '} ${pct(f.share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path}`);
- }
- if (ranked.length > 12) out.push(`${indent} … ${ranked.length - 12} more files`);
- return out.join('\n');
- }
- // ---------------------------------------------------------------------------
- // Explore allocation efficiency (CG-9)
- // ---------------------------------------------------------------------------
- // The envelope view above needs a human to say which files answer the question
- // (`--answer <glob>`). This one reads that off the AGENT'S OWN FINAL ANSWER and
- // reports a single number every run:
- //
- // allocation efficiency = bytes explore returned for files the answer drew on
- // ─────────────────────────────────────────────────
- // all bytes explore returned
- //
- // That is the #1500 defect as a number. On the CG-1 baseline self-query it sat
- // around 16–30%: one correct file, one marginal, three pure noise.
- //
- // ATTRIBUTION IS IMPERFECT AND THE ERROR IS ONE-SIDED. An agent can use a file's
- // source to rule it out, or to build a mental model, and never cite it — that
- // reads as waste. So this is a RELATIVE metric: valid for comparing two builds
- // on the SAME question, not as an absolute claim about how much of an envelope
- // earned its place. Quoting it as an absolute efficiency figure is a misuse.
- //
- // Two citation channels, deliberately ranked so the weaker one is separable:
- // * PATH — the answer names the file (`lib/response.js:126-220`, or the bare
- // `utils.js:225` agents drop into prose). Strong, and reported on its own as
- // the conservative floor.
- // * SYMBOL — the answer cites a symbol in a code span and only that file's
- // section header lists it. Catches answers written entirely in symbol names.
- // Guarded: a name carried by ≥3 of the returned files is too generic to
- // attribute and is dropped, or `send`/`get` would mark half the envelope
- // used and bias the metric optimistic — the one direction it must not lean.
- /** Symbol tokens too common in prose/code spans to attribute a file from. */
- const SYMBOL_STOPWORDS = new Set([
- 'function', 'return', 'const', 'this', 'true', 'false', 'null', 'void', 'undefined',
- 'string', 'object', 'number', 'boolean', 'array', 'class', 'import', 'export',
- 'async', 'await', 'default', 'type', 'value', 'name', 'data', 'self', 'super',
- 'else', 'then', 'case', 'from', 'with', 'when', 'where', 'that', 'they', 'this',
- ]);
- /** A name on this many of the returned files stops identifying any one of them. */
- const SYMBOL_AMBIGUITY_LIMIT = 3;
- /** Below this length a token is far more likely prose than a symbol citation. */
- const MIN_SYMBOL_LEN = 4;
- /**
- * What the agent's final answer CITES: file paths, and the identifiers it puts
- * in code spans. `extensions` are the file extensions actually present in the
- * envelope — the gate that lets `utils.js:225` through as a file citation while
- * rejecting `res.send` and `mime.contentType`, which are the same token shape.
- */
- export function answerCitations(text, extensions = new Set()) {
- const src = String(text ?? '');
- const paths = new Set();
- // Dotted path with at least one directory: `lib/response.js:126-220`.
- for (const m of src.matchAll(/(?:[\w@.+-]+\/)+[\w@.+-]+\.[A-Za-z]\w*/g)) paths.add(m[0]);
- // Bare basename, only when its extension is one the envelope actually shipped.
- for (const m of src.matchAll(/\b[\w@+-]+\.[A-Za-z]\w*/g)) {
- const ext = m[0].slice(m[0].lastIndexOf('.') + 1).toLowerCase();
- if (extensions.has(ext)) paths.add(m[0]);
- }
- // Identifiers inside code spans. Prose mentions are excluded on purpose: a
- // backtick is the agent marking the token as code, which is the whole signal.
- const symbols = new Set();
- for (const span of src.matchAll(/`([^`\n]+)`/g)) {
- for (const t of span[1].matchAll(/[A-Za-z_$][\w$]*/g)) {
- const tok = t[0];
- if (tok.length >= MIN_SYMBOL_LEN && !SYMBOL_STOPWORDS.has(tok.toLowerCase())) symbols.add(tok);
- }
- }
- return { paths: [...paths], symbols };
- }
- /**
- * Allocation efficiency over one session's explore responses and its final
- * answer(s). Per call AND pooled over the run — a run-level number alone hides
- * the common shape where call 1 is on target and call 3 is pure noise.
- */
- export function computeAllocation(exploreTexts, answerText) {
- const calls = exploreTexts.map(parseExploreCall).filter((c) => c.files.length);
- const extensions = new Set();
- for (const c of calls) {
- for (const f of c.files) {
- const dot = f.path.lastIndexOf('.');
- if (dot > 0) extensions.add(f.path.slice(dot + 1).toLowerCase());
- }
- }
- const cited = answerCitations(answerText, extensions);
- // symbol -> the returned files whose header lists it as DEFINED. A `variable`
- // is usually an import binding (`var compileETag = require('./utils')…`), so
- // when the same name is also a real definition somewhere in the envelope, the
- // definition wins and the aliasing file is not credited.
- const symbolFiles = new Map();
- for (const c of calls) {
- for (const f of c.files) {
- for (const s of f.symbols) {
- if (!symbolFiles.has(s.name)) symbolFiles.set(s.name, { strong: new Set(), weak: new Set() });
- const owners = symbolFiles.get(s.name);
- (s.kind === 'variable' || s.kind === 'constant' ? owners.weak : owners.strong).add(f.path);
- }
- }
- }
- const ownersOf = (name) => {
- const o = symbolFiles.get(name);
- return o ? (o.strong.size ? o.strong : o.weak) : null;
- };
- // `lib/response.js` is cited by `lib/response.js`, by `response.js`, and by an
- // absolute path ending in it — samePath already encodes exactly that.
- const viaPath = (path) => cited.paths.some((p) => samePath(path, p));
- const viaSymbol = (path) => {
- for (const s of cited.symbols) {
- const owners = ownersOf(s);
- if (owners && owners.size < SYMBOL_AMBIGUITY_LIMIT && owners.has(path)) return s;
- }
- return null;
- };
- const verdict = new Map(); // path -> { via, symbol }
- const judge = (path) => {
- if (!verdict.has(path)) {
- if (viaPath(path)) verdict.set(path, { via: 'path' });
- else {
- const s = viaSymbol(path);
- verdict.set(path, s ? { via: 'symbol', symbol: s } : { via: null });
- }
- }
- return verdict.get(path);
- };
- const perCall = calls.map((c, i) => {
- const files = c.files.map((f) => ({ ...f, ...judge(f.path) }));
- const used = files.filter((f) => f.via).reduce((s, f) => s + f.chars, 0);
- const usedPath = files.filter((f) => f.via === 'path').reduce((s, f) => s + f.chars, 0);
- return {
- call: i + 1, envelope: c.envelope, used, usedPath, files,
- efficiency: c.envelope ? used / c.envelope : 0,
- };
- });
- // Pooled: a file returned twice is charged twice, because it occupied the
- // window twice. Same accounting as formatEnvelope.
- const pooled = new Map();
- let envelope = 0;
- for (const c of perCall) {
- for (const f of c.files) {
- const prev = pooled.get(f.path) ?? { path: f.path, chars: 0, calls: 0, via: f.via, symbol: f.symbol };
- prev.chars += f.chars; prev.calls++;
- pooled.set(f.path, prev);
- envelope += f.chars;
- }
- }
- const files = [...pooled.values()].sort((a, b) => b.chars - a.chars);
- const used = files.filter((f) => f.via).reduce((s, f) => s + f.chars, 0);
- const usedPath = files.filter((f) => f.via === 'path').reduce((s, f) => s + f.chars, 0);
- return {
- calls: perCall, files, envelope, used, usedPath,
- efficiency: envelope ? used / envelope : 0,
- efficiencyPath: envelope ? usedPath / envelope : 0,
- filesReturned: files.length,
- filesUsed: files.filter((f) => f.via).length,
- hasAnswer: String(answerText ?? '').trim().length > 0,
- };
- }
- /**
- * Every answered codegraph_explore response in a transcript, in call order.
- * parseSession collects these as it walks the timeline; this is the same list
- * for callers that only have the raw events (parse-session.mjs).
- */
- export function collectExploreTexts(events) {
- const nameById = new Map();
- const texts = [];
- for (const ev of events) {
- const content = ev?.message?.content;
- if (!Array.isArray(content)) continue;
- for (const b of content) {
- if (b.type === 'tool_use') nameById.set(b.id, b.name);
- else if (b.type === 'tool_result' && !b.is_error
- && /codegraph_explore/.test(nameById.get(b.tool_use_id) || '')) texts.push(textOf(b.content));
- }
- }
- return texts;
- }
- /**
- * The final answer a session produced. Headless stream-json carries it on the
- * `result` event (one per resumed segment, all of which are answers); an
- * interactive transcript has none, so fall back to the LAST assistant text of
- * the main thread — intermediate narration would flood the citation set.
- */
- export function finalAnswerText(events) {
- const answers = events.filter((e) => e.type === 'result' && typeof e.result === 'string').map((e) => e.result);
- if (answers.length) return answers.join('\n\n');
- let last = '';
- for (const ev of events) {
- if (ev.type !== 'assistant' || (ev.parent_tool_use_id ?? null) !== null) continue;
- const text = (ev.message?.content || []).filter((b) => b.type === 'text').map((b) => b.text || '').join('');
- if (text.trim()) last = text;
- }
- return last;
- }
- /** The allocation block, as printed under a run and reused by aggregators. */
- export function formatAllocation(s, indent = ' ') {
- const a = s.allocation;
- if (!a || !a.calls.length) return `${indent}Explore allocation: no codegraph_explore responses with source sections`;
- const pct = (f) => `${(f * 100).toFixed(1)}%`;
- const n = (x) => x.toLocaleString('en-US');
- const out = [
- `${indent}Explore allocation — share of returned bytes the answer used ` +
- `(${a.calls.length} call${a.calls.length === 1 ? '' : 's'}, ${a.filesReturned} file${a.filesReturned === 1 ? '' : 's'}):`,
- `${indent} efficiency ${pct(a.efficiency).padStart(6)} ${n(a.used)} of ${n(a.envelope)} chars` +
- ` (${a.filesUsed}/${a.filesReturned} files cited; path-cited alone ${pct(a.efficiencyPath)})`,
- ];
- if (!a.hasAnswer) out.push(`${indent} !! no final answer text found — efficiency is not meaningful for this run`);
- for (const c of a.calls) {
- out.push(`${indent} call ${c.call}: ${pct(c.efficiency).padStart(6)} ${n(c.used)}/${n(c.envelope)} chars` +
- ` ${c.files.filter((f) => f.via).length}/${c.files.length} files`);
- }
- for (const f of a.files.slice(0, 12)) {
- const share = a.envelope ? f.chars / a.envelope : 0;
- const why = f.via === 'symbol' ? `symbol \`${f.symbol}\`` : f.via === 'path' ? 'path' : '—';
- out.push(`${indent} ${f.via ? '*' : ' '} ${pct(share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path} ${why}`);
- }
- if (a.files.length > 12) out.push(`${indent} … ${a.files.length - 12} more files`);
- out.push(`${indent} note: relative metric — attribution is by citation, so compare builds on the same question, not absolutes.`);
- return out.join('\n');
- }
- // ---------------------------------------------------------------------------
- // Explore sufficiency (CG-8)
- // ---------------------------------------------------------------------------
- // The agent's NEXT action after a codegraph_explore is free ground truth about
- // whether that response was enough. The buckets are chosen so each one maps to
- // a distinct fix:
- //
- // another codegraph call insufficient — the response did not answer
- // Read of a file we RETURNED allocation bug — right file, wrong bytes
- // Read of a file we did NOT recall bug — the file never surfaced
- // Grep/Glob recall bug (weaker: the agent is still hunting)
- // anything else / no tool sufficient — the agent moved on
- //
- // Three rules keep this honest:
- // * Only a call issued in a LATER assistant message counts as a reaction. A
- // Read fired in the same message as the explore was issued before its
- // response existed, so it cannot be a verdict on it (those are counted
- // separately as `concurrent`).
- // * ToolSearch/TodoWrite are stepped over: loading a deferred tool schema or
- // ticking a checklist says nothing about the response.
- // * SUBAGENT CALLS ARE A SEPARATE THREAD. Claude Code interleaves a subagent's
- // tool calls into the same stream, tagged `parent_tool_use_id` — verified on
- // a real run where a delegated search's greps landed between the parent's
- // own calls. Reactions are matched within one thread, or the subagent's
- // first grep would be scored as the parent's verdict on an explore it never
- // saw.
- //
- // A delegation (`Agent`/`Task`) is judged by what the SUBAGENT did first, since
- // that thread is right there in the transcript. Scoring the delegation itself as
- // "moved on" would have called this run sufficient while the subagent was off
- // grepping for the file — the one direction of error a tuning metric must not
- // have. A delegation that never runs a tool stays "moved on".
- /** Tools that carry no signal about whether the previous response was enough. */
- const TRANSPARENT_TOOLS = new Set(['ToolSearch', 'TodoWrite']);
- /** Tools that hand the work to a subagent whose thread we then judge instead. */
- const DELEGATION_TOOLS = new Set(['Agent', 'Task']);
- /** Buckets, worst → best. Labels double as the summary rows. */
- export const SUFFICIENCY = [
- ['explore_again', 'explore again', 'insufficient: did not answer'],
- ['read_returned', 'Read a file we returned', 'allocation: right file, wrong bytes'],
- ['read_missed', 'Read a file we did not return', 'recall: file never surfaced'],
- ['search', 'Grep/Glob', 'recall (weak): still hunting for the file'],
- ['sufficient', 'moved on / answered', 'sufficient'],
- ];
- const SUFFICIENCY_KEYS = SUFFICIENCY.map(([k]) => k);
- // Shell equivalents of Read and of Grep. Both arms have Bash, and on small
- // repos an agent reaches for `sed -n 100,200p file` as readily as for Read —
- // counting only the Read tool would score those explores as sufficient.
- const BASH_READ_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:cat|bat|head|tail|less|more|nl|sed|awk)\s+([^\n|;&]*)/;
- const BASH_SEARCH_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:grep|egrep|fgrep|rg|ag|ack|find|fd|ls|tree)\b/;
- /** What a Bash command is really doing, as far as retrieval is concerned. */
- function bashIntent(cmd) {
- const c = String(cmd || '');
- // A heredoc or a redirect is WRITING a file — `cat <<EOF > x` must not read
- // as a Read.
- if (!/<</.test(c) && !/>\s*\S/.test(c)) {
- const m = BASH_READ_RE.exec(c);
- if (m) {
- // Drop flags and numeric arguments (`sed -n '100,200p' lib/x.js`), then
- // take the last path-shaped token.
- const args = m[1].split(/\s+/).filter((a) => a && !a.startsWith('-') && !/^['"]?\d/.test(a));
- const path = args.reverse().find((a) => /[/.]/.test(a));
- if (path) return { kind: 'read', path: path.replace(/^['"]|['"]$/g, '') };
- }
- }
- if (BASH_SEARCH_RE.test(c)) return { kind: 'search' };
- return null;
- }
- const normPath = (p) => String(p ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
- /** Same file, with either side repo-relative and the other absolute. */
- function samePath(a, b) {
- const x = normPath(a), y = normPath(b);
- if (!x || !y) return false;
- return x === y || x.endsWith('/' + y) || y.endsWith('/' + x);
- }
- /**
- * The files whose SOURCE an explore response returned — its per-file sections,
- * which start with the unique ``**` `` marker (FILE_SECTION_PREFIX in
- * src/mcp/tools.ts). formatEnvelope keys off the same marker; it needs the byte
- * offsets too, which is why it re-scans rather than calling this.
- */
- export function exploreReturnedFiles(text) {
- return [...String(text ?? '').matchAll(/^\*\*`([^`]+)`\*\*/gm)].map((m) => m[1]);
- }
- // Every path-shaped token anywhere in a response — flow steps, blast radius,
- // symbol lists. A file in here but NOT in the returned set was POINTED AT and
- // not delivered, which is a different (and more damning) miss than one the
- // response never mentioned at all.
- const PATH_TOKEN_RE = /(?:[\w@.+-]+\/)+[\w@.+-]+\.[A-Za-z]\w*/g;
- /**
- * The reaction one action represents, given what the explore had returned.
- * `earlier` is what PREVIOUS explores in the same thread returned: a re-read of
- * a file we already shipped is an allocation miss wherever it was shipped, and
- * filing it as recall would point the fix at the wrong end of the pipeline.
- */
- function reactionOf(action, returned, mentioned, earlier = []) {
- const { name, input } = action;
- const readOf = (path, prefix) => {
- const base = normPath(path).split('/').pop() || String(path ?? '');
- if (returned.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base}` };
- if (earlier.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base} (returned by an earlier explore)` };
- const named = mentioned.some((m) => samePath(path, m));
- return { bucket: 'read_missed', next: `${prefix}Read ${base}${named ? ' (named, not returned)' : ''}`, named };
- };
- if (/codegraph/.test(name)) return { bucket: 'explore_again', next: name.replace(/^mcp__[^_]*__/, '') };
- if (name === 'Read' || name === 'NotebookRead') return readOf(input.file_path ?? input.notebook_path, '');
- if (name === 'Grep' || name === 'Glob') return { bucket: 'search', next: name };
- if (name === 'Bash') {
- const intent = bashIntent(input.command);
- if (intent?.kind === 'read') return readOf(intent.path, 'Bash ');
- if (intent?.kind === 'search') return { bucket: 'search', next: 'Bash search' };
- }
- return { bucket: 'sufficient', next: name };
- }
- /** Is this action one of the ways an agent gets file bytes into its head? */
- const isFileAccess = (a) =>
- a.name === 'Read' || a.name === 'NotebookRead' || a.name === 'Grep' || a.name === 'Glob'
- || (a.name === 'Bash' && bashIntent(a.input?.command) !== null);
- /**
- * Bucket every answered codegraph_explore call in a transcript by what the
- * agent did next. Takes the raw JSONL events so it serves both transcript
- * shapes: stream-json runs (parse-run.mjs) and interactive session logs
- * (parse-session.mjs) — both emit one assistant event per content block with
- * `message.id`, and tool results as `tool_result` blocks in user messages.
- */
- export function classifySufficiency(events) {
- // One action list PER THREAD: 'main', plus one per subagent (keyed by the
- // delegating tool_use id, which is what `parent_tool_use_id` carries).
- const threads = new Map();
- const nameById = new Map();
- const textById = new Map(); // explore tool_use_id -> response text
- for (const ev of events) {
- const content = ev?.message?.content;
- if (!Array.isArray(content)) continue;
- const thread = ev.parent_tool_use_id ?? 'main';
- if (ev.type === 'assistant') {
- if (!threads.has(thread)) threads.set(thread, []);
- const list = threads.get(thread);
- for (const b of content) {
- if (b.type !== 'tool_use') continue;
- nameById.set(b.id, b.name);
- // No message.id (never seen on a real log) degrades to "every call is
- // its own message", i.e. same-message calls read as reactions.
- list.push({ msgId: ev.message.id || `#${thread}-${list.length}`, id: b.id, name: b.name, input: b.input || {} });
- }
- } else if (ev.type === 'user') {
- for (const b of content) {
- if (b.type !== 'tool_result') continue;
- const name = nameById.get(b.tool_use_id) || '';
- if (/codegraph_explore/.test(name) && !b.is_error) textById.set(b.tool_use_id, textOf(b.content));
- }
- }
- }
- const calls = [];
- let errors = 0, concurrent = 0;
- // What a delegation really did: the subagent's first substantive call. A
- // nested delegation is skipped rather than followed, so a subagent that only
- // spawns another subagent leaves the call as "moved on".
- const throughDelegation = (action, returned, mentioned, earlier) => {
- const first = (threads.get(action.id) || []).find((x) => !TRANSPARENT_TOOLS.has(x.name) && !DELEGATION_TOOLS.has(x.name));
- if (!first) return { bucket: 'sufficient', next: action.name };
- const r = reactionOf(first, returned, mentioned, earlier);
- return { ...r, next: `${action.name} → ${r.next}` };
- };
- for (const [thread, actions] of threads) {
- const earlier = []; // files previous explores in THIS thread already shipped
- for (let i = 0; i < actions.length; i++) {
- const a = actions[i];
- if (!/codegraph_explore/.test(a.name)) continue;
- const text = textById.get(a.id);
- // No response text = the call errored, or the run ended before it
- // returned. Nothing to judge the sufficiency of; count it and move on.
- if (text === undefined) { errors++; continue; }
- const returned = exploreReturnedFiles(text);
- const mentioned = text.match(PATH_TOKEN_RE) || [];
- let reaction = { bucket: 'sufficient', next: '(final answer)' };
- for (let j = i + 1; j < actions.length; j++) {
- const b = actions[j];
- if (b.msgId === a.msgId) { if (isFileAccess(b)) concurrent++; continue; }
- if (TRANSPARENT_TOOLS.has(b.name)) continue;
- reaction = DELEGATION_TOOLS.has(b.name)
- ? throughDelegation(b, returned, mentioned, earlier)
- : reactionOf(b, returned, mentioned, earlier);
- break;
- }
- earlier.push(...returned);
- calls.push({ thread, query: String(a.input.query ?? ''), files: returned.length, chars: text.length, ...reaction });
- }
- }
- const counts = Object.fromEntries(SUFFICIENCY_KEYS.map((k) => [k, 0]));
- for (const c of calls) counts[c.bucket]++;
- return { calls, counts, errors, concurrent, answered: calls.length };
- }
- /** The sufficiency block, as printed under a run and reused by aggregators. */
- export function formatSufficiency(s, indent = ' ') {
- const f = s.sufficiency;
- if (!f.answered) {
- return `${indent}Explore sufficiency: no answered codegraph_explore calls`
- + (f.errors ? ` (${f.errors} errored or never returned)` : '');
- }
- const pct = (n) => ((n / f.answered) * 100).toFixed(0) + '%';
- const out = [`${indent}Explore sufficiency — what the agent did NEXT (${f.answered} answered call${f.answered === 1 ? '' : 's'}):`];
- for (const [key, label, meaning] of SUFFICIENCY) {
- out.push(`${indent} ${String(f.counts[key]).padStart(3)} ${pct(f.counts[key]).padStart(4)} ${label.padEnd(31)}${meaning}`);
- }
- f.calls.forEach((c, i) => {
- const q = c.query.length > 46 ? c.query.slice(0, 45) + '…' : c.query;
- const where = c.thread && c.thread !== 'main' ? ' [subagent]' : '';
- out.push(`${indent} ${i + 1}.${where} "${q}" [${c.files} file${c.files === 1 ? '' : 's'}] → ${c.next}`);
- });
- const notes = [];
- if (f.errors) notes.push(`${f.errors} errored/unanswered call${f.errors === 1 ? '' : 's'} (not bucketed)`);
- if (f.concurrent) notes.push(`${f.concurrent} file-access call${f.concurrent === 1 ? '' : 's'} in the SAME message as an explore (not a reaction)`);
- if (notes.length) out.push(`${indent} note: ${notes.join(' · ')}`);
- return out.join('\n');
- }
- // ---------------------------------------------------------------------------
- // `--selftest`: the occupancy math over synthetic transcripts with known
- // answers. It lives here rather than in a test file on purpose — a new
- // scripts/agent-eval/*.mjs scores into the self-query eval fixture's corpus.
- function selftest() {
- const { writeFileSync, mkdtempSync } = require0('fs');
- const { join } = require0('path');
- const { tmpdir } = require0('os');
- const dir = mkdtempSync(join(tmpdir(), 'cg-occ-'));
- let n = 0, failures = 0;
- const check = (name, got, want, tol) => {
- n++;
- const ok = Math.abs(got - want) <= tol;
- if (!ok) failures++;
- console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${Math.round(got)}, want ${want} ±${tol}`);
- };
- // Builders for the event shapes Claude Code actually emits.
- const req = (ctx, id, blocks) => blocks.map((b) => JSON.stringify({
- type: 'assistant',
- message: { id, content: [b], usage: { input_tokens: ctx, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 2 } },
- }));
- const use = (id, name, input = {}) => ({ type: 'tool_use', id, name, input });
- const res = (id, chars) => JSON.stringify({
- type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: 'x'.repeat(chars) }] }] },
- });
- const done = () => JSON.stringify({ type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {} });
- const write = (name, lines) => { const f = join(dir, name); writeFileSync(f, lines.join('\n') + '\n'); return f; };
- // 1. Attribution: ratio 2.5 chars/tok, two families, no shedding.
- // 10,000 explore chars over a 4,000-tok gap; 5,000 Read chars over 2,000.
- let f = write('basic.jsonl', [
- ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
- res('t1', 10000),
- ...req(14000, 'm2', [use('t2', 'Read')]),
- res('t2', 5000),
- ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
- done(),
- ]);
- let o = parseSession([f]).occupancy;
- check('chars/token', o.charsPerToken * 1000, 2500, 30);
- check('codegraph residual', o.residual.codegraph, 4000, 60);
- check('Read residual', o.residual.read, 2000, 40);
- check('file-access residual', o.residualFileAccess, 2000, 40);
- check('final context', o.ctxFinal, 16000, 0);
- check('fixed base', o.ctxBase, 10000, 0);
- check('nothing evicted', o.evicted, 0, 1);
- // 2. Dedupe: thinking + tool_use are two events sharing one id and one usage.
- // Counting usage per event would report 5 requests instead of 3.
- f = write('dupe.jsonl', [
- ...req(10000, 'm1', [{ type: 'thinking', thinking: '' }, use('t1', 'mcp__codegraph__codegraph_explore')]),
- res('t1', 10000),
- ...req(14000, 'm2', [{ type: 'thinking', thinking: '' }, use('t2', 'Read')]),
- res('t2', 5000),
- ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
- done(),
- ]);
- let s = parseSession([f]);
- check('turns deduped by message.id', s.turns, 3, 0);
- check('codegraph residual (deduped)', s.occupancy.residual.codegraph, 4000, 60);
- // 3. Compaction: the boundary clears everything resident before it.
- f = write('compact.jsonl', [
- ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
- res('t1', 10000),
- ...req(14000, 'm2', [use('t2', 'mcp__codegraph__codegraph_explore')]),
- JSON.stringify({ type: 'system', subtype: 'compact_boundary' }),
- res('t2', 5000),
- ...req(8000, 'm3', [{ type: 'text', text: 'done' }]),
- done(),
- ]);
- o = parseSession([f]).occupancy;
- check('post-compaction residual = last result only', o.residual.codegraph, 2000, 40);
- check('contributed still counts both', o.contributed.codegraph, 6000, 80);
- // 4. Micro-compaction: context grows less than the results added, so the
- // oldest result is shed first (FIFO) — here explore, leaving Read.
- f = write('micro.jsonl', [
- ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
- res('t1', 10000),
- ...req(14000, 'm2', [use('t2', 'Read')]),
- res('t2', 10000),
- ...req(14500, 'm3', [{ type: 'text', text: 'done' }]), // +500 for 4,000 tok of Read
- done(),
- ]);
- o = parseSession([f]).occupancy;
- check('FIFO evicted the older codegraph result', o.residual.codegraph, 500, 60);
- check('newer Read result survives', o.residual.read, 4000, 60);
- check('eviction recorded', o.evicted, 3500, 60);
- // 5. Multi-turn stitching: a resumed segment continues the same context, and
- // a turn that calls no tool leaves the earlier residual in place.
- const a = write('seg1.jsonl', [
- ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
- res('t1', 10000),
- ...req(14000, 'm2', [{ type: 'text', text: 'answer one' }]),
- done(),
- ]);
- const b = write('seg2.jsonl', [
- ...req(14600, 'm3', [{ type: 'text', text: 'answer two, from what is already here' }]),
- done(),
- ]);
- s = parseSession([a, b]);
- check('stitched turns', s.turns, 3, 0);
- check('residual carries into turn 2', s.occupancy.residual.codegraph, 4000, 60);
- check('stitched final context', s.occupancy.ctxFinal, 14600, 0);
- check('stitched cost sums segments', s.cost * 100, 20, 0.1);
- // ---- 6. Explore sufficiency: the bucket each explore call earns. --------
- const checkIs = (name, got, want) => {
- n++;
- const ok = got === want;
- if (!ok) failures++;
- console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
- };
- const EXPLORE = 'mcp__codegraph__codegraph_explore';
- // An explore response's shape that matters here: one `**`path`**` section per
- // file whose source it returned, plus whatever else it named.
- const exploreRes = (id, paths, extra = '', isError = false) => JSON.stringify({
- type: 'user',
- message: {
- content: [{
- type: 'tool_result', tool_use_id: id, ...(isError ? { is_error: true } : {}),
- content: [{ type: 'text', text: paths.map((p) => `**\`${p}\`** — fn(function)\n\n1\tcode here\n`).join('\n') + extra }],
- }],
- },
- });
- const suff = (lines) => classifySufficiency(lines.map((l) => JSON.parse(l)));
- // explore → explore is insufficient; the second explore → a Read of a file it
- // RETURNED is the allocation bucket (this is the CG-22 express baseline).
- let sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'res.send Content-Type ETag generation' })]),
- exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
- ...req(12000, 'm2', [use('e2', EXPLORE, { query: 'response.js res.send function body' })]),
- exploreRes('e2', ['lib/response.js']),
- ...req(14000, 'm3', [use('r1', 'Read', { file_path: '/private/tmp/t-base/lib/response.js' })]),
- res('r1', 3722),
- ...req(15000, 'm4', [{ type: 'text', text: 'done' }]),
- done(),
- ]);
- check('two answered explore calls', sf.answered, 2, 0);
- checkIs('explore → explore = insufficient', sf.calls[0].bucket, 'explore_again');
- checkIs('explore → Read of a returned file (abs path)', sf.calls[1].bucket, 'read_returned');
- // A file the response NAMED but did not return is still a recall miss — and
- // is flagged as named, since pointing without delivering is its own failure.
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- exploreRes('e1', ['lib/response.js'], '\n**Flow**\n1. lib/router/index.js:42 handle\n'),
- ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/router/index.js' })]),
- res('r1', 100),
- done(),
- ]);
- checkIs('explore → Read of a named-but-unreturned file', sf.calls[0].bucket, 'read_missed');
- checkIs(' …flagged as named', sf.calls[0].named, true);
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- exploreRes('e1', ['lib/response.js']),
- ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/never/mentioned.js' })]),
- res('r1', 100),
- done(),
- ]);
- checkIs('explore → Read of a file never surfaced', sf.calls[0].bucket, 'read_missed');
- checkIs(' …not flagged as named', sf.calls[0].named, false);
- // Grep, and the shell equivalents of Read and Grep.
- const oneShot = (next) => suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- exploreRes('e1', ['lib/response.js']),
- ...req(12000, 'm2', [next]),
- res(next.id, 100),
- done(),
- ]).calls[0];
- checkIs('explore → Grep', oneShot(use('g1', 'Grep', { pattern: 'send' })).bucket, 'search');
- checkIs('explore → Glob', oneShot(use('g1', 'Glob', { pattern: '**/*.js' })).bucket, 'search');
- checkIs('explore → Bash sed of a returned file',
- oneShot(use('b1', 'Bash', { command: "sed -n '100,200p' lib/response.js" })).bucket, 'read_returned');
- checkIs('explore → Bash grep', oneShot(use('b1', 'Bash', { command: 'grep -rn send lib/' })).bucket, 'search');
- checkIs('explore → Bash that writes a file is not a read',
- oneShot(use('b1', 'Bash', { command: "cat > /tmp/note.md <<'EOF'\nx\nEOF" })).bucket, 'sufficient');
- checkIs('explore → Bash npm test = moved on',
- oneShot(use('b1', 'Bash', { command: 'npm test' })).bucket, 'sufficient');
- checkIs('explore → Edit = sufficient',
- oneShot(use('x1', 'Edit', { file_path: '/t/lib/response.js' })).bucket, 'sufficient');
- // No further tool call at all: the agent answered from the response.
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- exploreRes('e1', ['lib/response.js']),
- ...req(12000, 'm2', [{ type: 'text', text: 'here is how it works' }]),
- done(),
- ]);
- checkIs('explore → final answer', sf.calls[0].bucket, 'sufficient');
- checkIs(' …labelled as the final answer', sf.calls[0].next, '(final answer)');
- // A Read issued in the SAME message as the explore predates its response, so
- // it is not a verdict on it — step past it and count it separately.
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' }), use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
- exploreRes('e1', ['lib/response.js']),
- res('r1', 100),
- ...req(12000, 'm2', [use('x1', 'Edit', { file_path: '/t/lib/response.js' })]),
- res('x1', 20),
- done(),
- ]);
- checkIs('same-message Read is not a reaction', sf.calls[0].bucket, 'sufficient');
- check(' …counted as concurrent instead', sf.concurrent, 1, 0);
- // ToolSearch/TodoWrite carry no signal — the Read behind them is the verdict.
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- exploreRes('e1', ['lib/response.js']),
- ...req(12000, 'm2', [use('t1', 'TodoWrite', {})]),
- res('t1', 20),
- ...req(13000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
- res('r1', 100),
- done(),
- ]);
- checkIs('bookkeeping tools are stepped over', sf.calls[0].bucket, 'read_returned');
- // A re-read of a file an EARLIER explore shipped is still an allocation miss:
- // we returned it and clipped it wrong, just not on this call.
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'first' })]),
- exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
- ...req(11000, 'm2', [use('e2', EXPLORE, { query: 'second' })]),
- exploreRes('e2', ['lib/response.js']),
- ...req(12000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/utils.js' })]),
- res('r1', 400),
- done(),
- ]);
- checkIs('re-read of an earlier explore’s file is allocation, not recall',
- sf.calls[1].bucket, 'read_returned');
- checkIs(' …and says which explore returned it',
- sf.calls[1].next, 'Read utils.js (returned by an earlier explore)');
- // A subagent's calls are interleaved into the same stream under
- // `parent_tool_use_id` (verified on a real excalidraw run). They belong to
- // their own thread: the parent's verdict is the delegation, judged by what
- // the subagent actually did first — here, grepping for a file we never
- // returned.
- const sub = (parent, obj) => JSON.stringify({ ...JSON.parse(obj), parent_tool_use_id: parent });
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- exploreRes('e1', ['lib/response.js']),
- ...req(12000, 'm2', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
- ...req(0, 'sm1', [use('sb1', 'Bash', { command: 'grep -rn nonce lib/' })]).map((l) => sub('a1', l)),
- sub('a1', res('sb1', 400)),
- ...req(14000, 'm3', [{ type: 'text', text: 'done' }]),
- res('a1', 900),
- done(),
- ]);
- checkIs('delegation is judged by what the subagent did', sf.calls[0].bucket, 'search');
- checkIs(' …and says so', sf.calls[0].next, 'Agent → Bash search');
- // A subagent's Read must NOT be read as the parent's reaction to an explore
- // the subagent never saw: the parent moved on, the subagent's own explore is
- // judged inside its own thread.
- sf = suff([
- ...req(10000, 'm1', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
- ...req(0, 'sm1', [use('e1', EXPLORE, { query: 'sub q' })]).map((l) => sub('a1', l)),
- sub('a1', exploreRes('e1', ['lib/response.js'])),
- ...req(11000, 'm2', [use('e2', EXPLORE, { query: 'parent q' })]),
- exploreRes('e2', ['lib/other.js']),
- ...req(0, 'sm2', [use('sr1', 'Read', { file_path: '/t/lib/response.js' })]).map((l) => sub('a1', l)),
- sub('a1', res('sr1', 400)),
- ...req(13000, 'm3', [{ type: 'text', text: 'done' }]),
- done(),
- ]);
- check('both threads bucketed', sf.answered, 2, 0);
- checkIs('parent explore is not blamed for a subagent Read',
- sf.calls.find((c) => c.query === 'parent q').bucket, 'sufficient');
- checkIs('subagent explore is judged in its own thread',
- sf.calls.find((c) => c.query === 'sub q').bucket, 'read_returned');
- // An errored explore has no response to judge; it is counted, not bucketed.
- sf = suff([
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- exploreRes('e1', [], 'not indexed', true),
- ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
- res('r1', 100),
- done(),
- ]);
- check('errored explore is not bucketed', sf.answered, 0, 0);
- check(' …but is counted', sf.errors, 1, 0);
- // ---- 7. Allocation efficiency: which returned bytes the answer used. ----
- // The response shape that matters here: a per-file section whose header lists
- // `name(kind)`, and a body big enough that the shares are readable.
- const alloc = (files, answer) => computeAllocation(
- [files.map(([p, syms, size]) =>
- `**\`${p}\`** — ${syms}\n\n\`\`\`js\n${'x'.repeat(size)}\n\`\`\`\n`).join('\n')
- + '\n> Treat the code above as already read.\n'],
- answer,
- );
- const round = (f) => Math.round(f * 1000) / 10;
- // The #1500 shape: one file answers, two are noise. The answer names it by
- // repo-relative path.
- let al = alloc(
- [['lib/response.js', 'send(function)', 4000], ['lib/express.js', 'app(variable)', 3000],
- ['lib/view.js', 'View(class)', 3000]],
- 'The Content-Type is decided in `lib/response.js:126` by `res.send`.',
- );
- check('allocation: one of three files cited', round(al.efficiency), 40.1, 0.6);
- checkIs(' …cited file is flagged path', al.files.find((f) => /response/.test(f.path)).via, 'path');
- checkIs(' …noise file is not', al.files.find((f) => /view/.test(f.path)).via, null);
- check(' …files cited', al.filesUsed, 1, 0);
- // A bare basename is how agents actually cite in prose — but only when the
- // extension is one the envelope shipped, or `res.send` reads as a file too.
- al = alloc([['lib/response.js', 'send(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
- 'It happens in response.js:134, inside `res.send`.');
- check('bare basename counts as a path citation', round(al.efficiency), 50, 0.6);
- al = alloc([['lib/response.js', 'send(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
- 'The `res.send` and `mime.contentType` calls do it.');
- checkIs('a dotted expression is not a file citation', al.files.every((f) => f.via !== 'path'), true);
- // Symbol citations: a code span naming a symbol the file DEFINES counts; a
- // file that merely CALLS it does not (the excalidraw dragElements.ts case).
- al = alloc([['src/mutateElement.ts', 'mutateElement(function)', 4000],
- ['src/dragElements.ts', 'mutateElement(calls), updateCoords(function)', 4000]],
- 'Mutation goes through `mutateElement`.');
- checkIs('symbol citation credits the definer', al.files.find((f) => /mutateElement.ts/.test(f.path)).via, 'symbol');
- checkIs(' …and not a caller of it', al.files.find((f) => /dragElements/.test(f.path)).via, null);
- // An import binding (`variable`) loses to the real definition of the name.
- al = alloc([['lib/utils.js', 'compileETag(function)', 4000],
- ['lib/application.js', 'compileETag(variable), set(calls)', 4000]],
- 'The generator comes from `compileETag`.');
- checkIs('a definition beats an import alias of the same name',
- al.files.find((f) => /utils/.test(f.path)).via, 'symbol');
- checkIs(' …and the aliasing file is not credited',
- al.files.find((f) => /application/.test(f.path)).via, null);
- // A name carried by ≥3 returned files identifies none of them: crediting them
- // all would bias the metric optimistic, the one direction it must not lean.
- al = alloc([['a/one.js', 'handle(function)', 3000], ['a/two.js', 'handle(function)', 3000],
- ['a/three.js', 'handle(function)', 3000]],
- 'It all runs through `handle`.');
- check('a symbol on 3 files attributes none of them', al.efficiency, 0, 0.001);
- // Prose is not a citation — only a code span is.
- al = alloc([['lib/response.js', 'sendResponse(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
- 'The sendResponse path handles it.');
- check('an unquoted symbol in prose does not count', al.efficiency, 0, 0.001);
- // Per-call: the run number pools, but call 2 being pure noise must still show.
- const twoCalls = computeAllocation([
- '**`lib/response.js`** — send(function)\n\n```js\n' + 'x'.repeat(4000) + '\n```\n\n> guidance\n',
- '**`lib/view.js`** — View(class)\n\n```js\n' + 'x'.repeat(4000) + '\n```\n\n> guidance\n',
- ], 'See `lib/response.js`.');
- check('per-call: call 1 fully used', round(twoCalls.calls[0].efficiency), 100, 0.1);
- check('per-call: call 2 fully wasted', round(twoCalls.calls[1].efficiency), 0, 0.1);
- check('run pools both calls', round(twoCalls.efficiency), 50, 0.6);
- // A file returned twice is charged twice — it occupied the window twice.
- const twice = computeAllocation([
- '**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000) + '\n\n> g\n',
- '**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000) + '\n\n> g\n',
- ], 'See `lib/response.js`.');
- check('a re-returned file is charged both times', twice.envelope, 8078, 120);
- check(' …and credited both times', round(twice.efficiency), 100, 0.1);
- // The answer text itself: the `result` event, and the interactive fallback.
- const ev = (l) => JSON.parse(l);
- checkIs('final answer comes from the result event',
- finalAnswerText([ev(done()), { type: 'result', result: 'the answer' }]), 'the answer');
- checkIs('…else the last main-thread assistant text', finalAnswerText([
- { type: 'assistant', message: { id: 'm1', content: [{ type: 'text', text: 'let me look' }] } },
- { type: 'assistant', parent_tool_use_id: 'a1', message: { id: 's1', content: [{ type: 'text', text: 'subagent report' }] } },
- { type: 'assistant', message: { id: 'm2', content: [{ type: 'text', text: 'the answer' }] } },
- ]), 'the answer');
- // End to end through parseSession, on the transcript shape a run really has.
- f = write('alloc.jsonl', [
- ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
- JSON.stringify({
- type: 'user',
- message: {
- content: [{
- type: 'tool_result', tool_use_id: 'e1',
- content: [{
- type: 'text',
- text: '**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000)
- + '\n\n**`lib/view.js`** — View(class)\n\n' + 'x'.repeat(4000) + '\n\n> guidance\n',
- }],
- }],
- },
- }),
- ...req(20000, 'm2', [{ type: 'text', text: 'done' }]),
- JSON.stringify({
- type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {},
- result: 'It is decided in `lib/response.js` by `res.send`.',
- }),
- ]);
- s = parseSession([f]);
- check('parseSession reports allocation', round(s.allocation.efficiency), 50, 0.6);
- checkIs(' …and the block renders', /efficiency\s+50\.\d%/.test(formatAllocation(s)), true);
- checkIs('a run with no explore says so',
- formatAllocation({ allocation: computeAllocation([], 'answer') }).includes('no codegraph_explore responses'), true);
- console.log(`\n${n - failures}/${n} checks passed`);
- return failures;
- }
- // `--selftest` needs sync fs helpers the module path doesn't import at top level.
- function require0(m) { return process.getBuiltinModule(m); }
- const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
- if (isMain && process.argv.includes('--selftest')) process.exit(selftest() ? 1 : 0);
- if (isMain) {
- // `--answer <glob>` is repeatable and implies `--envelope`. Its VALUE is not a
- // run file, so consume it here rather than letting the positional filter below
- // mistake a glob for a log path.
- const argv = process.argv.slice(2);
- const files = [];
- const answerGlobs = [];
- let wantEnvelope = false;
- let brief = false;
- for (let i = 0; i < argv.length; i++) {
- if (argv[i] === '--envelope') wantEnvelope = true;
- else if (argv[i] === '--brief') brief = true;
- else if (argv[i] === '--answer') { answerGlobs.push(argv[++i]); wantEnvelope = true; }
- else if (!argv[i].startsWith('--')) files.push(argv[i]);
- }
- if (!files.length) { console.error('usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--brief] [--envelope] [--answer <glob>]... | --selftest'); process.exit(1); }
- const s = parseSession(files);
- console.log(`\n=== ${files.map((f) => f.split('/').pop()).join(' + ')} ===`);
- console.log(`codegraph tools exposed: ${s.initTools ? s.initTools.length : '?'}${s.raced ? ' [MCP COLD-START RACE — tool call hit "No such tool available"]' : ''}`);
- if (s.cliContaminated) console.log(`!! ${s.cliContaminated} codegraph CLI call${s.cliContaminated === 1 ? '' : 's'} RETURNED OUTPUT via Bash — if this is a without-arm, the run is CONTAMINATED`);
- else if (s.cliCalls) console.log(` (${s.cliCalls} codegraph CLI attempt${s.cliCalls === 1 ? '' : 's'} blocked — no output entered the window)`);
- console.log(`\nTool calls (${s.toolCalls.length}):`);
- console.log(' by type:', JSON.stringify(s.counts));
- if (!brief) s.toolCalls.forEach((tc, i) => console.log(` ${i + 1}. ${tc}`));
- if (s.result) {
- const seg = s.results.length > 1 ? ` | ${s.results.length} segments (${s.results.map((r) => r.subtype).join(',')})` : '';
- console.log(`\nResult: ${s.result.subtype} | duration ${s.dur.toFixed(0)}s | turns ${s.turns}${seg}`);
- console.log(` tokens processed: ${s.processed.toLocaleString('en-US')} | cost $${s.cost.toFixed(3)}`);
- }
- console.log('');
- console.log(formatOccupancy(s));
- console.log('');
- console.log(formatSufficiency(s));
- console.log('');
- console.log(formatAllocation(s));
- if (wantEnvelope) {
- console.log('');
- console.log(formatEnvelope(s.exploreTexts, answerGlobs));
- }
- }
|