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.
@@ -0,0 +1,623 @@
1
+ // Codex rollout JSONL parser — extracted from codex-context-breakdown.js.
2
+ //
3
+ // Handles file discovery and per-file parsing. Does NOT hold any aggregation
4
+ // state; callers (computeCodexContextBreakdown) own the merge step.
5
+
6
+ "use strict";
7
+
8
+ const fs = require("node:fs");
9
+ const path = require("node:path");
10
+ const readline = require("node:readline");
11
+
12
+ const { listRolloutFiles } = require("./rollout");
13
+ const {
14
+ emptyTotals,
15
+ addInto,
16
+ inferExecCommandKind,
17
+ sanitizeCommandSignature,
18
+ getExecutableName,
19
+ buildExecStatsEntry,
20
+ } = require("./categorizer-utils");
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Timezone helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function dayKeyToIsoBounds(from, to) {
27
+ if (!from && !to) return { fromIso: null, toIso: null };
28
+ const fromDate = from ? new Date(`${from}T00:00:00Z`) : null;
29
+ const toDate = to ? new Date(`${to}T23:59:59Z`) : null;
30
+ if (fromDate && Number.isFinite(fromDate.getTime())) fromDate.setUTCHours(fromDate.getUTCHours() - 14);
31
+ if (toDate && Number.isFinite(toDate.getTime())) toDate.setUTCHours(toDate.getUTCHours() + 14);
32
+ return {
33
+ fromIso: fromDate ? fromDate.toISOString() : null,
34
+ toIso: toDate ? toDate.toISOString() : null,
35
+ };
36
+ }
37
+
38
+ function formatPartsDayKey(parts) {
39
+ if (!parts) return "";
40
+ const values = {};
41
+ for (const part of parts) {
42
+ if (part.type !== "literal") values[part.type] = part.value;
43
+ }
44
+ if (!values.year || !values.month || !values.day) return "";
45
+ return `${values.year}-${values.month}-${values.day}`;
46
+ }
47
+
48
+ function getZonedParts(date, timeZoneContext = {}) {
49
+ const { timeZone, offsetMinutes } = timeZoneContext || {};
50
+ const dt = date instanceof Date ? date : new Date(date);
51
+ if (!Number.isFinite(dt.getTime())) return null;
52
+
53
+ if (timeZone && typeof Intl !== "undefined" && Intl.DateTimeFormat) {
54
+ try {
55
+ return new Intl.DateTimeFormat("en-CA", {
56
+ timeZone,
57
+ year: "numeric",
58
+ month: "2-digit",
59
+ day: "2-digit",
60
+ hourCycle: "h23",
61
+ }).formatToParts(dt);
62
+ } catch {
63
+ // Fall through to offset handling.
64
+ }
65
+ }
66
+
67
+ if (Number.isFinite(offsetMinutes)) {
68
+ const shifted = new Date(dt.getTime() - Number(offsetMinutes) * 60_000);
69
+ return [
70
+ { type: "year", value: String(shifted.getUTCFullYear()).padStart(4, "0") },
71
+ { type: "month", value: String(shifted.getUTCMonth() + 1).padStart(2, "0") },
72
+ { type: "day", value: String(shifted.getUTCDate()).padStart(2, "0") },
73
+ ];
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ function timestampDayKey(timestamp, timeZoneContext) {
80
+ const ts = typeof timestamp === "string" ? timestamp : "";
81
+ if (!ts) return "";
82
+ const parts = getZonedParts(ts, timeZoneContext);
83
+ const zoned = formatPartsDayKey(parts);
84
+ if (zoned) return zoned;
85
+ return ts.slice(0, 10);
86
+ }
87
+
88
+ function isTimestampInRequestedDayRange(timestamp, { from, to, timeZoneContext } = {}) {
89
+ if (!from && !to) return true;
90
+ const day = timestampDayKey(timestamp, timeZoneContext);
91
+ if (!day) return false;
92
+ if (from && day < from) return false;
93
+ if (to && day > to) return false;
94
+ return true;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // File discovery
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function safeJsonParse(str) {
102
+ try {
103
+ return JSON.parse(str);
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function listJsonlFiles(rootDir) {
110
+ const out = [];
111
+ const stack = [rootDir];
112
+
113
+ while (stack.length > 0) {
114
+ const dir = stack.pop();
115
+ let entries;
116
+ try {
117
+ entries = fs.readdirSync(dir, { withFileTypes: true });
118
+ } catch {
119
+ continue;
120
+ }
121
+
122
+ for (const entry of entries) {
123
+ const filePath = path.join(dir, entry.name);
124
+ if (entry.isDirectory()) {
125
+ stack.push(filePath);
126
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
127
+ out.push(filePath);
128
+ }
129
+ }
130
+ }
131
+
132
+ out.sort((a, b) => a.localeCompare(b));
133
+ return out;
134
+ }
135
+
136
+ async function listCodexSessionFiles(baseDir) {
137
+ const rolloutFiles = await listRolloutFiles(baseDir).catch(() => []);
138
+ const allJsonlFiles = listJsonlFiles(baseDir);
139
+ if (allJsonlFiles.length === 0) return rolloutFiles;
140
+ if (rolloutFiles.length === 0) return allJsonlFiles;
141
+
142
+ const merged = new Set(rolloutFiles);
143
+ for (const filePath of allJsonlFiles) merged.add(filePath);
144
+ return Array.from(merged).sort((a, b) => a.localeCompare(b));
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Token count extraction
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function extractTokenCount(obj) {
152
+ const payload = obj?.payload;
153
+ if (!payload || obj?.type !== "event_msg") return null;
154
+ if (payload.type === "token_count") {
155
+ return { info: payload.info || null, timestamp: obj?.timestamp || null };
156
+ }
157
+ const msg = payload.msg;
158
+ if (msg && msg.type === "token_count") {
159
+ return { info: msg.info || null, timestamp: obj?.timestamp || null };
160
+ }
161
+ return null;
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Tool name helpers
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function normalizeToolName(payload) {
169
+ const name = payload?.name || "";
170
+ const ns = payload?.namespace || null;
171
+ if (ns && typeof ns === "string" && ns.startsWith("mcp__")) return `${ns}${name}`;
172
+ return name || "unknown";
173
+ }
174
+
175
+ function extractSkillNameFromFunctionCall(payload) {
176
+ if (!payload || payload.name !== "exec_command") return null;
177
+ const args = safeJsonParse(payload.arguments || "{}") || {};
178
+ const cmd = String(args.cmd || "");
179
+ const match = cmd.match(/(?:^|\/)skills\/(?:\.system\/)?([^/\s]+)\/SKILL\.md\b/);
180
+ return match ? match[1] : null;
181
+ }
182
+
183
+ function formatToolDisplayName(name) {
184
+ if (typeof name !== "string" || !name.startsWith("mcp__")) return name;
185
+ const parts = name.split("__");
186
+ if (parts.length < 3) return name;
187
+ const server = String(parts[1] || "").replace(/^plugin_/, "").replace(/_/g, "-");
188
+ const tool = parts.slice(2).join("__") || name;
189
+ return server ? `${server}/${tool}` : tool;
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Usage normalization
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function normalizeUsage(u) {
197
+ const out = {};
198
+ for (const k of [
199
+ "input_tokens",
200
+ "cached_input_tokens",
201
+ "cache_creation_input_tokens",
202
+ "output_tokens",
203
+ "reasoning_output_tokens",
204
+ "total_tokens",
205
+ ]) {
206
+ const n = Number(u?.[k] || 0);
207
+ out[k] = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
208
+ }
209
+ // Codex reports input inclusive of cached_input_tokens; keep our schema
210
+ // convention: non-cached input and cached input tracked separately.
211
+ out.input_tokens = Math.max(0, out.input_tokens - out.cached_input_tokens);
212
+ out.total_tokens =
213
+ out.input_tokens +
214
+ out.cached_input_tokens +
215
+ out.cache_creation_input_tokens +
216
+ out.output_tokens;
217
+ return out;
218
+ }
219
+
220
+ function totalsReset(curr, prev) {
221
+ const a = Number(curr?.total_tokens);
222
+ const b = Number(prev?.total_tokens);
223
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
224
+ return a < b;
225
+ }
226
+
227
+ function pickDelta(lastUsage, totalUsage, prevTotals) {
228
+ const hasLast = lastUsage && typeof lastUsage === "object";
229
+ const hasTotal = totalUsage && typeof totalUsage === "object";
230
+ const hasPrev = prevTotals && typeof prevTotals === "object";
231
+
232
+ if (hasTotal && hasPrev) {
233
+ if (totalsReset(totalUsage, prevTotals)) {
234
+ const resetUsage = hasLast ? lastUsage : totalUsage;
235
+ return normalizeUsage(resetUsage);
236
+ }
237
+ const delta = {};
238
+ for (const k of [
239
+ "input_tokens",
240
+ "cached_input_tokens",
241
+ "cache_creation_input_tokens",
242
+ "output_tokens",
243
+ "reasoning_output_tokens",
244
+ "total_tokens",
245
+ ]) {
246
+ const a = Number(totalUsage[k]);
247
+ const b = Number(prevTotals[k]);
248
+ if (Number.isFinite(a) && Number.isFinite(b)) delta[k] = Math.max(0, a - b);
249
+ }
250
+ return normalizeUsage(delta);
251
+ }
252
+
253
+ if (hasLast) return normalizeUsage(lastUsage);
254
+ if (hasTotal) return normalizeUsage(totalUsage);
255
+ return null;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Exec stats helpers (local to parser, not shared)
260
+ // ---------------------------------------------------------------------------
261
+
262
+ function durationBucket(ms) {
263
+ const n = Number(ms || 0);
264
+ if (!Number.isFinite(n) || n <= 0) return "unknown";
265
+ if (n < 1000) return "<1s";
266
+ if (n < 10_000) return "1-10s";
267
+ if (n < 60_000) return "10-60s";
268
+ if (n < 300_000) return "1-5m";
269
+ return ">5m";
270
+ }
271
+
272
+ function outputSizeBucket(lines, chars) {
273
+ const l = Number(lines || 0);
274
+ const c = Number(chars || 0);
275
+ if (!l && !c) return "quiet";
276
+ if (l <= 20 && c <= 2_000) return "small";
277
+ if (l <= 200 && c <= 20_000) return "medium";
278
+ if (l <= 1000 && c <= 100_000) return "large";
279
+ return "very_large";
280
+ }
281
+
282
+ function buildToolStatsEntry() {
283
+ return { calls: 0, totals: emptyTotals() };
284
+ }
285
+
286
+ function buildSkillStatsEntry(name) {
287
+ return { name, calls: 0, totals: emptyTotals() };
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Finalize helpers
292
+ // ---------------------------------------------------------------------------
293
+
294
+ function finalizeToolRows(map) {
295
+ return Array.from(map.values())
296
+ .map((row) => {
297
+ const rawName = row.raw_name || row.name;
298
+ return {
299
+ name: formatToolDisplayName(rawName),
300
+ raw_name: rawName,
301
+ calls: row.calls,
302
+ totals: row.totals,
303
+ };
304
+ })
305
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
306
+ }
307
+
308
+ function finalizeSkillRows(map) {
309
+ return Array.from(map.values())
310
+ .map((row) => ({
311
+ name: row.name,
312
+ calls: row.calls,
313
+ totals: row.totals,
314
+ }))
315
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
316
+ }
317
+
318
+ function finalizeExecRows(map) {
319
+ return Array.from(map.values())
320
+ .map((row) => ({
321
+ name: row.name,
322
+ calls: row.calls,
323
+ failures: row.failures,
324
+ duration_ms: row.duration_ms,
325
+ max_duration_ms: row.max_duration_ms,
326
+ output_chars: row.output_chars,
327
+ output_lines: row.output_lines,
328
+ totals: row.totals,
329
+ }))
330
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Main parser
335
+ // ---------------------------------------------------------------------------
336
+
337
+ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to = null, timeZoneContext = null } = {}) {
338
+ const stream = fs.createReadStream(filePath, { encoding: "utf8" });
339
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
340
+
341
+ let sessionId = null;
342
+ let cwd = null;
343
+ let model = null;
344
+ let provider = null;
345
+ let cliVersion = null;
346
+
347
+ let prevTotals = null;
348
+ let pendingCalls = []; // response_item function_call payloads since last token_count
349
+ let pendingSkills = [];
350
+ let pendingExecEnds = []; // exec_command_end payloads since last token_count
351
+
352
+ const totals = emptyTotals();
353
+ const byTool = new Map(); // tool_name -> {name,calls,totals}
354
+ const bySkill = new Map(); // skill_name -> {name,calls,totals}
355
+ const byExecKind = new Map(); // kind -> stats
356
+ const byExecExit = new Map(); // status:exit -> stats
357
+ const byExecExecutable = new Map(); // executable -> stats
358
+ const byExecCommand = new Map(); // sanitized executable + subcommand -> stats
359
+ const byExecDuration = new Map(); // duration bucket -> stats
360
+ const byExecOutput = new Map(); // output size bucket -> stats
361
+
362
+ let turnCount = 0;
363
+
364
+ function ensureTool(name) {
365
+ if (!byTool.has(name)) {
366
+ byTool.set(name, { name, ...buildToolStatsEntry() });
367
+ }
368
+ return byTool.get(name);
369
+ }
370
+
371
+ function ensureExec(map, key) {
372
+ if (!map.has(key)) map.set(key, { name: key, ...buildExecStatsEntry() });
373
+ return map.get(key);
374
+ }
375
+
376
+ function ensureSkill(name) {
377
+ if (!bySkill.has(name)) bySkill.set(name, buildSkillStatsEntry(name));
378
+ return bySkill.get(name);
379
+ }
380
+
381
+ function getExecKeys(p) {
382
+ if (!p || typeof p !== "object") return;
383
+ const cmdArr = Array.isArray(p.command) ? p.command : null;
384
+ const cmd = cmdArr && cmdArr.length > 0 ? String(cmdArr[cmdArr.length - 1] || "") : "";
385
+ const kind = p.parsed_cmd?.[0]?.type && p.parsed_cmd[0].type !== "unknown"
386
+ ? p.parsed_cmd[0].type
387
+ : inferExecCommandKind(cmd);
388
+
389
+ const status = String(p.status || "unknown");
390
+ const exit = Number.isFinite(Number(p.exit_code)) ? Number(p.exit_code) : null;
391
+ const exitKey = `${status}:${exit === null ? "unknown" : exit}`;
392
+
393
+ const dur = p.duration ? Math.round((Number(p.duration.secs || 0) * 1000) + Number(p.duration.nanos || 0) / 1e6) : 0;
394
+ const output = String(p.aggregated_output || p.stdout || "");
395
+ const outputChars = output.length;
396
+ const outputLines = output ? output.split("\n").length : 0;
397
+ return {
398
+ kind,
399
+ exitKey,
400
+ executable: getExecutableName(cmd),
401
+ command: sanitizeCommandSignature(cmd),
402
+ duration: durationBucket(dur),
403
+ output: outputSizeBucket(outputLines, outputChars),
404
+ dur,
405
+ outputChars,
406
+ outputLines,
407
+ failed: status !== "completed" || exit !== 0,
408
+ };
409
+ }
410
+
411
+ function absorbExecStats(map, key, details) {
412
+ const row = ensureExec(map, key);
413
+ row.calls += 1;
414
+ row.duration_ms += details.dur;
415
+ row.max_duration_ms = Math.max(row.max_duration_ms, details.dur);
416
+ row.output_chars += details.outputChars;
417
+ row.output_lines += details.outputLines;
418
+ if (details.failed) row.failures += 1;
419
+ }
420
+
421
+ function absorbExecEnd(p) {
422
+ const details = getExecKeys(p);
423
+ if (!details) return;
424
+ absorbExecStats(byExecKind, details.kind, details);
425
+ absorbExecStats(byExecExit, details.exitKey, details);
426
+ absorbExecStats(byExecExecutable, details.executable, details);
427
+ absorbExecStats(byExecCommand, details.command, details);
428
+ absorbExecStats(byExecDuration, details.duration, details);
429
+ absorbExecStats(byExecOutput, details.output, details);
430
+ }
431
+
432
+ function attributeTurn(delta) {
433
+ if (!delta || delta.total_tokens <= 0) return;
434
+ turnCount += 1;
435
+
436
+ const toolNames = pendingCalls
437
+ .map((c) => normalizeToolName(c))
438
+ .filter(Boolean);
439
+ const unique = [...new Set(toolNames)];
440
+ const tools = unique.length > 0 ? unique : ["text_response"];
441
+ const share = 1 / tools.length;
442
+
443
+ for (const name of tools) {
444
+ const row = ensureTool(name);
445
+ row.calls += share;
446
+ addInto(row.totals, {
447
+ input_tokens: delta.input_tokens * share,
448
+ cached_input_tokens: delta.cached_input_tokens * share,
449
+ cache_creation_input_tokens: delta.cache_creation_input_tokens * share,
450
+ output_tokens: delta.output_tokens * share,
451
+ reasoning_output_tokens: delta.reasoning_output_tokens * share,
452
+ total_tokens: delta.total_tokens * share,
453
+ });
454
+ }
455
+
456
+ const uniqueSkills = [...new Set(pendingSkills.filter(Boolean))];
457
+ if (uniqueSkills.length > 0) {
458
+ const skillShare = 1 / uniqueSkills.length;
459
+ for (const name of uniqueSkills) {
460
+ const row = ensureSkill(name);
461
+ row.calls += skillShare;
462
+ addInto(row.totals, {
463
+ input_tokens: delta.input_tokens * skillShare,
464
+ cached_input_tokens: delta.cached_input_tokens * skillShare,
465
+ cache_creation_input_tokens: delta.cache_creation_input_tokens * skillShare,
466
+ output_tokens: delta.output_tokens * skillShare,
467
+ reasoning_output_tokens: delta.reasoning_output_tokens * skillShare,
468
+ total_tokens: delta.total_tokens * skillShare,
469
+ });
470
+ }
471
+ }
472
+
473
+ // Attribute exec_command_end rows to exec stats; note these are not a
474
+ // token source — we attach the same tool-shared delta to the command
475
+ // classifier so users can find high-cost command families.
476
+ const execToolShare = tools.includes("exec_command") ? (1 / tools.length) : 0;
477
+ const execDelta = execToolShare > 0 ? {
478
+ input_tokens: delta.input_tokens * execToolShare,
479
+ cached_input_tokens: delta.cached_input_tokens * execToolShare,
480
+ cache_creation_input_tokens: delta.cache_creation_input_tokens * execToolShare,
481
+ output_tokens: delta.output_tokens * execToolShare,
482
+ reasoning_output_tokens: delta.reasoning_output_tokens * execToolShare,
483
+ total_tokens: delta.total_tokens * execToolShare,
484
+ } : null;
485
+
486
+ if (execDelta && pendingExecEnds.length > 0) {
487
+ const perExecShare = 1 / pendingExecEnds.length;
488
+ for (const p of pendingExecEnds) {
489
+ const details = getExecKeys(p);
490
+ if (!details) continue;
491
+ const attributed = {
492
+ input_tokens: execDelta.input_tokens * perExecShare,
493
+ cached_input_tokens: execDelta.cached_input_tokens * perExecShare,
494
+ cache_creation_input_tokens: execDelta.cache_creation_input_tokens * perExecShare,
495
+ output_tokens: execDelta.output_tokens * perExecShare,
496
+ reasoning_output_tokens: execDelta.reasoning_output_tokens * perExecShare,
497
+ total_tokens: execDelta.total_tokens * perExecShare,
498
+ };
499
+
500
+ addInto(ensureExec(byExecKind, details.kind).totals, attributed);
501
+ addInto(ensureExec(byExecExit, details.exitKey).totals, attributed);
502
+ addInto(ensureExec(byExecExecutable, details.executable).totals, attributed);
503
+ addInto(ensureExec(byExecCommand, details.command).totals, attributed);
504
+ addInto(ensureExec(byExecDuration, details.duration).totals, attributed);
505
+ addInto(ensureExec(byExecOutput, details.output).totals, attributed);
506
+
507
+ absorbExecEnd(p);
508
+ }
509
+ } else {
510
+ // Still ingest exec end stats without token attribution so the drill-down works.
511
+ for (const p of pendingExecEnds) absorbExecEnd(p);
512
+ }
513
+
514
+ addInto(totals, delta);
515
+ pendingCalls = [];
516
+ pendingSkills = [];
517
+ pendingExecEnds = [];
518
+ }
519
+
520
+ for await (const line of rl) {
521
+ if (!line) continue;
522
+ let obj;
523
+ try {
524
+ obj = JSON.parse(line);
525
+ } catch {
526
+ continue;
527
+ }
528
+ const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
529
+ if (!ts) continue;
530
+ if (fromIso && ts < fromIso) continue;
531
+ if (toIso && ts > toIso) continue;
532
+ if (!isTimestampInRequestedDayRange(ts, { from, to, timeZoneContext })) continue;
533
+
534
+ if (obj.type === "session_meta") {
535
+ const p = obj.payload || {};
536
+ sessionId = p.id || sessionId;
537
+ cwd = p.cwd || cwd;
538
+ cliVersion = p.cli_version || cliVersion;
539
+ provider = p.model_provider || provider;
540
+ }
541
+
542
+ if (obj.type === "turn_context") {
543
+ const p = obj.payload || {};
544
+ if (typeof p.cwd === "string") cwd = p.cwd;
545
+ if (typeof p.model === "string") model = p.model;
546
+ continue;
547
+ }
548
+
549
+ if (obj.type === "response_item" && obj.payload?.type === "function_call") {
550
+ pendingCalls.push(obj.payload);
551
+ const skill = extractSkillNameFromFunctionCall(obj.payload);
552
+ if (skill) pendingSkills.push(skill);
553
+ continue;
554
+ }
555
+
556
+ if (obj.type === "event_msg" && obj.payload?.type === "exec_command_end") {
557
+ pendingExecEnds.push(obj.payload);
558
+ continue;
559
+ }
560
+
561
+ const tokenCount = extractTokenCount(obj);
562
+ if (tokenCount) {
563
+ const info = tokenCount.info;
564
+ const lastUsage = info?.last_token_usage;
565
+ const totalUsage = info?.total_token_usage;
566
+ const delta = pickDelta(lastUsage, totalUsage, prevTotals);
567
+ if (totalUsage && typeof totalUsage === "object") prevTotals = totalUsage;
568
+ if (delta) attributeTurn(delta);
569
+ continue;
570
+ }
571
+ }
572
+
573
+ rl.close();
574
+ stream.close?.();
575
+
576
+ return {
577
+ sessionId,
578
+ cwd,
579
+ model: model || provider,
580
+ provider,
581
+ version: cliVersion,
582
+ filePath,
583
+ turnCount,
584
+ totals,
585
+ toolBreakdown: {
586
+ tool_rows: finalizeToolRows(byTool),
587
+ },
588
+ skillsBreakdown: {
589
+ skill_rows: finalizeSkillRows(bySkill),
590
+ },
591
+ execCommandBreakdown: {
592
+ byType: finalizeExecRows(byExecKind),
593
+ byExit: finalizeExecRows(byExecExit),
594
+ byExecutable: finalizeExecRows(byExecExecutable),
595
+ byCommand: finalizeExecRows(byExecCommand),
596
+ byDuration: finalizeExecRows(byExecDuration),
597
+ byOutput: finalizeExecRows(byExecOutput),
598
+ },
599
+ };
600
+ }
601
+
602
+ module.exports = {
603
+ parseCodexRolloutFile,
604
+ extractTokenCount,
605
+ extractSkillNameFromFunctionCall,
606
+ formatToolDisplayName,
607
+ normalizeToolName,
608
+ pickDelta,
609
+ normalizeUsage,
610
+ totalsReset,
611
+ listJsonlFiles,
612
+ listCodexSessionFiles,
613
+ safeJsonParse,
614
+ dayKeyToIsoBounds,
615
+ formatPartsDayKey,
616
+ getZonedParts,
617
+ timestampDayKey,
618
+ isTimestampInRequestedDayRange,
619
+ finalizeToolRows,
620
+ finalizeSkillRows,
621
+ finalizeExecRows,
622
+ buildSkillStatsEntry,
623
+ };