tledger 0.2.1 → 0.3.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.
@@ -11,22 +11,18 @@
11
11
  * Node 22.13 or newer is required for node:sqlite.
12
12
  */
13
13
 
14
- import { createHash, randomUUID } from "node:crypto";
14
+ import { createHash } from "node:crypto";
15
15
  import { createReadStream } from "node:fs";
16
16
  import {
17
17
  access,
18
- chmod,
19
- mkdir,
18
+ mkdtemp,
20
19
  readdir,
21
- rename,
22
20
  rm,
23
21
  stat,
24
- writeFile,
25
22
  } from "node:fs/promises";
26
- import { homedir } from "node:os";
23
+ import { homedir, tmpdir } from "node:os";
27
24
  import {
28
25
  basename,
29
- dirname,
30
26
  extname,
31
27
  resolve,
32
28
  } from "node:path";
@@ -34,7 +30,13 @@ import { pathToFileURL } from "node:url";
34
30
  import { createInterface } from "node:readline";
35
31
  import { DatabaseSync } from "node:sqlite";
36
32
 
37
- const SCHEMA_VERSION = 1;
33
+ import { writePrivateSnapshot } from "./token-ledger-snapshot.mjs";
34
+ import {
35
+ buildUsageBuckets,
36
+ SNAPSHOT_SCHEMA_VERSION,
37
+ usageBucketStats,
38
+ } from "./token-ledger-usage.mjs";
39
+
38
40
  const RATE_CARD_AS_OF = "2026-08-17";
39
41
  const FAST_MODE_MULTIPLIER = 1.5;
40
42
  const RATE_CARD_URL = "https://help.openai.com/en/articles/20001106";
@@ -42,6 +44,262 @@ const WEEK_MINUTES = 10_080;
42
44
  const UUID_AT_END =
43
45
  /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
44
46
 
47
+ function spoolText(value, maximumLength = 2_000) {
48
+ try {
49
+ return String(value ?? "").slice(0, maximumLength);
50
+ } catch {
51
+ return "";
52
+ }
53
+ }
54
+
55
+ function spoolSource(value) {
56
+ const descriptor = sourceDescriptor(value);
57
+ if (descriptor.subagent) return '{"subagent":true}';
58
+ return descriptor.label || "";
59
+ }
60
+
61
+ async function createUsageSpool() {
62
+ const directory = await mkdtemp(resolve(tmpdir(), "token-ledger-import-"));
63
+ const path = resolve(directory, "usage.sqlite");
64
+ const database = new DatabaseSync(path);
65
+ database.exec(`
66
+ PRAGMA journal_mode = OFF;
67
+ PRAGMA synchronous = OFF;
68
+ PRAGMA temp_store = FILE;
69
+ CREATE TABLE token_events (
70
+ event_key TEXT PRIMARY KEY,
71
+ turn_id TEXT NOT NULL,
72
+ input_tokens REAL NOT NULL,
73
+ cached_input_tokens REAL NOT NULL,
74
+ cache_write_input_tokens REAL NOT NULL,
75
+ output_tokens REAL NOT NULL,
76
+ reasoning_tokens REAL NOT NULL,
77
+ total_tokens REAL NOT NULL,
78
+ timestamp TEXT NOT NULL,
79
+ thread_id TEXT NOT NULL,
80
+ model TEXT NOT NULL,
81
+ effort TEXT NOT NULL,
82
+ cwd TEXT NOT NULL,
83
+ git_origin TEXT,
84
+ raw_source TEXT,
85
+ service_tier TEXT,
86
+ original_likely INTEGER NOT NULL
87
+ ) WITHOUT ROWID;
88
+ CREATE TABLE tool_calls (
89
+ call_key TEXT PRIMARY KEY,
90
+ turn_id TEXT NOT NULL,
91
+ thread_id TEXT NOT NULL,
92
+ original_likely INTEGER NOT NULL
93
+ ) WITHOUT ROWID;
94
+ CREATE TABLE turn_origins (
95
+ turn_id TEXT PRIMARY KEY,
96
+ thread_id TEXT NOT NULL,
97
+ timestamp TEXT NOT NULL,
98
+ delta_ms REAL NOT NULL,
99
+ model TEXT NOT NULL,
100
+ effort TEXT NOT NULL,
101
+ cwd TEXT NOT NULL,
102
+ git_origin TEXT,
103
+ raw_source TEXT,
104
+ service_tier TEXT
105
+ ) WITHOUT ROWID;
106
+ BEGIN IMMEDIATE;
107
+ `);
108
+ const insertToken = database.prepare(`
109
+ INSERT OR IGNORE INTO token_events (
110
+ event_key, turn_id, input_tokens, cached_input_tokens,
111
+ cache_write_input_tokens, output_tokens, reasoning_tokens,
112
+ total_tokens, timestamp, thread_id, model, effort, cwd,
113
+ git_origin, raw_source, service_tier, original_likely
114
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
115
+ `);
116
+ const promoteToken = database.prepare(`
117
+ UPDATE token_events
118
+ SET turn_id = ?, timestamp = ?, thread_id = ?, model = ?, effort = ?,
119
+ cwd = ?, git_origin = ?, raw_source = ?, service_tier = ?,
120
+ original_likely = 1
121
+ WHERE event_key = ? AND original_likely = 0
122
+ `);
123
+ const insertCall = database.prepare(`
124
+ INSERT OR IGNORE INTO tool_calls (
125
+ call_key, turn_id, thread_id, original_likely
126
+ ) VALUES (?, ?, ?, ?)
127
+ `);
128
+ const promoteCall = database.prepare(`
129
+ UPDATE tool_calls
130
+ SET turn_id = ?, thread_id = ?, original_likely = 1
131
+ WHERE call_key = ? AND original_likely = 0
132
+ `);
133
+ const insertOrigin = database.prepare(`
134
+ INSERT INTO turn_origins (
135
+ turn_id, thread_id, timestamp, delta_ms, model, effort, cwd,
136
+ git_origin, raw_source, service_tier
137
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
138
+ ON CONFLICT(turn_id) DO UPDATE SET
139
+ thread_id = excluded.thread_id,
140
+ timestamp = excluded.timestamp,
141
+ delta_ms = excluded.delta_ms,
142
+ model = excluded.model,
143
+ effort = excluded.effort,
144
+ cwd = excluded.cwd,
145
+ git_origin = excluded.git_origin,
146
+ raw_source = excluded.raw_source,
147
+ service_tier = excluded.service_tier
148
+ WHERE excluded.delta_ms < turn_origins.delta_ms
149
+ `);
150
+ const updateOrigin = database.prepare(`
151
+ UPDATE turn_origins
152
+ SET thread_id = ?, timestamp = ?, model = ?, effort = ?, cwd = ?,
153
+ git_origin = ?, raw_source = ?, service_tier = ?
154
+ WHERE turn_id = ?
155
+ `);
156
+ let writing = true;
157
+
158
+ function originValues(candidate) {
159
+ return [
160
+ candidate.turnId,
161
+ candidate.threadId,
162
+ candidate.timestamp,
163
+ Number.isFinite(candidate.deltaMs)
164
+ ? candidate.deltaMs
165
+ : Number.MAX_SAFE_INTEGER,
166
+ spoolText(candidate.model, 200),
167
+ spoolText(candidate.effort, 80),
168
+ spoolText(candidate.cwd),
169
+ spoolText(candidate.gitOrigin),
170
+ spoolSource(candidate.rawSource),
171
+ candidate.serviceTier == null
172
+ ? null
173
+ : spoolText(candidate.serviceTier, 80),
174
+ ];
175
+ }
176
+
177
+ return {
178
+ directory,
179
+ insertOrigin(candidate) {
180
+ return insertOrigin.run(...originValues(candidate)).changes > 0;
181
+ },
182
+ updateOrigin(candidate) {
183
+ const values = originValues(candidate);
184
+ updateOrigin.run(
185
+ values[1],
186
+ values[2],
187
+ values[4],
188
+ values[5],
189
+ values[6],
190
+ values[7],
191
+ values[8],
192
+ values[9],
193
+ values[0],
194
+ );
195
+ },
196
+ insertToken(eventKey, turnId, usage, occurrence, originalLikely) {
197
+ const values = [
198
+ eventKey,
199
+ turnId,
200
+ usage.inputTokens,
201
+ usage.cachedInputTokens,
202
+ usage.cacheWriteInputTokens,
203
+ usage.outputTokens,
204
+ usage.reasoningTokens,
205
+ usage.totalTokens,
206
+ occurrence.timestamp,
207
+ occurrence.threadId,
208
+ spoolText(occurrence.model, 200),
209
+ spoolText(occurrence.effort, 80),
210
+ spoolText(occurrence.cwd),
211
+ spoolText(occurrence.gitOrigin),
212
+ spoolSource(occurrence.rawSource),
213
+ occurrence.serviceTier == null
214
+ ? null
215
+ : spoolText(occurrence.serviceTier, 80),
216
+ originalLikely ? 1 : 0,
217
+ ];
218
+ const inserted = insertToken.run(...values).changes > 0;
219
+ if (!inserted && originalLikely) {
220
+ promoteToken.run(
221
+ turnId,
222
+ occurrence.timestamp,
223
+ occurrence.threadId,
224
+ spoolText(occurrence.model, 200),
225
+ spoolText(occurrence.effort, 80),
226
+ spoolText(occurrence.cwd),
227
+ spoolText(occurrence.gitOrigin),
228
+ spoolSource(occurrence.rawSource),
229
+ occurrence.serviceTier == null
230
+ ? null
231
+ : spoolText(occurrence.serviceTier, 80),
232
+ eventKey,
233
+ );
234
+ }
235
+ return inserted;
236
+ },
237
+ insertCall(callKey, turnId, threadId, originalLikely) {
238
+ const inserted = insertCall.run(
239
+ callKey,
240
+ turnId,
241
+ threadId,
242
+ originalLikely ? 1 : 0,
243
+ ).changes > 0;
244
+ if (!inserted && originalLikely) {
245
+ promoteCall.run(turnId, threadId, callKey);
246
+ }
247
+ },
248
+ finishWrites() {
249
+ if (!writing) return;
250
+ database.exec("COMMIT");
251
+ writing = false;
252
+ },
253
+ tokenRows() {
254
+ return database.prepare(`
255
+ SELECT token.event_key AS eventKey, token.turn_id AS turnId,
256
+ token.input_tokens AS inputTokens,
257
+ token.cached_input_tokens AS cachedInputTokens,
258
+ token.cache_write_input_tokens AS cacheWriteInputTokens,
259
+ token.output_tokens AS outputTokens,
260
+ token.reasoning_tokens AS reasoningTokens,
261
+ token.total_tokens AS totalTokens,
262
+ token.timestamp, token.thread_id AS threadId,
263
+ token.model, token.effort, token.cwd,
264
+ token.git_origin AS gitOrigin,
265
+ token.raw_source AS rawSource,
266
+ token.service_tier AS serviceTier,
267
+ token.original_likely AS originalLikely,
268
+ origin.thread_id AS originThreadId,
269
+ origin.timestamp AS originTimestamp,
270
+ origin.model AS originModel,
271
+ origin.effort AS originEffort,
272
+ origin.cwd AS originCwd,
273
+ origin.git_origin AS originGitOrigin,
274
+ origin.raw_source AS originRawSource,
275
+ origin.service_tier AS originServiceTier
276
+ FROM token_events AS token
277
+ LEFT JOIN turn_origins AS origin ON origin.turn_id = token.turn_id
278
+ ORDER BY token.timestamp, token.event_key
279
+ `).iterate();
280
+ },
281
+ callRows() {
282
+ return database.prepare(`
283
+ SELECT call.turn_id AS turnId,
284
+ COALESCE(origin.thread_id, call.thread_id) AS threadId
285
+ FROM tool_calls AS call
286
+ LEFT JOIN turn_origins AS origin ON origin.turn_id = call.turn_id
287
+ `).iterate();
288
+ },
289
+ close() {
290
+ if (writing) {
291
+ try {
292
+ database.exec("ROLLBACK");
293
+ } catch {
294
+ // The spool is disposable; cleanup below remains authoritative.
295
+ }
296
+ writing = false;
297
+ }
298
+ database.close();
299
+ },
300
+ };
301
+ }
302
+
45
303
  const RATE_CARD = {
46
304
  "gpt-5.6-sol": { input: 125, cached: 12.5, output: 750 },
47
305
  "gpt-5.6-terra": { input: 50, cached: 5, output: 300 },
@@ -61,7 +319,7 @@ Usage:
61
319
  node token-ledger-importer.mjs [options]
62
320
 
63
321
  Options:
64
- --output <file> Snapshot destination (default: token-ledger-snapshot.json)
322
+ --output <file> Snapshot destination (default: token-ledger-snapshot-v2.json.gz)
65
323
  --codex-home <dir> Codex data root (default: CODEX_HOME or ~/.codex)
66
324
  --since <ISO date> Ignore model calls before this timestamp
67
325
  --no-archived Skip archived_sessions
@@ -75,7 +333,7 @@ text.`;
75
333
 
76
334
  function parseArgs(argv) {
77
335
  const options = {
78
- output: resolve("token-ledger-snapshot.json"),
336
+ output: resolve("token-ledger-snapshot-v2.json.gz"),
79
337
  codexHome: resolve(process.env.CODEX_HOME || `${homedir()}/.codex`),
80
338
  includeArchived: true,
81
339
  since: null,
@@ -131,24 +389,39 @@ function safeIso(value, fallback = null) {
131
389
  }
132
390
 
133
391
  function tokenTuple(value) {
134
- const usage = value && typeof value === "object" ? value : {};
392
+ // token_count payloads arrive from untrusted JSONL. Read the named usage
393
+ // fields once and coerce each to a finite count so malformed records
394
+ // contribute zeros instead of leaking odd shapes downstream.
395
+ const {
396
+ input_tokens: inputTokens,
397
+ cached_input_tokens: cachedInputTokens,
398
+ cache_write_input_tokens: cacheWriteInputTokens,
399
+ output_tokens: outputTokens,
400
+ reasoning_output_tokens: reasoningOutputTokens,
401
+ total_tokens: totalTokens,
402
+ } = value ?? {};
135
403
  return [
136
- asFiniteNumber(usage.input_tokens),
137
- asFiniteNumber(usage.cached_input_tokens),
138
- asFiniteNumber(usage.cache_write_input_tokens),
139
- asFiniteNumber(usage.output_tokens),
140
- asFiniteNumber(usage.reasoning_output_tokens),
141
- asFiniteNumber(usage.total_tokens),
404
+ asFiniteNumber(inputTokens),
405
+ asFiniteNumber(cachedInputTokens),
406
+ asFiniteNumber(cacheWriteInputTokens),
407
+ asFiniteNumber(outputTokens),
408
+ asFiniteNumber(reasoningOutputTokens),
409
+ asFiniteNumber(totalTokens),
142
410
  ];
143
411
  }
144
412
 
145
413
  function usageFromTuple(tuple) {
414
+ // Cached input is a subset of input and reasoning is a subset of output.
415
+ // Clamp at export so one out-of-range source record cannot skew subset
416
+ // math downstream.
417
+ const inputTokens = tuple[0];
418
+ const outputTokens = tuple[3];
146
419
  return {
147
- inputTokens: tuple[0],
148
- cachedInputTokens: tuple[1],
420
+ inputTokens,
421
+ cachedInputTokens: Math.min(inputTokens, tuple[1]),
149
422
  cacheWriteInputTokens: tuple[2],
150
- outputTokens: tuple[3],
151
- reasoningTokens: tuple[4],
423
+ outputTokens,
424
+ reasoningTokens: Math.min(outputTokens, tuple[4]),
152
425
  totalTokens: tuple[5],
153
426
  };
154
427
  }
@@ -162,10 +435,14 @@ function hasDetailedBreakdown(usage) {
162
435
  }
163
436
 
164
437
  function normalizeModel(model) {
438
+ // Collapse underscore and whitespace separators to dashes so variants like
439
+ // "gpt-5.4 mini" resolve to their own rate-card entry instead of falling
440
+ // back to the base model's higher rate. Keep in lockstep with
441
+ // normalizeModel in bin/token-ledger-rates.mjs.
165
442
  const value = String(model || "unknown")
166
443
  .trim()
167
444
  .toLowerCase()
168
- .replaceAll("_", "-");
445
+ .replace(/[\s_]+/g, "-");
169
446
  if (RATE_CARD[value]) return value;
170
447
  if (value.startsWith("gpt-5.6-sol")) return "gpt-5.6-sol";
171
448
  if (value.startsWith("gpt-5.6-terra")) return "gpt-5.6-terra";
@@ -193,25 +470,40 @@ function creditsForUsage(model, usage) {
193
470
  );
194
471
  }
195
472
 
196
- function parseStructuredSource(value) {
197
- if (!value) return null;
198
- if (typeof value === "object") return value;
199
- if (typeof value !== "string") return null;
200
- const trimmed = value.trim();
201
- if (!trimmed.startsWith("{")) return trimmed;
473
+ function primitiveString(value) {
202
474
  try {
203
- return JSON.parse(trimmed);
475
+ const text = String.prototype.valueOf.call(value);
476
+ return text === value ? text : null;
204
477
  } catch {
205
- return trimmed;
478
+ return null;
206
479
  }
207
480
  }
208
481
 
482
+ function sourceDescriptor(value) {
483
+ // Thread sources reach us in two representations: rollout session_meta
484
+ // lines carry parsed JSON (a label string or an object with subagent
485
+ // details) while the sqlite source column carries the same value as text.
486
+ // Parse both here, once, so callers only branch on the named fields.
487
+ if (value == null) return { label: null, subagent: null };
488
+ const sourceText = primitiveString(value);
489
+ if (sourceText === null) {
490
+ return { label: null, subagent: value.subagent || null };
491
+ }
492
+ const text = sourceText.trim();
493
+ if (text.startsWith("{")) {
494
+ try {
495
+ const parsed = JSON.parse(text);
496
+ return { label: null, subagent: parsed.subagent || null };
497
+ } catch {
498
+ // Not JSON after all; fall through to the plain-label case.
499
+ }
500
+ }
501
+ return { label: text || null, subagent: null };
502
+ }
503
+
209
504
  function sourceLabels(threadSource, rawSource) {
210
- const source = parseStructuredSource(rawSource);
211
- if (
212
- threadSource === "subagent" ||
213
- (source && typeof source === "object" && source.subagent)
214
- ) {
505
+ const source = sourceDescriptor(rawSource);
506
+ if (threadSource === "subagent" || source.subagent) {
215
507
  return { source: "subagent", useType: "subagent" };
216
508
  }
217
509
  if (threadSource === "automation") {
@@ -220,10 +512,15 @@ function sourceLabels(threadSource, rawSource) {
220
512
  if (threadSource === "realtime_voice") {
221
513
  return { source: "voice", useType: "voice" };
222
514
  }
223
- if (source === "exec") return { source: "cli", useType: "cli" };
224
- if (source === "vscode") return { source: "desktop", useType: "interactive" };
225
- if (typeof source === "string" && source) {
226
- return { source: source.slice(0, 40), useType: threadSource || "interactive" };
515
+ if (source.label === "exec") return { source: "cli", useType: "cli" };
516
+ if (source.label === "vscode") {
517
+ return { source: "desktop", useType: "interactive" };
518
+ }
519
+ if (source.label) {
520
+ return {
521
+ source: source.label.slice(0, 40),
522
+ useType: threadSource || "interactive",
523
+ };
227
524
  }
228
525
  return { source: "unknown", useType: threadSource || "unknown" };
229
526
  }
@@ -291,28 +588,6 @@ async function pathExists(path) {
291
588
  }
292
589
  }
293
590
 
294
- export async function writePrivateSnapshot(output, snapshot) {
295
- const destination = resolve(output);
296
- const directory = dirname(destination);
297
- const temporary = resolve(
298
- directory,
299
- `.token-ledger-${process.pid}-${randomUUID()}.tmp`,
300
- );
301
- await mkdir(directory, { recursive: true });
302
- try {
303
- await writeFile(temporary, `${JSON.stringify(snapshot)}\n`, {
304
- encoding: "utf8",
305
- flag: "wx",
306
- mode: 0o600,
307
- });
308
- await chmod(temporary, 0o600);
309
- await rename(temporary, destination);
310
- await chmod(destination, 0o600);
311
- } finally {
312
- await rm(temporary, { force: true });
313
- }
314
- }
315
-
316
591
  export async function listJsonlFiles(root) {
317
592
  if (!(await pathExists(root))) return [];
318
593
  const found = [];
@@ -440,14 +715,22 @@ function taskStartCandidate(record, threadId, stateRow, fileContext) {
440
715
  }
441
716
 
442
717
  function rememberQuota(quotaMap, rateLimits, occurrence) {
443
- if (!rateLimits || typeof rateLimits !== "object") return;
444
- const buckets = [rateLimits.primary, rateLimits.secondary].filter(Boolean);
718
+ // rate_limits payloads come straight from JSONL. Read the named fields
719
+ // once; records without usable buckets drop out via the filter below.
720
+ const {
721
+ primary,
722
+ secondary,
723
+ limit_id: limitId,
724
+ limit_name: limitName,
725
+ plan_type: planType,
726
+ } = rateLimits ?? {};
727
+ const buckets = [primary, secondary].filter(Boolean);
445
728
  for (const bucket of buckets) {
446
729
  const windowMinutes = asFiniteNumber(bucket.window_minutes);
447
730
  const usedPercent = asFiniteNumber(bucket.used_percent);
448
731
  const resetsAt = asFiniteNumber(bucket.resets_at);
449
732
  if (!windowMinutes || !resetsAt) continue;
450
- const limitKey = String(rateLimits.limit_id || rateLimits.limit_name || "anonymous");
733
+ const limitKey = String(limitId || limitName || "anonymous");
451
734
  const key = [
452
735
  hash(limitKey, 16),
453
736
  windowMinutes,
@@ -460,11 +743,9 @@ function rememberQuota(quotaMap, rateLimits, occurrence) {
460
743
  usedPercent,
461
744
  windowMinutes,
462
745
  resetsAt,
463
- planType: String(rateLimits.plan_type || "unknown"),
746
+ planType: String(planType || "unknown"),
464
747
  limitKey: hash(limitKey, 16),
465
- limitName: rateLimits.limit_name
466
- ? String(rateLimits.limit_name).slice(0, 80)
467
- : null,
748
+ limitName: limitName ? String(limitName).slice(0, 80) : null,
468
749
  source: "log",
469
750
  turnId: occurrence.turnId || null,
470
751
  originalLikely: occurrence.originalLikely,
@@ -514,6 +795,7 @@ async function scanRollout(path, context) {
514
795
  const callOrdinals = new Map();
515
796
  let currentTurnId = "";
516
797
  let currentCandidate = null;
798
+ let currentCandidateSelected = false;
517
799
  let previousCumulative = null;
518
800
 
519
801
  const input = createReadStream(path, { encoding: "utf8" });
@@ -557,10 +839,7 @@ async function scanRollout(path, context) {
557
839
  stateRow,
558
840
  fileContext,
559
841
  );
560
- const currentBest = context.origins.get(currentTurnId);
561
- if (!currentBest || currentCandidate.deltaMs < currentBest.deltaMs) {
562
- context.origins.set(currentTurnId, currentCandidate);
563
- }
842
+ currentCandidateSelected = context.spool.insertOrigin(currentCandidate);
564
843
  }
565
844
  continue;
566
845
  }
@@ -574,6 +853,9 @@ async function scanRollout(path, context) {
574
853
  currentCandidate.model = fileContext.model;
575
854
  currentCandidate.effort = fileContext.effort;
576
855
  currentCandidate.cwd = fileContext.cwd;
856
+ if (currentCandidateSelected) {
857
+ context.spool.updateOrigin(currentCandidate);
858
+ }
577
859
  }
578
860
  continue;
579
861
  }
@@ -617,14 +899,12 @@ async function scanRollout(path, context) {
617
899
  const callKey = call.stableId
618
900
  ? `id|${call.stableId}`
619
901
  : `ordinal|${ordinalBase}|${ordinal}`;
620
- const existing = context.calls.get(callKey);
621
- if (!existing || (!existing.originalLikely && originalLikely)) {
622
- context.calls.set(callKey, {
623
- turnId: currentTurnId,
624
- threadId,
625
- originalLikely,
626
- });
627
- }
902
+ context.spool.insertCall(
903
+ callKey,
904
+ currentTurnId,
905
+ threadId,
906
+ originalLikely,
907
+ );
628
908
  continue;
629
909
  }
630
910
 
@@ -657,24 +937,16 @@ async function scanRollout(path, context) {
657
937
  }
658
938
  previousCumulative = totalTuple[5];
659
939
 
660
- const existing = context.tokens.get(eventKey);
661
- if (existing) {
662
- context.duplicateEventsSkipped += 1;
663
- if (!existing.originalLikely && originalLikely) {
664
- existing.occurrence = occurrence;
665
- existing.originalLikely = true;
666
- }
667
- continue;
668
- }
669
-
670
- context.tokens.set(eventKey, {
671
- key: eventKey,
672
- turnId: currentTurnId,
673
- usage: usageFromTuple(lastTuple),
940
+ const inserted = context.spool.insertToken(
941
+ eventKey,
942
+ currentTurnId,
943
+ usageFromTuple(lastTuple),
674
944
  occurrence,
675
945
  originalLikely,
676
- dedupeQuality: currentTurnId ? "turn-exact" : "legacy-heuristic",
677
- });
946
+ );
947
+ if (!inserted) {
948
+ context.duplicateEventsSkipped += 1;
949
+ }
678
950
  }
679
951
  }
680
952
 
@@ -708,144 +980,183 @@ function threadMetadata(threadId, stateRows, titles, parents, fallback = {}) {
708
980
  };
709
981
  }
710
982
 
983
+ function resolvedOccurrence(token) {
984
+ const origin = token.originThreadId == null
985
+ ? null
986
+ : {
987
+ threadId: token.originThreadId,
988
+ timestamp: token.originTimestamp,
989
+ model: token.originModel,
990
+ effort: token.originEffort,
991
+ cwd: token.originCwd,
992
+ gitOrigin: token.originGitOrigin,
993
+ rawSource: token.originRawSource,
994
+ serviceTier: token.originServiceTier,
995
+ };
996
+ const occurrence = token.originalLikely || !origin ? token : origin;
997
+ return {
998
+ origin,
999
+ occurrence,
1000
+ threadId: origin?.threadId || occurrence.threadId,
1001
+ timestamp: token.timestamp || origin?.timestamp,
1002
+ };
1003
+ }
1004
+
1005
+ function newThreadAggregate(metadata, timestamp) {
1006
+ return {
1007
+ metadata,
1008
+ firstActiveAt: timestamp,
1009
+ lastActiveAt: timestamp,
1010
+ inputTokens: 0,
1011
+ cachedInputTokens: 0,
1012
+ outputTokens: 0,
1013
+ reasoningTokens: 0,
1014
+ totalTokens: 0,
1015
+ toolCalls: 0,
1016
+ detailedTokens: 0,
1017
+ unknownBreakdownTokens: 0,
1018
+ rateCardCredits: 0,
1019
+ ratedTokens: 0,
1020
+ eventCount: 0,
1021
+ };
1022
+ }
1023
+
1024
+ function addToThreadAggregate(aggregate, event) {
1025
+ aggregate.lastActiveAt = event.timestamp;
1026
+ aggregate.inputTokens += event.inputTokens;
1027
+ aggregate.cachedInputTokens += event.cachedInputTokens;
1028
+ aggregate.outputTokens += event.outputTokens;
1029
+ aggregate.reasoningTokens += event.reasoningTokens;
1030
+ aggregate.totalTokens += event.totalTokens;
1031
+ aggregate.toolCalls += event.toolCalls;
1032
+ aggregate.eventCount += 1;
1033
+ if (event.breakdownAvailable) {
1034
+ aggregate.detailedTokens += event.totalTokens;
1035
+ } else {
1036
+ aggregate.unknownBreakdownTokens += event.totalTokens;
1037
+ }
1038
+ if (event.rateCardCredits !== null) {
1039
+ aggregate.rateCardCredits += event.rateCardCredits;
1040
+ aggregate.ratedTokens += event.totalTokens;
1041
+ }
1042
+ }
1043
+
711
1044
  function buildSnapshot(context, options, titles) {
712
- const events = [];
713
- for (const token of context.tokens.values()) {
714
- const origin = token.turnId ? context.origins.get(token.turnId) : null;
715
- const occurrence =
716
- token.originalLikely || !origin
717
- ? token.occurrence
718
- : {
719
- ...token.occurrence,
720
- ...origin,
721
- timestamp: token.occurrence.timestamp,
722
- };
723
- const threadId = origin?.threadId || occurrence.threadId;
724
- const metadata = threadMetadata(
725
- threadId,
726
- context.stateRows,
727
- titles,
728
- context.parents,
729
- origin || occurrence,
730
- );
731
- const timestamp = occurrence.timestamp || origin?.timestamp;
732
- if (
733
- options.since &&
734
- new Date(timestamp).getTime() < options.since.getTime()
735
- ) {
736
- continue;
737
- }
738
- const breakdownAvailable = hasDetailedBreakdown(token.usage);
739
- const serviceTier = occurrence.serviceTier || null;
740
- const baseCredits = creditsForUsage(metadata.model, token.usage);
741
- events.push({
742
- ...token.usage,
743
- id: `evt-${hash(token.key)}`,
744
- timestamp,
745
- threadId,
746
- threadTitle: metadata.title,
747
- project: metadata.project,
748
- model: metadata.model,
749
- effort: metadata.effort,
750
- source: metadata.source,
751
- useType: metadata.useType,
752
- turnId: token.turnId || "",
753
- toolCalls: 0,
754
- serviceTier,
755
- rateCardCredits:
756
- baseCredits === null
757
- ? null
758
- : serviceTier === "priority"
759
- ? baseCredits * FAST_MODE_MULTIPLIER
760
- : baseCredits,
761
- breakdownAvailable,
762
- dedupeQuality: token.dedupeQuality,
763
- });
1045
+ context.spool.finishWrites();
1046
+ const sinceMs = options.since ? options.since.getTime() : -Infinity;
1047
+ const lastEventKeyByTurn = new Map();
1048
+ let earliestEventAt = null;
1049
+ let latestEventAt = null;
1050
+
1051
+ // The spool is ordered on disk. This lightweight pass finds the event that
1052
+ // receives each turn's tool calls without retaining the usage rows.
1053
+ for (const token of context.spool.tokenRows()) {
1054
+ const { threadId, timestamp } = resolvedOccurrence(token);
1055
+ if (Date.parse(timestamp) < sinceMs) continue;
1056
+ earliestEventAt ||= timestamp;
1057
+ latestEventAt = timestamp;
1058
+ const key = token.turnId || `thread:${threadId}`;
1059
+ lastEventKeyByTurn.set(key, token.eventKey);
764
1060
  }
765
- events.sort(
766
- (left, right) =>
767
- new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
768
- );
769
1061
 
770
1062
  const toolCounts = new Map();
771
- for (const call of context.calls.values()) {
772
- const origin = call.turnId ? context.origins.get(call.turnId) : null;
773
- const threadId = origin?.threadId || call.threadId;
774
- const key = call.turnId || `thread:${threadId}`;
1063
+ for (const call of context.spool.callRows()) {
1064
+ const key = call.turnId || `thread:${call.threadId}`;
775
1065
  toolCounts.set(key, (toolCounts.get(key) || 0) + 1);
776
1066
  }
777
- const lastEventByTurn = new Map();
778
- for (const event of events) {
779
- const key = event.turnId || `thread:${event.threadId}`;
780
- lastEventByTurn.set(key, event);
781
- }
782
- for (const [key, count] of toolCounts) {
783
- const event = lastEventByTurn.get(key);
784
- if (event) event.toolCalls = count;
785
- }
786
1067
 
787
- const grouped = new Map();
788
- for (const event of events) {
789
- const rows = grouped.get(event.threadId) || [];
790
- rows.push(event);
791
- grouped.set(event.threadId, rows);
1068
+ const threadAggregates = new Map();
1069
+ let observedTokens = 0;
1070
+ let detailedTokens = 0;
1071
+ let legacyHeuristicEvents = 0;
1072
+ let observedModelCalls = 0;
1073
+
1074
+ function* compactableEvents() {
1075
+ for (const token of context.spool.tokenRows()) {
1076
+ const { origin, occurrence, threadId, timestamp } = resolvedOccurrence(token);
1077
+ if (Date.parse(timestamp) < sinceMs) continue;
1078
+ const metadata = threadMetadata(
1079
+ threadId,
1080
+ context.stateRows,
1081
+ titles,
1082
+ context.parents,
1083
+ origin || occurrence,
1084
+ );
1085
+ const usage = {
1086
+ inputTokens: Number(token.inputTokens),
1087
+ cachedInputTokens: Number(token.cachedInputTokens),
1088
+ cacheWriteInputTokens: Number(token.cacheWriteInputTokens),
1089
+ outputTokens: Number(token.outputTokens),
1090
+ reasoningTokens: Number(token.reasoningTokens),
1091
+ totalTokens: Number(token.totalTokens),
1092
+ };
1093
+ const breakdownAvailable = hasDetailedBreakdown(usage);
1094
+ const serviceTier = occurrence.serviceTier || null;
1095
+ const baseCredits = creditsForUsage(metadata.model, usage);
1096
+ const turnKey = token.turnId || `thread:${threadId}`;
1097
+ const event = {
1098
+ ...usage,
1099
+ timestamp,
1100
+ threadId,
1101
+ project: metadata.project,
1102
+ model: metadata.model,
1103
+ effort: metadata.effort,
1104
+ source: metadata.source,
1105
+ useType: metadata.useType,
1106
+ toolCalls:
1107
+ lastEventKeyByTurn.get(turnKey) === token.eventKey
1108
+ ? toolCounts.get(turnKey) || 0
1109
+ : 0,
1110
+ serviceTier,
1111
+ rateCardCredits:
1112
+ baseCredits === null
1113
+ ? null
1114
+ : serviceTier === "priority"
1115
+ ? baseCredits * FAST_MODE_MULTIPLIER
1116
+ : baseCredits,
1117
+ breakdownAvailable,
1118
+ };
1119
+
1120
+ let aggregate = threadAggregates.get(threadId);
1121
+ if (!aggregate) {
1122
+ aggregate = newThreadAggregate(metadata, timestamp);
1123
+ threadAggregates.set(threadId, aggregate);
1124
+ }
1125
+ addToThreadAggregate(aggregate, event);
1126
+ observedTokens += event.totalTokens;
1127
+ if (breakdownAvailable) detailedTokens += event.totalTokens;
1128
+ if (!token.turnId) legacyHeuristicEvents += 1;
1129
+ observedModelCalls += 1;
1130
+ yield event;
1131
+ }
792
1132
  }
793
1133
 
794
- const allThreadIds = new Set([
795
- ...context.stateRows.keys(),
796
- ...grouped.keys(),
797
- ]);
1134
+ const usageBuckets = buildUsageBuckets(compactableEvents(), {
1135
+ latestTimestampMs: latestEventAt ? Date.parse(latestEventAt) : 0,
1136
+ });
1137
+ const usageStats = usageBucketStats(usageBuckets);
1138
+ toolCounts.clear();
1139
+ lastEventKeyByTurn.clear();
1140
+
1141
+ const allThreadIds = new Set(context.stateRows.keys());
1142
+ for (const threadId of threadAggregates.keys()) allThreadIds.add(threadId);
798
1143
  const threads = [];
799
1144
  for (const threadId of allThreadIds) {
800
- const rows = grouped.get(threadId) || [];
801
- const first = rows[0];
802
- const last = rows.at(-1);
803
- const origin = first
804
- ? context.origins.get(first.turnId)
805
- : null;
806
- const metadata = threadMetadata(
1145
+ const aggregate = threadAggregates.get(threadId);
1146
+ const metadata = aggregate?.metadata || threadMetadata(
807
1147
  threadId,
808
1148
  context.stateRows,
809
1149
  titles,
810
1150
  context.parents,
811
- origin || first || {},
812
1151
  );
813
- const totals = rows.reduce(
814
- (sum, event) => {
815
- sum.inputTokens += event.inputTokens;
816
- sum.cachedInputTokens += event.cachedInputTokens;
817
- sum.outputTokens += event.outputTokens;
818
- sum.reasoningTokens += event.reasoningTokens;
819
- sum.totalTokens += event.totalTokens;
820
- sum.toolCalls += event.toolCalls;
821
- if (event.breakdownAvailable) sum.detailedTokens += event.totalTokens;
822
- else sum.unknownBreakdownTokens += event.totalTokens;
823
- if (event.rateCardCredits !== null) {
824
- sum.rateCardCredits += event.rateCardCredits;
825
- sum.ratedTokens += event.totalTokens;
826
- }
827
- return sum;
828
- },
829
- {
830
- inputTokens: 0,
831
- cachedInputTokens: 0,
832
- outputTokens: 0,
833
- reasoningTokens: 0,
834
- totalTokens: 0,
835
- toolCalls: 0,
836
- detailedTokens: 0,
837
- unknownBreakdownTokens: 0,
838
- rateCardCredits: 0,
839
- ratedTokens: 0,
840
- },
841
- );
842
- if (rows.length === 0 && !(metadata.reportedCumulativeTokens > 0)) continue;
843
- const coverage =
844
- rows.length === 0
1152
+ if (!aggregate && !(metadata.reportedCumulativeTokens > 0)) continue;
1153
+ const eventCount = aggregate?.eventCount || 0;
1154
+ const threadCoverage =
1155
+ eventCount === 0
845
1156
  ? "unresolved"
846
- : totals.detailedTokens === totals.totalTokens
1157
+ : aggregate.detailedTokens === aggregate.totalTokens
847
1158
  ? "complete"
848
- : totals.detailedTokens > 0
1159
+ : aggregate.detailedTokens > 0
849
1160
  ? "partial"
850
1161
  : "total-only";
851
1162
  threads.push({
@@ -857,27 +1168,32 @@ function buildSnapshot(context, options, titles) {
857
1168
  source: metadata.source,
858
1169
  useType: metadata.useType,
859
1170
  parentThreadId: metadata.parentThreadId,
860
- firstActiveAt: first?.timestamp || metadata.createdAt,
861
- lastActiveAt: last?.timestamp || metadata.updatedAt,
862
- totalTokens: totals.totalTokens,
863
- detailedTokens: totals.detailedTokens,
864
- unknownBreakdownTokens: totals.unknownBreakdownTokens,
1171
+ firstActiveAt: aggregate?.firstActiveAt || metadata.createdAt,
1172
+ lastActiveAt: aggregate?.lastActiveAt || metadata.updatedAt,
1173
+ totalTokens: aggregate?.totalTokens || 0,
1174
+ detailedTokens: aggregate?.detailedTokens || 0,
1175
+ unknownBreakdownTokens: aggregate?.unknownBreakdownTokens || 0,
865
1176
  reportedCumulativeTokens: metadata.reportedCumulativeTokens,
866
- inputTokens: totals.inputTokens,
867
- cachedInputTokens: totals.cachedInputTokens,
868
- outputTokens: totals.outputTokens,
869
- reasoningTokens: totals.reasoningTokens,
1177
+ inputTokens: aggregate?.inputTokens || 0,
1178
+ cachedInputTokens: aggregate?.cachedInputTokens || 0,
1179
+ outputTokens: aggregate?.outputTokens || 0,
1180
+ reasoningTokens: aggregate?.reasoningTokens || 0,
870
1181
  rateCardCredits:
871
- totals.totalTokens > 0 && totals.ratedTokens === totals.totalTokens
872
- ? totals.rateCardCredits
1182
+ aggregate?.totalTokens > 0 &&
1183
+ aggregate.ratedTokens === aggregate.totalTokens
1184
+ ? aggregate.rateCardCredits
873
1185
  : null,
874
- ratedTokens: totals.ratedTokens,
875
- toolCalls: totals.toolCalls,
876
- eventCount: rows.length,
877
- coverage,
1186
+ ratedTokens: aggregate?.ratedTokens || 0,
1187
+ toolCalls: aggregate?.toolCalls || 0,
1188
+ eventCount,
1189
+ coverage: threadCoverage,
878
1190
  });
879
1191
  }
880
1192
  threads.sort((left, right) => right.totalTokens - left.totalTokens);
1193
+ threadAggregates.clear();
1194
+ allThreadIds.clear();
1195
+ context.stateRows.clear();
1196
+ context.parents.clear();
881
1197
 
882
1198
  const quotas = [...context.quotas.values()]
883
1199
  .map((quota) => {
@@ -888,21 +1204,12 @@ function buildSnapshot(context, options, titles) {
888
1204
  })
889
1205
  .filter((quota) => {
890
1206
  if (!options.since) return true;
891
- return new Date(quota.timestamp).getTime() >= options.since.getTime();
1207
+ return Date.parse(quota.timestamp) >= sinceMs;
892
1208
  })
893
1209
  .sort(
894
- (left, right) =>
895
- new Date(left.timestamp).getTime() -
896
- new Date(right.timestamp).getTime(),
1210
+ (left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
897
1211
  );
898
1212
 
899
- const observedTokens = events.reduce(
900
- (sum, event) => sum + event.totalTokens,
901
- 0,
902
- );
903
- const detailedTokens = events
904
- .filter((event) => event.breakdownAvailable)
905
- .reduce((sum, event) => sum + event.totalTokens, 0);
906
1213
  const unknownBreakdownTokens = observedTokens - detailedTokens;
907
1214
  const stateCounterSumNonAdditive = threads.reduce(
908
1215
  (sum, thread) => sum + (thread.reportedCumulativeTokens || 0),
@@ -913,8 +1220,6 @@ function buildSnapshot(context, options, titles) {
913
1220
  thread.coverage === "unresolved" &&
914
1221
  (thread.reportedCumulativeTokens || 0) > 0,
915
1222
  ).length;
916
- const earliestEventAt = events[0]?.timestamp || null;
917
- const latestEventAt = events.at(-1)?.timestamp || null;
918
1223
  const weeklyCandidates = quotas.filter(
919
1224
  (quota) => quota.windowMinutes === WEEK_MINUTES,
920
1225
  );
@@ -925,9 +1230,7 @@ function buildSnapshot(context, options, titles) {
925
1230
  ...(accountWideWeekly.length ? accountWideWeekly : weeklyCandidates),
926
1231
  ]
927
1232
  .sort(
928
- (left, right) =>
929
- new Date(right.timestamp).getTime() -
930
- new Date(left.timestamp).getTime(),
1233
+ (left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp),
931
1234
  )[0];
932
1235
  const weeklyStart = weekly
933
1236
  ? (weekly.resetsAt - weekly.windowMinutes * 60) * 1_000
@@ -935,19 +1238,20 @@ function buildSnapshot(context, options, titles) {
935
1238
  const completeSinceWindowStart = Boolean(
936
1239
  weeklyStart &&
937
1240
  earliestEventAt &&
938
- new Date(earliestEventAt).getTime() <= weeklyStart &&
1241
+ Date.parse(earliestEventAt) <= weeklyStart &&
939
1242
  context.parseErrors === 0,
940
1243
  );
941
1244
 
942
1245
  const notes = [
943
1246
  "Observed totals sum globally de-duplicated last_token_usage model-call events.",
1247
+ "The snapshot keeps exact recent calls and compacts older usage into time buckets; token, cache, model, project, tool-call, and thread totals remain additive.",
944
1248
  "Codex thread counters are retained only as non-additive reference values because forks and subagents inherit cumulative history.",
945
1249
  "Historical rollout files can be pruned; a state-only counter cannot reveal that thread's unique token contribution.",
946
1250
  "Legacy events without turn IDs use a high-specificity usage-signature heuristic and are labeled in the ledger.",
947
1251
  ];
948
1252
 
949
1253
  return {
950
- schemaVersion: SCHEMA_VERSION,
1254
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
951
1255
  generatedAt: new Date().toISOString(),
952
1256
  label: "Local Codex snapshot",
953
1257
  provenance: {
@@ -968,9 +1272,10 @@ function buildSnapshot(context, options, titles) {
968
1272
  unknownBreakdownTokens,
969
1273
  stateCounterSumNonAdditive,
970
1274
  unresolvedThreadCounters,
971
- legacyHeuristicEvents: events.filter(
972
- (event) => event.dedupeQuality === "legacy-heuristic",
973
- ).length,
1275
+ legacyHeuristicEvents,
1276
+ observedModelCalls,
1277
+ usageBucketCount: usageStats.bucketCount,
1278
+ maximumUsageResolutionSeconds: usageStats.maximumResolutionSeconds,
974
1279
  detailedPercent:
975
1280
  observedTokens > 0 ? (detailedTokens / observedTokens) * 100 : 100,
976
1281
  earliestEventAt,
@@ -980,7 +1285,7 @@ function buildSnapshot(context, options, titles) {
980
1285
  },
981
1286
  quotaObservations: quotas,
982
1287
  threads,
983
- events,
1288
+ events: usageBuckets,
984
1289
  };
985
1290
  }
986
1291
 
@@ -999,14 +1304,13 @@ export async function collectUsage(options, onProgress = () => {}) {
999
1304
  .flat()
1000
1305
  .sort();
1001
1306
  const sizes = await Promise.all(files.map((path) => stat(path)));
1307
+ const spool = await createUsageSpool();
1002
1308
 
1003
1309
  const context = {
1004
1310
  stateRows: state.rows,
1005
1311
  parents: state.parents,
1006
- origins: new Map(),
1007
- tokens: new Map(),
1008
1312
  quotas: new Map(),
1009
- calls: new Map(),
1313
+ spool,
1010
1314
  filesScanned: 0,
1011
1315
  bytesScanned: 0,
1012
1316
  parseErrors: 0,
@@ -1014,24 +1318,28 @@ export async function collectUsage(options, onProgress = () => {}) {
1014
1318
  correctionIntervals: 0,
1015
1319
  };
1016
1320
 
1017
- for (let index = 0; index < files.length; index += 1) {
1018
- await scanRollout(files[index], context);
1019
- context.filesScanned += 1;
1020
- context.bytesScanned += sizes[index].size;
1021
- if (
1022
- index === 0 ||
1023
- index === files.length - 1 ||
1024
- (index + 1) % 10 === 0
1025
- ) {
1026
- onProgress({
1027
- current: index + 1,
1028
- total: files.length,
1029
- path: files[index],
1030
- });
1321
+ try {
1322
+ for (let index = 0; index < files.length; index += 1) {
1323
+ await scanRollout(files[index], context);
1324
+ context.filesScanned += 1;
1325
+ context.bytesScanned += sizes[index].size;
1326
+ if (
1327
+ index === 0 ||
1328
+ index === files.length - 1 ||
1329
+ (index + 1) % 10 === 0
1330
+ ) {
1331
+ onProgress({
1332
+ current: index + 1,
1333
+ total: files.length,
1334
+ path: files[index],
1335
+ });
1336
+ }
1031
1337
  }
1338
+ return buildSnapshot(context, options, titles);
1339
+ } finally {
1340
+ spool.close();
1341
+ await rm(spool.directory, { recursive: true, force: true });
1032
1342
  }
1033
-
1034
- return buildSnapshot(context, options, titles);
1035
1343
  }
1036
1344
 
1037
1345
  async function main() {
@@ -1058,16 +1366,18 @@ async function main() {
1058
1366
  );
1059
1367
  });
1060
1368
  process.stdout.write("\nToken Ledger: writing privacy-reduced snapshot…\n");
1061
- await writePrivateSnapshot(options.output, snapshot);
1062
- const outputSize = (await stat(options.output)).size;
1369
+ const writeResult = await writePrivateSnapshot(options.output, snapshot);
1370
+ const storedSnapshot = writeResult.snapshot;
1063
1371
  process.stdout.write(
1064
1372
  [
1065
1373
  `Snapshot: ${options.output}`,
1066
- `Observed model-call tokens: ${snapshot.coverage.observedTokens.toLocaleString()}`,
1067
- `Unique events: ${snapshot.events.length.toLocaleString()}`,
1068
- `Duplicate/copied events skipped: ${snapshot.coverage.duplicateEventsSkipped.toLocaleString()}`,
1069
- `Threads with unresolved state-only counters: ${snapshot.coverage.unresolvedThreadCounters.toLocaleString()}`,
1070
- `Snapshot size: ${(outputSize / 1_000_000).toFixed(1)} MB`,
1374
+ `Observed model-call tokens: ${storedSnapshot.coverage.observedTokens.toLocaleString()}`,
1375
+ `Unique model calls: ${storedSnapshot.coverage.observedModelCalls.toLocaleString()}`,
1376
+ `Stored usage buckets: ${storedSnapshot.events.length.toLocaleString()}`,
1377
+ `Duplicate/copied events skipped: ${storedSnapshot.coverage.duplicateEventsSkipped.toLocaleString()}`,
1378
+ `Threads with unresolved state-only counters: ${storedSnapshot.coverage.unresolvedThreadCounters.toLocaleString()}`,
1379
+ `Snapshot size: ${(writeResult.bytesWritten / 1_000_000).toFixed(1)} MB (${writeResult.encoding}; ${(writeResult.jsonBytes / 1_000_000).toFixed(1)} MB JSON before encoding)`,
1380
+ `Snapshot safety limit: ${(writeResult.maxBytes / 1_000_000).toFixed(1)} MB`,
1071
1381
  ].join("\n") + "\n",
1072
1382
  );
1073
1383
  }