analyze-sessions.mjs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875
  1. #!/usr/bin/env node
  2. /* eslint-disable */
  3. /**
  4. * analyze-sessions.js
  5. *
  6. * Scans ~/.claude/projects/**.jsonl transcript files and reports token usage,
  7. * message counts, runtime, cache breaks, subagent and skill activity.
  8. *
  9. * Output is human-readable text by default; pass --json for machine-readable.
  10. *
  11. * Usage:
  12. * node scripts/analyze-sessions.js [--dir <projects-dir>] [--json] [--since <ISO|7d|24h>] [--top N]
  13. *
  14. * Notes on JSONL structure (discovered empirically):
  15. * - One API response is split into MULTIPLE `type:"assistant"` entries (one per
  16. * content block). They share the same `requestId` / `message.id`, and only the
  17. * LAST one carries the final `output_tokens`. We dedupe by requestId and keep
  18. * the max output_tokens to avoid 3-10x overcounting.
  19. * - `type:"user"` entries include tool_result messages, interrupt markers,
  20. * compact summaries and meta-injected text. A "human" message is one where
  21. * isSidechain/isMeta/isCompactSummary are falsy and the content is a plain
  22. * string (or text block) that isn't a tool_result or interrupt marker.
  23. * - Subagent transcripts live in <project>/<sessionId>/subagents/*.jsonl with a
  24. * sibling *.meta.json containing {agentType}. When meta is absent we fall back
  25. * to the filename label (`agent-a<label>-<hash>.jsonl` → label) or "fork".
  26. * - Resumed sessions can re-serialize prior entries into a new file; we dedupe
  27. * globally by entry `uuid` so replayed history isn't double-counted.
  28. */
  29. import fs from 'fs'
  30. import os from 'os'
  31. import path from 'path'
  32. import readline from 'readline'
  33. // ---------------------------------------------------------------------------
  34. // CLI args
  35. // ---------------------------------------------------------------------------
  36. const argv = process.argv.slice(2)
  37. function flag(name, dflt) {
  38. const i = argv.indexOf(name)
  39. if (i === -1) return dflt
  40. const v = argv[i + 1]
  41. return v === undefined || v.startsWith('--') ? true : v
  42. }
  43. const ROOT = flag('--dir', path.join(os.homedir(), '.claude', 'projects'))
  44. const AS_JSON = argv.includes('--json')
  45. const TOP_N = parseInt(flag('--top', '15'), 10)
  46. const SINCE = parseSince(flag('--since', null))
  47. const CACHE_BREAK_THRESHOLD = parseInt(flag('--cache-break', '100000'), 10)
  48. const IDLE_GAP_MS = 5 * 60 * 1000 // gaps >5min don't count toward "active" time
  49. function parseSince(s) {
  50. if (!s) return null
  51. const m = /^(\d+)([dh])$/.exec(s)
  52. if (m) {
  53. const ms = m[2] === 'd' ? 86400000 : 3600000
  54. return new Date(Date.now() - parseInt(m[1], 10) * ms)
  55. }
  56. const d = new Date(s)
  57. return isNaN(d) ? null : d
  58. }
  59. // ---------------------------------------------------------------------------
  60. // Stats container
  61. // ---------------------------------------------------------------------------
  62. function newStats() {
  63. return {
  64. sessions: new Set(),
  65. apiCalls: 0,
  66. inputUncached: 0, // usage.input_tokens
  67. inputCacheCreate: 0, // usage.cache_creation_input_tokens
  68. inputCacheRead: 0, // usage.cache_read_input_tokens
  69. outputTokens: 0,
  70. humanMessages: 0,
  71. wallClockMs: 0,
  72. activeMs: 0,
  73. cacheBreaks: [], // [{ts, session, project, uncached, total}]
  74. subagentCalls: 0,
  75. subagentTokens: 0, // total (in+out) inside subagent transcripts
  76. skillInvocations: {}, // name -> count
  77. firstTs: null,
  78. lastTs: null,
  79. }
  80. }
  81. function addUsage(s, u) {
  82. s.apiCalls++
  83. s.inputUncached += u.input_tokens || 0
  84. s.inputCacheCreate += u.cache_creation_input_tokens || 0
  85. s.inputCacheRead += u.cache_read_input_tokens || 0
  86. s.outputTokens += u.output_tokens || 0
  87. }
  88. // ---------------------------------------------------------------------------
  89. // File discovery
  90. // ---------------------------------------------------------------------------
  91. function* walk(dir) {
  92. let ents
  93. try {
  94. ents = fs.readdirSync(dir, { withFileTypes: true })
  95. } catch {
  96. return
  97. }
  98. for (const e of ents) {
  99. const p = path.join(dir, e.name)
  100. if (e.isDirectory()) yield* walk(p)
  101. else if (e.isFile() && e.name.endsWith('.jsonl')) yield p
  102. }
  103. }
  104. function classifyFile(p) {
  105. // returns { project, sessionId, kind, agentId?, agentTypeHint? }
  106. // agentTypeHint is from meta.json or filename label; final type is resolved
  107. // in main() after the parent-transcript map is built.
  108. const rel = path.relative(ROOT, p)
  109. const parts = rel.split(path.sep)
  110. const project = parts[0]
  111. const subIdx = parts.indexOf('subagents')
  112. if (subIdx !== -1) {
  113. const sessionId = parts[subIdx - 1]
  114. const base = path.basename(p, '.jsonl')
  115. const agentId = base.replace(/^agent-/, '')
  116. return {
  117. project,
  118. sessionId,
  119. kind: 'subagent',
  120. agentId,
  121. agentTypeHint:
  122. inferAgentTypeFromMeta(p) || inferAgentTypeFromFilename(base),
  123. }
  124. }
  125. if (parts.includes('workflows')) {
  126. const sessionId = parts[1]
  127. return { project, sessionId, kind: 'subagent', agentTypeHint: 'workflow' }
  128. }
  129. const sessionId = path.basename(p, '.jsonl')
  130. return { project, sessionId, kind: 'main' }
  131. }
  132. function inferAgentTypeFromMeta(jsonlPath) {
  133. const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json')
  134. try {
  135. const m = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
  136. if (m && typeof m.agentType === 'string') return m.agentType
  137. } catch {
  138. /* no meta */
  139. }
  140. return null
  141. }
  142. function inferAgentTypeFromFilename(base) {
  143. // agentId = 'a' + hex16 OR 'a' + label + '-' + hex16 (src/utils/uuid.ts)
  144. const m = /^agent-a([a-zA-Z_][\w-]*?)-[0-9a-f]{6,}$/.exec(base)
  145. if (m) return m[1] // internal background fork label
  146. return null // unlabeled — resolve via agentIdToType map or default to 'fork'
  147. }
  148. // ---------------------------------------------------------------------------
  149. // Per-file streaming parse
  150. // ---------------------------------------------------------------------------
  151. const seenUuids = new Set() // global dedupe across resumed sessions
  152. const seenRequestIds = new Set() // global dedupe for usage accounting
  153. const toolUseIdToType = new Map() // tool_use id -> subagent_type (from Agent/Task tool_use)
  154. const agentIdToType = new Map() // agentId -> subagent_type (linked via tool_result)
  155. const toolUseIdToPrompt = new Map() // tool_use id -> promptKey (Agent spawned during this prompt)
  156. const agentIdToPrompt = new Map() // agentId -> promptKey
  157. const prompts = new Map() // promptKey -> { text, ts, project, sessionId, ...usage }
  158. const sessionTurns = new Map() // sessionId -> [promptKey, ...] in transcript order
  159. const sessionSpans = new Map() // sessionId -> {project, firstTs, lastTs, tokens}
  160. function promptRecord(key, init) {
  161. let r = prompts.get(key)
  162. if (!r) {
  163. r = {
  164. text: init.text,
  165. ts: init.ts,
  166. project: init.project,
  167. sessionId: init.sessionId,
  168. apiCalls: 0,
  169. subagentCalls: 0,
  170. inputUncached: 0,
  171. inputCacheCreate: 0,
  172. inputCacheRead: 0,
  173. outputTokens: 0,
  174. }
  175. prompts.set(key, r)
  176. }
  177. return r
  178. }
  179. async function processFile(p, info, buckets) {
  180. const rl = readline.createInterface({
  181. input: fs.createReadStream(p, { encoding: 'utf8' }),
  182. crlfDelay: Infinity,
  183. })
  184. // Per-file: dedupe API calls by requestId, keep the one with max output_tokens.
  185. // We collect first, then commit, because earlier blocks have stale output counts.
  186. const fileApiCalls = new Map() // key -> {usage, ts}
  187. let firstTs = null
  188. let lastTs = null
  189. let prevTs = null
  190. let activeMs = 0
  191. let currentSkill = null // skill attribution for this turn
  192. // Prompt attribution: in main files this is set on each human message; in
  193. // subagent files it's inherited from the spawning prompt (via agentIdToPrompt).
  194. let currentPrompt =
  195. info.kind === 'subagent' && info.agentId
  196. ? agentIdToPrompt.get(info.agentId) || null
  197. : null
  198. const project = buckets.project
  199. const overall = buckets.overall
  200. const subagent = buckets.subagent // may be null
  201. const skillStats = buckets.skillStats // map name -> stats
  202. for await (const line of rl) {
  203. if (!line) continue
  204. let e
  205. try {
  206. e = JSON.parse(line)
  207. } catch {
  208. continue
  209. }
  210. // global uuid dedupe (resumed sessions replay history)
  211. if (e.uuid) {
  212. if (seenUuids.has(e.uuid)) continue
  213. seenUuids.add(e.uuid)
  214. }
  215. // timestamp tracking
  216. if (e.timestamp) {
  217. const ts = Date.parse(e.timestamp)
  218. if (!isNaN(ts)) {
  219. if (SINCE && ts < SINCE.getTime()) continue
  220. if (firstTs === null) firstTs = ts
  221. if (prevTs !== null) {
  222. const gap = ts - prevTs
  223. if (gap > 0 && gap < IDLE_GAP_MS) activeMs += gap
  224. }
  225. prevTs = ts
  226. lastTs = ts
  227. }
  228. }
  229. if (e.type === 'user') {
  230. // Link Agent tool_result -> agentId for type + prompt attribution.
  231. const tur = e.toolUseResult
  232. if (tur && tur.agentId) {
  233. const c0 = Array.isArray(e.message?.content)
  234. ? e.message.content[0]
  235. : null
  236. const tuid = c0 && c0.tool_use_id
  237. if (tuid) {
  238. const st = toolUseIdToType.get(tuid)
  239. if (st) agentIdToType.set(tur.agentId, st)
  240. const pk = toolUseIdToPrompt.get(tuid)
  241. if (pk) {
  242. agentIdToPrompt.set(tur.agentId, pk)
  243. const r = prompts.get(pk)
  244. if (r) r.subagentCalls++
  245. }
  246. }
  247. }
  248. handleUser(
  249. e,
  250. info,
  251. { project, overall, subagent },
  252. v => {
  253. currentSkill = v
  254. },
  255. pk => {
  256. currentPrompt = pk
  257. },
  258. )
  259. continue
  260. }
  261. if (e.type === 'assistant') {
  262. const msg = e.message || {}
  263. const usage = msg.usage
  264. // detect Skill / Agent tool calls in content
  265. if (Array.isArray(msg.content)) {
  266. for (const c of msg.content) {
  267. if (c && c.type === 'tool_use') {
  268. if (c.name === 'Skill' && c.input && c.input.skill) {
  269. const sk = String(c.input.skill)
  270. bumpSkill(overall, sk)
  271. bumpSkill(project, sk)
  272. if (subagent) bumpSkill(subagent, sk)
  273. currentSkill = sk
  274. }
  275. if (c.name === 'Agent' || c.name === 'Task') {
  276. if (c.input && c.input.subagent_type) {
  277. toolUseIdToType.set(c.id, String(c.input.subagent_type))
  278. }
  279. if (currentPrompt) toolUseIdToPrompt.set(c.id, currentPrompt)
  280. }
  281. }
  282. }
  283. }
  284. if (!usage) continue
  285. const key =
  286. e.requestId ||
  287. (msg.id && msg.id.startsWith('msg_0') && msg.id.length > 10
  288. ? msg.id
  289. : null) ||
  290. `${p}:${e.uuid || ''}`
  291. const prev = fileApiCalls.get(key)
  292. if (
  293. !prev ||
  294. (usage.output_tokens || 0) >= (prev.usage.output_tokens || 0)
  295. ) {
  296. fileApiCalls.set(key, {
  297. usage,
  298. ts: e.timestamp,
  299. skill: currentSkill,
  300. prompt: currentPrompt,
  301. })
  302. }
  303. continue
  304. }
  305. }
  306. // commit timestamps
  307. if (firstTs !== null && lastTs !== null) {
  308. const wall = lastTs - firstTs
  309. for (const s of [overall, project, subagent].filter(Boolean)) {
  310. s.wallClockMs += wall
  311. s.activeMs += activeMs
  312. if (!s.firstTs || firstTs < s.firstTs) s.firstTs = firstTs
  313. if (!s.lastTs || lastTs > s.lastTs) s.lastTs = lastTs
  314. }
  315. }
  316. // session span (for by_day timeline) — subagent files roll into parent sessionId
  317. let span = sessionSpans.get(info.sessionId)
  318. if (!span) {
  319. span = { project: info.project, firstTs: null, lastTs: null, tokens: 0 }
  320. sessionSpans.set(info.sessionId, span)
  321. }
  322. if (firstTs !== null) {
  323. if (span.firstTs === null || firstTs < span.firstTs) span.firstTs = firstTs
  324. if (span.lastTs === null || lastTs > span.lastTs) span.lastTs = lastTs
  325. }
  326. // commit API calls
  327. for (const [key, { usage, ts, skill, prompt }] of fileApiCalls) {
  328. if (key && seenRequestIds.has(key)) continue
  329. seenRequestIds.add(key)
  330. const tot =
  331. (usage.input_tokens || 0) +
  332. (usage.cache_creation_input_tokens || 0) +
  333. (usage.cache_read_input_tokens || 0) +
  334. (usage.output_tokens || 0)
  335. span.tokens += tot
  336. const targets = [overall, project]
  337. if (subagent) targets.push(subagent)
  338. if (skill && skillStats) {
  339. if (!skillStats.has(skill)) skillStats.set(skill, newStats())
  340. targets.push(skillStats.get(skill))
  341. }
  342. for (const s of targets) addUsage(s, usage)
  343. if (prompt) {
  344. const r = prompts.get(prompt)
  345. if (r) {
  346. r.apiCalls++
  347. r.inputUncached += usage.input_tokens || 0
  348. r.inputCacheCreate += usage.cache_creation_input_tokens || 0
  349. r.inputCacheRead += usage.cache_read_input_tokens || 0
  350. r.outputTokens += usage.output_tokens || 0
  351. }
  352. }
  353. // subagent token accounting on parent buckets
  354. if (info.kind === 'subagent') {
  355. overall.subagentTokens += tot
  356. project.subagentTokens += tot
  357. if (subagent) subagent.subagentTokens += tot
  358. }
  359. // cache break detection
  360. const uncached =
  361. (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
  362. if (uncached > CACHE_BREAK_THRESHOLD) {
  363. const total = uncached + (usage.cache_read_input_tokens || 0)
  364. const cb = {
  365. ts,
  366. session: info.sessionId,
  367. project: info.project,
  368. uncached,
  369. total,
  370. kind: info.kind,
  371. agentType: info.agentType,
  372. prompt,
  373. }
  374. overall.cacheBreaks.push(cb)
  375. project.cacheBreaks.push(cb)
  376. if (subagent) subagent.cacheBreaks.push(cb)
  377. }
  378. }
  379. // only count this file toward session/subagent tallies if it had in-range entries
  380. if (firstTs !== null || fileApiCalls.size > 0) {
  381. for (const s of [overall, project, subagent].filter(Boolean)) {
  382. s.sessions.add(info.sessionId)
  383. }
  384. if (info.kind === 'subagent') {
  385. overall.subagentCalls++
  386. project.subagentCalls++
  387. if (subagent) subagent.subagentCalls++
  388. }
  389. }
  390. }
  391. function handleUser(
  392. e,
  393. info,
  394. { project, overall, subagent },
  395. setSkill,
  396. setPrompt,
  397. ) {
  398. if (e.isMeta || e.isCompactSummary) return
  399. const content = e.message && e.message.content
  400. let isToolResult = false
  401. let text = null
  402. if (typeof content === 'string') {
  403. text = content
  404. } else if (Array.isArray(content)) {
  405. const first = content[0]
  406. if (first && first.type === 'tool_result') isToolResult = true
  407. else if (first && first.type === 'text') text = first.text || ''
  408. }
  409. if (isToolResult) return
  410. let slashCmd = null
  411. if (text) {
  412. // Auto-continuations (task notifications, scheduled wakeups) are not new
  413. // human prompts; keep attributing to the previously active prompt.
  414. if (
  415. text.startsWith('<task-notification') ||
  416. text.startsWith('<scheduled-wakeup') ||
  417. text.startsWith('<background-task')
  418. ) {
  419. return
  420. }
  421. const m = /<command-(?:name|message)>\/?([^<]+)<\/command-/.exec(text)
  422. if (m) {
  423. slashCmd = m[1].trim()
  424. bumpSkill(overall, slashCmd)
  425. bumpSkill(project, slashCmd)
  426. if (subagent) bumpSkill(subagent, slashCmd)
  427. setSkill(slashCmd)
  428. } else {
  429. setSkill(null) // plain human message resets skill attribution
  430. }
  431. if (text.startsWith('[Request interrupted')) return
  432. }
  433. // Only count as human message / start a prompt in main (non-sidechain) transcripts
  434. if (info.kind === 'main' && !e.isSidechain) {
  435. overall.humanMessages++
  436. project.humanMessages++
  437. const pk = e.uuid || `${info.sessionId}:${e.timestamp}`
  438. promptRecord(pk, {
  439. text: promptPreview(text, slashCmd),
  440. ts: e.timestamp,
  441. project: info.project,
  442. sessionId: info.sessionId,
  443. })
  444. setPrompt(pk)
  445. let turns = sessionTurns.get(info.sessionId)
  446. if (!turns) sessionTurns.set(info.sessionId, (turns = []))
  447. turns.push(pk)
  448. }
  449. }
  450. function promptPreview(text, slashCmd) {
  451. if (slashCmd) return `/${slashCmd}`
  452. if (!text) return '(non-text)'
  453. const t = text
  454. .replace(/<[^>]+>/g, ' ')
  455. .replace(/\s+/g, ' ')
  456. .trim()
  457. return t.length > 240 ? t.slice(0, 237) + '…' : t
  458. }
  459. // ±2 user messages around a given prompt, with the api-call count that
  460. // followed each one. Used for drill-down in the HTML report.
  461. function buildContext(pk) {
  462. const r = prompts.get(pk)
  463. if (!r) return null
  464. const turns = sessionTurns.get(r.sessionId)
  465. if (!turns) return null
  466. const i = turns.indexOf(pk)
  467. if (i === -1) return null
  468. const lo = Math.max(0, i - 2)
  469. const hi = Math.min(turns.length, i + 3)
  470. return turns.slice(lo, hi).map((k, j) => {
  471. const t = prompts.get(k) || {}
  472. return {
  473. text: t.text || '',
  474. ts: t.ts || null,
  475. calls: t.apiCalls || 0,
  476. here: lo + j === i,
  477. }
  478. })
  479. }
  480. function bumpSkill(s, name) {
  481. s.skillInvocations[name] = (s.skillInvocations[name] || 0) + 1
  482. }
  483. const _btCache = new Map()
  484. function birthtime(p) {
  485. let t = _btCache.get(p)
  486. if (t === undefined) {
  487. try {
  488. t = fs.statSync(p).birthtimeMs
  489. } catch {
  490. t = 0
  491. }
  492. _btCache.set(p, t)
  493. }
  494. return t
  495. }
  496. // ---------------------------------------------------------------------------
  497. // Main
  498. // ---------------------------------------------------------------------------
  499. async function main() {
  500. const overall = newStats()
  501. const perProject = new Map() // project -> stats
  502. const perSubagent = new Map() // agentType -> stats
  503. const perSkill = new Map() // skill -> stats (token-attributed)
  504. // Classify, then sort main files before subagent files. Fork-style subagents
  505. // replay parent entries with identical uuids; processing parents first ensures
  506. // the global uuid-dedupe attributes those entries to the parent, not the fork.
  507. // Among subagents, sort by birthtime so a parent subagent is processed before
  508. // any nested children it spawned (needed for prompt-attribution propagation).
  509. const files = [...walk(ROOT)]
  510. .map(p => ({ p, info: classifyFile(p) }))
  511. .sort((a, b) => {
  512. const ka = a.info.kind === 'main' ? 0 : 1
  513. const kb = b.info.kind === 'main' ? 0 : 1
  514. if (ka !== kb) return ka - kb
  515. if (ka === 1) return birthtime(a.p) - birthtime(b.p)
  516. return 0
  517. })
  518. let n = 0
  519. for (const { p, info } of files) {
  520. if (!perProject.has(info.project)) perProject.set(info.project, newStats())
  521. const project = perProject.get(info.project)
  522. let subagent = null
  523. if (info.kind === 'subagent') {
  524. // Resolve agent type: meta.json/filename hint > parent-transcript map > 'fork'
  525. const at =
  526. info.agentTypeHint ||
  527. (info.agentId && agentIdToType.get(info.agentId)) ||
  528. 'fork'
  529. info.agentType = at
  530. if (!perSubagent.has(at)) perSubagent.set(at, newStats())
  531. subagent = perSubagent.get(at)
  532. }
  533. await processFile(p, info, {
  534. overall,
  535. project,
  536. subagent,
  537. skillStats: perSkill,
  538. })
  539. n++
  540. if (!AS_JSON && n % 200 === 0) {
  541. process.stderr.write(`\r scanned ${n}/${files.length} files…`)
  542. }
  543. }
  544. if (!AS_JSON)
  545. process.stderr.write(`\r scanned ${n}/${files.length} files.\n`)
  546. // Drop empty buckets (created for files that had no in-range entries under --since)
  547. for (const m of [perProject, perSubagent, perSkill]) {
  548. for (const [k, v] of m) {
  549. if (v.apiCalls === 0 && v.sessions.size === 0) m.delete(k)
  550. }
  551. }
  552. if (AS_JSON) {
  553. printJson({ overall, perProject, perSubagent, perSkill })
  554. } else {
  555. printText({ overall, perProject, perSubagent, perSkill })
  556. }
  557. }
  558. // ---------------------------------------------------------------------------
  559. // Output
  560. // ---------------------------------------------------------------------------
  561. function fmt(n) {
  562. if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
  563. if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
  564. if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
  565. return String(n)
  566. }
  567. function pct(a, b) {
  568. return b > 0 ? ((100 * a) / b).toFixed(1) + '%' : '—'
  569. }
  570. function hrs(ms) {
  571. return (ms / 3600000).toFixed(1)
  572. }
  573. function summarize(s) {
  574. const inTotal = s.inputUncached + s.inputCacheCreate + s.inputCacheRead
  575. return {
  576. sessions: s.sessions.size,
  577. api_calls: s.apiCalls,
  578. input_tokens: {
  579. uncached: s.inputUncached,
  580. cache_create: s.inputCacheCreate,
  581. cache_read: s.inputCacheRead,
  582. total: inTotal,
  583. pct_cached:
  584. inTotal > 0 ? +((100 * s.inputCacheRead) / inTotal).toFixed(1) : 0,
  585. },
  586. output_tokens: s.outputTokens,
  587. human_messages: s.humanMessages,
  588. hours: { wall_clock: +hrs(s.wallClockMs), active: +hrs(s.activeMs) },
  589. cache_breaks_over_100k: s.cacheBreaks.length,
  590. subagent: {
  591. calls: s.subagentCalls,
  592. total_tokens: s.subagentTokens,
  593. avg_tokens_per_call:
  594. s.subagentCalls > 0
  595. ? Math.round(s.subagentTokens / s.subagentCalls)
  596. : 0,
  597. },
  598. skill_invocations: s.skillInvocations,
  599. span: s.firstTs
  600. ? {
  601. from: new Date(s.firstTs).toISOString(),
  602. to: new Date(s.lastTs).toISOString(),
  603. }
  604. : null,
  605. }
  606. }
  607. function printJson({ overall, perProject, perSubagent, perSkill }) {
  608. const out = {
  609. root: ROOT,
  610. generated_at: new Date().toISOString(),
  611. overall: summarize(overall),
  612. cache_breaks: overall.cacheBreaks
  613. .sort((a, b) => b.uncached - a.uncached)
  614. .slice(0, 100)
  615. .map(({ prompt, ...b }) => ({
  616. ...b,
  617. context: prompt ? buildContext(prompt) : null,
  618. })),
  619. by_project: Object.fromEntries(
  620. [...perProject].map(([k, v]) => [k, summarize(v)]),
  621. ),
  622. by_subagent_type: Object.fromEntries(
  623. [...perSubagent].map(([k, v]) => [k, summarize(v)]),
  624. ),
  625. by_skill: Object.fromEntries(
  626. [...perSkill].map(([k, v]) => [k, summarize(v)]),
  627. ),
  628. top_prompts: topPrompts(100),
  629. by_day: buildByDay(),
  630. }
  631. process.stdout.write(JSON.stringify(out, null, 2) + '\n')
  632. }
  633. // Group sessions into local-date buckets for the timeline view. A session is
  634. // placed on the day its first message landed; tokens for that session (incl.
  635. // subagents) count toward that day even if it ran past midnight.
  636. function buildByDay() {
  637. const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
  638. const days = new Map() // yyyy-mm-dd -> {date, dow, tokens, sessions:[]}
  639. for (const [id, s] of sessionSpans) {
  640. if (s.firstTs === null || s.tokens === 0) continue
  641. const d0 = new Date(s.firstTs)
  642. const key = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-${String(d0.getDate()).padStart(2, '0')}`
  643. let day = days.get(key)
  644. if (!day) {
  645. day = { date: key, dow: DOW[d0.getDay()], tokens: 0, sessions: [] }
  646. days.set(key, day)
  647. }
  648. const base = new Date(
  649. d0.getFullYear(),
  650. d0.getMonth(),
  651. d0.getDate(),
  652. ).getTime()
  653. day.tokens += s.tokens
  654. day.sessions.push({
  655. id,
  656. project: s.project,
  657. tokens: s.tokens,
  658. start_min: Math.max(0, Math.round((s.firstTs - base) / 60000)),
  659. end_min: Math.max(1, Math.round((s.lastTs - base) / 60000)),
  660. })
  661. }
  662. for (const d of days.values()) {
  663. // peak concurrency via 10-min buckets, capped at 24h for display
  664. const b = new Array(144).fill(0)
  665. for (const s of d.sessions) {
  666. const lo = Math.min(143, Math.floor(s.start_min / 10))
  667. const hi = Math.min(144, Math.ceil(Math.min(s.end_min, 1440) / 10))
  668. for (let i = lo; i < hi; i++) b[i]++
  669. }
  670. d.peak = Math.max(0, ...b)
  671. d.peak_at_min = d.peak > 0 ? b.indexOf(d.peak) * 10 : 0
  672. d.sessions.sort((a, b) => a.start_min - b.start_min)
  673. }
  674. return [...days.values()].sort((a, b) => a.date.localeCompare(b.date))
  675. }
  676. function promptTotal(r) {
  677. return (
  678. r.inputUncached + r.inputCacheCreate + r.inputCacheRead + r.outputTokens
  679. )
  680. }
  681. function topPrompts(n) {
  682. return [...prompts.entries()]
  683. .filter(([, r]) => r.apiCalls > 0)
  684. .sort((a, b) => promptTotal(b[1]) - promptTotal(a[1]))
  685. .slice(0, n)
  686. .map(([pk, r]) => ({
  687. ts: r.ts,
  688. project: r.project,
  689. session: r.sessionId,
  690. text: r.text,
  691. api_calls: r.apiCalls,
  692. subagent_calls: r.subagentCalls,
  693. total_tokens: promptTotal(r),
  694. input: {
  695. uncached: r.inputUncached,
  696. cache_create: r.inputCacheCreate,
  697. cache_read: r.inputCacheRead,
  698. },
  699. output: r.outputTokens,
  700. context: buildContext(pk),
  701. }))
  702. }
  703. function printText({ overall, perProject, perSubagent, perSkill }) {
  704. const line = (...a) => console.log(...a)
  705. const hr = () => line('─'.repeat(78))
  706. line()
  707. line(`Claude Code session analysis — ${ROOT}`)
  708. if (SINCE) line(`(since ${SINCE.toISOString()})`)
  709. hr()
  710. printBlock('OVERALL', overall)
  711. hr()
  712. line(
  713. `CACHE BREAKS (>${fmt(CACHE_BREAK_THRESHOLD)} uncached input on a single call)`,
  714. )
  715. const breaks = overall.cacheBreaks
  716. .sort((a, b) => b.uncached - a.uncached)
  717. .slice(0, TOP_N)
  718. if (breaks.length === 0) line(' none')
  719. for (const b of breaks) {
  720. line(
  721. ` ${fmt(b.uncached).padStart(8)} uncached / ${fmt(b.total).padStart(8)} total ` +
  722. `${(b.ts || '').slice(0, 19)} ${b.project}` +
  723. (b.kind === 'subagent' ? ` [${b.agentType}]` : ''),
  724. )
  725. }
  726. if (overall.cacheBreaks.length > TOP_N)
  727. line(` … ${overall.cacheBreaks.length - TOP_N} more`)
  728. hr()
  729. line(
  730. 'MOST EXPENSIVE PROMPTS (total tokens incl. subagents spawned during the turn)',
  731. )
  732. const top = topPrompts(TOP_N)
  733. if (top.length === 0) line(' none')
  734. for (const r of top) {
  735. const inTot = r.input.uncached + r.input.cache_create + r.input.cache_read
  736. line(
  737. ` ${fmt(r.total_tokens).padStart(8)} ` +
  738. `(in ${fmt(inTot)} ${pct(r.input.cache_read, inTot)} cached, out ${fmt(r.output)}) ` +
  739. `${r.api_calls} calls` +
  740. (r.subagent_calls ? `, ${r.subagent_calls} subagents` : '') +
  741. ` ${(r.ts || '').slice(0, 16)} ${r.project}`,
  742. )
  743. line(` "${r.text}"`)
  744. }
  745. line(
  746. ' (note: internal background forks like task_summary/compact are not attributed to a prompt)',
  747. )
  748. hr()
  749. line('BY PROJECT (top by total input tokens)')
  750. const projects = [...perProject.entries()].sort(
  751. (a, b) => totalIn(b[1]) - totalIn(a[1]),
  752. )
  753. for (const [name, s] of projects.slice(0, TOP_N)) {
  754. printBlock(name, s, ' ')
  755. line()
  756. }
  757. if (projects.length > TOP_N)
  758. line(` … ${projects.length - TOP_N} more projects`)
  759. hr()
  760. line('BY SUBAGENT TYPE')
  761. const agents = [...perSubagent.entries()].sort(
  762. (a, b) => totalIn(b[1]) - totalIn(a[1]),
  763. )
  764. for (const [name, s] of agents) {
  765. printBlock(name, s, ' ')
  766. line()
  767. }
  768. hr()
  769. line(
  770. 'BY SKILL / SLASH COMMAND (tokens attributed = from invocation until next human msg)',
  771. )
  772. const skills = [...perSkill.entries()].sort(
  773. (a, b) => totalIn(b[1]) - totalIn(a[1]),
  774. )
  775. for (const [name, s] of skills.slice(0, TOP_N)) {
  776. printBlock(name, s, ' ')
  777. line()
  778. }
  779. if (skills.length > TOP_N) line(` … ${skills.length - TOP_N} more`)
  780. line()
  781. }
  782. function totalIn(s) {
  783. return s.inputUncached + s.inputCacheCreate + s.inputCacheRead
  784. }
  785. function printBlock(title, s, indent = '') {
  786. const inTotal = totalIn(s)
  787. console.log(`${indent}${title}`)
  788. console.log(
  789. `${indent} sessions: ${s.sessions.size} api calls: ${s.apiCalls} human msgs: ${s.humanMessages}`,
  790. )
  791. console.log(
  792. `${indent} input: ${fmt(inTotal)} total ` +
  793. `(uncached ${fmt(s.inputUncached)}, cache-create ${fmt(s.inputCacheCreate)}, cache-read ${fmt(s.inputCacheRead)} = ${pct(s.inputCacheRead, inTotal)} cached)`,
  794. )
  795. console.log(`${indent} output: ${fmt(s.outputTokens)}`)
  796. console.log(
  797. `${indent} hours: ${hrs(s.wallClockMs)} wall-clock, ${hrs(s.activeMs)} active (gaps >5m excluded)`,
  798. )
  799. console.log(
  800. `${indent} cache breaks >${fmt(CACHE_BREAK_THRESHOLD)}: ${s.cacheBreaks.length}`,
  801. )
  802. console.log(
  803. `${indent} subagents: ${s.subagentCalls} calls, ${fmt(s.subagentTokens)} tokens, avg ${fmt(
  804. s.subagentCalls ? Math.round(s.subagentTokens / s.subagentCalls) : 0,
  805. )}/call`,
  806. )
  807. const topSkills = Object.entries(s.skillInvocations)
  808. .sort((a, b) => b[1] - a[1])
  809. .slice(0, 5)
  810. if (topSkills.length)
  811. console.log(
  812. `${indent} skills: ${topSkills.map(([k, v]) => `${k}×${v}`).join(', ')}`,
  813. )
  814. }
  815. main().catch(e => {
  816. console.error(e)
  817. process.exit(1)
  818. })