parse-run.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  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 ...]
  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. // ---------------------------------------------------------------------------
  13. // Why occupancy, and how it's measured
  14. // ---------------------------------------------------------------------------
  15. // A single-question A/B reports cost/tokens/time/tool-calls for ONE answer. It
  16. // cannot see what issue #1500 measured: a tool response stays in the window for
  17. // everything that follows, so it is charged against every later turn's headroom.
  18. // That is a per-session cost our single-question runs structurally miss.
  19. //
  20. // Tokens are MEASURED, not estimated at bytes/4. For assistant request k,
  21. // ctx_k = usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokens
  22. // is the exact token count of that request's whole prompt. So
  23. // gap_k = ctx_k - ctx_{k-1}
  24. // is exactly the tokens appended since the previous request: the previous
  25. // assistant output (thinking + text + tool_use JSON) plus the tool_results and
  26. // user text that followed it. We split gap_k across those blocks in proportion
  27. // to their characters, which attributes each tool_result its measured share.
  28. // (Measured on real runs, explore output lands near 2.3 chars/token — bytes/4
  29. // under-counts it by ~40%, which is why the estimate isn't good enough.)
  30. //
  31. // Two traps this file works around, both verified against real logs:
  32. // * Claude Code emits ONE assistant event PER CONTENT BLOCK, all carrying the
  33. // same message.id and the same `usage`. Summing usage per event double-counts
  34. // every turn that emits both thinking and a tool_use — dedupe by message.id.
  35. // * The streamed `output_tokens` is a partial snapshot (observed `out=2` on a
  36. // turn that really generated ~1100). Never trust it; the char-proportional
  37. // split doesn't need it.
  38. //
  39. // Residual ≠ contributed. Content leaves the window two ways, and both are
  40. // tracked: a `compact_boundary` system event (everything prior is replaced by a
  41. // summary) and micro-compaction (ctx drops mid-run — oldest tool results are
  42. // dropped first, so eviction is applied FIFO).
  43. import { readFileSync } from 'fs';
  44. import { pathToFileURL } from 'url';
  45. // Nominal window for the share-of-window column. Override for a [1m] context.
  46. const WINDOW_TOKENS = Number(process.env.CG_WINDOW_TOKENS || 200_000);
  47. const CHARS_PER_TOKEN_FALLBACK = 3.0;
  48. /** Which tool family a tool_use belongs to. */
  49. function familyOf(name) {
  50. if (/codegraph/.test(name)) return 'codegraph';
  51. if (name === 'Read' || name === 'NotebookRead') return 'read';
  52. if (name === 'Grep' || name === 'Glob') return 'search';
  53. if (name === 'Bash' || name === 'BashOutput') return 'bash';
  54. return 'other';
  55. }
  56. const FAMILIES = ['codegraph', 'read', 'search', 'bash', 'other'];
  57. // The without-arm's way of getting the same bytes: reading and searching files.
  58. const FILE_ACCESS = ['read', 'search', 'bash'];
  59. const textOf = (content) =>
  60. Array.isArray(content) ? content.map((c) => c.text ?? (typeof c === 'string' ? c : JSON.stringify(c))).join('')
  61. : typeof content === 'string' ? content
  62. : content == null ? '' : JSON.stringify(content);
  63. /** Characters an assistant content block occupies once it is back in the prompt. */
  64. function assistantBlockChars(b) {
  65. if (b.type === 'text') return (b.text || '').length;
  66. if (b.type === 'thinking') return (b.thinking || '').length;
  67. if (b.type === 'tool_use') return JSON.stringify(b.input ?? {}).length + (b.name || '').length;
  68. return JSON.stringify(b).length;
  69. }
  70. /**
  71. * Parse one session (its segment files, in order) into tool + occupancy stats.
  72. * Exported so parse-bench-readme.mjs can aggregate without duplicating any of
  73. * this — deliberately NOT a separate module file: a new scripts/agent-eval/*.mjs
  74. * scores into the self-query eval fixture's own corpus and moves its numbers.
  75. */
  76. export function parseSession(files) {
  77. const events = [];
  78. for (const f of files) {
  79. for (const line of readFileSync(f, 'utf8').split('\n')) {
  80. if (!line) continue;
  81. try { events.push(JSON.parse(line)); } catch { /* partial line */ }
  82. }
  83. }
  84. const toolCalls = []; // display sequence
  85. const nameById = new Map(); // tool_use_id -> tool name
  86. const counts = {}; // tool name -> calls
  87. let initTools = null, result = null, raced = false, cliCalls = 0;
  88. const results = []; // one `result` event per session segment (multi-turn)
  89. let compactions = 0;
  90. // A timeline of everything appended to the context, in order. `req` entries
  91. // are assistant requests (carrying that request's ctx); `add` entries are
  92. // characters appended (assistant output blocks, tool results, user text).
  93. const timeline = [];
  94. const seenMsgIds = new Set();
  95. for (const ev of events) {
  96. if (ev.type === 'system' && ev.subtype === 'init') {
  97. initTools = (ev.tools || []).filter((t) => /codegraph/.test(t));
  98. }
  99. if (ev.type === 'system' && (ev.subtype === 'compact_boundary' || ev.subtype === 'compaction')) {
  100. compactions++;
  101. timeline.push({ kind: 'compact' });
  102. }
  103. if (ev.type === 'assistant' && ev.message) {
  104. const id = ev.message.id;
  105. // One event per content block, same id + same usage: count usage once,
  106. // but take the content blocks from every event that carries the id.
  107. if (id && !seenMsgIds.has(id)) {
  108. seenMsgIds.add(id);
  109. const u = ev.message.usage || {};
  110. const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
  111. timeline.push({ kind: 'req', ctx, out: u.output_tokens || 0 });
  112. }
  113. for (const b of ev.message.content || []) {
  114. timeline.push({ kind: 'add', family: null, chars: assistantBlockChars(b) });
  115. if (b.type === 'tool_use') {
  116. nameById.set(b.id, b.name);
  117. counts[b.name] = (counts[b.name] || 0) + 1;
  118. let detail = '';
  119. if (b.name === 'Task') detail = ` [subagent_type=${b.input?.subagent_type ?? '?'}] ${(b.input?.description ?? '').slice(0, 40)}`;
  120. else if (/codegraph/.test(b.name)) detail = ` ${JSON.stringify(b.input?.query ?? b.input?.task ?? b.input?.symbol ?? '').slice(0, 60)}`;
  121. else if (b.name === 'Bash') {
  122. detail = ` ${(b.input?.command ?? '').slice(0, 50)}`;
  123. // An arm with no codegraph MCP can still shell out to the CLI — the
  124. // target repo carries the .codegraph/ index and the binary is on
  125. // PATH. That silently turns a "without" arm into codegraph-over-CLI.
  126. if (/\bcodegraph\b/.test(b.input?.command ?? '')) cliCalls++;
  127. }
  128. else if (b.name === 'Read') detail = ` ${(b.input?.file_path ?? '').split('/').slice(-1)[0]}`;
  129. toolCalls.push(`${b.name}${detail}`);
  130. }
  131. }
  132. }
  133. if (ev.type === 'user' && ev.message) {
  134. const content = ev.message.content;
  135. if (Array.isArray(content)) {
  136. for (const b of content) {
  137. if (b.type === 'tool_result') {
  138. const t = textOf(b.content);
  139. // MCP cold-start race: the agent fired before `serve --mcp` had
  140. // registered its tools, so it floundered into grep/Read. That
  141. // measures startup latency, not steady-state value — flag it.
  142. if (/No such tool available/.test(t)) raced = true;
  143. const name = nameById.get(b.tool_use_id) || '';
  144. timeline.push({ kind: 'add', family: familyOf(name), chars: t.length, tool: name });
  145. } else {
  146. timeline.push({ kind: 'add', family: null, chars: textOf([b]).length });
  147. }
  148. }
  149. } else if (typeof content === 'string') {
  150. timeline.push({ kind: 'add', family: null, chars: content.length });
  151. }
  152. }
  153. if (ev.type === 'result') { result = ev; results.push(ev); }
  154. }
  155. // ---- Pass 1: chars/token, calibrated on tool-result-dominated gaps. ------
  156. // Splitting a gap in proportion to characters over-attributes to tool results
  157. // whenever the assistant's own output is under-represented in the transcript
  158. // (redacted/empty thinking blocks are the common case — a gap whose only
  159. // visible chars were a 73-char tool_result charged it the whole 830-token
  160. // delta, 5.5 tok/char). So calibrate the ratio on gaps that are ≥80% tool
  161. // result by characters, then price every result at that ratio.
  162. const reqIdx = timeline.map((t, i) => (t.kind === 'req' ? i : -1)).filter((i) => i >= 0);
  163. const gaps = [];
  164. for (let k = 1; k < reqIdx.length; k++) {
  165. const prev = timeline[reqIdx[k - 1]], cur = timeline[reqIdx[k]];
  166. let chars = 0, toolChars = 0, compacted = false;
  167. const byFamily = {};
  168. for (let i = reqIdx[k - 1] + 1; i < reqIdx[k]; i++) {
  169. const t = timeline[i];
  170. if (t.kind === 'compact') { compacted = true; continue; }
  171. if (t.kind !== 'add') continue;
  172. chars += t.chars;
  173. if (t.family) { toolChars += t.chars; byFamily[t.family] = (byFamily[t.family] || 0) + t.chars; }
  174. }
  175. gaps.push({ delta: cur.ctx - prev.ctx, chars, toolChars, byFamily, compacted });
  176. }
  177. const clean = gaps.filter((g) => !g.compacted && g.delta > 0 && g.chars > 500 && g.toolChars / g.chars >= 0.8);
  178. // A gap where the window also SHED content has a delta far below what was
  179. // added, which reads as absurdly dense text and would drag the whole run's
  180. // ratio with it. Shedding can only push a gap's chars/token UP, so take the
  181. // lower median as the honest centre and drop anything well above it, then
  182. // pool the survivors. (On runs that never shed, every ratio is within a few
  183. // percent of the others and this changes nothing.)
  184. const ratios = clean.map((g) => g.toolChars / g.delta).sort((a, b) => a - b);
  185. const lowerMedian = ratios.length ? ratios[Math.floor((ratios.length - 1) / 2)] : 0;
  186. let sumD = 0, sumC = 0;
  187. for (const g of clean) {
  188. if (lowerMedian > 0 && g.toolChars / g.delta > lowerMedian * 1.5) continue; // shed
  189. sumD += g.delta; sumC += g.toolChars;
  190. }
  191. if (sumD === 0) { // no clean gap — fall back to every growing gap, all chars
  192. for (const g of gaps) if (!g.compacted && g.delta > 0 && g.chars > 0) { sumD += g.delta; sumC += g.chars; }
  193. }
  194. const charsPerToken = sumD > 0 ? sumC / sumD : CHARS_PER_TOKEN_FALLBACK;
  195. const calibrated = sumD > 0;
  196. // How far a single result's token density strays from the run-level ratio.
  197. // On a gap that is almost entirely one tool result, `delta` IS that result's
  198. // token count, so |chars/ratio - delta| / delta is the attribution error for
  199. // that result. The median over such gaps is the metric's real error bar.
  200. const errs = [];
  201. for (const g of gaps) {
  202. if (g.compacted || g.delta <= 0 || g.chars <= 500) continue;
  203. if (g.toolChars / g.chars < 0.95) continue;
  204. errs.push(Math.abs(g.toolChars / charsPerToken - g.delta) / g.delta);
  205. }
  206. errs.sort((a, b) => a - b);
  207. const dispersion = errs.length ? errs[(errs.length - 1) >> 1] : null;
  208. // ---- Pass 2: attribute gap tokens, then apply evictions FIFO. ------------
  209. const contributed = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  210. const resultChars = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  211. const resultCount = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  212. for (const t of timeline) if (t.kind === 'add' && t.family) { resultChars[t.family] += t.chars; resultCount[t.family]++; }
  213. let queue = []; // resident contributions, oldest first
  214. let evicted = 0;
  215. const evict = (tokens) => {
  216. let left = tokens;
  217. while (left > 0 && queue.length) {
  218. const head = queue[0];
  219. if (head.tokens <= left) { left -= head.tokens; evicted += head.tokens; queue.shift(); }
  220. else { head.tokens -= left; evicted += left; left = 0; }
  221. }
  222. };
  223. for (const g of gaps) {
  224. if (g.compacted) {
  225. // Everything before the boundary is gone; the summary replaces it.
  226. evicted += queue.reduce((s, q) => s + q.tokens, 0);
  227. queue = [];
  228. }
  229. let toolTokens = 0;
  230. for (const [fam, ch] of Object.entries(g.byFamily)) {
  231. const tok = ch / charsPerToken;
  232. toolTokens += tok;
  233. contributed[fam] += tok;
  234. queue.push({ family: fam, tokens: tok });
  235. }
  236. // The gap grew by `delta`; the tool results account for `toolTokens` of it.
  237. // A shortfall means the window also shed content — micro-compaction drops
  238. // the OLDEST tool results first, so evict FIFO. The tolerance keeps
  239. // attribution noise (a run-level ratio priced against one gap's delta,
  240. // typically ±2%) from reading as an eviction; real shedding is thousands.
  241. const shortfall = toolTokens - g.delta;
  242. if (!g.compacted && shortfall > Math.max(200, toolTokens * 0.05)) evict(shortfall);
  243. }
  244. const residual = Object.fromEntries(FAMILIES.map((f) => [f, 0]));
  245. for (const q of queue) residual[q.family] += q.tokens;
  246. const ctxFinal = reqIdx.length ? timeline[reqIdx[reqIdx.length - 1]].ctx : 0;
  247. // The FIRST request's prompt is system + tool schemas + the question, before
  248. // any tool has answered. Differencing the arms' ctxBase prices codegraph's
  249. // FIXED occupancy — its tool schema and MCP `initialize` instructions — which
  250. // it pays whether or not the agent ever calls it.
  251. const ctxBase = reqIdx.length ? timeline[reqIdx[0]].ctx : 0;
  252. // Multi-turn: duration/cost/tokens are per-segment, so sum them. `result.usage`
  253. // is cumulative WITHIN a segment (verified: its in+cache+out equals the sum of
  254. // that segment's per-request prompts), so summing segments is correct and does
  255. // NOT double-count. It is a "tokens processed" figure — every request re-counts
  256. // the whole prefix — which is exactly why it can't answer the occupancy question.
  257. const sumUsage = (k) => results.reduce((s, r) => s + (r.usage?.[k] || 0), 0);
  258. const processed = sumUsage('input_tokens') + sumUsage('cache_read_input_tokens')
  259. + sumUsage('cache_creation_input_tokens') + sumUsage('output_tokens');
  260. return {
  261. files, toolCalls, counts, initTools, result, results, raced, cliCalls,
  262. ok: results.length > 0 && results.every((r) => r.subtype === 'success'),
  263. turns: reqIdx.length,
  264. tools: toolCalls.filter((t) => !t.startsWith('ToolSearch')).length,
  265. reads: counts.Read || 0,
  266. grep: (counts.Grep || 0) + (counts.Glob || 0),
  267. cg: Object.entries(counts).filter(([n]) => /codegraph/.test(n)).reduce((s, [, v]) => s + v, 0),
  268. dur: results.reduce((s, r) => s + (r.duration_ms || 0), 0) / 1000,
  269. cost: results.reduce((s, r) => s + (r.total_cost_usd || 0), 0),
  270. processed,
  271. occupancy: {
  272. ctxFinal, ctxBase, windowTokens: WINDOW_TOKENS,
  273. charsPerToken, calibrated, compactions, dispersion, evicted: Math.round(evicted),
  274. residual: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(residual[f])])),
  275. contributed: Object.fromEntries(FAMILIES.map((f) => [f, Math.round(contributed[f])])),
  276. chars: resultChars, results: resultCount,
  277. residualFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + residual[f], 0)),
  278. contributedFileAccess: Math.round(FILE_ACCESS.reduce((s, f) => s + contributed[f], 0)),
  279. charsFileAccess: FILE_ACCESS.reduce((s, f) => s + resultChars[f], 0),
  280. },
  281. };
  282. }
  283. /** The occupancy block, as printed under a run and reused by the aggregator. */
  284. export function formatOccupancy(s, indent = ' ') {
  285. const o = s.occupancy;
  286. const n = (x) => x.toLocaleString('en-US');
  287. const pctCtx = (t) => (o.ctxFinal > 0 ? ((t / o.ctxFinal) * 100).toFixed(1) : '0.0');
  288. const pctWin = (t) => ((t / o.windowTokens) * 100).toFixed(1);
  289. const rows = [];
  290. const row = (label, tok, chars, results) => rows.push(
  291. `${indent} ${label.padEnd(18)}${(n(tok) + ' tok').padStart(12)} ${(pctCtx(tok) + '%').padStart(6)} of ctx ` +
  292. `${(pctWin(tok) + '%').padStart(6)} of ${Math.round(o.windowTokens / 1000)}k win` +
  293. (chars !== undefined ? ` (${n(chars)} chars, ${results} result${results === 1 ? '' : 's'})` : '')
  294. );
  295. const out = [`${indent}Residual context occupancy at end of run:`];
  296. 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`);
  297. row('codegraph', o.residual.codegraph, o.chars.codegraph, o.results.codegraph);
  298. row('Read', o.residual.read, o.chars.read, o.results.read);
  299. row('Grep/Glob', o.residual.search, o.chars.search, o.results.search);
  300. row('Bash', o.residual.bash, o.chars.bash, o.results.bash);
  301. row('→ file-access', o.residualFileAccess, o.charsFileAccess,
  302. o.results.read + o.results.search + o.results.bash);
  303. row('other tools', o.residual.other, o.chars.other, o.results.other);
  304. const toolTotal = Object.values(o.residual).reduce((a, b) => a + b, 0);
  305. row('base (prompt+prose)', Math.max(0, o.ctxFinal - toolTotal));
  306. out.push(`${indent} ${' of which fixed'.padEnd(18)}${(n(o.ctxBase) + ' tok').padStart(12)} system + tool schemas + question, before any tool answered`);
  307. out.push(...rows);
  308. const dropped = o.contributed.codegraph + o.contributedFileAccess + o.contributed.other
  309. - (o.residual.codegraph + o.residualFileAccess + o.residual.other);
  310. out.push(
  311. `${indent} measure: ${o.charsPerToken.toFixed(2)} chars/tok ${o.calibrated ? 'measured' : '(FALLBACK — no clean gap to calibrate on)'}` +
  312. (o.dispersion !== null ? ` ±${(o.dispersion * 100).toFixed(1)}%` : '') +
  313. ` · turns ${s.turns} · compactions ${o.compactions}` +
  314. (o.evicted > 0 || dropped > 1 ? ` · evicted ${n(o.evicted)} tok` : '')
  315. );
  316. return out.join('\n');
  317. }
  318. // ---------------------------------------------------------------------------
  319. // `--selftest`: the occupancy math over synthetic transcripts with known
  320. // answers. It lives here rather than in a test file on purpose — a new
  321. // scripts/agent-eval/*.mjs scores into the self-query eval fixture's corpus.
  322. function selftest() {
  323. const { writeFileSync, mkdtempSync } = require0('fs');
  324. const { join } = require0('path');
  325. const { tmpdir } = require0('os');
  326. const dir = mkdtempSync(join(tmpdir(), 'cg-occ-'));
  327. let n = 0, failures = 0;
  328. const check = (name, got, want, tol) => {
  329. n++;
  330. const ok = Math.abs(got - want) <= tol;
  331. if (!ok) failures++;
  332. console.log(`${ok ? ' ok ' : ' FAIL'} ${name}: got ${Math.round(got)}, want ${want} ±${tol}`);
  333. };
  334. // Builders for the event shapes Claude Code actually emits.
  335. const req = (ctx, id, blocks) => blocks.map((b) => JSON.stringify({
  336. type: 'assistant',
  337. message: { id, content: [b], usage: { input_tokens: ctx, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 2 } },
  338. }));
  339. const use = (id, name) => ({ type: 'tool_use', id, name, input: {} });
  340. const res = (id, chars) => JSON.stringify({
  341. type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: 'x'.repeat(chars) }] }] },
  342. });
  343. const done = () => JSON.stringify({ type: 'result', subtype: 'success', duration_ms: 1000, total_cost_usd: 0.1, usage: {} });
  344. const write = (name, lines) => { const f = join(dir, name); writeFileSync(f, lines.join('\n') + '\n'); return f; };
  345. // 1. Attribution: ratio 2.5 chars/tok, two families, no shedding.
  346. // 10,000 explore chars over a 4,000-tok gap; 5,000 Read chars over 2,000.
  347. let f = write('basic.jsonl', [
  348. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  349. res('t1', 10000),
  350. ...req(14000, 'm2', [use('t2', 'Read')]),
  351. res('t2', 5000),
  352. ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
  353. done(),
  354. ]);
  355. let o = parseSession([f]).occupancy;
  356. check('chars/token', o.charsPerToken * 1000, 2500, 30);
  357. check('codegraph residual', o.residual.codegraph, 4000, 60);
  358. check('Read residual', o.residual.read, 2000, 40);
  359. check('file-access residual', o.residualFileAccess, 2000, 40);
  360. check('final context', o.ctxFinal, 16000, 0);
  361. check('fixed base', o.ctxBase, 10000, 0);
  362. check('nothing evicted', o.evicted, 0, 1);
  363. // 2. Dedupe: thinking + tool_use are two events sharing one id and one usage.
  364. // Counting usage per event would report 5 requests instead of 3.
  365. f = write('dupe.jsonl', [
  366. ...req(10000, 'm1', [{ type: 'thinking', thinking: '' }, use('t1', 'mcp__codegraph__codegraph_explore')]),
  367. res('t1', 10000),
  368. ...req(14000, 'm2', [{ type: 'thinking', thinking: '' }, use('t2', 'Read')]),
  369. res('t2', 5000),
  370. ...req(16000, 'm3', [{ type: 'text', text: 'done' }]),
  371. done(),
  372. ]);
  373. let s = parseSession([f]);
  374. check('turns deduped by message.id', s.turns, 3, 0);
  375. check('codegraph residual (deduped)', s.occupancy.residual.codegraph, 4000, 60);
  376. // 3. Compaction: the boundary clears everything resident before it.
  377. f = write('compact.jsonl', [
  378. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  379. res('t1', 10000),
  380. ...req(14000, 'm2', [use('t2', 'mcp__codegraph__codegraph_explore')]),
  381. JSON.stringify({ type: 'system', subtype: 'compact_boundary' }),
  382. res('t2', 5000),
  383. ...req(8000, 'm3', [{ type: 'text', text: 'done' }]),
  384. done(),
  385. ]);
  386. o = parseSession([f]).occupancy;
  387. check('post-compaction residual = last result only', o.residual.codegraph, 2000, 40);
  388. check('contributed still counts both', o.contributed.codegraph, 6000, 80);
  389. // 4. Micro-compaction: context grows less than the results added, so the
  390. // oldest result is shed first (FIFO) — here explore, leaving Read.
  391. f = write('micro.jsonl', [
  392. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  393. res('t1', 10000),
  394. ...req(14000, 'm2', [use('t2', 'Read')]),
  395. res('t2', 10000),
  396. ...req(14500, 'm3', [{ type: 'text', text: 'done' }]), // +500 for 4,000 tok of Read
  397. done(),
  398. ]);
  399. o = parseSession([f]).occupancy;
  400. check('FIFO evicted the older codegraph result', o.residual.codegraph, 500, 60);
  401. check('newer Read result survives', o.residual.read, 4000, 60);
  402. check('eviction recorded', o.evicted, 3500, 60);
  403. // 5. Multi-turn stitching: a resumed segment continues the same context, and
  404. // a turn that calls no tool leaves the earlier residual in place.
  405. const a = write('seg1.jsonl', [
  406. ...req(10000, 'm1', [use('t1', 'mcp__codegraph__codegraph_explore')]),
  407. res('t1', 10000),
  408. ...req(14000, 'm2', [{ type: 'text', text: 'answer one' }]),
  409. done(),
  410. ]);
  411. const b = write('seg2.jsonl', [
  412. ...req(14600, 'm3', [{ type: 'text', text: 'answer two, from what is already here' }]),
  413. done(),
  414. ]);
  415. s = parseSession([a, b]);
  416. check('stitched turns', s.turns, 3, 0);
  417. check('residual carries into turn 2', s.occupancy.residual.codegraph, 4000, 60);
  418. check('stitched final context', s.occupancy.ctxFinal, 14600, 0);
  419. check('stitched cost sums segments', s.cost * 100, 20, 0.1);
  420. console.log(`\n${n - failures}/${n} checks passed`);
  421. return failures;
  422. }
  423. // `--selftest` needs sync fs helpers the module path doesn't import at top level.
  424. function require0(m) { return process.getBuiltinModule(m); }
  425. const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
  426. if (isMain && process.argv.includes('--selftest')) process.exit(selftest() ? 1 : 0);
  427. if (isMain) {
  428. const files = process.argv.slice(2).filter((a) => !a.startsWith('--'));
  429. if (!files.length) { console.error('usage: parse-run.mjs <run.jsonl> [run.t2.jsonl ...] | --selftest'); process.exit(1); }
  430. const s = parseSession(files);
  431. console.log(`\n=== ${files.map((f) => f.split('/').pop()).join(' + ')} ===`);
  432. console.log(`codegraph tools exposed: ${s.initTools ? s.initTools.length : '?'}${s.raced ? ' [MCP COLD-START RACE — tool call hit "No such tool available"]' : ''}`);
  433. if (s.cliCalls) console.log(`!! ${s.cliCalls} Bash call${s.cliCalls === 1 ? '' : 's'} invoked the codegraph CLI — if this is a without-arm, the run is CONTAMINATED`);
  434. console.log(`\nTool calls (${s.toolCalls.length}):`);
  435. console.log(' by type:', JSON.stringify(s.counts));
  436. s.toolCalls.forEach((tc, i) => console.log(` ${i + 1}. ${tc}`));
  437. if (s.result) {
  438. const seg = s.results.length > 1 ? ` | ${s.results.length} segments (${s.results.map((r) => r.subtype).join(',')})` : '';
  439. console.log(`\nResult: ${s.result.subtype} | duration ${s.dur.toFixed(0)}s | turns ${s.turns}${seg}`);
  440. console.log(` tokens processed: ${s.processed.toLocaleString('en-US')} | cost $${s.cost.toFixed(3)}`);
  441. }
  442. console.log('');
  443. console.log(formatOccupancy(s));
  444. }