tledger 0.1.2

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,1248 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Token Ledger local collector
5
+ *
6
+ * Reads Codex's local JSONL rollouts and metadata database, then writes a
7
+ * privacy-reduced snapshot for the Token Ledger CLI. It never exports message
8
+ * bodies, tool arguments/results, reasoning text, instructions, credential
9
+ * fields, or full local paths in the generated snapshot.
10
+ *
11
+ * Node 22.13 or newer is required for node:sqlite.
12
+ */
13
+
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import { createReadStream } from "node:fs";
16
+ import {
17
+ access,
18
+ chmod,
19
+ mkdir,
20
+ readdir,
21
+ rename,
22
+ rm,
23
+ stat,
24
+ writeFile,
25
+ } from "node:fs/promises";
26
+ import { availableParallelism, homedir } from "node:os";
27
+ import {
28
+ basename,
29
+ dirname,
30
+ extname,
31
+ resolve,
32
+ } from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+ import { createInterface } from "node:readline";
35
+ import {
36
+ isMainThread,
37
+ parentPort,
38
+ Worker,
39
+ workerData,
40
+ } from "node:worker_threads";
41
+
42
+ const SCHEMA_VERSION = 1;
43
+ const WEEK_MINUTES = 10_080;
44
+ const DEFAULT_MAX_SCAN_WORKERS = 4;
45
+ const MAX_SCAN_WORKERS = 6;
46
+ const SCAN_WORKER_MODE = "token-ledger-rollout-scanner";
47
+ const UUID_AT_END =
48
+ /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
49
+ const RELEVANT_RECORD_TYPE =
50
+ /"type"\s*:\s*"(?:session_meta|turn_context|task_started|thread_settings_applied|token_count|function_call|custom_tool_call|tool_search_call|web_search_call|image_generation_call)"/;
51
+ const RESPONSE_CALL_TYPES = new Set([
52
+ "function_call",
53
+ "custom_tool_call",
54
+ "tool_search_call",
55
+ "web_search_call",
56
+ "image_generation_call",
57
+ ]);
58
+ let databaseSyncPromise;
59
+
60
+ function loadDatabaseSync() {
61
+ if (!databaseSyncPromise) {
62
+ databaseSyncPromise = (async () => {
63
+ const originalEmitWarning = process.emitWarning;
64
+ process.emitWarning = function emitWarning(warning, ...arguments_) {
65
+ const message = warning instanceof Error ? warning.message : String(warning);
66
+ const type = typeof arguments_[0] === "string"
67
+ ? arguments_[0]
68
+ : arguments_[0]?.type;
69
+ if (
70
+ type === "ExperimentalWarning" &&
71
+ message === "SQLite is an experimental feature and might change at any time"
72
+ ) {
73
+ return;
74
+ }
75
+ return originalEmitWarning.call(this, warning, ...arguments_);
76
+ };
77
+ try {
78
+ return (await import("node:sqlite")).DatabaseSync;
79
+ } finally {
80
+ process.emitWarning = originalEmitWarning;
81
+ }
82
+ })();
83
+ }
84
+ return databaseSyncPromise;
85
+ }
86
+
87
+ function usage() {
88
+ return `Token Ledger local collector
89
+
90
+ Usage:
91
+ node lib/token-ledger-collector.mjs [options]
92
+
93
+ Options:
94
+ --output <file> Snapshot destination (default: token-ledger-snapshot.json)
95
+ --codex-home <dir> Codex data root (default: CODEX_HOME or ~/.codex)
96
+ --since <ISO date> Ignore model calls before this timestamp
97
+ --no-archived Skip archived_sessions
98
+ --help Show this help
99
+
100
+ The snapshot contains local token metadata and project labels only. It never
101
+ contains display titles, message bodies, tool payloads, reasoning text,
102
+ credential fields, or full local paths. Project labels can still be sensitive.`;
103
+ }
104
+
105
+ function parseArgs(argv) {
106
+ const options = {
107
+ output: resolve("token-ledger-snapshot.json"),
108
+ codexHome: resolve(process.env.CODEX_HOME || `${homedir()}/.codex`),
109
+ includeArchived: true,
110
+ since: null,
111
+ };
112
+
113
+ for (let index = 0; index < argv.length; index += 1) {
114
+ const argument = argv[index];
115
+ if (argument === "--help" || argument === "-h") {
116
+ options.help = true;
117
+ } else if (argument === "--no-archived") {
118
+ options.includeArchived = false;
119
+ } else if (argument === "--output") {
120
+ const value = argv[++index];
121
+ if (!value) throw new Error("--output requires a file path.");
122
+ options.output = resolve(value);
123
+ } else if (argument === "--codex-home") {
124
+ const value = argv[++index];
125
+ if (!value) throw new Error("--codex-home requires a directory.");
126
+ options.codexHome = resolve(value);
127
+ } else if (argument === "--since") {
128
+ const value = argv[++index];
129
+ if (!value || Number.isNaN(new Date(value).getTime())) {
130
+ throw new Error("--since requires a valid ISO date.");
131
+ }
132
+ options.since = new Date(value);
133
+ } else {
134
+ throw new Error(`Unknown option: ${argument}`);
135
+ }
136
+ }
137
+ return options;
138
+ }
139
+
140
+ function hash(value, length = 24) {
141
+ return createHash("sha256").update(String(value)).digest("hex").slice(0, length);
142
+ }
143
+
144
+ export function sourceFingerprint(codexHome, includeArchived = true) {
145
+ return hash(JSON.stringify({
146
+ codexHome: resolve(codexHome),
147
+ includeArchived,
148
+ }));
149
+ }
150
+
151
+ function sanitizeLabel(value, fallback = "unknown") {
152
+ const label = String(value ?? "")
153
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
154
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
155
+ .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
156
+ .replace(/\s+/g, " ")
157
+ .trim();
158
+ return (label || fallback).slice(0, 160);
159
+ }
160
+
161
+ export async function writePrivateSnapshot(output, snapshot) {
162
+ const destination = resolve(output);
163
+ const directory = dirname(destination);
164
+ const temporary = resolve(
165
+ directory,
166
+ `.token-ledger-${process.pid}-${randomUUID()}.tmp`,
167
+ );
168
+ await mkdir(directory, { recursive: true });
169
+ try {
170
+ await writeFile(temporary, `${JSON.stringify(snapshot)}\n`, {
171
+ encoding: "utf8",
172
+ flag: "wx",
173
+ mode: 0o600,
174
+ });
175
+ await chmod(temporary, 0o600);
176
+ await rename(temporary, destination);
177
+ await chmod(destination, 0o600);
178
+ } finally {
179
+ await rm(temporary, { force: true });
180
+ }
181
+ }
182
+
183
+ function asFiniteNumber(value) {
184
+ const number = Number(value ?? 0);
185
+ return Number.isFinite(number) ? number : 0;
186
+ }
187
+
188
+ function isoFromEpoch(value, fallback = null) {
189
+ const number = Number(value);
190
+ if (!Number.isFinite(number) || number <= 0) return fallback;
191
+ const milliseconds = number > 10_000_000_000 ? number : number * 1_000;
192
+ const date = new Date(milliseconds);
193
+ return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
194
+ }
195
+
196
+ function safeIso(value, fallback = null) {
197
+ const date = new Date(value);
198
+ return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
199
+ }
200
+
201
+ function tokenTuple(value) {
202
+ const usage = value && typeof value === "object" ? value : {};
203
+ return [
204
+ asFiniteNumber(usage.input_tokens),
205
+ asFiniteNumber(usage.cached_input_tokens),
206
+ asFiniteNumber(usage.cache_write_input_tokens),
207
+ asFiniteNumber(usage.output_tokens),
208
+ asFiniteNumber(usage.reasoning_output_tokens),
209
+ asFiniteNumber(usage.total_tokens),
210
+ ];
211
+ }
212
+
213
+ function usageFromTuple(tuple) {
214
+ return {
215
+ inputTokens: tuple[0],
216
+ cachedInputTokens: tuple[1],
217
+ cacheWriteInputTokens: tuple[2],
218
+ outputTokens: tuple[3],
219
+ reasoningTokens: tuple[4],
220
+ totalTokens: tuple[5],
221
+ };
222
+ }
223
+
224
+ function hasDetailedBreakdown(usage) {
225
+ if (usage.totalTokens === 0) return true;
226
+ return (
227
+ usage.inputTokens + usage.outputTokens === usage.totalTokens &&
228
+ (usage.inputTokens > 0 || usage.outputTokens > 0)
229
+ );
230
+ }
231
+
232
+ function normalizeModel(model) {
233
+ const value = sanitizeLabel(model, "unknown")
234
+ .toLowerCase()
235
+ .replaceAll("_", "-");
236
+ if (value.startsWith("gpt-5.6-sol")) return "gpt-5.6-sol";
237
+ if (value.startsWith("gpt-5.6-terra")) return "gpt-5.6-terra";
238
+ if (value.startsWith("gpt-5.6-luna")) return "gpt-5.6-luna";
239
+ if (value.startsWith("gpt-5.5-cyber")) return "gpt-5.5-cyber";
240
+ if (value.startsWith("gpt-5.5")) return "gpt-5.5";
241
+ if (value.startsWith("gpt-5.4-mini")) return "gpt-5.4-mini";
242
+ if (value.startsWith("gpt-5.4")) return "gpt-5.4";
243
+ if (value.startsWith("gpt-5.3-codex")) return "gpt-5.3-codex";
244
+ if (value.startsWith("gpt-5.2")) return "gpt-5.2";
245
+ return value || "unknown";
246
+ }
247
+
248
+ function parseStructuredSource(value) {
249
+ if (!value) return null;
250
+ if (typeof value === "object") return value;
251
+ if (typeof value !== "string") return null;
252
+ const trimmed = value.trim();
253
+ if (!trimmed.startsWith("{")) return trimmed;
254
+ try {
255
+ return JSON.parse(trimmed);
256
+ } catch {
257
+ return trimmed;
258
+ }
259
+ }
260
+
261
+ function sourceLabels(threadSource, rawSource) {
262
+ const source = parseStructuredSource(rawSource);
263
+ if (
264
+ threadSource === "subagent" ||
265
+ (source && typeof source === "object" && source.subagent)
266
+ ) {
267
+ return { source: "subagent", useType: "subagent" };
268
+ }
269
+ if (threadSource === "automation") {
270
+ return { source: "automation", useType: "automation" };
271
+ }
272
+ if (threadSource === "realtime_voice") {
273
+ return { source: "voice", useType: "voice" };
274
+ }
275
+ if (source === "exec") return { source: "cli", useType: "cli" };
276
+ if (source === "vscode") return { source: "desktop", useType: "interactive" };
277
+ if (typeof source === "string" && source) {
278
+ return {
279
+ source: sanitizeLabel(source).slice(0, 40),
280
+ useType: sanitizeLabel(threadSource || "interactive").slice(0, 40),
281
+ };
282
+ }
283
+ return {
284
+ source: "unknown",
285
+ useType: sanitizeLabel(threadSource || "unknown").slice(0, 40),
286
+ };
287
+ }
288
+
289
+ function cleanRemote(value) {
290
+ if (!value) return null;
291
+ const remote = String(value).trim();
292
+ const scp = remote.match(/^[^@]+@([^:]+):(.+)$/);
293
+ if (scp) {
294
+ return sanitizeLabel(`${scp[1]}/${scp[2].replace(/\.git$/i, "")}`);
295
+ }
296
+ try {
297
+ const url = new URL(remote);
298
+ const path = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "");
299
+ return sanitizeLabel(path ? `${url.hostname}/${path}` : url.hostname);
300
+ } catch {
301
+ const withoutCredentials = remote.replace(/\/\/[^/@]+@/, "//");
302
+ return sanitizeLabel(withoutCredentials.replace(/\.git$/i, ""));
303
+ }
304
+ }
305
+
306
+ function projectLabel(cwd, gitOrigin) {
307
+ const remote = cleanRemote(gitOrigin);
308
+ if (remote) {
309
+ const parts = remote.split("/").filter(Boolean);
310
+ return sanitizeLabel(parts.slice(-2).join("/"), "Unknown project");
311
+ }
312
+ const path = String(cwd || "").replaceAll("\\", "/").replace(/\/+$/, "");
313
+ const worktree = path.match(/\/\.codex\/worktrees\/[^/]+\/([^/]+)$/);
314
+ if (worktree) return worktree[1];
315
+ const name = basename(path);
316
+ return name && name !== "." && name !== "/"
317
+ ? sanitizeLabel(name, "Unknown project")
318
+ : "Unknown project";
319
+ }
320
+
321
+ async function pathExists(path) {
322
+ try {
323
+ await access(path);
324
+ return true;
325
+ } catch {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ export async function listJsonlFiles(root) {
331
+ if (!(await pathExists(root))) return [];
332
+ const found = [];
333
+ const queue = [root];
334
+ while (queue.length) {
335
+ const directory = queue.pop();
336
+ const entries = await readdir(directory, { withFileTypes: true });
337
+ for (const entry of entries) {
338
+ const path = resolve(directory, entry.name);
339
+ if (entry.isDirectory()) queue.push(path);
340
+ else if (entry.isFile() && extname(entry.name) === ".jsonl") found.push(path);
341
+ }
342
+ }
343
+ return found;
344
+ }
345
+
346
+ export async function sourceState(codexHome, includeArchived = true) {
347
+ const roots = [resolve(codexHome, "sessions")];
348
+ if (includeArchived) {
349
+ roots.push(resolve(codexHome, "archived_sessions"));
350
+ }
351
+ const files = (await Promise.all(roots.map((root) => listJsonlFiles(root))))
352
+ .flat();
353
+ const preferredState = resolve(codexHome, "state_5.sqlite");
354
+ const legacyState = resolve(codexHome, "sqlite", "state_5.sqlite");
355
+ const statePath = (await pathExists(preferredState))
356
+ ? preferredState
357
+ : (await pathExists(legacyState))
358
+ ? legacyState
359
+ : null;
360
+ const sourceFiles = statePath ? [...files, statePath] : files;
361
+ if (!sourceFiles.length) {
362
+ return { latestMtimeMs: 0, fileCount: 0 };
363
+ }
364
+ const stats = await Promise.all(sourceFiles.map((path) => stat(path)));
365
+ return {
366
+ latestMtimeMs: Math.max(...stats.map((entry) => entry.mtimeMs)),
367
+ fileCount: sourceFiles.length,
368
+ };
369
+ }
370
+
371
+ export async function latestSourceModifiedAt(codexHome, includeArchived = true) {
372
+ return (await sourceState(codexHome, includeArchived)).latestMtimeMs;
373
+ }
374
+
375
+ async function readState(codexHome) {
376
+ const preferred = resolve(codexHome, "state_5.sqlite");
377
+ const legacy = resolve(codexHome, "sqlite", "state_5.sqlite");
378
+ const path = (await pathExists(preferred))
379
+ ? preferred
380
+ : (await pathExists(legacy))
381
+ ? legacy
382
+ : null;
383
+ const rows = new Map();
384
+ const parents = new Map();
385
+ if (!path) return { path: null, rows, parents };
386
+
387
+ const DatabaseSync = await loadDatabaseSync();
388
+ const database = new DatabaseSync(path, { readOnly: true });
389
+ try {
390
+ const threadRows = database
391
+ .prepare(
392
+ `SELECT id, created_at, updated_at, source, cwd,
393
+ tokens_used, git_origin_url, model, reasoning_effort,
394
+ thread_source
395
+ FROM threads`,
396
+ )
397
+ .all();
398
+ for (const row of threadRows) rows.set(String(row.id), row);
399
+
400
+ const edgeRows = database
401
+ .prepare(
402
+ "SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges",
403
+ )
404
+ .all();
405
+ for (const edge of edgeRows) {
406
+ parents.set(String(edge.child_thread_id), String(edge.parent_thread_id));
407
+ }
408
+ } finally {
409
+ database.close();
410
+ }
411
+ return { path, rows, parents };
412
+ }
413
+
414
+ function taskStartCandidate(record, threadId, stateRow, fileContext) {
415
+ const outerMs = new Date(record.timestamp).getTime();
416
+ const started = asFiniteNumber(record.payload?.started_at);
417
+ const startedMs = started > 10_000_000_000 ? started : started * 1_000;
418
+ const timestamp = isoFromEpoch(started, safeIso(record.timestamp, new Date(0).toISOString()));
419
+ const deltaMs =
420
+ Number.isFinite(outerMs) && Number.isFinite(startedMs)
421
+ ? Math.abs(outerMs - startedMs)
422
+ : Number.POSITIVE_INFINITY;
423
+ return {
424
+ turnId: String(record.payload?.turn_id || ""),
425
+ threadId,
426
+ timestamp,
427
+ outerTimestamp: safeIso(record.timestamp, timestamp),
428
+ deltaMs,
429
+ model: fileContext.model || stateRow?.model || "unknown",
430
+ effort: fileContext.effort || stateRow?.reasoning_effort || "unknown",
431
+ cwd: fileContext.cwd || stateRow?.cwd || "",
432
+ gitOrigin: fileContext.gitOrigin || stateRow?.git_origin_url || null,
433
+ rawSource: fileContext.rawSource || stateRow?.source || null,
434
+ };
435
+ }
436
+
437
+ function rememberQuota(quotaMap, rateLimits, occurrence) {
438
+ if (!rateLimits || typeof rateLimits !== "object") return;
439
+ const buckets = [rateLimits.primary, rateLimits.secondary].filter(Boolean);
440
+ for (const bucket of buckets) {
441
+ const windowMinutes = asFiniteNumber(bucket.window_minutes);
442
+ const usedPercent = asFiniteNumber(bucket.used_percent);
443
+ const resetsAt = asFiniteNumber(bucket.resets_at);
444
+ if (!windowMinutes || !resetsAt) continue;
445
+ const limitKey = String(rateLimits.limit_id || rateLimits.limit_name || "anonymous");
446
+ const key = [
447
+ hash(limitKey, 16),
448
+ windowMinutes,
449
+ resetsAt,
450
+ usedPercent,
451
+ ].join("|");
452
+ const candidate = {
453
+ id: `quota-${hash(key)}`,
454
+ timestamp: occurrence.timestamp,
455
+ usedPercent,
456
+ windowMinutes,
457
+ resetsAt,
458
+ scope: rateLimits.limit_name ? "named" : "account",
459
+ source: "log",
460
+ turnId: occurrence.turnId || null,
461
+ originalLikely: occurrence.originalLikely,
462
+ };
463
+ rememberQuotaCandidate(quotaMap, key, candidate);
464
+ }
465
+ }
466
+
467
+ function responseCall(record) {
468
+ if (record.type !== "response_item") return null;
469
+ const payload = record.payload;
470
+ if (!payload || !RESPONSE_CALL_TYPES.has(payload.type)) return null;
471
+ return {
472
+ type: payload.type,
473
+ name: String(payload.name || payload.namespace || payload.type).slice(0, 80),
474
+ stableId: payload.call_id || payload.id || null,
475
+ };
476
+ }
477
+
478
+ export function rolloutLineMayAffectUsage(line) {
479
+ return RELEVANT_RECORD_TYPE.test(line);
480
+ }
481
+
482
+ function rolloutThreadId(path) {
483
+ const match = path.match(UUID_AT_END);
484
+ return match?.[1] || `file-${hash(path)}`;
485
+ }
486
+
487
+ function createScanFragment() {
488
+ return {
489
+ parents: new Map(),
490
+ origins: new Map(),
491
+ tokens: new Map(),
492
+ quotas: new Map(),
493
+ calls: new Map(),
494
+ parseErrors: 0,
495
+ duplicateEventsSkipped: 0,
496
+ correctionIntervals: 0,
497
+ };
498
+ }
499
+
500
+ function rememberOrigin(originMap, turnId, candidate) {
501
+ const current = originMap.get(turnId);
502
+ if (!current || candidate.deltaMs < current.deltaMs) {
503
+ originMap.set(turnId, candidate);
504
+ }
505
+ }
506
+
507
+ function rememberCall(callMap, key, candidate) {
508
+ const current = callMap.get(key);
509
+ if (!current || (!current.originalLikely && candidate.originalLikely)) {
510
+ callMap.set(key, candidate);
511
+ }
512
+ }
513
+
514
+ function rememberToken(context, key, candidate) {
515
+ const current = context.tokens.get(key);
516
+ if (!current) {
517
+ context.tokens.set(key, candidate);
518
+ return;
519
+ }
520
+ context.duplicateEventsSkipped += 1;
521
+ if (!current.originalLikely && candidate.originalLikely) {
522
+ current.occurrence = candidate.occurrence;
523
+ current.originalLikely = true;
524
+ }
525
+ }
526
+
527
+ function rememberQuotaCandidate(quotaMap, key, candidate) {
528
+ const current = quotaMap.get(key);
529
+ if (
530
+ !current ||
531
+ (!current.originalLikely && candidate.originalLikely) ||
532
+ (current.originalLikely === candidate.originalLikely &&
533
+ candidate.timestamp < current.timestamp)
534
+ ) {
535
+ quotaMap.set(key, candidate);
536
+ }
537
+ }
538
+
539
+ function mergeScanFragment(context, fragment) {
540
+ for (const [threadId, parentThreadId] of fragment.parents) {
541
+ context.parents.set(threadId, parentThreadId);
542
+ }
543
+ for (const [turnId, candidate] of fragment.origins) {
544
+ rememberOrigin(context.origins, turnId, candidate);
545
+ }
546
+ for (const [key, candidate] of fragment.tokens) {
547
+ rememberToken(context, key, candidate);
548
+ }
549
+ for (const [key, candidate] of fragment.quotas) {
550
+ rememberQuotaCandidate(context.quotas, key, candidate);
551
+ }
552
+ for (const [key, candidate] of fragment.calls) {
553
+ rememberCall(context.calls, key, candidate);
554
+ }
555
+ context.parseErrors += fragment.parseErrors;
556
+ context.duplicateEventsSkipped += fragment.duplicateEventsSkipped;
557
+ context.correctionIntervals += fragment.correctionIntervals;
558
+ }
559
+
560
+ async function scanRollout(path, stateRow) {
561
+ const context = createScanFragment();
562
+ const threadId = rolloutThreadId(path);
563
+ const fileContext = {
564
+ model: stateRow?.model || "unknown",
565
+ effort: stateRow?.reasoning_effort || "unknown",
566
+ cwd: stateRow?.cwd || "",
567
+ gitOrigin: stateRow?.git_origin_url || null,
568
+ rawSource: stateRow?.source || null,
569
+ };
570
+ const callOrdinals = new Map();
571
+ let currentTurnId = "";
572
+ let currentCandidate = null;
573
+ let previousCumulative = null;
574
+
575
+ const input = createReadStream(path, { encoding: "utf8" });
576
+ const lines = createInterface({ input, crlfDelay: Infinity });
577
+ for await (const line of lines) {
578
+ if (!line || !rolloutLineMayAffectUsage(line)) continue;
579
+ let record;
580
+ try {
581
+ record = JSON.parse(line);
582
+ } catch {
583
+ context.parseErrors += 1;
584
+ continue;
585
+ }
586
+
587
+ if (record.type === "session_meta") {
588
+ const payload = record.payload;
589
+ if (payload?.id === threadId) {
590
+ fileContext.cwd = payload.cwd || fileContext.cwd;
591
+ fileContext.gitOrigin =
592
+ payload.git?.repository_url || fileContext.gitOrigin;
593
+ fileContext.rawSource = payload.source || fileContext.rawSource;
594
+ if (payload.parent_thread_id || payload.forked_from_id) {
595
+ context.parents.set(
596
+ threadId,
597
+ String(payload.parent_thread_id || payload.forked_from_id),
598
+ );
599
+ }
600
+ const spawnedParent =
601
+ payload.source?.subagent?.thread_spawn?.parent_thread_id;
602
+ if (spawnedParent) context.parents.set(threadId, String(spawnedParent));
603
+ }
604
+ continue;
605
+ }
606
+
607
+ if (record.type === "event_msg" && record.payload?.type === "task_started") {
608
+ currentTurnId = String(record.payload.turn_id || currentTurnId || "");
609
+ if (currentTurnId) {
610
+ currentCandidate = taskStartCandidate(
611
+ record,
612
+ threadId,
613
+ stateRow,
614
+ fileContext,
615
+ );
616
+ rememberOrigin(context.origins, currentTurnId, currentCandidate);
617
+ }
618
+ continue;
619
+ }
620
+
621
+ if (record.type === "turn_context") {
622
+ currentTurnId = String(record.payload?.turn_id || currentTurnId || "");
623
+ fileContext.model = record.payload?.model || fileContext.model;
624
+ fileContext.effort = record.payload?.effort || fileContext.effort;
625
+ fileContext.cwd = record.payload?.cwd || fileContext.cwd;
626
+ if (currentCandidate?.turnId === currentTurnId) {
627
+ currentCandidate.model = fileContext.model;
628
+ currentCandidate.effort = fileContext.effort;
629
+ currentCandidate.cwd = fileContext.cwd;
630
+ }
631
+ continue;
632
+ }
633
+
634
+ if (
635
+ record.type === "event_msg" &&
636
+ record.payload?.type === "thread_settings_applied"
637
+ ) {
638
+ const settings = record.payload?.thread_settings;
639
+ fileContext.model = settings?.model || fileContext.model;
640
+ fileContext.effort = settings?.reasoning_effort || fileContext.effort;
641
+ continue;
642
+ }
643
+
644
+ const originalLikely = Boolean(currentCandidate?.deltaMs <= 2_000);
645
+ const occurrence = {
646
+ threadId,
647
+ turnId: currentTurnId,
648
+ timestamp: safeIso(
649
+ record.timestamp,
650
+ currentCandidate?.timestamp || new Date(0).toISOString(),
651
+ ),
652
+ originalLikely,
653
+ model: fileContext.model,
654
+ effort: fileContext.effort,
655
+ cwd: fileContext.cwd,
656
+ gitOrigin: fileContext.gitOrigin,
657
+ rawSource: fileContext.rawSource,
658
+ };
659
+
660
+ const call = responseCall(record);
661
+ if (call) {
662
+ const ordinalBase = `${currentTurnId}|${call.type}|${call.name}`;
663
+ const ordinal = (callOrdinals.get(ordinalBase) || 0) + 1;
664
+ callOrdinals.set(ordinalBase, ordinal);
665
+ const callKey = call.stableId
666
+ ? `id|${call.stableId}`
667
+ : `ordinal|${ordinalBase}|${ordinal}`;
668
+ rememberCall(context.calls, callKey, {
669
+ turnId: currentTurnId,
670
+ threadId,
671
+ originalLikely,
672
+ });
673
+ continue;
674
+ }
675
+
676
+ if (record.type !== "event_msg" || record.payload?.type !== "token_count") {
677
+ continue;
678
+ }
679
+
680
+ rememberQuota(context.quotas, record.payload.rate_limits, occurrence);
681
+ const info = record.payload.info;
682
+ if (!info?.last_token_usage) continue;
683
+
684
+ const totalTuple = tokenTuple(info.total_token_usage);
685
+ const lastTuple = tokenTuple(info.last_token_usage);
686
+ if (lastTuple[5] <= 0) continue;
687
+ const contextWindow = asFiniteNumber(info.model_context_window);
688
+ const eventKey = currentTurnId
689
+ ? JSON.stringify([
690
+ currentTurnId,
691
+ totalTuple,
692
+ lastTuple,
693
+ contextWindow,
694
+ ])
695
+ : JSON.stringify(["legacy", totalTuple, lastTuple, contextWindow]);
696
+
697
+ if (
698
+ previousCumulative !== null &&
699
+ totalTuple[5] < previousCumulative
700
+ ) {
701
+ context.correctionIntervals += 1;
702
+ }
703
+ previousCumulative = totalTuple[5];
704
+
705
+ rememberToken(context, eventKey, {
706
+ key: eventKey,
707
+ turnId: currentTurnId,
708
+ usage: usageFromTuple(lastTuple),
709
+ occurrence,
710
+ originalLikely,
711
+ dedupeQuality: currentTurnId ? "turn-exact" : "legacy-heuristic",
712
+ });
713
+ }
714
+ return context;
715
+ }
716
+
717
+ export function scanWorkerCount(
718
+ fileCount,
719
+ requestedWorkers = null,
720
+ parallelism = availableParallelism(),
721
+ ) {
722
+ if (!Number.isInteger(fileCount) || fileCount < 0) {
723
+ throw new Error("fileCount must be a non-negative integer.");
724
+ }
725
+ if (fileCount === 0) return 0;
726
+ if (
727
+ requestedWorkers !== null &&
728
+ (!Number.isInteger(requestedWorkers) ||
729
+ requestedWorkers < 1 ||
730
+ requestedWorkers > MAX_SCAN_WORKERS)
731
+ ) {
732
+ throw new Error(`workers must be an integer from 1 to ${MAX_SCAN_WORKERS}.`);
733
+ }
734
+ const detectedParallelism =
735
+ Number.isInteger(parallelism) && parallelism > 0 ? parallelism : 1;
736
+ const automaticWorkers = Math.min(
737
+ DEFAULT_MAX_SCAN_WORKERS,
738
+ Math.max(1, detectedParallelism - 1),
739
+ );
740
+ return Math.min(fileCount, requestedWorkers ?? automaticWorkers);
741
+ }
742
+
743
+ function reportScanProgress(onProgress, current, total, path) {
744
+ if (current === 1 || current === total || current % 10 === 0) {
745
+ onProgress({ current, total, path });
746
+ }
747
+ }
748
+
749
+ function workerFailure(message) {
750
+ const error = new Error(message?.error?.message || "Rollout scan worker failed.");
751
+ error.name = message?.error?.name || "Error";
752
+ if (message?.error?.code) error.code = message.error.code;
753
+ return error;
754
+ }
755
+
756
+ function scanWithWorker(worker, job) {
757
+ return new Promise((resolveJob, rejectJob) => {
758
+ const cleanup = () => {
759
+ worker.off("message", onMessage);
760
+ worker.off("error", onError);
761
+ worker.off("exit", onExit);
762
+ };
763
+ const onMessage = (message) => {
764
+ cleanup();
765
+ if (message?.jobId !== job.index) {
766
+ rejectJob(new Error("Rollout scan worker returned an unexpected job."));
767
+ } else if (message.type === "error") {
768
+ rejectJob(workerFailure(message));
769
+ } else if (message.type === "result") {
770
+ resolveJob(message.fragment);
771
+ } else {
772
+ rejectJob(new Error("Rollout scan worker returned an unknown response."));
773
+ }
774
+ };
775
+ const onError = (error) => {
776
+ cleanup();
777
+ rejectJob(error);
778
+ };
779
+ const onExit = (code) => {
780
+ cleanup();
781
+ rejectJob(
782
+ new Error(`Rollout scan worker exited before completing its job (${code}).`),
783
+ );
784
+ };
785
+ worker.once("message", onMessage);
786
+ worker.once("error", onError);
787
+ worker.once("exit", onExit);
788
+ try {
789
+ worker.postMessage({
790
+ type: "scan",
791
+ jobId: job.index,
792
+ path: job.path,
793
+ stateRow: job.stateRow,
794
+ });
795
+ } catch (error) {
796
+ cleanup();
797
+ rejectJob(error);
798
+ }
799
+ });
800
+ }
801
+
802
+ async function scanRolloutsSequential(jobs, onProgress) {
803
+ const fragments = new Array(jobs.length);
804
+ for (let index = 0; index < jobs.length; index += 1) {
805
+ const job = jobs[index];
806
+ fragments[job.index] = await scanRollout(job.path, job.stateRow);
807
+ reportScanProgress(onProgress, index + 1, jobs.length, job.path);
808
+ }
809
+ return fragments;
810
+ }
811
+
812
+ async function scanRolloutsInParallel(jobs, workerCount, onProgress) {
813
+ const scheduled = [...jobs].sort(
814
+ (left, right) => right.size - left.size || left.index - right.index,
815
+ );
816
+ const fragments = new Array(jobs.length);
817
+ const workers = Array.from(
818
+ { length: workerCount },
819
+ () => new Worker(new URL(import.meta.url), {
820
+ execArgv: [],
821
+ workerData: { mode: SCAN_WORKER_MODE },
822
+ }),
823
+ );
824
+ let nextJob = 0;
825
+ let completed = 0;
826
+ try {
827
+ await Promise.all(workers.map(async (worker) => {
828
+ while (nextJob < scheduled.length) {
829
+ const job = scheduled[nextJob];
830
+ nextJob += 1;
831
+ fragments[job.index] = await scanWithWorker(worker, job);
832
+ completed += 1;
833
+ reportScanProgress(onProgress, completed, jobs.length, job.path);
834
+ }
835
+ }));
836
+ return fragments;
837
+ } finally {
838
+ await Promise.allSettled(workers.map((worker) => worker.terminate()));
839
+ }
840
+ }
841
+
842
+ async function runScanWorker() {
843
+ parentPort.on("message", async (message) => {
844
+ if (message?.type !== "scan") return;
845
+ try {
846
+ const fragment = await scanRollout(message.path, message.stateRow);
847
+ parentPort.postMessage({
848
+ type: "result",
849
+ jobId: message.jobId,
850
+ fragment,
851
+ });
852
+ } catch (error) {
853
+ parentPort.postMessage({
854
+ type: "error",
855
+ jobId: message.jobId,
856
+ error: {
857
+ name: error instanceof Error ? error.name : "Error",
858
+ message: error instanceof Error ? error.message : String(error),
859
+ code: error?.code || null,
860
+ },
861
+ });
862
+ }
863
+ });
864
+ }
865
+
866
+ function threadMetadata(threadId, stateRows, parents, fallback = {}) {
867
+ const row = stateRows.get(threadId);
868
+ const labels = sourceLabels(
869
+ row?.thread_source,
870
+ fallback.rawSource || row?.source,
871
+ );
872
+ return {
873
+ id: threadId,
874
+ project: projectLabel(
875
+ fallback.cwd || row?.cwd,
876
+ fallback.gitOrigin || row?.git_origin_url,
877
+ ),
878
+ model: normalizeModel(fallback.model || row?.model || "unknown"),
879
+ effort: sanitizeLabel(
880
+ fallback.effort || row?.reasoning_effort || "unknown",
881
+ ).slice(0, 40),
882
+ source: labels.source,
883
+ useType: labels.useType,
884
+ parentThreadId: parents.get(threadId) || null,
885
+ reportedCumulativeTokens:
886
+ row && row.tokens_used !== null
887
+ ? asFiniteNumber(row.tokens_used)
888
+ : null,
889
+ createdAt: isoFromEpoch(row?.created_at),
890
+ updatedAt: isoFromEpoch(row?.updated_at),
891
+ };
892
+ }
893
+
894
+ function buildSnapshot(context, options) {
895
+ const events = [];
896
+ for (const token of context.tokens.values()) {
897
+ const origin = token.turnId ? context.origins.get(token.turnId) : null;
898
+ const occurrence =
899
+ token.originalLikely || !origin
900
+ ? token.occurrence
901
+ : {
902
+ ...token.occurrence,
903
+ ...origin,
904
+ timestamp: token.occurrence.timestamp,
905
+ };
906
+ const threadId = origin?.threadId || occurrence.threadId;
907
+ const metadata = threadMetadata(
908
+ threadId,
909
+ context.stateRows,
910
+ context.parents,
911
+ origin || occurrence,
912
+ );
913
+ const timestamp = occurrence.timestamp || origin?.timestamp;
914
+ if (
915
+ options.since &&
916
+ new Date(timestamp).getTime() < options.since.getTime()
917
+ ) {
918
+ continue;
919
+ }
920
+ const breakdownAvailable = hasDetailedBreakdown(token.usage);
921
+ events.push({
922
+ ...token.usage,
923
+ id: `evt-${hash(token.key)}`,
924
+ timestamp,
925
+ threadId,
926
+ project: metadata.project,
927
+ model: metadata.model,
928
+ effort: metadata.effort,
929
+ source: metadata.source,
930
+ useType: metadata.useType,
931
+ turnId: token.turnId || "",
932
+ toolCalls: 0,
933
+ breakdownAvailable,
934
+ dedupeQuality: token.dedupeQuality,
935
+ });
936
+ }
937
+ events.sort(
938
+ (left, right) =>
939
+ new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
940
+ );
941
+
942
+ const toolCounts = new Map();
943
+ for (const call of context.calls.values()) {
944
+ const origin = call.turnId ? context.origins.get(call.turnId) : null;
945
+ const threadId = origin?.threadId || call.threadId;
946
+ const key = call.turnId || `thread:${threadId}`;
947
+ toolCounts.set(key, (toolCounts.get(key) || 0) + 1);
948
+ }
949
+ const lastEventByTurn = new Map();
950
+ for (const event of events) {
951
+ const key = event.turnId || `thread:${event.threadId}`;
952
+ lastEventByTurn.set(key, event);
953
+ }
954
+ for (const [key, count] of toolCounts) {
955
+ const event = lastEventByTurn.get(key);
956
+ if (event) event.toolCalls = count;
957
+ }
958
+
959
+ const grouped = new Map();
960
+ for (const event of events) {
961
+ const rows = grouped.get(event.threadId) || [];
962
+ rows.push(event);
963
+ grouped.set(event.threadId, rows);
964
+ }
965
+
966
+ const allThreadIds = new Set([
967
+ ...context.stateRows.keys(),
968
+ ...grouped.keys(),
969
+ ]);
970
+ const threads = [];
971
+ for (const threadId of allThreadIds) {
972
+ const rows = grouped.get(threadId) || [];
973
+ const first = rows[0];
974
+ const last = rows.at(-1);
975
+ const origin = first
976
+ ? context.origins.get(first.turnId)
977
+ : null;
978
+ const metadata = threadMetadata(
979
+ threadId,
980
+ context.stateRows,
981
+ context.parents,
982
+ origin || first || {},
983
+ );
984
+ const totals = rows.reduce(
985
+ (sum, event) => {
986
+ sum.inputTokens += event.inputTokens;
987
+ sum.cachedInputTokens += event.cachedInputTokens;
988
+ sum.outputTokens += event.outputTokens;
989
+ sum.reasoningTokens += event.reasoningTokens;
990
+ sum.totalTokens += event.totalTokens;
991
+ sum.toolCalls += event.toolCalls;
992
+ if (event.breakdownAvailable) sum.detailedTokens += event.totalTokens;
993
+ else sum.unknownBreakdownTokens += event.totalTokens;
994
+ return sum;
995
+ },
996
+ {
997
+ inputTokens: 0,
998
+ cachedInputTokens: 0,
999
+ outputTokens: 0,
1000
+ reasoningTokens: 0,
1001
+ totalTokens: 0,
1002
+ toolCalls: 0,
1003
+ detailedTokens: 0,
1004
+ unknownBreakdownTokens: 0,
1005
+ },
1006
+ );
1007
+ if (rows.length === 0 && !(metadata.reportedCumulativeTokens > 0)) continue;
1008
+ const coverage =
1009
+ rows.length === 0
1010
+ ? "unresolved"
1011
+ : totals.detailedTokens === totals.totalTokens
1012
+ ? "complete"
1013
+ : totals.detailedTokens > 0
1014
+ ? "partial"
1015
+ : "total-only";
1016
+ threads.push({
1017
+ id: threadId,
1018
+ project: metadata.project,
1019
+ model: metadata.model,
1020
+ effort: metadata.effort,
1021
+ source: metadata.source,
1022
+ useType: metadata.useType,
1023
+ parentThreadId: metadata.parentThreadId,
1024
+ firstActiveAt: first?.timestamp || metadata.createdAt,
1025
+ lastActiveAt: last?.timestamp || metadata.updatedAt,
1026
+ totalTokens: totals.totalTokens,
1027
+ detailedTokens: totals.detailedTokens,
1028
+ unknownBreakdownTokens: totals.unknownBreakdownTokens,
1029
+ reportedCumulativeTokens: metadata.reportedCumulativeTokens,
1030
+ inputTokens: totals.inputTokens,
1031
+ cachedInputTokens: totals.cachedInputTokens,
1032
+ outputTokens: totals.outputTokens,
1033
+ reasoningTokens: totals.reasoningTokens,
1034
+ toolCalls: totals.toolCalls,
1035
+ eventCount: rows.length,
1036
+ coverage,
1037
+ });
1038
+ }
1039
+ threads.sort((left, right) => right.totalTokens - left.totalTokens);
1040
+
1041
+ const quotas = [...context.quotas.values()]
1042
+ .map((quota) => {
1043
+ const exported = { ...quota };
1044
+ delete exported.turnId;
1045
+ delete exported.originalLikely;
1046
+ return exported;
1047
+ })
1048
+ .filter((quota) => {
1049
+ if (!options.since) return true;
1050
+ return new Date(quota.timestamp).getTime() >= options.since.getTime();
1051
+ })
1052
+ .sort(
1053
+ (left, right) =>
1054
+ new Date(left.timestamp).getTime() -
1055
+ new Date(right.timestamp).getTime(),
1056
+ );
1057
+
1058
+ const observedTokens = events.reduce(
1059
+ (sum, event) => sum + event.totalTokens,
1060
+ 0,
1061
+ );
1062
+ const detailedTokens = events
1063
+ .filter((event) => event.breakdownAvailable)
1064
+ .reduce((sum, event) => sum + event.totalTokens, 0);
1065
+ const unknownBreakdownTokens = observedTokens - detailedTokens;
1066
+ const stateCounterSumNonAdditive = threads.reduce(
1067
+ (sum, thread) => sum + (thread.reportedCumulativeTokens || 0),
1068
+ 0,
1069
+ );
1070
+ const unresolvedThreadCounters = threads.filter(
1071
+ (thread) =>
1072
+ thread.coverage === "unresolved" &&
1073
+ (thread.reportedCumulativeTokens || 0) > 0,
1074
+ ).length;
1075
+ const earliestEventAt = events[0]?.timestamp || null;
1076
+ const latestEventAt = events.at(-1)?.timestamp || null;
1077
+ const weeklyCandidates = quotas.filter(
1078
+ (quota) => quota.windowMinutes === WEEK_MINUTES,
1079
+ );
1080
+ const accountWideWeekly = weeklyCandidates.filter(
1081
+ (quota) => quota.scope !== "named",
1082
+ );
1083
+ const weekly = [
1084
+ ...(accountWideWeekly.length ? accountWideWeekly : weeklyCandidates),
1085
+ ]
1086
+ .sort(
1087
+ (left, right) =>
1088
+ new Date(right.timestamp).getTime() -
1089
+ new Date(left.timestamp).getTime(),
1090
+ )[0];
1091
+ const weeklyStart = weekly
1092
+ ? (weekly.resetsAt - weekly.windowMinutes * 60) * 1_000
1093
+ : null;
1094
+ const completeSinceWindowStart = Boolean(
1095
+ weeklyStart &&
1096
+ earliestEventAt &&
1097
+ new Date(earliestEventAt).getTime() <= weeklyStart &&
1098
+ context.parseErrors === 0,
1099
+ );
1100
+
1101
+ const notes = [
1102
+ "Observed totals sum globally de-duplicated last_token_usage model-call events.",
1103
+ "Codex thread counters are retained only as non-additive reference values because forks and subagents inherit cumulative history.",
1104
+ "Historical rollout files can be pruned; a state-only counter cannot reveal that thread's unique token contribution.",
1105
+ "Legacy events without turn IDs use a high-specificity usage-signature heuristic and are labeled in the ledger.",
1106
+ ];
1107
+
1108
+ return {
1109
+ schemaVersion: SCHEMA_VERSION,
1110
+ generatedAt: new Date().toISOString(),
1111
+ label: "Local Codex snapshot",
1112
+ provenance: {
1113
+ kind: "codex-local-metadata",
1114
+ sourceFingerprint: sourceFingerprint(
1115
+ options.codexHome,
1116
+ options.includeArchived,
1117
+ ),
1118
+ privacy:
1119
+ "Contains token metadata and project labels only; display titles, credential fields, message bodies, reasoning text, tool payloads, and full local paths are not exported. Project labels can still be sensitive.",
1120
+ },
1121
+ coverage: {
1122
+ filesScanned: context.filesScanned,
1123
+ sourceFileCount: context.sourceFileCount,
1124
+ bytesScanned: context.bytesScanned,
1125
+ parseErrors: context.parseErrors,
1126
+ duplicateEventsSkipped: context.duplicateEventsSkipped,
1127
+ correctionIntervals: context.correctionIntervals,
1128
+ observedTokens,
1129
+ detailedTokens,
1130
+ unknownBreakdownTokens,
1131
+ stateCounterSumNonAdditive,
1132
+ unresolvedThreadCounters,
1133
+ legacyHeuristicEvents: events.filter(
1134
+ (event) => event.dedupeQuality === "legacy-heuristic",
1135
+ ).length,
1136
+ detailedPercent:
1137
+ observedTokens > 0 ? (detailedTokens / observedTokens) * 100 : 100,
1138
+ earliestEventAt,
1139
+ latestEventAt,
1140
+ completeSinceWindowStart,
1141
+ notes,
1142
+ },
1143
+ quotaObservations: quotas,
1144
+ threads,
1145
+ events,
1146
+ };
1147
+ }
1148
+
1149
+ export async function collectUsage(options, onProgress = () => {}) {
1150
+ const state = await readState(options.codexHome);
1151
+ const roots = [resolve(options.codexHome, "sessions")];
1152
+ if (options.includeArchived) {
1153
+ roots.push(resolve(options.codexHome, "archived_sessions"));
1154
+ }
1155
+ const files = (
1156
+ await Promise.all(roots.map((root) => listJsonlFiles(root)))
1157
+ )
1158
+ .flat()
1159
+ .sort();
1160
+ const sizes = await Promise.all(files.map((path) => stat(path)));
1161
+ const jobs = files.map((path, index) => ({
1162
+ index,
1163
+ path,
1164
+ size: sizes[index].size,
1165
+ stateRow: state.rows.get(rolloutThreadId(path)) || null,
1166
+ }));
1167
+
1168
+ const context = {
1169
+ stateRows: state.rows,
1170
+ parents: state.parents,
1171
+ origins: new Map(),
1172
+ tokens: new Map(),
1173
+ quotas: new Map(),
1174
+ calls: new Map(),
1175
+ filesScanned: 0,
1176
+ sourceFileCount: files.length + (state.path ? 1 : 0),
1177
+ bytesScanned: 0,
1178
+ parseErrors: 0,
1179
+ duplicateEventsSkipped: 0,
1180
+ correctionIntervals: 0,
1181
+ };
1182
+
1183
+ const workerCount = scanWorkerCount(files.length, options.workers);
1184
+ const fragments = workerCount > 1
1185
+ ? await scanRolloutsInParallel(jobs, workerCount, onProgress)
1186
+ : await scanRolloutsSequential(jobs, onProgress);
1187
+ for (const fragment of fragments) {
1188
+ mergeScanFragment(context, fragment);
1189
+ }
1190
+ context.filesScanned = files.length;
1191
+ context.bytesScanned = sizes.reduce((sum, entry) => sum + entry.size, 0);
1192
+
1193
+ return buildSnapshot(context, options);
1194
+ }
1195
+
1196
+ async function main() {
1197
+ let options;
1198
+ try {
1199
+ options = parseArgs(process.argv.slice(2));
1200
+ } catch (error) {
1201
+ process.stderr.write(`${sanitizeLabel(error.message)}\n\n${usage()}\n`);
1202
+ process.exitCode = 1;
1203
+ return;
1204
+ }
1205
+ if (options.help) {
1206
+ process.stdout.write(`${usage()}\n`);
1207
+ return;
1208
+ }
1209
+ if (!(await pathExists(options.codexHome))) {
1210
+ throw new Error(
1211
+ `Codex data directory not found: ${sanitizeLabel(options.codexHome)}`,
1212
+ );
1213
+ }
1214
+
1215
+ process.stdout.write("Token Ledger: scanning local Codex metadata…\n");
1216
+ const snapshot = await collectUsage(options, ({ current, total }) => {
1217
+ process.stdout.write(
1218
+ `\rToken Ledger: scanned ${current.toLocaleString()}/${total.toLocaleString()} rollout files`,
1219
+ );
1220
+ });
1221
+ process.stdout.write("\nToken Ledger: writing privacy-reduced snapshot…\n");
1222
+ await writePrivateSnapshot(options.output, snapshot);
1223
+ const outputSize = (await stat(options.output)).size;
1224
+ process.stdout.write(
1225
+ [
1226
+ `Snapshot: ${sanitizeLabel(options.output)}`,
1227
+ `Observed model-call tokens: ${snapshot.coverage.observedTokens.toLocaleString()}`,
1228
+ `Unique events: ${snapshot.events.length.toLocaleString()}`,
1229
+ `Duplicate/copied events skipped: ${snapshot.coverage.duplicateEventsSkipped.toLocaleString()}`,
1230
+ `Threads with unresolved state-only counters: ${snapshot.coverage.unresolvedThreadCounters.toLocaleString()}`,
1231
+ `Snapshot size: ${(outputSize / 1_000_000).toFixed(1)} MB`,
1232
+ ].join("\n") + "\n",
1233
+ );
1234
+ }
1235
+
1236
+ const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
1237
+ if (!isMainThread && workerData?.mode === SCAN_WORKER_MODE) {
1238
+ runScanWorker();
1239
+ } else if (isMainThread && import.meta.url === invokedPath) {
1240
+ main().catch((error) => {
1241
+ process.stderr.write(
1242
+ `Token Ledger collector failed: ${
1243
+ error instanceof Error ? sanitizeLabel(error.message) : sanitizeLabel(error)
1244
+ }\n`,
1245
+ );
1246
+ process.exitCode = 1;
1247
+ });
1248
+ }