tokentracker-cli 0.10.2 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,18 @@ const os = require("node:os");
15
15
  const path = require("node:path");
16
16
  const readline = require("node:readline");
17
17
 
18
+ const {
19
+ categorizeTool,
20
+ inferExecCommandKind,
21
+ sanitizeCommandSignature,
22
+ getExecutableName,
23
+ emptyTotals,
24
+ addInto,
25
+ roundTotals,
26
+ buildExecStatsEntry,
27
+ allocateByLargestRemainder,
28
+ } = require("./categorizer-utils");
29
+
18
30
  const CATEGORY_KEYS = [
19
31
  "system_prefix",
20
32
  "conversation_history",
@@ -27,14 +39,10 @@ const CATEGORY_KEYS = [
27
39
 
28
40
  const SUBAGENT_TOOL_NAMES = new Set(["Agent", "Task"]);
29
41
 
30
- function emptyTotals() {
42
+ function emptyToolBreakdown() {
31
43
  return {
32
- input_tokens: 0,
33
- cached_input_tokens: 0,
34
- cache_creation_input_tokens: 0,
35
- output_tokens: 0,
36
- reasoning_output_tokens: 0,
37
- total_tokens: 0,
44
+ total_calls: 0,
45
+ tools: [],
38
46
  };
39
47
  }
40
48
 
@@ -44,113 +52,281 @@ function emptyCategoryMap() {
44
52
  return out;
45
53
  }
46
54
 
47
- function addInto(target, source) {
48
- target.input_tokens += source.input_tokens || 0;
49
- target.cached_input_tokens += source.cached_input_tokens || 0;
50
- target.cache_creation_input_tokens += source.cache_creation_input_tokens || 0;
51
- target.output_tokens += source.output_tokens || 0;
52
- target.reasoning_output_tokens += source.reasoning_output_tokens || 0;
53
- target.total_tokens += source.total_tokens || 0;
55
+ function extractExecCommands(content) {
56
+ const commands = [];
57
+ for (const block of Array.isArray(content) ? content : []) {
58
+ if (!block || typeof block !== "object") continue;
59
+ if (block.type !== "tool_use") continue;
60
+ if (block.name !== "Bash" && block.name !== "exec_command") continue;
61
+ const input = block.input || {};
62
+ const command =
63
+ typeof input.command === "string" ? input.command
64
+ : typeof input.cmd === "string" ? input.cmd
65
+ : "";
66
+ if (command.trim()) commands.push(command.trim());
67
+ }
68
+ return commands;
54
69
  }
55
70
 
56
- function defaultClaudeProjectsDir() {
57
- return path.join(os.homedir(), ".claude", "projects");
71
+ function ensureExecRow(map, key) {
72
+ const safeKey = key || "unknown";
73
+ if (!map.has(safeKey)) map.set(safeKey, { name: safeKey, ...buildExecStatsEntry() });
74
+ return map.get(safeKey);
58
75
  }
59
76
 
60
- function listSessionFiles(rootDir) {
61
- const out = [];
62
- const stack = [rootDir];
63
- while (stack.length > 0) {
64
- const dir = stack.pop();
65
- let entries;
66
- try {
67
- entries = fssync.readdirSync(dir, { withFileTypes: true });
68
- } catch (_e) {
69
- continue;
77
+ function recordExecCommandUsage(execLedger, commands, totals) {
78
+ if (!execLedger || !Array.isArray(commands) || commands.length === 0) return;
79
+ const perCommandRows = new Map();
80
+ for (const command of commands) {
81
+ if (!perCommandRows.has(command)) perCommandRows.set(command, { calls: 0 });
82
+ perCommandRows.get(command).calls += 1;
83
+ }
84
+ const totalsByCommand = allocateTotalsAcrossRows(totals, perCommandRows);
85
+
86
+ for (const [command, row] of perCommandRows.entries()) {
87
+ const commandTotals = totalsByCommand.get(command) || {};
88
+ const kind = inferExecCommandKind(command);
89
+ const executable = getExecutableName(command);
90
+ const signature = sanitizeCommandSignature(command);
91
+ const exitKey = "unknown:unknown";
92
+
93
+ const targets = [
94
+ [execLedger.by_type, kind],
95
+ [execLedger.by_executable, executable],
96
+ [execLedger.by_command, signature],
97
+ [execLedger.by_exit, exitKey],
98
+ ];
99
+ for (const [map, key] of targets) {
100
+ const target = ensureExecRow(map, key);
101
+ target.calls += Math.max(1, Number(row.calls || 0));
102
+ addInto(target.totals, commandTotals);
70
103
  }
71
- for (const entry of entries) {
72
- const fp = path.join(dir, entry.name);
73
- if (entry.isDirectory()) {
74
- stack.push(fp);
75
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
76
- out.push(fp);
77
- }
104
+ execLedger.total_calls += Math.max(1, Number(row.calls || 0));
105
+ }
106
+ }
107
+
108
+ function allocateTotalsAcrossRows(totals, rows) {
109
+ const entries = Array.from(rows?.entries?.() || []);
110
+ if (entries.length === 0) return new Map();
111
+ const weights = {};
112
+ const order = entries.map(([name]) => name).sort();
113
+ for (const name of order) {
114
+ const row = rows.get(name) || {};
115
+ weights[name] = Math.max(0, Number(row.output_tokens || row.calls || 0));
116
+ }
117
+ if (order.every((name) => !weights[name])) {
118
+ for (const name of order) weights[name] = 1;
119
+ }
120
+ const out = new Map();
121
+ for (const name of order) out.set(name, emptyTotals());
122
+ for (const key of [
123
+ "input_tokens",
124
+ "cached_input_tokens",
125
+ "cache_creation_input_tokens",
126
+ "output_tokens",
127
+ "reasoning_output_tokens",
128
+ "total_tokens",
129
+ ]) {
130
+ const alloc = allocateByLargestRemainder(Math.max(0, Number(totals?.[key] || 0)), weights, order);
131
+ for (const name of order) {
132
+ out.get(name)[key] = alloc[name] || 0;
78
133
  }
79
134
  }
80
135
  return out;
81
136
  }
82
137
 
83
- // Distribute one assistant message's output tokens across categories by the
84
- // character-length ratio of each content block. Thinking goes to reasoning,
85
- // tool_use(Agent|Task) subagents, tool_use(other) tool_calls, text
86
- // assistant_response. If reasoning_output_tokens is reported separately, use
87
- // that exact figure for reasoning instead of pro-rating.
88
- function splitOutputByContent(usage, content, breakdown) {
138
+ function extractSkillNames(content) {
139
+ const names = [];
140
+ for (const block of Array.isArray(content) ? content : []) {
141
+ if (!block || typeof block !== "object") continue;
142
+ if (block.type !== "tool_use" || block.name !== "Skill") continue;
143
+ const skill = typeof block.input?.skill === "string" ? block.input.skill.trim() : "";
144
+ if (skill) names.push(skill);
145
+ }
146
+ return names;
147
+ }
148
+
149
+ function recordSkillUsage(skillLedger, skillNames, totals) {
150
+ if (!skillLedger || !Array.isArray(skillNames) || skillNames.length === 0) return;
151
+ const perMessageRows = new Map();
152
+ for (const name of skillNames) {
153
+ if (!perMessageRows.has(name)) perMessageRows.set(name, { calls: 0 });
154
+ perMessageRows.get(name).calls += 1;
155
+ }
156
+ const totalsByName = allocateTotalsAcrossRows(totals, perMessageRows);
157
+ for (const [name, row] of perMessageRows.entries()) {
158
+ if (!skillLedger.by_name.has(name)) {
159
+ skillLedger.by_name.set(name, { name, calls: 0, totals: emptyTotals() });
160
+ }
161
+ const target = skillLedger.by_name.get(name);
162
+ target.calls += row.calls || 0;
163
+ addInto(target.totals, totalsByName.get(name) || {});
164
+ }
165
+ skillLedger.total_calls += skillNames.length;
166
+ }
167
+
168
+ function computeOutputTokenBreakdown(usage, content) {
89
169
  const total = Math.max(0, Number(usage.output_tokens || 0));
90
170
  const reasoningExplicit = Math.max(0, Number(usage.reasoning_output_tokens || 0));
91
- if (total === 0) return;
92
-
93
171
  const blocks = Array.isArray(content) ? content : [];
94
- const buckets = { reasoning: 0, tool_calls: 0, subagents: 0, assistant_response: 0 };
95
- let totalChars = 0;
96
172
 
173
+ if (total === 0) {
174
+ return {
175
+ bucket_tokens: { reasoning: 0, tool_calls: 0, subagents: 0, assistant_response: 0 },
176
+ tool_calls: { total_calls: 0, by_name: new Map() },
177
+ subagents: { total_calls: 0, by_name: new Map() },
178
+ };
179
+ }
180
+
181
+ const bucketChars = { reasoning: 0, tool_calls: 0, subagents: 0, assistant_response: 0 };
182
+ const toolCallChars = new Map();
183
+ const subagentChars = new Map();
184
+ const toolCallCounts = new Map();
185
+ const subagentCounts = new Map();
186
+
187
+ let totalChars = 0;
97
188
  for (const block of blocks) {
98
189
  if (!block || typeof block !== "object") continue;
99
190
  const type = block.type;
100
191
  let chars = 0;
192
+
101
193
  if (type === "thinking") {
102
194
  chars = String(block.thinking || block.text || "").length || 1;
103
- buckets.reasoning += chars;
195
+ bucketChars.reasoning += chars;
104
196
  } else if (type === "text") {
105
197
  chars = String(block.text || "").length || 1;
106
- buckets.assistant_response += chars;
198
+ bucketChars.assistant_response += chars;
107
199
  } else if (type === "tool_use") {
108
200
  const inputJson = block.input ? JSON.stringify(block.input) : "";
109
201
  chars = (block.name || "").length + inputJson.length + 1;
110
- if (SUBAGENT_TOOL_NAMES.has(block.name)) buckets.subagents += chars;
111
- else buckets.tool_calls += chars;
202
+ if (SUBAGENT_TOOL_NAMES.has(block.name)) {
203
+ bucketChars.subagents += chars;
204
+ subagentChars.set(block.name, (subagentChars.get(block.name) || 0) + chars);
205
+ subagentCounts.set(block.name, (subagentCounts.get(block.name) || 0) + 1);
206
+ } else {
207
+ bucketChars.tool_calls += chars;
208
+ toolCallChars.set(block.name, (toolCallChars.get(block.name) || 0) + chars);
209
+ toolCallCounts.set(block.name, (toolCallCounts.get(block.name) || 0) + 1);
210
+ }
112
211
  } else {
113
212
  continue;
114
213
  }
214
+
115
215
  totalChars += chars;
116
216
  }
117
217
 
118
218
  if (totalChars === 0) {
119
- breakdown.assistant_response.output_tokens += total;
120
- breakdown.assistant_response.total_tokens += total;
121
- return;
219
+ return {
220
+ bucket_tokens: { reasoning: 0, tool_calls: 0, subagents: 0, assistant_response: total },
221
+ tool_calls: { total_calls: 0, by_name: new Map() },
222
+ subagents: { total_calls: 0, by_name: new Map() },
223
+ };
122
224
  }
123
225
 
124
- // If the API reported reasoning tokens explicitly, peel them off first
125
- // and pro-rate the rest of the output across the remaining buckets.
126
- let nonReasoningOutput = total;
127
- if (reasoningExplicit > 0) {
128
- const reasoningShare = Math.min(reasoningExplicit, total);
129
- breakdown.reasoning.output_tokens += reasoningShare;
130
- breakdown.reasoning.reasoning_output_tokens += reasoningShare;
131
- breakdown.reasoning.total_tokens += reasoningShare;
132
- nonReasoningOutput = total - reasoningShare;
133
- // Drop the thinking-char contribution; it was just paid for.
134
- totalChars -= buckets.reasoning;
135
- buckets.reasoning = 0;
136
- }
226
+ const explicitReasoning = reasoningExplicit > 0 ? Math.min(reasoningExplicit, total) : 0;
227
+ const nonReasoningOutput = total - explicitReasoning;
137
228
 
138
- if (nonReasoningOutput <= 0 || totalChars <= 0) return;
229
+ const allocChars = { ...bucketChars };
230
+ let allocTotalChars = totalChars;
231
+ if (explicitReasoning > 0) {
232
+ allocTotalChars -= allocChars.reasoning;
233
+ allocChars.reasoning = 0;
234
+ }
139
235
 
140
- // Largest-remainder rounding so the four sub-buckets sum exactly to
141
- // nonReasoningOutput (no off-by-one drift across thousands of messages).
142
236
  const order = ["reasoning", "tool_calls", "subagents", "assistant_response"];
143
- const exact = order.map((k) => (buckets[k] / totalChars) * nonReasoningOutput);
144
- const floored = exact.map((x) => Math.floor(x));
145
- const remainder = nonReasoningOutput - floored.reduce((a, b) => a + b, 0);
146
- const remainders = exact
147
- .map((x, i) => ({ i, frac: x - Math.floor(x) }))
148
- .sort((a, b) => b.frac - a.frac);
149
- for (let k = 0; k < remainder; k++) floored[remainders[k % order.length].i] += 1;
150
-
151
- for (let i = 0; i < order.length; i++) {
152
- const key = order[i];
153
- const tok = floored[i];
237
+ const prorated = allocateByLargestRemainder(Math.max(0, nonReasoningOutput), allocChars, order);
238
+
239
+ const bucketTokens = {
240
+ reasoning: explicitReasoning + (prorated.reasoning || 0),
241
+ tool_calls: prorated.tool_calls || 0,
242
+ subagents: prorated.subagents || 0,
243
+ assistant_response: prorated.assistant_response || 0,
244
+ };
245
+
246
+ if (allocTotalChars <= 0 && nonReasoningOutput > 0) {
247
+ bucketTokens.reasoning = explicitReasoning;
248
+ bucketTokens.tool_calls = 0;
249
+ bucketTokens.subagents = 0;
250
+ bucketTokens.assistant_response = nonReasoningOutput;
251
+ }
252
+
253
+ const toolTokensByName = new Map();
254
+ if (bucketTokens.tool_calls > 0 && toolCallChars.size > 0) {
255
+ const keys = [...toolCallChars.keys()].sort();
256
+ const weights = {};
257
+ for (const k of keys) weights[k] = toolCallChars.get(k) || 0;
258
+ const alloc = allocateByLargestRemainder(bucketTokens.tool_calls, weights, keys);
259
+ for (const k of keys) toolTokensByName.set(k, alloc[k] || 0);
260
+ }
261
+
262
+ const subagentTokensByName = new Map();
263
+ if (bucketTokens.subagents > 0 && subagentChars.size > 0) {
264
+ const keys = [...subagentChars.keys()].sort();
265
+ const weights = {};
266
+ for (const k of keys) weights[k] = subagentChars.get(k) || 0;
267
+ const alloc = allocateByLargestRemainder(bucketTokens.subagents, weights, keys);
268
+ for (const k of keys) subagentTokensByName.set(k, alloc[k] || 0);
269
+ }
270
+
271
+ return {
272
+ bucket_tokens: bucketTokens,
273
+ tool_calls: {
274
+ total_calls: [...toolCallCounts.values()].reduce((a, b) => a + b, 0),
275
+ by_name: new Map(
276
+ [...toolCallCounts.entries()].map(([name, calls]) => [
277
+ name,
278
+ { name, calls, output_tokens: toolTokensByName.get(name) || 0 },
279
+ ]),
280
+ ),
281
+ },
282
+ subagents: {
283
+ total_calls: [...subagentCounts.values()].reduce((a, b) => a + b, 0),
284
+ by_name: new Map(
285
+ [...subagentCounts.entries()].map(([name, calls]) => [
286
+ name,
287
+ { name, calls, output_tokens: subagentTokensByName.get(name) || 0 },
288
+ ]),
289
+ ),
290
+ },
291
+ };
292
+ }
293
+
294
+ function defaultClaudeProjectsDir() {
295
+ return path.join(os.homedir(), ".claude", "projects");
296
+ }
297
+
298
+ function listSessionFiles(rootDir) {
299
+ const out = [];
300
+ const stack = [rootDir];
301
+ while (stack.length > 0) {
302
+ const dir = stack.pop();
303
+ let entries;
304
+ try {
305
+ entries = fssync.readdirSync(dir, { withFileTypes: true });
306
+ } catch (_e) {
307
+ continue;
308
+ }
309
+ for (const entry of entries) {
310
+ const fp = path.join(dir, entry.name);
311
+ if (entry.isDirectory()) {
312
+ stack.push(fp);
313
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
314
+ out.push(fp);
315
+ }
316
+ }
317
+ }
318
+ return out;
319
+ }
320
+
321
+ // Distribute one assistant message's output tokens across categories by the
322
+ // character-length ratio of each content block. Thinking goes to reasoning,
323
+ // tool_use(Agent|Task) → subagents, tool_use(other) → tool_calls, text →
324
+ // assistant_response. If reasoning_output_tokens is reported separately, use
325
+ // that exact figure for reasoning instead of pro-rating.
326
+ function splitOutputByContent(usage, content, breakdown) {
327
+ const out = computeOutputTokenBreakdown(usage, content);
328
+ for (const key of ["reasoning", "tool_calls", "subagents", "assistant_response"]) {
329
+ const tok = out.bucket_tokens[key] || 0;
154
330
  if (tok === 0) continue;
155
331
  breakdown[key].output_tokens += tok;
156
332
  breakdown[key].total_tokens += tok;
@@ -161,7 +337,7 @@ function splitOutputByContent(usage, content, breakdown) {
161
337
  // Per-session state lets us pick out the *first* meaningful cache_creation
162
338
  // chunk and call that the system_prefix. Subsequent cache_creations are
163
339
  // incremental — we attribute them to conversation_history.
164
- function classifyOneMessage(obj, sessionState, breakdown) {
340
+ function classifyOneMessage(obj, sessionState, breakdown, toolLedger = null, skillLedger = null, execLedger = null) {
165
341
  const usage = obj?.message?.usage;
166
342
  if (!usage || typeof usage !== "object") return;
167
343
 
@@ -195,18 +371,90 @@ function classifyOneMessage(obj, sessionState, breakdown) {
195
371
  }
196
372
  }
197
373
 
374
+ recordSkillUsage(
375
+ skillLedger,
376
+ extractSkillNames(obj?.message?.content),
377
+ {
378
+ input_tokens: inputNonCached,
379
+ cached_input_tokens: cacheRead,
380
+ cache_creation_input_tokens: cacheCreate,
381
+ output_tokens: output,
382
+ reasoning_output_tokens: 0,
383
+ total_tokens: inputNonCached + cacheRead + cacheCreate + output,
384
+ },
385
+ );
386
+
198
387
  // Split output across reasoning / tool_calls / subagents / assistant_response.
199
388
  if (output > 0) {
200
- splitOutputByContent(
389
+ const out = computeOutputTokenBreakdown(
201
390
  { output_tokens: output, reasoning_output_tokens: usage.reasoning_output_tokens },
202
391
  obj?.message?.content,
203
- breakdown,
204
392
  );
393
+ for (const key of ["reasoning", "tool_calls", "subagents", "assistant_response"]) {
394
+ const tok = out.bucket_tokens[key] || 0;
395
+ if (tok === 0) continue;
396
+ breakdown[key].output_tokens += tok;
397
+ breakdown[key].total_tokens += tok;
398
+ if (key === "reasoning") breakdown[key].reasoning_output_tokens += tok;
399
+ }
400
+
401
+ recordExecCommandUsage(
402
+ execLedger,
403
+ extractExecCommands(obj?.message?.content),
404
+ {
405
+ input_tokens: inputNonCached,
406
+ cached_input_tokens: cacheRead,
407
+ cache_creation_input_tokens: cacheCreate,
408
+ output_tokens: out.bucket_tokens.tool_calls || 0,
409
+ reasoning_output_tokens: 0,
410
+ total_tokens: inputNonCached + cacheRead + cacheCreate + (out.bucket_tokens.tool_calls || 0),
411
+ },
412
+ );
413
+
414
+ if (toolLedger) {
415
+ const ledgerTotals = {
416
+ input_tokens: inputNonCached,
417
+ cached_input_tokens: cacheRead,
418
+ cache_creation_input_tokens: cacheCreate,
419
+ output_tokens: out.bucket_tokens.tool_calls || 0,
420
+ reasoning_output_tokens: 0,
421
+ total_tokens: inputNonCached + cacheRead + cacheCreate + (out.bucket_tokens.tool_calls || 0),
422
+ };
423
+ const toolTotalsByName = allocateTotalsAcrossRows(ledgerTotals, out.tool_calls.by_name);
424
+ for (const [name, row] of out.tool_calls.by_name.entries()) {
425
+ if (!toolLedger.tool_calls.by_name.has(name)) {
426
+ toolLedger.tool_calls.by_name.set(name, { name, calls: 0, totals: emptyTotals() });
427
+ }
428
+ const target = toolLedger.tool_calls.by_name.get(name);
429
+ target.calls += row.calls || 0;
430
+ addInto(target.totals, toolTotalsByName.get(name) || {});
431
+ }
432
+ toolLedger.tool_calls.total_calls += out.tool_calls.total_calls || 0;
433
+
434
+ const subagentTotals = {
435
+ input_tokens: inputNonCached,
436
+ cached_input_tokens: cacheRead,
437
+ cache_creation_input_tokens: cacheCreate,
438
+ output_tokens: out.bucket_tokens.subagents || 0,
439
+ reasoning_output_tokens: 0,
440
+ total_tokens: inputNonCached + cacheRead + cacheCreate + (out.bucket_tokens.subagents || 0),
441
+ };
442
+ const subagentTotalsByName = allocateTotalsAcrossRows(subagentTotals, out.subagents.by_name);
443
+ for (const [name, row] of out.subagents.by_name.entries()) {
444
+ if (!toolLedger.subagents.by_name.has(name)) {
445
+ toolLedger.subagents.by_name.set(name, { name, calls: 0, totals: emptyTotals() });
446
+ }
447
+ const target = toolLedger.subagents.by_name.get(name);
448
+ target.calls += row.calls || 0;
449
+ addInto(target.totals, subagentTotalsByName.get(name) || {});
450
+ }
451
+ toolLedger.subagents.total_calls += out.subagents.total_calls || 0;
452
+ }
205
453
  }
206
454
  }
207
455
 
208
456
  // Read one session jsonl streaming, in timestamp range, dedup by msgId+reqId.
209
- async function categorizeSessionFile(filePath, { fromIso, toIso, seenHashes }, breakdown) {
457
+ async function categorizeSessionFile(filePath, { fromIso, toIso, seenHashes }, breakdown, toolLedger = null, skillLedger = null, execLedger = null) {
210
458
  let stream;
211
459
  try {
212
460
  stream = fssync.createReadStream(filePath, { encoding: "utf8" });
@@ -238,7 +486,7 @@ async function categorizeSessionFile(filePath, { fromIso, toIso, seenHashes }, b
238
486
  seenHashes.add(hash);
239
487
  }
240
488
 
241
- classifyOneMessage(obj, sessionState, breakdown);
489
+ classifyOneMessage(obj, sessionState, breakdown, toolLedger, skillLedger, execLedger);
242
490
  counted += 1;
243
491
  }
244
492
  rl.close();
@@ -271,6 +519,7 @@ function dayKeyToIsoBounds(from, to) {
271
519
  // case the watcher misses something.
272
520
  const CACHE = new Map();
273
521
  const CACHE_TTL_MS = 60_000;
522
+ const CACHE_SCHEMA_VERSION = "skills-exec-v2";
274
523
 
275
524
  function maxMtimeMs(files) {
276
525
  let max = 0;
@@ -303,7 +552,7 @@ async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir
303
552
  };
304
553
  }
305
554
 
306
- const cacheKey = `${root}|${from || ""}|${to || ""}|${files.length}|${maxMtimeMs(files)}`;
555
+ const cacheKey = `${CACHE_SCHEMA_VERSION}|${root}|${from || ""}|${to || ""}|${files.length}|${maxMtimeMs(files)}`;
307
556
  const cached = CACHE.get(cacheKey);
308
557
  if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
309
558
  return cached.value;
@@ -314,12 +563,27 @@ async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir
314
563
  const seenHashes = new Set();
315
564
  let messageCount = 0;
316
565
  let sessionCount = 0;
566
+ const toolLedger = {
567
+ tool_calls: { total_calls: 0, by_name: new Map() },
568
+ subagents: { total_calls: 0, by_name: new Map() },
569
+ };
570
+ const skillLedger = { total_calls: 0, by_name: new Map() };
571
+ const execLedger = {
572
+ total_calls: 0,
573
+ by_type: new Map(),
574
+ by_executable: new Map(),
575
+ by_command: new Map(),
576
+ by_exit: new Map(),
577
+ };
317
578
 
318
579
  for (const fp of files) {
319
580
  const counted = await categorizeSessionFile(
320
581
  fp,
321
582
  { fromIso, toIso, seenHashes },
322
583
  breakdown,
584
+ toolLedger,
585
+ skillLedger,
586
+ execLedger,
323
587
  );
324
588
  if (counted > 0) sessionCount += 1;
325
589
  messageCount += counted;
@@ -341,17 +605,210 @@ async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir
341
605
  }),
342
606
  session_count: sessionCount,
343
607
  message_count: messageCount,
608
+ message_breakdown: buildMessageBreakdown(breakdown),
609
+ tool_calls_breakdown: buildToolCallsBreakdown(toolLedger),
610
+ skills_breakdown: buildSkillsBreakdown(skillLedger),
611
+ exec_command_breakdown: buildExecCommandBreakdown(execLedger),
612
+ configured_resources: getConfiguredResources({ projectDir }),
344
613
  };
345
614
 
346
615
  CACHE.set(cacheKey, { at: Date.now(), value: result });
347
616
  // Bound cache size — categorizer is cheap to recompute, no point hoarding.
348
- if (CACHE.size > 32) {
617
+ while (CACHE.size > 32) {
349
618
  const oldest = [...CACHE.entries()].sort((a, b) => a[1].at - b[1].at)[0];
350
619
  if (oldest) CACHE.delete(oldest[0]);
351
620
  }
352
621
  return result;
353
622
  }
354
623
 
624
+ function buildToolCallsBreakdown(toolLedger) {
625
+ if (!toolLedger || !toolLedger.tool_calls || !toolLedger.subagents) {
626
+ return {
627
+ tool_calls: emptyToolBreakdown(),
628
+ subagents: emptyToolBreakdown(),
629
+ };
630
+ }
631
+
632
+ function mapToSortedRows(map) {
633
+ const rows = Array.from(map.values()).map((row) => ({
634
+ name: row.name,
635
+ calls: row.calls,
636
+ totals: row.totals || {
637
+ ...emptyTotals(),
638
+ output_tokens: row.output_tokens || 0,
639
+ total_tokens: row.total_tokens || row.output_tokens || 0,
640
+ },
641
+ }));
642
+ rows.sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
643
+ return rows;
644
+ }
645
+
646
+ const toolCallsRows = mapToSortedRows(toolLedger.tool_calls.by_name || new Map());
647
+ const subagentRows = mapToSortedRows(toolLedger.subagents.by_name || new Map());
648
+
649
+ function groupIntoCategories(rows) {
650
+ const byCategory = new Map(); // name -> {name,calls,totals,tools:[]}
651
+ for (const row of rows) {
652
+ const toolName = String(row?.name || "");
653
+ if (!toolName) continue;
654
+ const cat = categorizeTool(toolName);
655
+ if (!byCategory.has(cat)) {
656
+ byCategory.set(cat, {
657
+ name: cat,
658
+ calls: 0,
659
+ totals: emptyTotals(),
660
+ tools: [],
661
+ });
662
+ }
663
+ const bucket = byCategory.get(cat);
664
+ bucket.calls += Number(row.calls || 0);
665
+ addInto(bucket.totals, row.totals || {});
666
+ bucket.tools.push({
667
+ name: toolName,
668
+ calls: Number(row.calls || 0),
669
+ totals: row.totals || emptyTotals(),
670
+ });
671
+ }
672
+ const categories = Array.from(byCategory.values())
673
+ .map((c) => ({
674
+ name: c.name,
675
+ calls: Math.round(c.calls || 0),
676
+ totals: {
677
+ input_tokens: Math.round(c.totals.input_tokens || 0),
678
+ cached_input_tokens: Math.round(c.totals.cached_input_tokens || 0),
679
+ cache_creation_input_tokens: Math.round(c.totals.cache_creation_input_tokens || 0),
680
+ output_tokens: Math.round(c.totals.output_tokens || 0),
681
+ reasoning_output_tokens: Math.round(c.totals.reasoning_output_tokens || 0),
682
+ total_tokens: Math.round(c.totals.total_tokens || 0),
683
+ },
684
+ tools: c.tools
685
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0))
686
+ .map((t) => ({
687
+ name: t.name,
688
+ calls: Math.round(t.calls || 0),
689
+ totals: {
690
+ input_tokens: Math.round(t.totals.input_tokens || 0),
691
+ cached_input_tokens: Math.round(t.totals.cached_input_tokens || 0),
692
+ cache_creation_input_tokens: Math.round(t.totals.cache_creation_input_tokens || 0),
693
+ output_tokens: Math.round(t.totals.output_tokens || 0),
694
+ reasoning_output_tokens: Math.round(t.totals.reasoning_output_tokens || 0),
695
+ total_tokens: Math.round(t.totals.total_tokens || 0),
696
+ },
697
+ })),
698
+ }))
699
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
700
+ return categories;
701
+ }
702
+
703
+ return {
704
+ tool_calls: {
705
+ total_calls: toolLedger.tool_calls.total_calls || 0,
706
+ tools: toolCallsRows,
707
+ categories: groupIntoCategories(toolCallsRows),
708
+ },
709
+ subagents: {
710
+ total_calls: toolLedger.subagents.total_calls || 0,
711
+ tools: subagentRows,
712
+ categories: groupIntoCategories(subagentRows),
713
+ },
714
+ privacy: {
715
+ includes_inputs: false,
716
+ note: "Aggregated tool names only; tool inputs are never recorded.",
717
+ },
718
+ };
719
+ }
720
+
721
+ function buildSkillsBreakdown(skillLedger) {
722
+ const rows = Array.from(skillLedger?.by_name?.values?.() || [])
723
+ .map((row) => ({
724
+ name: row.name,
725
+ calls: Math.round(row.calls || 0),
726
+ totals: roundTotals(row.totals || emptyTotals()),
727
+ }))
728
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
729
+
730
+ return {
731
+ total_calls: Math.round(skillLedger?.total_calls || 0),
732
+ skills: rows,
733
+ privacy: {
734
+ includes_inputs: false,
735
+ note: "Aggregated skill names only; skill inputs are never returned.",
736
+ },
737
+ };
738
+ }
739
+
740
+ function serializeExecRows(map) {
741
+ return Array.from(map?.values?.() || [])
742
+ .map((row) => ({
743
+ name: row.name,
744
+ calls: Math.round(row.calls || 0),
745
+ failures: Math.round(row.failures || 0),
746
+ duration_ms: Math.round(row.duration_ms || 0),
747
+ max_duration_ms: Math.round(row.max_duration_ms || 0),
748
+ output_chars: Math.round(row.output_chars || 0),
749
+ output_lines: Math.round(row.output_lines || 0),
750
+ totals: roundTotals(row.totals || emptyTotals()),
751
+ }))
752
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
753
+ }
754
+
755
+ function buildExecCommandBreakdown(execLedger) {
756
+ return {
757
+ total_calls: Math.round(execLedger?.total_calls || 0),
758
+ by_type: serializeExecRows(execLedger?.by_type),
759
+ by_executable: serializeExecRows(execLedger?.by_executable),
760
+ by_command: serializeExecRows(execLedger?.by_command),
761
+ by_duration: [],
762
+ by_output: [],
763
+ by_exit: serializeExecRows(execLedger?.by_exit),
764
+ privacy: {
765
+ includes_commands: false,
766
+ note: "Claude Bash commands are grouped into sanitized signatures; raw commands are not returned.",
767
+ },
768
+ };
769
+ }
770
+
771
+ function buildMessageBreakdown(breakdown) {
772
+ const rows = [
773
+ {
774
+ key: "user_input",
775
+ name: "User input",
776
+ totals: breakdown.user_input || emptyTotals(),
777
+ },
778
+ {
779
+ key: "conversation_history",
780
+ name: "Conversation history",
781
+ totals: breakdown.conversation_history || emptyTotals(),
782
+ },
783
+ {
784
+ key: "assistant_response",
785
+ name: "Assistant response",
786
+ totals: breakdown.assistant_response || emptyTotals(),
787
+ },
788
+ ];
789
+
790
+ return {
791
+ categories: rows
792
+ .map((row) => ({
793
+ key: row.key,
794
+ name: row.name,
795
+ totals: {
796
+ input_tokens: Math.round(row.totals.input_tokens || 0),
797
+ cached_input_tokens: Math.round(row.totals.cached_input_tokens || 0),
798
+ cache_creation_input_tokens: Math.round(row.totals.cache_creation_input_tokens || 0),
799
+ output_tokens: Math.round(row.totals.output_tokens || 0),
800
+ reasoning_output_tokens: Math.round(row.totals.reasoning_output_tokens || 0),
801
+ total_tokens: Math.round(row.totals.total_tokens || 0),
802
+ },
803
+ }))
804
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0)),
805
+ privacy: {
806
+ includes_content: false,
807
+ note: "Aggregated message token categories only; prompt and assistant text are never returned.",
808
+ },
809
+ };
810
+ }
811
+
355
812
  // Lightweight on-disk count of static resources Claude Code's /context UI
356
813
  // also surfaces (Skills, MCP servers, Memory files, Custom agents). These are
357
814
  // counts of what's *installed*, not historical token usage — the same way