parse-run.mjs 71 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401
  1. #!/usr/bin/env node
  2. // Parse Claude Code stream-json run log(s): tool-call sequence, token usage, and
  3. // RESIDUAL CONTEXT OCCUPANCY — how many tokens of the context window each tool
  4. // family's responses still occupy when the run ends.
  5. //
  6. // Usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--brief] [--envelope] [--answer <glob>]...
  7. // Multiple files = one multi-turn session's segments, IN ORDER (run-all.sh
  8. // writes run-<label>.jsonl, run-<label>.t2.jsonl, … for a `Q1||Q2||Q3` set).
  9. // `--resume` does not replay prior messages, so the segments concatenate
  10. // cleanly and token accounting carries across the boundary.
  11. //
  12. // `--brief` drops the numbered tool-call transcript and keeps everything else,
  13. // for harnesses that print one of these blocks per run (ab-new-vs-baseline.sh
  14. // at RUNS>=2 is otherwise mostly call listings).
  15. //
  16. // Every run also reports EXPLORE SUFFICIENCY — each codegraph_explore call
  17. // bucketed by what the agent did next (see classifySufficiency) — and EXPLORE
  18. // ALLOCATION EFFICIENCY, the share of the bytes explore returned that belonged
  19. // to files the agent's final answer actually cited (see computeAllocation).
  20. //
  21. // `--envelope` additionally reports how the codegraph_explore responses were
  22. // DIVIDED across files — the per-file share of the source envelope (#1500).
  23. // `--answer <glob>` (repeatable, implies --envelope) marks the files that
  24. // actually answer the question and reports their combined share: bar 2 of the
  25. // CG-1/CG-22 allocation gate. See formatEnvelope for why it parses the
  26. // rendered markdown rather than the CG-4 diagnostic sidecar.
  27. //
  28. // ---------------------------------------------------------------------------
  29. // Why occupancy, and how it's measured
  30. // ---------------------------------------------------------------------------
  31. // A single-question A/B reports cost/tokens/time/tool-calls for ONE answer. It
  32. // cannot see what issue #1500 measured: a tool response stays in the window for
  33. // everything that follows, so it is charged against every later turn's headroom.
  34. // That is a per-session cost our single-question runs structurally miss.
  35. //
  36. // Tokens are MEASURED, not estimated at bytes/4. For assistant request k,
  37. // ctx_k = usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokens
  38. // is the exact token count of that request's whole prompt. So
  39. // gap_k = ctx_k - ctx_{k-1}
  40. // is exactly the tokens appended since the previous request: the previous
  41. // assistant output (thinking + text + tool_use JSON) plus the tool_results and
  42. // user text that followed it. We split gap_k across those blocks in proportion
  43. // to their characters, which attributes each tool_result its measured share.
  44. // (Measured on real runs, explore output lands near 2.3 chars/token — bytes/4
  45. // under-counts it by ~40%, which is why the estimate isn't good enough.)
  46. //
  47. // Two traps this file works around, both verified against real logs:
  48. // * Claude Code emits ONE assistant event PER CONTENT BLOCK, all carrying the
  49. // same message.id and the same `usage`. Summing usage per event double-counts
  50. // every turn that emits both thinking and a tool_use — dedupe by message.id.
  51. // * The streamed `output_tokens` is a partial snapshot (observed `out=2` on a
  52. // turn that really generated ~1100). Never trust it; the char-proportional
  53. // split doesn't need it.
  54. //
  55. // Residual ≠ contributed. Content leaves the window two ways, and both are
  56. // tracked: a `compact_boundary` system event (everything prior is replaced by a
  57. // summary) and micro-compaction (ctx drops mid-run — oldest tool results are
  58. // dropped first, so eviction is applied FIFO).
  59. import { readFileSync } from 'fs';
  60. import { pathToFileURL } from 'url';
  61. // Nominal window for the share-of-window column. Override for a [1m] context.
  62. const WINDOW_TOKENS = Number(process.env.CG_WINDOW_TOKENS || 200_000);
  63. const CHARS_PER_TOKEN_FALLBACK = 3.0;
  64. /** Which tool family a tool_use belongs to. */
  65. function familyOf(name) {
  66. if (/codegraph/.test(name)) return 'codegraph';
  67. if (name === 'Read' || name === 'NotebookRead') return 'read';
  68. if (name === 'Grep' || name === 'Glob') return 'search';
  69. if (name === 'Bash' || name === 'BashOutput') return 'bash';
  70. return 'other';
  71. }
  72. const FAMILIES = ['codegraph', 'read', 'search', 'bash', 'other'];
  73. // The without-arm's way of getting the same bytes: reading and searching files.
  74. const FILE_ACCESS = ['read', 'search', 'bash'];
  75. // A Bash command that INVOKES the codegraph CLI, in any command position and by
  76. // any path. Mentions are not invocations: `grep codegraph src/`, `ls .codegraph`
  77. // and `which codegraph` all pass. Kept in step with run-all.sh's blocking hook.
  78. const CG_CLI_RE = /(^|[;&|(]|&&|\|\||\$\(|`)\s*(?:[A-Za-z_]\w*=\S*\s+)*[\w./~-]*codegraph(\s|$)/;
  79. const textOf = (content) =>
  80. Array.isArray(content) ? content.map((c) => c.text ?? (typeof c === 'string' ? c : JSON.stringify(c))).join('')
  81. : typeof content === 'string' ? content
  82. : content == null ? '' : JSON.stringify(content);
  83. /** Characters an assistant content block occupies once it is back in the prompt. */
  84. function assistantBlockChars(b) {
  85. if (b.type === 'text') return (b.text || '').length;
  86. if (b.type === 'thinking') return (b.thinking || '').length;
  87. if (b.type === 'tool_use') return JSON.stringify(b.input ?? {}).length + (b.name || '').length;
  88. return JSON.stringify(b).length;
  89. }
  90. /**
  91. * Parse one session (its segment files, in order) into tool + occupancy stats.
  92. * Exported so parse-bench-readme.mjs can aggregate without duplicating any of
  93. * this — deliberately NOT a separate module file: a new scripts/agent-eval/*.mjs
  94. * scores into the self-query eval fixture's own corpus and moves its numbers.
  95. */
  96. export function parseSession(files) {
  97. const events = [];
  98. for (const f of files) {
  99. for (const line of readFileSync(f, 'utf8').split('\n')) {
  100. if (!line) continue;
  101. try { events.push(JSON.parse(line)); } catch { /* partial line */ }
  102. }
  103. }
  104. const toolCalls = []; // display sequence
  105. const nameById = new Map(); // tool_use_id -> tool name
  106. const cliById = new Set(); // tool_use_ids that tried to run the codegraph CLI
  107. const counts = {}; // tool name -> calls
  108. // Attempts vs successes: run-all.sh's hook DENIES CLI invocations, and a
  109. // denied attempt puts no codegraph output in the window. Only a call that
  110. // actually returned content contaminates the arm.
  111. let initTools = null, result = null, raced = false, cliCalls = 0, cliContaminated = 0;
  112. const results = []; // one `result` event per session segment (multi-turn)
  113. let compactions = 0;
  114. // Raw codegraph_explore response text, in call order. Feeds the envelope view
  115. // (see formatEnvelope) — kept here rather than re-parsed from the log later so
  116. // a multi-segment session's responses stay in one ordered list.
  117. const exploreTexts = [];
  118. // A timeline of everything appended to the context, in order. `req` entries
  119. // are assistant requests (carrying that request's ctx); `add` entries are
  120. // characters appended (assistant output blocks, tool results, user text).
  121. const timeline = [];
  122. const seenMsgIds = new Set();
  123. for (const ev of events) {
  124. if (ev.type === 'system' && ev.subtype === 'init') {
  125. initTools = (ev.tools || []).filter((t) => /codegraph/.test(t));
  126. }
  127. if (ev.type === 'system' && (ev.subtype === 'compact_boundary' || ev.subtype === 'compaction')) {
  128. compactions++;
  129. timeline.push({ kind: 'compact' });
  130. }
  131. if (ev.type === 'assistant' && ev.message) {
  132. const id = ev.message.id;
  133. // One event per content block, same id + same usage: count usage once,
  134. // but take the content blocks from every event that carries the id.
  135. if (id && !seenMsgIds.has(id)) {
  136. seenMsgIds.add(id);
  137. const u = ev.message.usage || {};
  138. const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
  139. timeline.push({ kind: 'req', ctx, out: u.output_tokens || 0 });
  140. }
  141. for (const b of ev.message.content || []) {
  142. timeline.push({ kind: 'add', family: null, chars: assistantBlockChars(b) });
  143. if (b.type === 'tool_use') {
  144. nameById.set(b.id, b.name);
  145. counts[b.name] = (counts[b.name] || 0) + 1;
  146. let detail = '';
  147. if (b.name === 'Task') detail = ` [subagent_type=${b.input?.subagent_type ?? '?'}] ${(b.input?.description ?? '').slice(0, 40)}`;
  148. else if (/codegraph/.test(b.name)) detail = ` ${JSON.stringify(b.input?.query ?? b.input?.task ?? b.input?.symbol ?? '').slice(0, 60)}`;
  149. else if (b.name === 'Bash') {
  150. detail = ` ${(b.input?.command ?? '').slice(0, 50)}`;
  151. // An arm with no codegraph MCP can still shell out to the CLI — the
  152. // target repo carries the .codegraph/ index and the binary is on
  153. // PATH. That silently turns a "without" arm into codegraph-over-CLI.
  154. if (CG_CLI_RE.test(b.input?.command ?? '')) { cliCalls++; cliById.add(b.id); }
  155. }
  156. else if (b.name === 'Read') detail = ` ${(b.input?.file_path ?? '').split('/').slice(-1)[0]}`;
  157. toolCalls.push(`${b.name}${detail}`);
  158. }
  159. }
  160. }
  161. if (ev.type === 'user' && ev.message) {
  162. const content = ev.message.content;
  163. if (Array.isArray(content)) {
  164. for (const b of content) {
  165. if (b.type === 'tool_result') {
  166. const t = textOf(b.content);
  167. // MCP cold-start race: the agent fired before `serve --mcp` had
  168. // registered its tools, so it floundered into grep/Read. That
  169. // measures startup latency, not steady-state value — flag it.
  170. if (/No such tool available/.test(t)) raced = true;
  171. // A CLI attempt that came back an error was blocked (by the hook, or
  172. // by the binary being genuinely absent) and put nothing in context.
  173. if (cliById.has(b.tool_use_id) && !b.is_error) cliContaminated++;
  174. const name = nameById.get(b.tool_use_id) || '';
  175. if (/codegraph_explore/.test(name) && !b.is_error) exploreTexts.push(t);
  176. timeline.push({ kind: 'add', family: familyOf(name), chars: t.length, tool: name });
  177. } else {
  178. timeline.push({ kind: 'add', family: null, chars: textOf([b]).length });
  179. }
  180. }
  181. } else if (typeof content === 'string') {
  182. timeline.push({ kind: 'add', family: null, chars: content.length });
  183. }
  184. }
  185. if (ev.type === 'result') { result = ev; results.push(ev); }
  186. }
  187. // ---- Pass 1: chars/token, calibrated on tool-result-dominated gaps. ------
  188. // Splitting a gap in proportion to characters over-attributes to tool results
  189. // whenever the assistant's own output is under-represented in the transcript
  190. // (redacted/empty thinking blocks are the common case — a gap whose only
  191. // visible chars were a 73-char tool_result charged it the whole 830-token
  192. // delta, 5.5 tok/char). So calibrate the ratio on gaps that are ≥80% tool
  193. // result by characters, then price every result at that ratio.
  194. const reqIdx = timeline.map((t, i) => (t.kind === 'req' ? i : -1)).filter((i) => i >= 0);
  195. const gaps = [];
  196. for (let k = 1; k < reqIdx.length; k++) {
  197. const prev = timeline[reqIdx[k - 1]], cur = timeline[reqIdx[k]];
  198. let chars = 0, toolChars = 0, compacted = false;
  199. const byFamily = {};
  200. for (let i = reqIdx[k - 1] + 1; i < reqIdx[k]; i++) {
  201. const t = timeline[i];
  202. if (t.kind === 'compact') { compacted = true; continue; }
  203. if (t.kind !== 'add') continue;
  204. chars += t.chars;
  205. if (t.family) { toolChars += t.chars; byFamily[t.family] = (byFamily[t.family] || 0) + t.chars; }
  206. }
  207. gaps.push({ delta: cur.ctx - prev.ctx, chars, toolChars, byFamily, compacted });
  208. }
  209. const clean = gaps.filter((g) => !g.compacted && g.delta > 0 && g.chars > 500 && g.toolChars / g.chars >= 0.8);
  210. // A gap where the window also SHED content has a delta far below what was
  211. // added, which reads as absurdly dense text and would drag the whole run's
  212. // ratio with it. Shedding can only push a gap's chars/token UP, so take the
  213. // lower median as the honest centre and drop anything well above it, then
  214. // pool the survivors. (On runs that never shed, every ratio is within a few
  215. // percent of the others and this changes nothing.)
  216. const ratios = clean.map((g) => g.toolChars / g.delta).sort((a, b) => a - b);
  217. const lowerMedian = ratios.length ? ratios[Math.floor((ratios.length - 1) / 2)] : 0;
  218. let sumD = 0, sumC = 0;
  219. for (const g of clean) {
  220. if (lowerMedian > 0 && g.toolChars / g.delta > lowerMedian * 1.5) continue; // shed
  221. sumD += g.delta; sumC += g.toolChars;
  222. }
  223. if (sumD === 0) { // no clean gap — fall back to every growing gap, all chars
  224. for (const g of gaps) if (!g.compacted && g.delta > 0 && g.chars > 0) { sumD += g.delta; sumC += g.chars; }
  225. }
  226. const charsPerToken = sumD > 0 ? sumC / sumD : CHARS_PER_TOKEN_FALLBACK;
  227. const calibrated = sumD > 0;
  228. // How far a single result's token density strays from the run-level ratio.
  229. // On a gap that is almost entirely one tool result, `delta` IS that result's
  230. // token count, so |chars/ratio - delta| / delta is the attribution error for
  231. // that result. The median over such gaps is the metric's real error bar.
  232. const errs = [];
  233. for (const g of gaps) {
  234. if (g.compacted || g.delta <= 0 || g.chars <= 500) continue;
  235. if (g.toolChars / g.chars < 0.95) continue;
  236. errs.push(Math.abs(g.toolChars / charsPerToken - g.delta) / g.delta);
  237. }
  238. errs.sort((a, b) => a - b);
  239. const dispersion = errs.length ? errs[(errs.length - 1) >> 1] : null;
  240. // ---- Pass 2: attribute gap tokens, then apply evictions FIFO. ------------
  241. const contributed = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  242. const resultChars = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  243. const resultCount = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  244. for (const t of timeline) if (t.kind === 'add' && t.family) { resultChars[t.family] += t.chars; resultCount[t.family]++; }
  245. let queue = []; // resident contributions, oldest first
  246. let evicted = 0;
  247. const evict = (tokens) => {
  248. let left = tokens;
  249. while (left > 0 && queue.length) {
  250. const head = queue[0];
  251. if (head.tokens <= left) { left -= head.tokens; evicted += head.tokens; queue.shift(); }
  252. else { head.tokens -= left; evicted += left; left = 0; }
  253. }
  254. };
  255. for (const g of gaps) {
  256. if (g.compacted) {
  257. // Everything before the boundary is gone; the summary replaces it.
  258. evicted += queue.reduce((s, q) => s + q.tokens, 0);
  259. queue = [];
  260. }
  261. let toolTokens = 0;
  262. for (const [fam, ch] of Object.entries(g.byFamily)) {
  263. const tok = ch / charsPerToken;
  264. toolTokens += tok;
  265. contributed[fam] += tok;
  266. queue.push({ family: fam, tokens: tok });
  267. }
  268. // The gap grew by `delta`; the tool results account for `toolTokens` of it.
  269. // A shortfall means the window also shed content — micro-compaction drops
  270. // the OLDEST tool results first, so evict FIFO. The tolerance keeps
  271. // attribution noise (a run-level ratio priced against one gap's delta,
  272. // typically ±2%) from reading as an eviction; real shedding is thousands.
  273. const shortfall = toolTokens - g.delta;
  274. if (!g.compacted && shortfall > Math.max(200, toolTokens * 0.05)) evict(shortfall);
  275. }
  276. const residual = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  277. for (const q of queue) residual[q.family] += q.tokens;
  278. const ctxFinal = reqIdx.length ? timeline[reqIdx[reqIdx.length - 1]].ctx : 0;
  279. // The FIRST request's prompt is system + tool schemas + the question, before
  280. // any tool has answered. Differencing the arms' ctxBase prices codegraph's
  281. // FIXED occupancy — its tool schema and MCP `initialize` instructions — which
  282. // it pays whether or not the agent ever calls it.
  283. const ctxBase = reqIdx.length ? timeline[reqIdx[0]].ctx : 0;
  284. // "Tokens processed" = the SUM of every assistant request's whole prompt. Every
  285. // request re-counts the prefix, which is what makes this a throughput figure and
  286. // why it cannot answer the occupancy question (that is `residual`, above).
  287. //
  288. // It must be summed PER TURN, not read off `result.usage`. The comment that used
  289. // to sit here asserted result.usage was "cumulative WITHIN a segment"; that was
  290. // true when the README figures were measured and is NOT true in current Claude
  291. // Code, where it reports the LAST turn only. Nothing in this repo changed — the
  292. // host did, silently, and the harness kept reporting the smaller number.
  293. //
  294. // The damage is one-sided and therefore worse than noise: it under-counts
  295. // whichever arm takes more turns, which is always the WITHOUT arm. Measured on
  296. // the 2026-08-05 campaign it turned a real 62% token saving into 19%, and
  297. // manufactured a token REGRESSION on tokio (-41%) and alamofire (-25%) that does
  298. // not exist. CLAUDE.md already warned about this field; the code did not follow.
  299. //
  300. // Dedupe by message.id: Claude Code emits one assistant event per content block,
  301. // each carrying the same `usage`, so summing per EVENT double-counts (~1.7x on a
  302. // real run). Same rule the occupancy timeline above uses.
  303. const seenUsageIds = new Set();
  304. let processed = 0;
  305. for (const ev of events) {
  306. if (ev.type !== 'assistant' || !ev.message?.id) continue;
  307. if (seenUsageIds.has(ev.message.id)) continue;
  308. seenUsageIds.add(ev.message.id);
  309. const u = ev.message.usage || {};
  310. processed += (u.input_tokens || 0) + (u.cache_read_input_tokens || 0)
  311. + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0);
  312. }
  313. return {
  314. files, toolCalls, counts, initTools, result, results, raced, cliCalls, cliContaminated,
  315. exploreTexts,
  316. // What the agent did after each explore — the free sufficiency signal (CG-8).
  317. sufficiency: classifySufficiency(events),
  318. // How much of what explore returned the answer actually drew on (CG-9).
  319. allocation: computeAllocation(exploreTexts, finalAnswerText(events)),
  320. ok: results.length > 0 && results.every((r) => r.subtype === 'success'),
  321. turns: reqIdx.length,
  322. tools: toolCalls.filter((t) => !t.startsWith('ToolSearch')).length,
  323. reads: counts.Read || 0,
  324. grep: (counts.Grep || 0) + (counts.Glob || 0),
  325. cg: Object.entries(counts).filter(([n]) => /codegraph/.test(n)).reduce((s, [, v]) => s + v, 0),
  326. dur: results.reduce((s, r) => s + (r.duration_ms || 0), 0) / 1000,
  327. cost: results.reduce((s, r) => s + (r.total_cost_usd || 0), 0),
  328. processed,
  329. occupancy: {
  330. ctxFinal, ctxBase, windowTokens: WINDOW_TOKENS,
  331. charsPerToken, calibrated, compactions, dispersion, evicted: Math.round(evicted),
  332. residual: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(residual[f])])),
  333. contributed: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(contributed[f])])),
  334. chars: resultChars, results: resultCount,
  335. residualFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + residual[f], 0)),
  336. contributedFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + contributed[f], 0)),
  337. charsFileAccess: FILE_ACCESS.reduce((s, f) => s + resultChars[f], 0),
  338. },
  339. };
  340. }
  341. /** The occupancy block, as printed under a run and reused by the aggregator. */
  342. export function formatOccupancy(s, indent = ' ') {
  343. const o = s.occupancy;
  344. const n = (x) => x.toLocaleString('en-US');
  345. const pctCtx = (t) => (o.ctxFinal > 0 ? ((t / o.ctxFinal) * 100).toFixed(1) : '0.0');
  346. const pctWin = (t) => ((t / o.windowTokens) * 100).toFixed(1);
  347. const rows = [];
  348. const row = (label, tok, chars, results) => rows.push(
  349. `${indent} ${label.padEnd(18)}${(n(tok) + ' tok').padStart(12)} ${(pctCtx(tok) + '%').padStart(6)} of ctx ` +
  350. `${(pctWin(tok) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k win` +
  351. (chars !== undefined ? ` (${n(chars)} chars, ${results} result${results === 1 ? '' : 's'})` : '')
  352. );
  353. const out = [`${indent}Residual context occupancy at end of run:`];
  354. 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`);
  355. row('codegraph', o.residual.codegraph, o.chars.codegraph, o.results.codegraph);
  356. row('Read', o.residual.read, o.chars.read, o.results.read);
  357. row('Grep/Glob', o.residual.search, o.chars.search, o.results.search);
  358. row('Bash', o.residual.bash, o.chars.bash, o.results.bash);
  359. row('→ file-access', o.residualFileAccess, o.charsFileAccess,
  360. o.results.read + o.results.search + o.results.bash);
  361. row('other tools', o.residual.other, o.chars.other, o.results.other);
  362. const toolTotal = Object.values(o.residual).reduce((a, b) => a + b, 0);
  363. row('base (prompt+prose)', Math.max(0, o.ctxFinal - toolTotal));
  364. out.push(`${indent} ${' of which fixed'.padEnd(18)}${(n(o.ctxBase) + ' tok').padStart(12)} system + tool schemas + question, before any tool answered`);
  365. out.push(...rows);
  366. const dropped = o.contributed.codegraph + o.contributedFileAccess + o.contributed.other
  367. - (o.residual.codegraph + o.residualFileAccess + o.residual.other);
  368. out.push(
  369. `${indent} measure: ${o.charsPerToken.toFixed(2)} chars/tok ${o.calibrated ? 'measured' : '(FALLBACK — no clean gap to calibrate on)'}` +
  370. (o.dispersion !== null ? ` ±${(o.dispersion * 100).toFixed(1)}%` : '') +
  371. ` · turns ${s.turns} · compactions ${o.compactions}` +
  372. (o.evicted > 0 || dropped > 1 ? ` · evicted ${n(o.evicted)} tok` : '')
  373. );
  374. return out.join('\n');
  375. }
  376. /**
  377. * One codegraph_explore response, split into the per-file source sections the
  378. * allocator divided its budget across.
  379. *
  380. * Parsed out of the RENDERED MARKDOWN, not the CG-4 diagnostic sidecar: the
  381. * sidecar only exists on a post-CG-4 build, so it cannot measure a baseline arm.
  382. * The markdown parse is the only instrument that measures both arms of a
  383. * new-vs-baseline A/B the same way. Both the envelope view and the allocation
  384. * metric key off this one parse.
  385. *
  386. * Returns `{ envelope, files: [{ path, chars, symbols }] }`, where `symbols` are
  387. * the names the section header lists — filtered to the ones the file DEFINES.
  388. * The header renders `name(kind)` for every node in the shipped clusters, and
  389. * that includes edge-kind pseudo-entries for call sites (`mutateElement(calls)`
  390. * on a file that merely calls it). Attributing a file from those would credit
  391. * every caller of a cited symbol — measured on a real excalidraw run, it marked
  392. * `dragElements.ts` used because the answer named `mutateElement`.
  393. */
  394. /** NodeKind values that mean "this file defines it" (src/types.ts, less file/import/export). */
  395. const DEFINING_KINDS = new Set([
  396. 'module', 'class', 'struct', 'interface', 'trait', 'protocol', 'function', 'method',
  397. 'property', 'field', 'variable', 'constant', 'enum', 'enum_member', 'type_alias',
  398. 'namespace', 'route', 'component',
  399. ]);
  400. export function parseExploreCall(text) {
  401. const src = String(text ?? '');
  402. const re = /^\*\*`([^`]+)`\*\*(.*)$/gm;
  403. const marks = [];
  404. let m;
  405. while ((m = re.exec(src)) !== null) marks.push({ path: m[1], header: m[2] || '', at: m.index });
  406. if (!marks.length) return { envelope: 0, files: [] };
  407. // Sections run to the next header, or to the trailing guidance quote.
  408. const tail = src.indexOf('\n> ', marks[marks.length - 1].at);
  409. const end = tail === -1 ? src.length : tail;
  410. const files = marks.map((mark, i) => ({
  411. path: mark.path,
  412. chars: (i + 1 < marks.length ? marks[i + 1].at : end) - mark.at,
  413. // `mutateElement(calls), onFinished(variable), +32 more` → the defined ones.
  414. symbols: [...mark.header.matchAll(/([A-Za-z_$][\w$.]*)\(([a-z_]+)\)/g)]
  415. .filter((s) => DEFINING_KINDS.has(s[2]))
  416. .map((s) => ({ name: s[1], kind: s[2] })),
  417. }));
  418. return { envelope: files.reduce((s, f) => s + f.chars, 0), files };
  419. }
  420. /**
  421. * How the codegraph_explore responses the agent received were DIVIDED across
  422. * files — the per-file share of the source envelope (#1500 / epic CG-1).
  423. *
  424. * `answerGlobs` marks the files that actually answer the question; the summary
  425. * reports their combined share, which is bar 2 of the CG-1/CG-22 gate. That is
  426. * HAND-SPECIFIED ground truth; the allocation metric below infers the same
  427. * intersection from the agent's own answer instead.
  428. */
  429. export function formatEnvelope(exploreTexts, answerGlobs = [], indent = ' ') {
  430. // `tools/cache/**` -> /^tools\/cache\/.*$/ . Same semantics as probe-allocation.
  431. // The `**` sentinel is written as an escape, never a literal NUL byte — a raw
  432. // one makes git treat this whole script as binary and costs every future diff.
  433. const glob2re = (glob) => {
  434. const S = '\\u0000';
  435. const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
  436. .replace(/\*\*/g, S).replace(/\*/g, '[^/]*').replaceAll(S, '.*');
  437. return new RegExp(`^${body}$`);
  438. };
  439. const answerRes = answerGlobs.map(glob2re);
  440. const isAnswer = (p) => answerRes.some((re) => re.test(p));
  441. // Share is over the sum of the per-file sections, i.e. of the source envelope
  442. // the allocator divides.
  443. const pooled = new Map();
  444. let envelope = 0;
  445. for (const text of exploreTexts) {
  446. for (const f of parseExploreCall(text).files) {
  447. pooled.set(f.path, (pooled.get(f.path) ?? 0) + f.chars);
  448. envelope += f.chars;
  449. }
  450. }
  451. const ranked = [...pooled.entries()]
  452. .map(([path, chars]) => ({ path, chars, share: envelope ? chars / envelope : 0, answer: isAnswer(path) }))
  453. .sort((a, b) => b.chars - a.chars);
  454. const answerChars = ranked.filter((r) => r.answer).reduce((s, r) => s + r.chars, 0);
  455. const pct = (f) => `${(f * 100).toFixed(1)}%`;
  456. const out = [];
  457. out.push(`${indent}Explore envelope: ${envelope.toLocaleString('en-US')} chars over ${exploreTexts.length} response(s)`);
  458. if (answerGlobs.length) {
  459. out.push(`${indent} answer-set share: ${pct(envelope ? answerChars / envelope : 0)} | top file answers: ${ranked[0]?.answer ?? false}`);
  460. }
  461. for (const f of ranked.slice(0, 12)) {
  462. out.push(`${indent} ${f.answer ? '*' : ' '} ${pct(f.share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path}`);
  463. }
  464. if (ranked.length > 12) out.push(`${indent} … ${ranked.length - 12} more files`);
  465. return out.join('\n');
  466. }
  467. // ---------------------------------------------------------------------------
  468. // Explore allocation efficiency (CG-9)
  469. // ---------------------------------------------------------------------------
  470. // The envelope view above needs a human to say which files answer the question
  471. // (`--answer <glob>`). This one reads that off the AGENT'S OWN FINAL ANSWER and
  472. // reports a single number every run:
  473. //
  474. // allocation efficiency = bytes explore returned for files the answer drew on
  475. // ─────────────────────────────────────────────────
  476. // all bytes explore returned
  477. //
  478. // That is the #1500 defect as a number. On the CG-1 baseline self-query it sat
  479. // around 16–30%: one correct file, one marginal, three pure noise.
  480. //
  481. // ATTRIBUTION IS IMPERFECT AND THE ERROR IS ONE-SIDED. An agent can use a file's
  482. // source to rule it out, or to build a mental model, and never cite it — that
  483. // reads as waste. So this is a RELATIVE metric: valid for comparing two builds
  484. // on the SAME question, not as an absolute claim about how much of an envelope
  485. // earned its place. Quoting it as an absolute efficiency figure is a misuse.
  486. //
  487. // Two citation channels, deliberately ranked so the weaker one is separable:
  488. // * PATH — the answer names the file (`lib/response.js:126-220`, or the bare
  489. // `utils.js:225` agents drop into prose). Strong, and reported on its own as
  490. // the conservative floor.
  491. // * SYMBOL — the answer cites a symbol in a code span and only that file's
  492. // section header lists it. Catches answers written entirely in symbol names.
  493. // Guarded: a name carried by ≥3 of the returned files is too generic to
  494. // attribute and is dropped, or `send`/`get` would mark half the envelope
  495. // used and bias the metric optimistic — the one direction it must not lean.
  496. /** Symbol tokens too common in prose/code spans to attribute a file from. */
  497. const SYMBOL_STOPWORDS = new Set([
  498. 'function', 'return', 'const', 'this', 'true', 'false', 'null', 'void', 'undefined',
  499. 'string', 'object', 'number', 'boolean', 'array', 'class', 'import', 'export',
  500. 'async', 'await', 'default', 'type', 'value', 'name', 'data', 'self', 'super',
  501. 'else', 'then', 'case', 'from', 'with', 'when', 'where', 'that', 'they', 'this',
  502. ]);
  503. /** A name on this many of the returned files stops identifying any one of them. */
  504. const SYMBOL_AMBIGUITY_LIMIT = 3;
  505. /** Below this length a token is far more likely prose than a symbol citation. */
  506. const MIN_SYMBOL_LEN = 4;
  507. /**
  508. * What the agent's final answer CITES: file paths, and the identifiers it puts
  509. * in code spans. `extensions` are the file extensions actually present in the
  510. * envelope — the gate that lets `utils.js:225` through as a file citation while
  511. * rejecting `res.send` and `mime.contentType`, which are the same token shape.
  512. */
  513. export function answerCitations(text, extensions = new Set()) {
  514. const src = String(text ?? '');
  515. const paths = new Set();
  516. // Dotted path with at least one directory: `lib/response.js:126-220`.
  517. for (const m of src.matchAll(/(?:[\w@.+-]+\/)+[\w@.+-]+\.[A-Za-z]\w*/g)) paths.add(m[0]);
  518. // Bare basename, only when its extension is one the envelope actually shipped.
  519. for (const m of src.matchAll(/\b[\w@+-]+\.[A-Za-z]\w*/g)) {
  520. const ext = m[0].slice(m[0].lastIndexOf('.') + 1).toLowerCase();
  521. if (extensions.has(ext)) paths.add(m[0]);
  522. }
  523. // Identifiers inside code spans. Prose mentions are excluded on purpose: a
  524. // backtick is the agent marking the token as code, which is the whole signal.
  525. const symbols = new Set();
  526. for (const span of src.matchAll(/`([^`\n]+)`/g)) {
  527. for (const t of span[1].matchAll(/[A-Za-z_$][\w$]*/g)) {
  528. const tok = t[0];
  529. if (tok.length >= MIN_SYMBOL_LEN && !SYMBOL_STOPWORDS.has(tok.toLowerCase())) symbols.add(tok);
  530. }
  531. }
  532. return { paths: [...paths], symbols };
  533. }
  534. /**
  535. * Allocation efficiency over one session's explore responses and its final
  536. * answer(s). Per call AND pooled over the run — a run-level number alone hides
  537. * the common shape where call 1 is on target and call 3 is pure noise.
  538. */
  539. export function computeAllocation(exploreTexts, answerText) {
  540. const calls = exploreTexts.map(parseExploreCall).filter((c) => c.files.length);
  541. const extensions = new Set();
  542. for (const c of calls) {
  543. for (const f of c.files) {
  544. const dot = f.path.lastIndexOf('.');
  545. if (dot > 0) extensions.add(f.path.slice(dot + 1).toLowerCase());
  546. }
  547. }
  548. const cited = answerCitations(answerText, extensions);
  549. // symbol -> the returned files whose header lists it as DEFINED. A `variable`
  550. // is usually an import binding (`var compileETag = require('./utils')…`), so
  551. // when the same name is also a real definition somewhere in the envelope, the
  552. // definition wins and the aliasing file is not credited.
  553. const symbolFiles = new Map();
  554. for (const c of calls) {
  555. for (const f of c.files) {
  556. for (const s of f.symbols) {
  557. if (!symbolFiles.has(s.name)) symbolFiles.set(s.name, { strong: new Set(), weak: new Set() });
  558. const owners = symbolFiles.get(s.name);
  559. (s.kind === 'variable' || s.kind === 'constant' ? owners.weak : owners.strong).add(f.path);
  560. }
  561. }
  562. }
  563. const ownersOf = (name) => {
  564. const o = symbolFiles.get(name);
  565. return o ? (o.strong.size ? o.strong : o.weak) : null;
  566. };
  567. // `lib/response.js` is cited by `lib/response.js`, by `response.js`, and by an
  568. // absolute path ending in it — samePath already encodes exactly that.
  569. const viaPath = (path) => cited.paths.some((p) => samePath(path, p));
  570. const viaSymbol = (path) => {
  571. for (const s of cited.symbols) {
  572. const owners = ownersOf(s);
  573. if (owners && owners.size < SYMBOL_AMBIGUITY_LIMIT && owners.has(path)) return s;
  574. }
  575. return null;
  576. };
  577. const verdict = new Map(); // path -> { via, symbol }
  578. const judge = (path) => {
  579. if (!verdict.has(path)) {
  580. if (viaPath(path)) verdict.set(path, { via: 'path' });
  581. else {
  582. const s = viaSymbol(path);
  583. verdict.set(path, s ? { via: 'symbol', symbol: s } : { via: null });
  584. }
  585. }
  586. return verdict.get(path);
  587. };
  588. const perCall = calls.map((c, i) => {
  589. const files = c.files.map((f) => ({ ...f, ...judge(f.path) }));
  590. const used = files.filter((f) => f.via).reduce((s, f) => s + f.chars, 0);
  591. const usedPath = files.filter((f) => f.via === 'path').reduce((s, f) => s + f.chars, 0);
  592. return {
  593. call: i + 1, envelope: c.envelope, used, usedPath, files,
  594. efficiency: c.envelope ? used / c.envelope : 0,
  595. };
  596. });
  597. // Pooled: a file returned twice is charged twice, because it occupied the
  598. // window twice. Same accounting as formatEnvelope.
  599. const pooled = new Map();
  600. let envelope = 0;
  601. for (const c of perCall) {
  602. for (const f of c.files) {
  603. const prev = pooled.get(f.path) ?? { path: f.path, chars: 0, calls: 0, via: f.via, symbol: f.symbol };
  604. prev.chars += f.chars; prev.calls++;
  605. pooled.set(f.path, prev);
  606. envelope += f.chars;
  607. }
  608. }
  609. const files = [...pooled.values()].sort((a, b) => b.chars - a.chars);
  610. const used = files.filter((f) => f.via).reduce((s, f) => s + f.chars, 0);
  611. const usedPath = files.filter((f) => f.via === 'path').reduce((s, f) => s + f.chars, 0);
  612. return {
  613. calls: perCall, files, envelope, used, usedPath,
  614. efficiency: envelope ? used / envelope : 0,
  615. efficiencyPath: envelope ? usedPath / envelope : 0,
  616. filesReturned: files.length,
  617. filesUsed: files.filter((f) => f.via).length,
  618. hasAnswer: String(answerText ?? '').trim().length > 0,
  619. };
  620. }
  621. /**
  622. * Every answered codegraph_explore response in a transcript, in call order.
  623. * parseSession collects these as it walks the timeline; this is the same list
  624. * for callers that only have the raw events (parse-session.mjs).
  625. */
  626. export function collectExploreTexts(events) {
  627. const nameById = new Map();
  628. const texts = [];
  629. for (const ev of events) {
  630. const content = ev?.message?.content;
  631. if (!Array.isArray(content)) continue;
  632. for (const b of content) {
  633. if (b.type === 'tool_use') nameById.set(b.id, b.name);
  634. else if (b.type === 'tool_result' && !b.is_error
  635. && /codegraph_explore/.test(nameById.get(b.tool_use_id) || '')) texts.push(textOf(b.content));
  636. }
  637. }
  638. return texts;
  639. }
  640. /**
  641. * The final answer a session produced. Headless stream-json carries it on the
  642. * `result` event (one per resumed segment, all of which are answers); an
  643. * interactive transcript has none, so fall back to the LAST assistant text of
  644. * the main thread — intermediate narration would flood the citation set.
  645. */
  646. export function finalAnswerText(events) {
  647. const answers = events.filter((e) => e.type === 'result' && typeof e.result === 'string').map((e) => e.result);
  648. if (answers.length) return answers.join('\n\n');
  649. let last = '';
  650. for (const ev of events) {
  651. if (ev.type !== 'assistant' || (ev.parent_tool_use_id ?? null) !== null) continue;
  652. const text = (ev.message?.content || []).filter((b) => b.type === 'text').map((b) => b.text || '').join('');
  653. if (text.trim()) last = text;
  654. }
  655. return last;
  656. }
  657. /** The allocation block, as printed under a run and reused by aggregators. */
  658. export function formatAllocation(s, indent = ' ') {
  659. const a = s.allocation;
  660. if (!a || !a.calls.length) return `${indent}Explore allocation: no codegraph_explore responses with source sections`;
  661. const pct = (f) => `${(f * 100).toFixed(1)}%`;
  662. const n = (x) => x.toLocaleString('en-US');
  663. const out = [
  664. `${indent}Explore allocation — share of returned bytes the answer used ` +
  665. `(${a.calls.length} call${a.calls.length === 1 ? '' : 's'}, ${a.filesReturned} file${a.filesReturned === 1 ? '' : 's'}):`,
  666. `${indent} efficiency ${pct(a.efficiency).padStart(6)} ${n(a.used)} of ${n(a.envelope)} chars` +
  667. ` (${a.filesUsed}/${a.filesReturned} files cited; path-cited alone ${pct(a.efficiencyPath)})`,
  668. ];
  669. if (!a.hasAnswer) out.push(`${indent} !! no final answer text found — efficiency is not meaningful for this run`);
  670. for (const c of a.calls) {
  671. out.push(`${indent} call ${c.call}: ${pct(c.efficiency).padStart(6)} ${n(c.used)}/${n(c.envelope)} chars` +
  672. ` ${c.files.filter((f) => f.via).length}/${c.files.length} files`);
  673. }
  674. for (const f of a.files.slice(0, 12)) {
  675. const share = a.envelope ? f.chars / a.envelope : 0;
  676. const why = f.via === 'symbol' ? `symbol \`${f.symbol}\`` : f.via === 'path' ? 'path' : '—';
  677. out.push(`${indent} ${f.via ? '*' : ' '} ${pct(share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path} ${why}`);
  678. }
  679. if (a.files.length > 12) out.push(`${indent} … ${a.files.length - 12} more files`);
  680. out.push(`${indent} note: relative metric — attribution is by citation, so compare builds on the same question, not absolutes.`);
  681. return out.join('\n');
  682. }
  683. // ---------------------------------------------------------------------------
  684. // Explore sufficiency (CG-8)
  685. // ---------------------------------------------------------------------------
  686. // The agent's NEXT action after a codegraph_explore is free ground truth about
  687. // whether that response was enough. The buckets are chosen so each one maps to
  688. // a distinct fix:
  689. //
  690. // another codegraph call insufficient — the response did not answer
  691. // Read of a file we RETURNED allocation bug — right file, wrong bytes
  692. // Read of a file we did NOT recall bug — the file never surfaced
  693. // Grep/Glob recall bug (weaker: the agent is still hunting)
  694. // anything else / no tool sufficient — the agent moved on
  695. //
  696. // Three rules keep this honest:
  697. // * Only a call issued in a LATER assistant message counts as a reaction. A
  698. // Read fired in the same message as the explore was issued before its
  699. // response existed, so it cannot be a verdict on it (those are counted
  700. // separately as `concurrent`).
  701. // * ToolSearch/TodoWrite are stepped over: loading a deferred tool schema or
  702. // ticking a checklist says nothing about the response.
  703. // * SUBAGENT CALLS ARE A SEPARATE THREAD. Claude Code interleaves a subagent's
  704. // tool calls into the same stream, tagged `parent_tool_use_id` — verified on
  705. // a real run where a delegated search's greps landed between the parent's
  706. // own calls. Reactions are matched within one thread, or the subagent's
  707. // first grep would be scored as the parent's verdict on an explore it never
  708. // saw.
  709. //
  710. // A delegation (`Agent`/`Task`) is judged by what the SUBAGENT did first, since
  711. // that thread is right there in the transcript. Scoring the delegation itself as
  712. // "moved on" would have called this run sufficient while the subagent was off
  713. // grepping for the file — the one direction of error a tuning metric must not
  714. // have. A delegation that never runs a tool stays "moved on".
  715. /** Tools that carry no signal about whether the previous response was enough. */
  716. const TRANSPARENT_TOOLS = new Set(['ToolSearch', 'TodoWrite']);
  717. /** Tools that hand the work to a subagent whose thread we then judge instead. */
  718. const DELEGATION_TOOLS = new Set(['Agent', 'Task']);
  719. /** Buckets, worst → best. Labels double as the summary rows. */
  720. export const SUFFICIENCY = [
  721. ['explore_again', 'explore again', 'insufficient: did not answer'],
  722. ['read_returned', 'Read a file we returned', 'allocation: right file, wrong bytes'],
  723. ['read_missed', 'Read a file we did not return', 'recall: file never surfaced'],
  724. ['search', 'Grep/Glob', 'recall (weak): still hunting for the file'],
  725. ['sufficient', 'moved on / answered', 'sufficient'],
  726. ];
  727. const SUFFICIENCY_KEYS = SUFFICIENCY.map(([k]) => k);
  728. // Shell equivalents of Read and of Grep. Both arms have Bash, and on small
  729. // repos an agent reaches for `sed -n 100,200p file` as readily as for Read —
  730. // counting only the Read tool would score those explores as sufficient.
  731. const BASH_READ_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:cat|bat|head|tail|less|more|nl|sed|awk)\s+([^\n|;&]*)/;
  732. const BASH_SEARCH_RE = /(?:^|[;&|]|\$\(|`)\s*(?:sudo\s+)?(?:grep|egrep|fgrep|rg|ag|ack|find|fd|ls|tree)\b/;
  733. /** What a Bash command is really doing, as far as retrieval is concerned. */
  734. function bashIntent(cmd) {
  735. const c = String(cmd || '');
  736. // A heredoc or a redirect is WRITING a file — `cat <<EOF > x` must not read
  737. // as a Read.
  738. if (!/<</.test(c) && !/>\s*\S/.test(c)) {
  739. const m = BASH_READ_RE.exec(c);
  740. if (m) {
  741. // Drop flags and numeric arguments (`sed -n '100,200p' lib/x.js`), then
  742. // take the last path-shaped token.
  743. const args = m[1].split(/\s+/).filter((a) => a && !a.startsWith('-') && !/^['"]?\d/.test(a));
  744. const path = args.reverse().find((a) => /[/.]/.test(a));
  745. if (path) return { kind: 'read', path: path.replace(/^['"]|['"]$/g, '') };
  746. }
  747. }
  748. if (BASH_SEARCH_RE.test(c)) return { kind: 'search' };
  749. return null;
  750. }
  751. const normPath = (p) => String(p ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
  752. /** Same file, with either side repo-relative and the other absolute. */
  753. function samePath(a, b) {
  754. const x = normPath(a), y = normPath(b);
  755. if (!x || !y) return false;
  756. return x === y || x.endsWith('/' + y) || y.endsWith('/' + x);
  757. }
  758. /**
  759. * The files whose SOURCE an explore response returned — its per-file sections,
  760. * which start with the unique ``**` `` marker (FILE_SECTION_PREFIX in
  761. * src/mcp/tools.ts). formatEnvelope keys off the same marker; it needs the byte
  762. * offsets too, which is why it re-scans rather than calling this.
  763. */
  764. export function exploreReturnedFiles(text) {
  765. return [...String(text ?? '').matchAll(/^\*\*`([^`]+)`\*\*/gm)].map((m) => m[1]);
  766. }
  767. // Every path-shaped token anywhere in a response — flow steps, blast radius,
  768. // symbol lists. A file in here but NOT in the returned set was POINTED AT and
  769. // not delivered, which is a different (and more damning) miss than one the
  770. // response never mentioned at all.
  771. const PATH_TOKEN_RE = /(?:[\w@.+-]+\/)+[\w@.+-]+\.[A-Za-z]\w*/g;
  772. /**
  773. * The reaction one action represents, given what the explore had returned.
  774. * `earlier` is what PREVIOUS explores in the same thread returned: a re-read of
  775. * a file we already shipped is an allocation miss wherever it was shipped, and
  776. * filing it as recall would point the fix at the wrong end of the pipeline.
  777. */
  778. function reactionOf(action, returned, mentioned, earlier = []) {
  779. const { name, input } = action;
  780. const readOf = (path, prefix) => {
  781. const base = normPath(path).split('/').pop() || String(path ?? '');
  782. if (returned.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base}` };
  783. if (earlier.some((r) => samePath(path, r))) return { bucket: 'read_returned', next: `${prefix}Read ${base} (returned by an earlier explore)` };
  784. const named = mentioned.some((m) => samePath(path, m));
  785. return { bucket: 'read_missed', next: `${prefix}Read ${base}${named ? ' (named, not returned)' : ''}`, named };
  786. };
  787. if (/codegraph/.test(name)) return { bucket: 'explore_again', next: name.replace(/^mcp__[^_]*__/, '') };
  788. if (name === 'Read' || name === 'NotebookRead') return readOf(input.file_path ?? input.notebook_path, '');
  789. if (name === 'Grep' || name === 'Glob') return { bucket: 'search', next: name };
  790. if (name === 'Bash') {
  791. const intent = bashIntent(input.command);
  792. if (intent?.kind === 'read') return readOf(intent.path, 'Bash ');
  793. if (intent?.kind === 'search') return { bucket: 'search', next: 'Bash search' };
  794. }
  795. return { bucket: 'sufficient', next: name };
  796. }
  797. /** Is this action one of the ways an agent gets file bytes into its head? */
  798. const isFileAccess = (a) =>
  799. a.name === 'Read' || a.name === 'NotebookRead' || a.name === 'Grep' || a.name === 'Glob'
  800. || (a.name === 'Bash' && bashIntent(a.input?.command) !== null);
  801. /**
  802. * Bucket every answered codegraph_explore call in a transcript by what the
  803. * agent did next. Takes the raw JSONL events so it serves both transcript
  804. * shapes: stream-json runs (parse-run.mjs) and interactive session logs
  805. * (parse-session.mjs) — both emit one assistant event per content block with
  806. * `message.id`, and tool results as `tool_result` blocks in user messages.
  807. */
  808. export function classifySufficiency(events) {
  809. // One action list PER THREAD: 'main', plus one per subagent (keyed by the
  810. // delegating tool_use id, which is what `parent_tool_use_id` carries).
  811. const threads = new Map();
  812. const nameById = new Map();
  813. const textById = new Map(); // explore tool_use_id -> response text
  814. for (const ev of events) {
  815. const content = ev?.message?.content;
  816. if (!Array.isArray(content)) continue;
  817. const thread = ev.parent_tool_use_id ?? 'main';
  818. if (ev.type === 'assistant') {
  819. if (!threads.has(thread)) threads.set(thread, []);
  820. const list = threads.get(thread);
  821. for (const b of content) {
  822. if (b.type !== 'tool_use') continue;
  823. nameById.set(b.id, b.name);
  824. // No message.id (never seen on a real log) degrades to "every call is
  825. // its own message", i.e. same-message calls read as reactions.
  826. list.push({ msgId: ev.message.id || `#${thread}-${list.length}`, id: b.id, name: b.name, input: b.input || {} });
  827. }
  828. } else if (ev.type === 'user') {
  829. for (const b of content) {
  830. if (b.type !== 'tool_result') continue;
  831. const name = nameById.get(b.tool_use_id) || '';
  832. if (/codegraph_explore/.test(name) && !b.is_error) textById.set(b.tool_use_id, textOf(b.content));
  833. }
  834. }
  835. }
  836. const calls = [];
  837. let errors = 0, concurrent = 0;
  838. // What a delegation really did: the subagent's first substantive call. A
  839. // nested delegation is skipped rather than followed, so a subagent that only
  840. // spawns another subagent leaves the call as "moved on".
  841. const throughDelegation = (action, returned, mentioned, earlier) => {
  842. const first = (threads.get(action.id) || []).find((x) => !TRANSPARENT_TOOLS.has(x.name) && !DELEGATION_TOOLS.has(x.name));
  843. if (!first) return { bucket: 'sufficient', next: action.name };
  844. const r = reactionOf(first, returned, mentioned, earlier);
  845. return { ...r, next: `${action.name} → ${r.next}` };
  846. };
  847. for (const [thread, actions] of threads) {
  848. const earlier = []; // files previous explores in THIS thread already shipped
  849. for (let i = 0; i < actions.length; i++) {
  850. const a = actions[i];
  851. if (!/codegraph_explore/.test(a.name)) continue;
  852. const text = textById.get(a.id);
  853. // No response text = the call errored, or the run ended before it
  854. // returned. Nothing to judge the sufficiency of; count it and move on.
  855. if (text === undefined) { errors++; continue; }
  856. const returned = exploreReturnedFiles(text);
  857. const mentioned = text.match(PATH_TOKEN_RE) || [];
  858. let reaction = { bucket: 'sufficient', next: '(final answer)' };
  859. for (let j = i + 1; j < actions.length; j++) {
  860. const b = actions[j];
  861. if (b.msgId === a.msgId) { if (isFileAccess(b)) concurrent++; continue; }
  862. if (TRANSPARENT_TOOLS.has(b.name)) continue;
  863. reaction = DELEGATION_TOOLS.has(b.name)
  864. ? throughDelegation(b, returned, mentioned, earlier)
  865. : reactionOf(b, returned, mentioned, earlier);
  866. break;
  867. }
  868. earlier.push(...returned);
  869. calls.push({ thread, query: String(a.input.query ?? ''), files: returned.length, chars: text.length, ...reaction });
  870. }
  871. }
  872. const counts = Object.fromEntries(SUFFICIENCY_KEYS.map((k) => [k, 0]));
  873. for (const c of calls) counts[c.bucket]++;
  874. return { calls, counts, errors, concurrent, answered: calls.length };
  875. }
  876. /** The sufficiency block, as printed under a run and reused by aggregators. */
  877. export function formatSufficiency(s, indent = ' ') {
  878. const f = s.sufficiency;
  879. if (!f.answered) {
  880. return `${indent}Explore sufficiency: no answered codegraph_explore calls`
  881. + (f.errors ? ` (${f.errors} errored or never returned)` : '');
  882. }
  883. const pct = (n) => ((n / f.answered) * 100).toFixed(0) + '%';
  884. const out = [`${indent}Explore sufficiency — what the agent did NEXT (${f.answered} answered call${f.answered === 1 ? '' : 's'}):`];
  885. for (const [key, label, meaning] of SUFFICIENCY) {
  886. out.push(`${indent} ${String(f.counts[key]).padStart(3)} ${pct(f.counts[key]).padStart(4)} ${label.padEnd(31)}${meaning}`);
  887. }
  888. f.calls.forEach((c, i) => {
  889. const q = c.query.length > 46 ? c.query.slice(0, 45) + '…' : c.query;
  890. const where = c.thread && c.thread !== 'main' ? ' [subagent]' : '';
  891. out.push(`${indent} ${i + 1}.${where} "${q}" [${c.files} file${c.files === 1 ? '' : 's'}] → ${c.next}`);
  892. });
  893. const notes = [];
  894. if (f.errors) notes.push(`${f.errors} errored/unanswered call${f.errors === 1 ? '' : 's'} (not bucketed)`);
  895. if (f.concurrent) notes.push(`${f.concurrent} file-access call${f.concurrent === 1 ? '' : 's'} in the SAME message as an explore (not a reaction)`);
  896. if (notes.length) out.push(`${indent} note: ${notes.join(' · ')}`);
  897. return out.join('\n');
  898. }
  899. // ---------------------------------------------------------------------------
  900. // `--selftest`: the occupancy math over synthetic transcripts with known
  901. // answers. It lives here rather than in a test file on purpose — a new
  902. // scripts/agent-eval/*.mjs scores into the self-query eval fixture's corpus.
  903. function selftest() {
  904. const { writeFileSync, mkdtempSync } = require0('fs');
  905. const { join } = require0('path');
  906. const { tmpdir } = require0('os');
  907. const dir = mkdtempSync(join(tmpdir(), 'cg-occ-'));
  908. let n = 0, failures = 0;
  909. const check = (name, got, want, tol) => {
  910. n++;
  911. const ok = Math.abs(got - want) <= tol;
  912. if (!ok) failures++;
  913. console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${Math.round(got)}, want ${want} ±${tol}`);
  914. };
  915. // Builders for the event shapes Claude Code actually emits.
  916. const req = (ctx, id, blocks) => blocks.map((b) => JSON.stringify({
  917. type: 'assistant',
  918. message: { id, content: [b], usage: { input_tokens: ctx, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 2 } },
  919. }));
  920. const use = (id, name, input = {}) => ({ type: 'tool_use', id, name, input });
  921. const res = (id, chars) => JSON.stringify({
  922. type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: 'x'.repeat(chars) }] }] },
  923. });
  924. const done = () => JSON.stringify({ type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {} });
  925. const write = (name, lines) => { const f = join(dir, name); writeFileSync(f, lines.join('\n') + '\n'); return f; };
  926. // 1. Attribution: ratio 2.5 chars/tok, two families, no shedding.
  927. // 10,000 explore chars over a 4,000-tok gap; 5,000 Read chars over 2,000.
  928. let f = write('basic.jsonl', [
  929. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  930. res('t1', 10000),
  931. ...req(14000, 'm2', [use('t2', 'Read')]),
  932. res('t2', 5000),
  933. ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
  934. done(),
  935. ]);
  936. let o = parseSession([f]).occupancy;
  937. check('chars/token', o.charsPerToken * 1000, 2500, 30);
  938. check('codegraph residual', o.residual.codegraph, 4000, 60);
  939. check('Read residual', o.residual.read, 2000, 40);
  940. check('file-access residual', o.residualFileAccess, 2000, 40);
  941. check('final context', o.ctxFinal, 16000, 0);
  942. check('fixed base', o.ctxBase, 10000, 0);
  943. check('nothing evicted', o.evicted, 0, 1);
  944. // 2. Dedupe: thinking + tool_use are two events sharing one id and one usage.
  945. // Counting usage per event would report 5 requests instead of 3.
  946. f = write('dupe.jsonl', [
  947. ...req(10000, 'm1', [{ type: 'thinking', thinking: '' }, use('t1', 'mcp__codegraph__codegraph_explore')]),
  948. res('t1', 10000),
  949. ...req(14000, 'm2', [{ type: 'thinking', thinking: '' }, use('t2', 'Read')]),
  950. res('t2', 5000),
  951. ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
  952. done(),
  953. ]);
  954. let s = parseSession([f]);
  955. check('turns deduped by message.id', s.turns, 3, 0);
  956. check('codegraph residual (deduped)', s.occupancy.residual.codegraph, 4000, 60);
  957. // 3. Compaction: the boundary clears everything resident before it.
  958. f = write('compact.jsonl', [
  959. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  960. res('t1', 10000),
  961. ...req(14000, 'm2', [use('t2', 'mcp__codegraph__codegraph_explore')]),
  962. JSON.stringify({ type: 'system', subtype: 'compact_boundary' }),
  963. res('t2', 5000),
  964. ...req(8000, 'm3', [{ type: 'text', text: 'done' }]),
  965. done(),
  966. ]);
  967. o = parseSession([f]).occupancy;
  968. check('post-compaction residual = last result only', o.residual.codegraph, 2000, 40);
  969. check('contributed still counts both', o.contributed.codegraph, 6000, 80);
  970. // 4. Micro-compaction: context grows less than the results added, so the
  971. // oldest result is shed first (FIFO) — here explore, leaving Read.
  972. f = write('micro.jsonl', [
  973. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  974. res('t1', 10000),
  975. ...req(14000, 'm2', [use('t2', 'Read')]),
  976. res('t2', 10000),
  977. ...req(14500, 'm3', [{ type: 'text', text: 'done' }]), // +500 for 4,000 tok of Read
  978. done(),
  979. ]);
  980. o = parseSession([f]).occupancy;
  981. check('FIFO evicted the older codegraph result', o.residual.codegraph, 500, 60);
  982. check('newer Read result survives', o.residual.read, 4000, 60);
  983. check('eviction recorded', o.evicted, 3500, 60);
  984. // 5. Multi-turn stitching: a resumed segment continues the same context, and
  985. // a turn that calls no tool leaves the earlier residual in place.
  986. const a = write('seg1.jsonl', [
  987. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  988. res('t1', 10000),
  989. ...req(14000, 'm2', [{ type: 'text', text: 'answer one' }]),
  990. done(),
  991. ]);
  992. const b = write('seg2.jsonl', [
  993. ...req(14600, 'm3', [{ type: 'text', text: 'answer two, from what is already here' }]),
  994. done(),
  995. ]);
  996. s = parseSession([a, b]);
  997. check('stitched turns', s.turns, 3, 0);
  998. check('residual carries into turn 2', s.occupancy.residual.codegraph, 4000, 60);
  999. check('stitched final context', s.occupancy.ctxFinal, 14600, 0);
  1000. check('stitched cost sums segments', s.cost * 100, 20, 0.1);
  1001. // ---- 6. Explore sufficiency: the bucket each explore call earns. --------
  1002. const checkIs = (name, got, want) => {
  1003. n++;
  1004. const ok = got === want;
  1005. if (!ok) failures++;
  1006. console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
  1007. };
  1008. const EXPLORE = 'mcp__codegraph__codegraph_explore';
  1009. // An explore response's shape that matters here: one `**`path`**` section per
  1010. // file whose source it returned, plus whatever else it named.
  1011. const exploreRes = (id, paths, extra = '', isError = false) => JSON.stringify({
  1012. type: 'user',
  1013. message: {
  1014. content: [{
  1015. type: 'tool_result', tool_use_id: id, ...(isError ? { is_error: true } : {}),
  1016. content: [{ type: 'text', text: paths.map((p) => `**\`${p}\`** — fn(function)\n\n1\tcode here\n`).join('\n') + extra }],
  1017. }],
  1018. },
  1019. });
  1020. const suff = (lines) => classifySufficiency(lines.map((l) => JSON.parse(l)));
  1021. // explore → explore is insufficient; the second explore → a Read of a file it
  1022. // RETURNED is the allocation bucket (this is the CG-22 express baseline).
  1023. let sf = suff([
  1024. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'res.send Content-Type ETag generation' })]),
  1025. exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
  1026. ...req(12000, 'm2', [use('e2', EXPLORE, { query: 'response.js res.send function body' })]),
  1027. exploreRes('e2', ['lib/response.js']),
  1028. ...req(14000, 'm3', [use('r1', 'Read', { file_path: '/private/tmp/t-base/lib/response.js' })]),
  1029. res('r1', 3722),
  1030. ...req(15000, 'm4', [{ type: 'text', text: 'done' }]),
  1031. done(),
  1032. ]);
  1033. check('two answered explore calls', sf.answered, 2, 0);
  1034. checkIs('explore → explore = insufficient', sf.calls[0].bucket, 'explore_again');
  1035. checkIs('explore → Read of a returned file (abs path)', sf.calls[1].bucket, 'read_returned');
  1036. // A file the response NAMED but did not return is still a recall miss — and
  1037. // is flagged as named, since pointing without delivering is its own failure.
  1038. sf = suff([
  1039. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1040. exploreRes('e1', ['lib/response.js'], '\n**Flow**\n1. lib/router/index.js:42 handle\n'),
  1041. ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/router/index.js' })]),
  1042. res('r1', 100),
  1043. done(),
  1044. ]);
  1045. checkIs('explore → Read of a named-but-unreturned file', sf.calls[0].bucket, 'read_missed');
  1046. checkIs(' …flagged as named', sf.calls[0].named, true);
  1047. sf = suff([
  1048. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1049. exploreRes('e1', ['lib/response.js']),
  1050. ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/never/mentioned.js' })]),
  1051. res('r1', 100),
  1052. done(),
  1053. ]);
  1054. checkIs('explore → Read of a file never surfaced', sf.calls[0].bucket, 'read_missed');
  1055. checkIs(' …not flagged as named', sf.calls[0].named, false);
  1056. // Grep, and the shell equivalents of Read and Grep.
  1057. const oneShot = (next) => suff([
  1058. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1059. exploreRes('e1', ['lib/response.js']),
  1060. ...req(12000, 'm2', [next]),
  1061. res(next.id, 100),
  1062. done(),
  1063. ]).calls[0];
  1064. checkIs('explore → Grep', oneShot(use('g1', 'Grep', { pattern: 'send' })).bucket, 'search');
  1065. checkIs('explore → Glob', oneShot(use('g1', 'Glob', { pattern: '**/*.js' })).bucket, 'search');
  1066. checkIs('explore → Bash sed of a returned file',
  1067. oneShot(use('b1', 'Bash', { command: "sed -n '100,200p' lib/response.js" })).bucket, 'read_returned');
  1068. checkIs('explore → Bash grep', oneShot(use('b1', 'Bash', { command: 'grep -rn send lib/' })).bucket, 'search');
  1069. checkIs('explore → Bash that writes a file is not a read',
  1070. oneShot(use('b1', 'Bash', { command: "cat > /tmp/note.md <<'EOF'\nx\nEOF" })).bucket, 'sufficient');
  1071. checkIs('explore → Bash npm test = moved on',
  1072. oneShot(use('b1', 'Bash', { command: 'npm test' })).bucket, 'sufficient');
  1073. checkIs('explore → Edit = sufficient',
  1074. oneShot(use('x1', 'Edit', { file_path: '/t/lib/response.js' })).bucket, 'sufficient');
  1075. // No further tool call at all: the agent answered from the response.
  1076. sf = suff([
  1077. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1078. exploreRes('e1', ['lib/response.js']),
  1079. ...req(12000, 'm2', [{ type: 'text', text: 'here is how it works' }]),
  1080. done(),
  1081. ]);
  1082. checkIs('explore → final answer', sf.calls[0].bucket, 'sufficient');
  1083. checkIs(' …labelled as the final answer', sf.calls[0].next, '(final answer)');
  1084. // A Read issued in the SAME message as the explore predates its response, so
  1085. // it is not a verdict on it — step past it and count it separately.
  1086. sf = suff([
  1087. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' }), use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
  1088. exploreRes('e1', ['lib/response.js']),
  1089. res('r1', 100),
  1090. ...req(12000, 'm2', [use('x1', 'Edit', { file_path: '/t/lib/response.js' })]),
  1091. res('x1', 20),
  1092. done(),
  1093. ]);
  1094. checkIs('same-message Read is not a reaction', sf.calls[0].bucket, 'sufficient');
  1095. check(' …counted as concurrent instead', sf.concurrent, 1, 0);
  1096. // ToolSearch/TodoWrite carry no signal — the Read behind them is the verdict.
  1097. sf = suff([
  1098. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1099. exploreRes('e1', ['lib/response.js']),
  1100. ...req(12000, 'm2', [use('t1', 'TodoWrite', {})]),
  1101. res('t1', 20),
  1102. ...req(13000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
  1103. res('r1', 100),
  1104. done(),
  1105. ]);
  1106. checkIs('bookkeeping tools are stepped over', sf.calls[0].bucket, 'read_returned');
  1107. // A re-read of a file an EARLIER explore shipped is still an allocation miss:
  1108. // we returned it and clipped it wrong, just not on this call.
  1109. sf = suff([
  1110. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'first' })]),
  1111. exploreRes('e1', ['lib/response.js', 'lib/utils.js']),
  1112. ...req(11000, 'm2', [use('e2', EXPLORE, { query: 'second' })]),
  1113. exploreRes('e2', ['lib/response.js']),
  1114. ...req(12000, 'm3', [use('r1', 'Read', { file_path: '/t/lib/utils.js' })]),
  1115. res('r1', 400),
  1116. done(),
  1117. ]);
  1118. checkIs('re-read of an earlier explore’s file is allocation, not recall',
  1119. sf.calls[1].bucket, 'read_returned');
  1120. checkIs(' …and says which explore returned it',
  1121. sf.calls[1].next, 'Read utils.js (returned by an earlier explore)');
  1122. // A subagent's calls are interleaved into the same stream under
  1123. // `parent_tool_use_id` (verified on a real excalidraw run). They belong to
  1124. // their own thread: the parent's verdict is the delegation, judged by what
  1125. // the subagent actually did first — here, grepping for a file we never
  1126. // returned.
  1127. const sub = (parent, obj) => JSON.stringify({ ...JSON.parse(obj), parent_tool_use_id: parent });
  1128. sf = suff([
  1129. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1130. exploreRes('e1', ['lib/response.js']),
  1131. ...req(12000, 'm2', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
  1132. ...req(0, 'sm1', [use('sb1', 'Bash', { command: 'grep -rn nonce lib/' })]).map((l) => sub('a1', l)),
  1133. sub('a1', res('sb1', 400)),
  1134. ...req(14000, 'm3', [{ type: 'text', text: 'done' }]),
  1135. res('a1', 900),
  1136. done(),
  1137. ]);
  1138. checkIs('delegation is judged by what the subagent did', sf.calls[0].bucket, 'search');
  1139. checkIs(' …and says so', sf.calls[0].next, 'Agent → Bash search');
  1140. // A subagent's Read must NOT be read as the parent's reaction to an explore
  1141. // the subagent never saw: the parent moved on, the subagent's own explore is
  1142. // judged inside its own thread.
  1143. sf = suff([
  1144. ...req(10000, 'm1', [use('a1', 'Agent', { subagent_type: 'Explore' })]),
  1145. ...req(0, 'sm1', [use('e1', EXPLORE, { query: 'sub q' })]).map((l) => sub('a1', l)),
  1146. sub('a1', exploreRes('e1', ['lib/response.js'])),
  1147. ...req(11000, 'm2', [use('e2', EXPLORE, { query: 'parent q' })]),
  1148. exploreRes('e2', ['lib/other.js']),
  1149. ...req(0, 'sm2', [use('sr1', 'Read', { file_path: '/t/lib/response.js' })]).map((l) => sub('a1', l)),
  1150. sub('a1', res('sr1', 400)),
  1151. ...req(13000, 'm3', [{ type: 'text', text: 'done' }]),
  1152. done(),
  1153. ]);
  1154. check('both threads bucketed', sf.answered, 2, 0);
  1155. checkIs('parent explore is not blamed for a subagent Read',
  1156. sf.calls.find((c) => c.query === 'parent q').bucket, 'sufficient');
  1157. checkIs('subagent explore is judged in its own thread',
  1158. sf.calls.find((c) => c.query === 'sub q').bucket, 'read_returned');
  1159. // An errored explore has no response to judge; it is counted, not bucketed.
  1160. sf = suff([
  1161. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1162. exploreRes('e1', [], 'not indexed', true),
  1163. ...req(12000, 'm2', [use('r1', 'Read', { file_path: '/t/lib/response.js' })]),
  1164. res('r1', 100),
  1165. done(),
  1166. ]);
  1167. check('errored explore is not bucketed', sf.answered, 0, 0);
  1168. check(' …but is counted', sf.errors, 1, 0);
  1169. // ---- 7. Allocation efficiency: which returned bytes the answer used. ----
  1170. // The response shape that matters here: a per-file section whose header lists
  1171. // `name(kind)`, and a body big enough that the shares are readable.
  1172. const alloc = (files, answer) => computeAllocation(
  1173. [files.map(([p, syms, size]) =>
  1174. `**\`${p}\`** — ${syms}\n\n\`\`\`js\n${'x'.repeat(size)}\n\`\`\`\n`).join('\n')
  1175. + '\n> Treat the code above as already read.\n'],
  1176. answer,
  1177. );
  1178. const round = (f) => Math.round(f * 1000) / 10;
  1179. // The #1500 shape: one file answers, two are noise. The answer names it by
  1180. // repo-relative path.
  1181. let al = alloc(
  1182. [['lib/response.js', 'send(function)', 4000], ['lib/express.js', 'app(variable)', 3000],
  1183. ['lib/view.js', 'View(class)', 3000]],
  1184. 'The Content-Type is decided in `lib/response.js:126` by `res.send`.',
  1185. );
  1186. check('allocation: one of three files cited', round(al.efficiency), 40.1, 0.6);
  1187. checkIs(' …cited file is flagged path', al.files.find((f) => /response/.test(f.path)).via, 'path');
  1188. checkIs(' …noise file is not', al.files.find((f) => /view/.test(f.path)).via, null);
  1189. check(' …files cited', al.filesUsed, 1, 0);
  1190. // A bare basename is how agents actually cite in prose — but only when the
  1191. // extension is one the envelope shipped, or `res.send` reads as a file too.
  1192. al = alloc([['lib/response.js', 'send(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
  1193. 'It happens in response.js:134, inside `res.send`.');
  1194. check('bare basename counts as a path citation', round(al.efficiency), 50, 0.6);
  1195. al = alloc([['lib/response.js', 'send(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
  1196. 'The `res.send` and `mime.contentType` calls do it.');
  1197. checkIs('a dotted expression is not a file citation', al.files.every((f) => f.via !== 'path'), true);
  1198. // Symbol citations: a code span naming a symbol the file DEFINES counts; a
  1199. // file that merely CALLS it does not (the excalidraw dragElements.ts case).
  1200. al = alloc([['src/mutateElement.ts', 'mutateElement(function)', 4000],
  1201. ['src/dragElements.ts', 'mutateElement(calls), updateCoords(function)', 4000]],
  1202. 'Mutation goes through `mutateElement`.');
  1203. checkIs('symbol citation credits the definer', al.files.find((f) => /mutateElement.ts/.test(f.path)).via, 'symbol');
  1204. checkIs(' …and not a caller of it', al.files.find((f) => /dragElements/.test(f.path)).via, null);
  1205. // An import binding (`variable`) loses to the real definition of the name.
  1206. al = alloc([['lib/utils.js', 'compileETag(function)', 4000],
  1207. ['lib/application.js', 'compileETag(variable), set(calls)', 4000]],
  1208. 'The generator comes from `compileETag`.');
  1209. checkIs('a definition beats an import alias of the same name',
  1210. al.files.find((f) => /utils/.test(f.path)).via, 'symbol');
  1211. checkIs(' …and the aliasing file is not credited',
  1212. al.files.find((f) => /application/.test(f.path)).via, null);
  1213. // A name carried by ≥3 returned files identifies none of them: crediting them
  1214. // all would bias the metric optimistic, the one direction it must not lean.
  1215. al = alloc([['a/one.js', 'handle(function)', 3000], ['a/two.js', 'handle(function)', 3000],
  1216. ['a/three.js', 'handle(function)', 3000]],
  1217. 'It all runs through `handle`.');
  1218. check('a symbol on 3 files attributes none of them', al.efficiency, 0, 0.001);
  1219. // Prose is not a citation — only a code span is.
  1220. al = alloc([['lib/response.js', 'sendResponse(function)', 4000], ['lib/view.js', 'View(class)', 4000]],
  1221. 'The sendResponse path handles it.');
  1222. check('an unquoted symbol in prose does not count', al.efficiency, 0, 0.001);
  1223. // Per-call: the run number pools, but call 2 being pure noise must still show.
  1224. const twoCalls = computeAllocation([
  1225. '**`lib/response.js`** — send(function)\n\n```js\n' + 'x'.repeat(4000) + '\n```\n\n> guidance\n',
  1226. '**`lib/view.js`** — View(class)\n\n```js\n' + 'x'.repeat(4000) + '\n```\n\n> guidance\n',
  1227. ], 'See `lib/response.js`.');
  1228. check('per-call: call 1 fully used', round(twoCalls.calls[0].efficiency), 100, 0.1);
  1229. check('per-call: call 2 fully wasted', round(twoCalls.calls[1].efficiency), 0, 0.1);
  1230. check('run pools both calls', round(twoCalls.efficiency), 50, 0.6);
  1231. // A file returned twice is charged twice — it occupied the window twice.
  1232. const twice = computeAllocation([
  1233. '**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000) + '\n\n> g\n',
  1234. '**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000) + '\n\n> g\n',
  1235. ], 'See `lib/response.js`.');
  1236. check('a re-returned file is charged both times', twice.envelope, 8078, 120);
  1237. check(' …and credited both times', round(twice.efficiency), 100, 0.1);
  1238. // The answer text itself: the `result` event, and the interactive fallback.
  1239. const ev = (l) => JSON.parse(l);
  1240. checkIs('final answer comes from the result event',
  1241. finalAnswerText([ev(done()), { type: 'result', result: 'the answer' }]), 'the answer');
  1242. checkIs('…else the last main-thread assistant text', finalAnswerText([
  1243. { type: 'assistant', message: { id: 'm1', content: [{ type: 'text', text: 'let me look' }] } },
  1244. { type: 'assistant', parent_tool_use_id: 'a1', message: { id: 's1', content: [{ type: 'text', text: 'subagent report' }] } },
  1245. { type: 'assistant', message: { id: 'm2', content: [{ type: 'text', text: 'the answer' }] } },
  1246. ]), 'the answer');
  1247. // End to end through parseSession, on the transcript shape a run really has.
  1248. f = write('alloc.jsonl', [
  1249. ...req(10000, 'm1', [use('e1', EXPLORE, { query: 'q' })]),
  1250. JSON.stringify({
  1251. type: 'user',
  1252. message: {
  1253. content: [{
  1254. type: 'tool_result', tool_use_id: 'e1',
  1255. content: [{
  1256. type: 'text',
  1257. text: '**`lib/response.js`** — send(function)\n\n' + 'x'.repeat(4000)
  1258. + '\n\n**`lib/view.js`** — View(class)\n\n' + 'x'.repeat(4000) + '\n\n> guidance\n',
  1259. }],
  1260. }],
  1261. },
  1262. }),
  1263. ...req(20000, 'm2', [{ type: 'text', text: 'done' }]),
  1264. JSON.stringify({
  1265. type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {},
  1266. result: 'It is decided in `lib/response.js` by `res.send`.',
  1267. }),
  1268. ]);
  1269. s = parseSession([f]);
  1270. check('parseSession reports allocation', round(s.allocation.efficiency), 50, 0.6);
  1271. checkIs(' …and the block renders', /efficiency\s+50\.\d%/.test(formatAllocation(s)), true);
  1272. checkIs('a run with no explore says so',
  1273. formatAllocation({ allocation: computeAllocation([], 'answer') }).includes('no codegraph_explore responses'), true);
  1274. console.log(`\n${n - failures}/${n} checks passed`);
  1275. return failures;
  1276. }
  1277. // `--selftest` needs sync fs helpers the module path doesn't import at top level.
  1278. function require0(m) { return process.getBuiltinModule(m); }
  1279. const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
  1280. if (isMain && process.argv.includes('--selftest')) process.exit(selftest() ? 1 : 0);
  1281. if (isMain) {
  1282. // `--answer <glob>` is repeatable and implies `--envelope`. Its VALUE is not a
  1283. // run file, so consume it here rather than letting the positional filter below
  1284. // mistake a glob for a log path.
  1285. const argv = process.argv.slice(2);
  1286. const files = [];
  1287. const answerGlobs = [];
  1288. let wantEnvelope = false;
  1289. let brief = false;
  1290. for (let i = 0; i < argv.length; i++) {
  1291. if (argv[i] === '--envelope') wantEnvelope = true;
  1292. else if (argv[i] === '--brief') brief = true;
  1293. else if (argv[i] === '--answer') { answerGlobs.push(argv[++i]); wantEnvelope = true; }
  1294. else if (!argv[i].startsWith('--')) files.push(argv[i]);
  1295. }
  1296. if (!files.length) { console.error('usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] [--brief] [--envelope] [--answer <glob>]... | --selftest'); process.exit(1); }
  1297. const s = parseSession(files);
  1298. console.log(`\n=== ${files.map((f) => f.split('/').pop()).join(' + ')} ===`);
  1299. console.log(`codegraph tools exposed: ${s.initTools ? s.initTools.length : '?'}${s.raced ? ' [MCP COLD-START RACE — tool call hit "No such tool available"]' : ''}`);
  1300. 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`);
  1301. else if (s.cliCalls) console.log(` (${s.cliCalls} codegraph CLI attempt${s.cliCalls === 1 ? '' : 's'} blocked — no output entered the window)`);
  1302. console.log(`\nTool calls (${s.toolCalls.length}):`);
  1303. console.log(' by type:', JSON.stringify(s.counts));
  1304. if (!brief) s.toolCalls.forEach((tc, i) => console.log(` ${i + 1}. ${tc}`));
  1305. if (s.result) {
  1306. const seg = s.results.length > 1 ? ` | ${s.results.length} segments (${s.results.map((r) => r.subtype).join(',')})` : '';
  1307. console.log(`\nResult: ${s.result.subtype} | duration ${s.dur.toFixed(0)}s | turns ${s.turns}${seg}`);
  1308. console.log(` tokens processed: ${s.processed.toLocaleString('en-US')} | cost $${s.cost.toFixed(3)}`);
  1309. }
  1310. console.log('');
  1311. console.log(formatOccupancy(s));
  1312. console.log('');
  1313. console.log(formatSufficiency(s));
  1314. console.log('');
  1315. console.log(formatAllocation(s));
  1316. if (wantEnvelope) {
  1317. console.log('');
  1318. console.log(formatEnvelope(s.exploreTexts, answerGlobs));
  1319. }
  1320. }