parse-run.mjs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. #!/usr/bin/env node
  2. // Parse a Claude Code stream-json run log: tool-call sequence + token usage.
  3. //
  4. // With --envelope it also reports how the codegraph_explore responses the agent
  5. // received were DIVIDED across files — the per-file share of the source envelope.
  6. // That view is parsed out of the rendered markdown rather than the CG-4
  7. // diagnostic sidecar, so it works on ANY build (the sidecar only exists post-CG-4)
  8. // and is therefore the only way to measure both arms of a new-vs-baseline A/B the
  9. // same way. `--answer <glob>` (repeatable) marks the files that actually answer
  10. // the question, and the summary reports their combined share.
  11. //
  12. // Usage: parse-run.mjs <run.jsonl> [--envelope] [--answer <glob>]...
  13. import { readFileSync } from 'fs';
  14. const argv = process.argv.slice(2);
  15. const answerGlobs = [];
  16. let file = null;
  17. let wantEnvelope = false;
  18. for (let i = 0; i < argv.length; i++) {
  19. if (argv[i] === '--envelope') wantEnvelope = true;
  20. else if (argv[i] === '--answer') { answerGlobs.push(argv[++i]); wantEnvelope = true; }
  21. else if (!argv[i].startsWith('--') && file === null) file = argv[i];
  22. }
  23. const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
  24. const toolCalls = [];
  25. let result = null;
  26. let initTools = null;
  27. const exploreQueries = new Map(); // tool_use id -> query
  28. const exploreTexts = []; // response text, in call order
  29. for (const line of lines) {
  30. let ev;
  31. try { ev = JSON.parse(line); } catch { continue; }
  32. if (ev.type === 'system' && ev.subtype === 'init') {
  33. initTools = (ev.tools || []).filter(t => /codegraph/.test(t));
  34. }
  35. if (ev.type === 'assistant' && ev.message?.content) {
  36. for (const block of ev.message.content) {
  37. if (block.type === 'tool_use') {
  38. let detail = '';
  39. if (block.name === 'Task') detail = ` [subagent_type=${block.input?.subagent_type ?? '?'}] ${(block.input?.description ?? '').slice(0,40)}`;
  40. else if (/codegraph/.test(block.name)) detail = ` ${JSON.stringify(block.input?.query ?? block.input?.task ?? block.input?.symbol ?? '').slice(0,60)}`;
  41. else if (block.name === 'Bash') detail = ` ${(block.input?.command ?? '').slice(0,50)}`;
  42. else if (block.name === 'Read') detail = ` ${(block.input?.file_path ?? '').split('/').slice(-1)[0]}`;
  43. toolCalls.push(`${block.name}${detail}`);
  44. if (/codegraph_explore/.test(block.name)) exploreQueries.set(block.id, block.input?.query ?? '');
  45. }
  46. }
  47. }
  48. if (ev.type === 'user' && ev.message?.content) {
  49. for (const block of ev.message.content) {
  50. if (block.type === 'tool_result' && exploreQueries.has(block.tool_use_id)) {
  51. exploreTexts.push(typeof block.content === 'string'
  52. ? block.content
  53. : (block.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n'));
  54. }
  55. }
  56. }
  57. if (ev.type === 'result') result = ev;
  58. }
  59. console.log(`\n=== ${file.split('/').pop()} ===`);
  60. console.log(`codegraph tools exposed: ${initTools ? initTools.length : '?'}`);
  61. console.log(`\nTool calls (${toolCalls.length}):`);
  62. const counts = {};
  63. for (const tc of toolCalls) { const n = tc.split(' ')[0]; counts[n] = (counts[n]||0)+1; }
  64. console.log(' by type:', JSON.stringify(counts));
  65. toolCalls.forEach((tc, i) => console.log(` ${i+1}. ${tc}`));
  66. if (result) {
  67. const u = result.usage || {};
  68. const totalIn = (u.input_tokens||0) + (u.cache_read_input_tokens||0) + (u.cache_creation_input_tokens||0);
  69. console.log(`\nResult: ${result.subtype} | duration ${(result.duration_ms/1000).toFixed(0)}s | turns ${result.num_turns}`);
  70. console.log(` tokens: in=${totalIn} out=${u.output_tokens||0} | cost $${(result.total_cost_usd||0).toFixed(3)}`);
  71. }
  72. // ---- envelope share (opt-in) ------------------------------------------------
  73. if (wantEnvelope) {
  74. // `tools/cache/**` -> /^tools\/cache\/.*$/ . Same semantics as probe-allocation.
  75. // The `**` sentinel is written as an escape, never a literal NUL byte — a raw
  76. // one makes git treat this whole script as binary and costs every future diff.
  77. const glob2re = (glob) => {
  78. const S = '\u0000';
  79. const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
  80. .replace(/\*\*/g, S).replace(/\*/g, '[^/]*').replaceAll(S, '.*');
  81. return new RegExp(`^${body}$`);
  82. };
  83. const answerRes = answerGlobs.map(glob2re);
  84. const isAnswer = (p) => answerRes.some(re => re.test(p));
  85. // Each rendered file section starts with **`path`** — its bytes run to the next
  86. // such header (or to the trailing guidance quote). Share is over the sum of the
  87. // sections, i.e. of the source envelope the allocator divides.
  88. const pooled = new Map();
  89. let envelope = 0;
  90. for (const text of exploreTexts) {
  91. const re = /^\*\*`([^`]+)`\*\*/gm;
  92. const marks = [];
  93. let m;
  94. while ((m = re.exec(text)) !== null) marks.push({ path: m[1], at: m.index });
  95. if (!marks.length) continue;
  96. const tail = text.indexOf('\n> ', marks[marks.length - 1].at);
  97. const end = tail === -1 ? text.length : tail;
  98. marks.forEach((mark, i) => {
  99. const chars = (i + 1 < marks.length ? marks[i + 1].at : end) - mark.at;
  100. pooled.set(mark.path, (pooled.get(mark.path) ?? 0) + chars);
  101. envelope += chars;
  102. });
  103. }
  104. const ranked = [...pooled.entries()]
  105. .map(([path, chars]) => ({ path, chars, share: envelope ? chars / envelope : 0, answer: isAnswer(path) }))
  106. .sort((a, b) => b.chars - a.chars);
  107. const answerChars = ranked.filter(r => r.answer).reduce((s, r) => s + r.chars, 0);
  108. const pct = (f) => `${(f * 100).toFixed(1)}%`;
  109. console.log(`\nExplore envelope: ${envelope.toLocaleString('en-US')} chars over ${exploreTexts.length} response(s)`);
  110. if (answerGlobs.length) {
  111. console.log(` answer-set share: ${pct(envelope ? answerChars / envelope : 0)} | top file answers: ${ranked[0]?.answer ?? false}`);
  112. }
  113. for (const f of ranked.slice(0, 12)) {
  114. console.log(` ${f.answer ? '*' : ' '} ${pct(f.share).padStart(6)} ${String(f.chars).padStart(6)} ${f.path}`);
  115. }
  116. if (ranked.length > 12) console.log(` … ${ranked.length - 12} more files`);
  117. }