tokentracker-cli 0.10.2 → 0.11.0

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,400 @@
1
+ // Codex CLI "Context Breakdown" — tool-oriented view.
2
+ //
3
+ // Privacy commitment: tokens + timestamps only. We do not return prompt text,
4
+ // assistant text, tool outputs, file contents, or exec_command arguments.
5
+ //
6
+ // Data source: ~/.codex/sessions/**/rollout-*.jsonl
7
+ // We treat each token_count event as the authoritative delta and attribute
8
+ // that delta to "turn" activity since the last token_count. Tool attribution
9
+ // is heuristic: delta is split evenly across tools used in that turn.
10
+
11
+ "use strict";
12
+
13
+ const fs = require("node:fs");
14
+ const os = require("node:os");
15
+ const path = require("node:path");
16
+
17
+ const {
18
+ emptyTotals,
19
+ addInto,
20
+ roundTotals,
21
+ buildExecStatsEntry,
22
+ allocateByLargestRemainder,
23
+ categorizeTool,
24
+ } = require("./categorizer-utils");
25
+
26
+ const {
27
+ parseCodexRolloutFile,
28
+ listCodexSessionFiles,
29
+ dayKeyToIsoBounds,
30
+ finalizeToolRows,
31
+ finalizeSkillRows,
32
+ finalizeExecRows,
33
+ buildSkillStatsEntry,
34
+ } = require("./codex-rollout-parser");
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Merge helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function mergeRollupTotals(target, add) {
41
+ addInto(target, add);
42
+ }
43
+
44
+ function mergeRows(map, rows) {
45
+ for (const row of rows || []) {
46
+ const name = row?.name ? String(row.name) : "";
47
+ const rawName = row?.raw_name ? String(row.raw_name) : name;
48
+ const key = rawName || name;
49
+ if (!key) continue;
50
+ if (!map.has(key)) {
51
+ map.set(key, { name, raw_name: rawName, calls: 0, totals: emptyTotals() });
52
+ }
53
+ const cur = map.get(key);
54
+ cur.name = name;
55
+ cur.raw_name = rawName;
56
+ cur.calls += Number(row.calls || 0);
57
+ mergeRollupTotals(cur.totals, row.totals || {});
58
+ }
59
+ }
60
+
61
+ function mergeSkillRows(map, rows) {
62
+ for (const row of rows || []) {
63
+ const name = row?.name ? String(row.name) : "";
64
+ if (!name) continue;
65
+ if (!map.has(name)) map.set(name, buildSkillStatsEntry(name));
66
+ const cur = map.get(name);
67
+ cur.calls += Number(row.calls || 0);
68
+ mergeRollupTotals(cur.totals, row.totals || {});
69
+ }
70
+ }
71
+
72
+ function mergeExecRows(map, rows) {
73
+ for (const row of rows || []) {
74
+ const name = row?.name ? String(row.name) : "";
75
+ if (!name) continue;
76
+ if (!map.has(name)) map.set(name, { name, ...buildExecStatsEntry() });
77
+ const cur = map.get(name);
78
+ cur.calls += Number(row.calls || 0);
79
+ cur.failures += Number(row.failures || 0);
80
+ cur.duration_ms += Number(row.duration_ms || 0);
81
+ cur.max_duration_ms = Math.max(cur.max_duration_ms, Number(row.max_duration_ms || 0));
82
+ cur.output_chars += Number(row.output_chars || 0);
83
+ cur.output_lines += Number(row.output_lines || 0);
84
+ mergeRollupTotals(cur.totals, row.totals || {});
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Period helpers
90
+ // ---------------------------------------------------------------------------
91
+
92
+ function normalizePeriod(period) {
93
+ const p = String(period || "").trim().toLowerCase();
94
+ if (!p) return null;
95
+ if (["day", "week", "month", "total"].includes(p)) return p;
96
+ return null;
97
+ }
98
+
99
+ function buildDateRange({ period, date }) {
100
+ const anchor = date ? new Date(`${date}T00:00:00Z`) : new Date();
101
+ if (!Number.isFinite(anchor.getTime())) return null;
102
+ const end = new Date(`${anchor.toISOString().slice(0, 10)}T23:59:59Z`);
103
+ if (!Number.isFinite(end.getTime())) return null;
104
+
105
+ let start;
106
+ if (period === "day") start = new Date(`${anchor.toISOString().slice(0, 10)}T00:00:00Z`);
107
+ else if (period === "week") start = new Date(end.getTime() - 6 * 86400_000);
108
+ else if (period === "month") start = new Date(end.getTime() - 29 * 86400_000);
109
+ else if (period === "total") start = null;
110
+ else return null;
111
+
112
+ return {
113
+ from: start ? start.toISOString().slice(0, 10) : null,
114
+ to: end.toISOString().slice(0, 10),
115
+ };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Cache
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const CACHE = new Map();
123
+ const CACHE_TTL_MS = 60_000;
124
+ const CACHE_SCHEMA_VERSION = "codex-context-v2";
125
+
126
+ function maxMtimeMs(files) {
127
+ let max = 0;
128
+ for (const filePath of files) {
129
+ try {
130
+ const st = fs.statSync(filePath);
131
+ if (st.mtimeMs > max) max = st.mtimeMs;
132
+ } catch {}
133
+ }
134
+ return max;
135
+ }
136
+
137
+ function cacheTimeZoneKey(timeZoneContext) {
138
+ if (!timeZoneContext) return "";
139
+ return `${timeZoneContext.timeZone || ""}|${Number.isFinite(timeZoneContext.offsetMinutes) ? timeZoneContext.offsetMinutes : ""}`;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Main entry
144
+ // ---------------------------------------------------------------------------
145
+
146
+ async function computeCodexContextBreakdown({
147
+ from = null,
148
+ to = null,
149
+ period = null,
150
+ date = null,
151
+ codexDir = null,
152
+ top = 20,
153
+ timeZoneContext = null,
154
+ } = {}) {
155
+ let fromKey = from;
156
+ let toKey = to;
157
+ if ((!fromKey && !toKey) && normalizePeriod(period)) {
158
+ const range = buildDateRange({ period: normalizePeriod(period), date });
159
+ fromKey = range?.from || null;
160
+ toKey = range?.to || null;
161
+ }
162
+
163
+ const { fromIso, toIso } = dayKeyToIsoBounds(fromKey, toKey);
164
+ const baseDir = codexDir || path.join(os.homedir(), ".codex", "sessions");
165
+
166
+ const files = await listCodexSessionFiles(baseDir);
167
+ const cacheKey = [
168
+ CACHE_SCHEMA_VERSION,
169
+ baseDir,
170
+ fromKey || "",
171
+ toKey || "",
172
+ cacheTimeZoneKey(timeZoneContext),
173
+ Number.isFinite(top) ? top : 20,
174
+ files.length,
175
+ maxMtimeMs(files),
176
+ ].join("|");
177
+ const cached = CACHE.get(cacheKey);
178
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
179
+ return cached.value;
180
+ }
181
+
182
+ const sessions = [];
183
+
184
+ for (const filePath of files) {
185
+ const parsed = await parseCodexRolloutFile(filePath, {
186
+ fromIso,
187
+ toIso,
188
+ from: fromKey,
189
+ to: toKey,
190
+ timeZoneContext,
191
+ });
192
+ if (!parsed || !parsed.totals || !parsed.totals.total_tokens) continue;
193
+ sessions.push(parsed);
194
+ }
195
+
196
+ const grand = emptyTotals();
197
+ const byTool = new Map();
198
+ const bySkill = new Map();
199
+ const byExecType = new Map();
200
+ const byExecExit = new Map();
201
+ const byExecExecutable = new Map();
202
+ const byExecCommand = new Map();
203
+ const byExecDuration = new Map();
204
+ const byExecOutput = new Map();
205
+
206
+ for (const s of sessions) {
207
+ mergeRollupTotals(grand, s.totals);
208
+ mergeRows(byTool, s.toolBreakdown?.tool_rows);
209
+ mergeSkillRows(bySkill, s.skillsBreakdown?.skill_rows);
210
+ mergeExecRows(byExecType, s.execCommandBreakdown?.byType);
211
+ mergeExecRows(byExecExit, s.execCommandBreakdown?.byExit);
212
+ mergeExecRows(byExecExecutable, s.execCommandBreakdown?.byExecutable);
213
+ mergeExecRows(byExecCommand, s.execCommandBreakdown?.byCommand);
214
+ mergeExecRows(byExecDuration, s.execCommandBreakdown?.byDuration);
215
+ mergeExecRows(byExecOutput, s.execCommandBreakdown?.byOutput);
216
+ }
217
+
218
+ const toolRows = finalizeToolRows(new Map([...byTool.entries()].map(([k, v]) => [k, v])));
219
+ const skillRows = finalizeSkillRows(new Map([...bySkill.entries()].map(([k, v]) => [k, v])));
220
+ const execTypeRows = finalizeExecRows(new Map([...byExecType.entries()].map(([k, v]) => [k, v])));
221
+ const execExitRows = finalizeExecRows(new Map([...byExecExit.entries()].map(([k, v]) => [k, v])));
222
+ const execExecutableRows = finalizeExecRows(new Map([...byExecExecutable.entries()].map(([k, v]) => [k, v])));
223
+ const execCommandRows = finalizeExecRows(new Map([...byExecCommand.entries()].map(([k, v]) => [k, v])));
224
+ const execDurationRows = finalizeExecRows(new Map([...byExecDuration.entries()].map(([k, v]) => [k, v])));
225
+ const execOutputRows = finalizeExecRows(new Map([...byExecOutput.entries()].map(([k, v]) => [k, v])));
226
+ const limitedTop = Number.isFinite(top) ? top : 20;
227
+ const toolRowsLimited = toolRows.slice(0, limitedTop).map((r) => ({
228
+ name: r.name,
229
+ calls: Math.round(r.calls || 0),
230
+ totals: roundTotals(r.totals),
231
+ }));
232
+ const skillRowsLimited = skillRows.slice(0, limitedTop).map((r) => ({
233
+ name: r.name,
234
+ calls: Math.round(r.calls || 0),
235
+ totals: roundTotals(r.totals),
236
+ }));
237
+
238
+ const byCategory = new Map(); // category -> {name,calls,totals,tools:Map}
239
+ for (const row of toolRows) {
240
+ const cat = categorizeTool(row.raw_name || row.name);
241
+ if (!byCategory.has(cat)) {
242
+ byCategory.set(cat, { name: cat, calls: 0, totals: emptyTotals(), tools: new Map() });
243
+ }
244
+ const target = byCategory.get(cat);
245
+ target.calls += row.calls || 0;
246
+ mergeRollupTotals(target.totals, row.totals || {});
247
+ target.tools.set(row.raw_name || row.name, row);
248
+ }
249
+ const categoryRows = Array.from(byCategory.values())
250
+ .map((c) => ({
251
+ name: c.name,
252
+ calls: Math.round(c.calls || 0),
253
+ totals: roundTotals(c.totals),
254
+ tools: finalizeToolRows(new Map([...c.tools.entries()].map(([k, v]) => [k, v])))
255
+ .slice(0, limitedTop)
256
+ .map((r) => ({
257
+ name: r.name,
258
+ calls: Math.round(r.calls || 0),
259
+ totals: roundTotals(r.totals),
260
+ })),
261
+ }))
262
+ .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
263
+
264
+ const nonTextToolTotal = toolRows.reduce((sum, row) => {
265
+ if ((row.raw_name || row.name) === "text_response") return sum;
266
+ return sum + Number(row.totals?.total_tokens || 0);
267
+ }, 0);
268
+ const displayedMessageTotal = Math.max(
269
+ 0,
270
+ Number(grand.total_tokens || 0) - Number(grand.reasoning_output_tokens || 0) - nonTextToolTotal,
271
+ );
272
+ const textResponse = toolRows.find((row) => (row.raw_name || row.name) === "text_response");
273
+ const textResponseTotals = textResponse?.totals || emptyTotals();
274
+ const textResponseHistoryWeight = Math.max(
275
+ 0,
276
+ Number(textResponseTotals.cached_input_tokens || 0) + Number(textResponseTotals.cache_creation_input_tokens || 0),
277
+ );
278
+ const messageAlloc = allocateByLargestRemainder(
279
+ displayedMessageTotal,
280
+ {
281
+ user_input: Math.max(0, Number(textResponseTotals.input_tokens || 0)),
282
+ conversation_history: textResponseHistoryWeight,
283
+ assistant_response: Math.max(0, Number(textResponseTotals.output_tokens || 0)),
284
+ },
285
+ ["user_input", "conversation_history", "assistant_response"],
286
+ );
287
+ const historyAlloc = allocateByLargestRemainder(
288
+ messageAlloc.conversation_history || 0,
289
+ {
290
+ cached_input_tokens: Math.max(0, Number(textResponseTotals.cached_input_tokens || 0)),
291
+ cache_creation_input_tokens: Math.max(0, Number(textResponseTotals.cache_creation_input_tokens || 0)),
292
+ },
293
+ ["cached_input_tokens", "cache_creation_input_tokens"],
294
+ );
295
+ const textResponseInput = messageAlloc.user_input || 0;
296
+ const textResponseHistory = messageAlloc.conversation_history || 0;
297
+ const textResponseOutput = messageAlloc.assistant_response || 0;
298
+ const messageBreakdown = [
299
+ {
300
+ key: "user_input",
301
+ name: "User input",
302
+ totals: roundTotals({
303
+ input_tokens: textResponseInput,
304
+ cached_input_tokens: 0,
305
+ cache_creation_input_tokens: 0,
306
+ output_tokens: 0,
307
+ reasoning_output_tokens: 0,
308
+ total_tokens: textResponseInput,
309
+ }),
310
+ },
311
+ {
312
+ key: "conversation_history",
313
+ name: "Conversation history",
314
+ totals: roundTotals({
315
+ input_tokens: 0,
316
+ cached_input_tokens: historyAlloc.cached_input_tokens || 0,
317
+ cache_creation_input_tokens: historyAlloc.cache_creation_input_tokens || 0,
318
+ output_tokens: 0,
319
+ reasoning_output_tokens: 0,
320
+ total_tokens: textResponseHistory,
321
+ }),
322
+ },
323
+ {
324
+ key: "assistant_response",
325
+ name: "Assistant response",
326
+ totals: roundTotals({
327
+ input_tokens: 0,
328
+ cached_input_tokens: 0,
329
+ cache_creation_input_tokens: 0,
330
+ output_tokens: textResponseOutput,
331
+ reasoning_output_tokens: 0,
332
+ total_tokens: textResponseOutput,
333
+ }),
334
+ },
335
+ ].sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
336
+
337
+ const serializeExecRows = (rows) => rows.slice(0, limitedTop).map((r) => ({
338
+ name: r.name,
339
+ calls: r.calls,
340
+ failures: r.failures,
341
+ duration_ms: r.duration_ms,
342
+ max_duration_ms: r.max_duration_ms,
343
+ output_chars: r.output_chars,
344
+ output_lines: r.output_lines,
345
+ totals: roundTotals(r.totals),
346
+ }));
347
+
348
+ const result = {
349
+ source: "codex",
350
+ scope: "supported",
351
+ breakdown_status: "ok",
352
+ totals: grand,
353
+ session_count: sessions.length,
354
+ message_count: sessions.reduce((a, s) => a + (s.turnCount || 0), 0),
355
+ message_breakdown: {
356
+ categories: messageBreakdown,
357
+ privacy: {
358
+ includes_content: false,
359
+ note: "Aggregated message token categories only; prompt and assistant text are never returned.",
360
+ },
361
+ },
362
+ tool_calls_breakdown: {
363
+ total_calls: Math.round(toolRows.reduce((a, r) => a + Number(r.calls || 0), 0)),
364
+ tools: toolRowsLimited,
365
+ categories: categoryRows.slice(0, limitedTop),
366
+ tools_total: toolRows.reduce((a, r) => a + Math.round(r.totals?.total_tokens || 0), 0),
367
+ privacy: {
368
+ includes_inputs: false,
369
+ note: "Aggregated tool names only; no tool arguments or outputs are included.",
370
+ },
371
+ },
372
+ skills_breakdown: {
373
+ total_calls: Math.round(skillRows.reduce((a, r) => a + Number(r.calls || 0), 0)),
374
+ skills: skillRowsLimited,
375
+ privacy: {
376
+ includes_inputs: false,
377
+ note: "Codex skill use is inferred from exec_command reads of SKILL.md; command arguments are not returned.",
378
+ },
379
+ },
380
+ exec_command_breakdown: {
381
+ by_type: serializeExecRows(execTypeRows),
382
+ by_executable: serializeExecRows(execExecutableRows),
383
+ by_command: serializeExecRows(execCommandRows),
384
+ by_duration: serializeExecRows(execDurationRows),
385
+ by_output: serializeExecRows(execOutputRows),
386
+ by_exit: serializeExecRows(execExitRows),
387
+ },
388
+ };
389
+
390
+ CACHE.set(cacheKey, { at: Date.now(), value: result });
391
+ while (CACHE.size > 32) {
392
+ const oldest = [...CACHE.entries()].sort((a, b) => a[1].at - b[1].at)[0];
393
+ if (oldest) CACHE.delete(oldest[0]);
394
+ }
395
+ return result;
396
+ }
397
+
398
+ module.exports = {
399
+ computeCodexContextBreakdown,
400
+ };