parse-session.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. #!/usr/bin/env node
  2. // Parse the newest Claude Code session log for a project + its subagent logs,
  3. // and report the tool-call breakdown (main + subagents). Works for interactive
  4. // runs (driven via itrun.sh) — Claude Code writes full transcripts to
  5. // ~/.claude/projects/<escaped-cwd>/<session>.jsonl with subagents/ alongside.
  6. import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'fs';
  7. import { join } from 'path';
  8. import { homedir } from 'os';
  9. import {
  10. classifySufficiency, formatSufficiency,
  11. collectExploreTexts, computeAllocation, finalAnswerText, formatAllocation,
  12. } from './parse-run.mjs';
  13. const projectArg = process.argv[2];
  14. if (!projectArg) { console.error('usage: parse-session.mjs <project-dir>'); process.exit(1); }
  15. // Claude Code escapes the (real) cwd by replacing every "/" with "-".
  16. const real = realpathSync(projectArg);
  17. const escaped = real.replace(/\//g, '-');
  18. const projDir = join(homedir(), '.claude', 'projects', escaped);
  19. if (!existsSync(projDir)) { console.error('no session logs at', projDir); process.exit(1); }
  20. // Newest top-level session .jsonl
  21. const sessions = readdirSync(projDir)
  22. .filter(f => f.endsWith('.jsonl'))
  23. .map(f => ({ f, m: statSync(join(projDir, f)).mtimeMs }))
  24. .sort((a, b) => b.m - a.m);
  25. if (sessions.length === 0) { console.error('no .jsonl sessions in', projDir); process.exit(1); }
  26. const sessionId = sessions[0].f.replace('.jsonl', '');
  27. function tally(file) {
  28. const counts = {};
  29. for (const line of readFileSync(file, 'utf8').split('\n')) {
  30. if (!line) continue;
  31. let ev; try { ev = JSON.parse(line); } catch { continue; }
  32. const content = ev.message?.content;
  33. if (!Array.isArray(content)) continue;
  34. for (const b of content) {
  35. if (b.type === 'tool_use') counts[b.name] = (counts[b.name] || 0) + 1;
  36. }
  37. }
  38. return counts;
  39. }
  40. // Sum token usage from a transcript. The TUI's "Done (…Xk tokens…)" line only
  41. // covers a subagent's throughput; this works for main-thread runs too and is
  42. // consistent across both paths. `gen` = output, `fresh` = uncached input
  43. // (input + cache_creation), `cached` = cache reads (≈free), `total` = all.
  44. function sumTokens(file) {
  45. const t = { gen: 0, fresh: 0, cached: 0 };
  46. for (const line of readFileSync(file, 'utf8').split('\n')) {
  47. if (!line) continue;
  48. let ev; try { ev = JSON.parse(line); } catch { continue; }
  49. const u = ev.message?.usage;
  50. if (!u) continue;
  51. t.gen += u.output_tokens || 0;
  52. t.fresh += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
  53. t.cached += u.cache_read_input_tokens || 0;
  54. }
  55. return t;
  56. }
  57. const mainCounts = tally(join(projDir, sessionId + '.jsonl'));
  58. // Subagent transcripts live under <session>/subagents/*.jsonl
  59. const subDir = join(projDir, sessionId, 'subagents');
  60. const subCounts = {};
  61. let subAgentFiles = 0;
  62. if (existsSync(subDir)) {
  63. for (const f of readdirSync(subDir).filter(f => f.endsWith('.jsonl'))) {
  64. subAgentFiles++;
  65. const c = tally(join(subDir, f));
  66. for (const [k, v] of Object.entries(c)) subCounts[k] = (subCounts[k] || 0) + v;
  67. }
  68. }
  69. const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1])
  70. .map(([k, v]) => ` ${String(v).padStart(3)} ${k}`).join('\n') || ' (none)';
  71. console.log(`session: ${sessionId}`);
  72. console.log(`\nMAIN thread tools:\n${fmt(mainCounts)}`);
  73. console.log(`\nSUBAGENT tools (${subAgentFiles} subagent transcript${subAgentFiles === 1 ? '' : 's'}):\n${fmt(subCounts)}`);
  74. const explore = subCounts['mcp__codegraph__codegraph_explore'] || mainCounts['mcp__codegraph__codegraph_explore'] || 0;
  75. const reads = (subCounts['Read'] || 0) + (mainCounts['Read'] || 0);
  76. const greps = (subCounts['Grep'] || 0) + (mainCounts['Grep'] || 0) + (subCounts['Bash'] || 0) + (mainCounts['Bash'] || 0);
  77. console.log(`\nVERDICT: codegraph_explore used ${explore}x | Read ${reads} | Grep/Bash ${greps}`);
  78. // Token totals (main + subagents), consistent across main-thread and subagent runs.
  79. const tok = { gen: 0, fresh: 0, cached: 0 };
  80. const addTok = (t) => { tok.gen += t.gen; tok.fresh += t.fresh; tok.cached += t.cached; };
  81. addTok(sumTokens(join(projDir, sessionId + '.jsonl')));
  82. if (existsSync(subDir)) {
  83. for (const f of readdirSync(subDir).filter(f => f.endsWith('.jsonl'))) addTok(sumTokens(join(subDir, f)));
  84. }
  85. const k = (n) => (n / 1000).toFixed(1) + 'k';
  86. console.log(`TOKENS: gen ${k(tok.gen)} | fresh-in ${k(tok.fresh)} | cached-in ${k(tok.cached)} | billable≈ ${k(tok.gen + tok.fresh)}`);
  87. // What the agent did after each codegraph_explore (CG-8) — the same classifier
  88. // the headless A/B uses, over the interactive transcript.
  89. //
  90. // A subagent's calls live in their OWN file here (headless stream-json
  91. // interleaves them into one stream instead), so they are stitched back in:
  92. // each `agent-*.meta.json` carries the `toolUseId` of the Task that spawned it,
  93. // which is exactly the `parent_tool_use_id` the classifier keys threads on.
  94. // Without that, a delegated search would score as "the agent moved on".
  95. const parseLines = (file, parentToolUseId) => readFileSync(file, 'utf8').split('\n')
  96. .filter(Boolean)
  97. .map((l) => { try { return JSON.parse(l); } catch { return null; } })
  98. .filter(Boolean)
  99. .map((ev) => (parentToolUseId ? { ...ev, parent_tool_use_id: parentToolUseId } : ev));
  100. const events = parseLines(join(projDir, sessionId + '.jsonl'));
  101. if (existsSync(subDir)) {
  102. for (const f of readdirSync(subDir).filter((f) => f.endsWith('.jsonl'))) {
  103. let parent = null;
  104. const meta = join(subDir, f.replace(/\.jsonl$/, '.meta.json'));
  105. if (existsSync(meta)) { try { parent = JSON.parse(readFileSync(meta, 'utf8')).toolUseId ?? null; } catch { /* unreadable */ } }
  106. events.push(...parseLines(join(subDir, f), parent ?? `subagent:${f}`));
  107. }
  108. }
  109. console.log('');
  110. console.log(formatSufficiency({ sufficiency: classifySufficiency(events) }, ''));
  111. // How much of what explore returned the answer drew on (CG-9). An interactive
  112. // transcript has no `result` event, so the answer is the last main-thread
  113. // assistant text — finalAnswerText already falls back to it.
  114. console.log('');
  115. console.log(formatAllocation(
  116. { allocation: computeAllocation(collectExploreTexts(events), finalAnswerText(events)) }, ''));