tledger 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
479
+ }
480
+ }
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
+ }
206
500
  }
501
+ return { label: text || null, subagent: null };
207
502
  }
208
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,
@@ -456,15 +739,16 @@ function rememberQuota(quotaMap, rateLimits, occurrence) {
456
739
  ].join("|");
457
740
  const candidate = {
458
741
  id: `quota-${hash(key)}`,
742
+ // Keep the first and last occurrence of an unchanged reading without
743
+ // storing every repeated provider sample in the snapshot.
459
744
  timestamp: occurrence.timestamp,
745
+ lastSeenAt: occurrence.timestamp,
460
746
  usedPercent,
461
747
  windowMinutes,
462
748
  resetsAt,
463
- planType: String(rateLimits.plan_type || "unknown"),
749
+ planType: String(planType || "unknown"),
464
750
  limitKey: hash(limitKey, 16),
465
- limitName: rateLimits.limit_name
466
- ? String(rateLimits.limit_name).slice(0, 80)
467
- : null,
751
+ limitName: limitName ? String(limitName).slice(0, 80) : null,
468
752
  source: "log",
469
753
  turnId: occurrence.turnId || null,
470
754
  originalLikely: occurrence.originalLikely,
@@ -472,11 +756,21 @@ function rememberQuota(quotaMap, rateLimits, occurrence) {
472
756
  const current = quotaMap.get(key);
473
757
  if (
474
758
  !current ||
475
- (!current.originalLikely && candidate.originalLikely) ||
476
- (current.originalLikely === candidate.originalLikely &&
477
- candidate.timestamp < current.timestamp)
759
+ (!current.originalLikely && candidate.originalLikely)
478
760
  ) {
479
761
  quotaMap.set(key, candidate);
762
+ } else if (current.originalLikely === candidate.originalLikely) {
763
+ quotaMap.set(key, {
764
+ ...current,
765
+ timestamp:
766
+ candidate.timestamp < current.timestamp
767
+ ? candidate.timestamp
768
+ : current.timestamp,
769
+ lastSeenAt:
770
+ candidate.timestamp > current.lastSeenAt
771
+ ? candidate.timestamp
772
+ : current.lastSeenAt,
773
+ });
480
774
  }
481
775
  }
482
776
  }
@@ -514,6 +808,7 @@ async function scanRollout(path, context) {
514
808
  const callOrdinals = new Map();
515
809
  let currentTurnId = "";
516
810
  let currentCandidate = null;
811
+ let currentCandidateSelected = false;
517
812
  let previousCumulative = null;
518
813
 
519
814
  const input = createReadStream(path, { encoding: "utf8" });
@@ -557,10 +852,7 @@ async function scanRollout(path, context) {
557
852
  stateRow,
558
853
  fileContext,
559
854
  );
560
- const currentBest = context.origins.get(currentTurnId);
561
- if (!currentBest || currentCandidate.deltaMs < currentBest.deltaMs) {
562
- context.origins.set(currentTurnId, currentCandidate);
563
- }
855
+ currentCandidateSelected = context.spool.insertOrigin(currentCandidate);
564
856
  }
565
857
  continue;
566
858
  }
@@ -574,6 +866,9 @@ async function scanRollout(path, context) {
574
866
  currentCandidate.model = fileContext.model;
575
867
  currentCandidate.effort = fileContext.effort;
576
868
  currentCandidate.cwd = fileContext.cwd;
869
+ if (currentCandidateSelected) {
870
+ context.spool.updateOrigin(currentCandidate);
871
+ }
577
872
  }
578
873
  continue;
579
874
  }
@@ -617,14 +912,12 @@ async function scanRollout(path, context) {
617
912
  const callKey = call.stableId
618
913
  ? `id|${call.stableId}`
619
914
  : `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
- }
915
+ context.spool.insertCall(
916
+ callKey,
917
+ currentTurnId,
918
+ threadId,
919
+ originalLikely,
920
+ );
628
921
  continue;
629
922
  }
630
923
 
@@ -657,24 +950,16 @@ async function scanRollout(path, context) {
657
950
  }
658
951
  previousCumulative = totalTuple[5];
659
952
 
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),
953
+ const inserted = context.spool.insertToken(
954
+ eventKey,
955
+ currentTurnId,
956
+ usageFromTuple(lastTuple),
674
957
  occurrence,
675
958
  originalLikely,
676
- dedupeQuality: currentTurnId ? "turn-exact" : "legacy-heuristic",
677
- });
959
+ );
960
+ if (!inserted) {
961
+ context.duplicateEventsSkipped += 1;
962
+ }
678
963
  }
679
964
  }
680
965
 
@@ -708,144 +993,183 @@ function threadMetadata(threadId, stateRows, titles, parents, fallback = {}) {
708
993
  };
709
994
  }
710
995
 
996
+ function resolvedOccurrence(token) {
997
+ const origin = token.originThreadId == null
998
+ ? null
999
+ : {
1000
+ threadId: token.originThreadId,
1001
+ timestamp: token.originTimestamp,
1002
+ model: token.originModel,
1003
+ effort: token.originEffort,
1004
+ cwd: token.originCwd,
1005
+ gitOrigin: token.originGitOrigin,
1006
+ rawSource: token.originRawSource,
1007
+ serviceTier: token.originServiceTier,
1008
+ };
1009
+ const occurrence = token.originalLikely || !origin ? token : origin;
1010
+ return {
1011
+ origin,
1012
+ occurrence,
1013
+ threadId: origin?.threadId || occurrence.threadId,
1014
+ timestamp: token.timestamp || origin?.timestamp,
1015
+ };
1016
+ }
1017
+
1018
+ function newThreadAggregate(metadata, timestamp) {
1019
+ return {
1020
+ metadata,
1021
+ firstActiveAt: timestamp,
1022
+ lastActiveAt: timestamp,
1023
+ inputTokens: 0,
1024
+ cachedInputTokens: 0,
1025
+ outputTokens: 0,
1026
+ reasoningTokens: 0,
1027
+ totalTokens: 0,
1028
+ toolCalls: 0,
1029
+ detailedTokens: 0,
1030
+ unknownBreakdownTokens: 0,
1031
+ rateCardCredits: 0,
1032
+ ratedTokens: 0,
1033
+ eventCount: 0,
1034
+ };
1035
+ }
1036
+
1037
+ function addToThreadAggregate(aggregate, event) {
1038
+ aggregate.lastActiveAt = event.timestamp;
1039
+ aggregate.inputTokens += event.inputTokens;
1040
+ aggregate.cachedInputTokens += event.cachedInputTokens;
1041
+ aggregate.outputTokens += event.outputTokens;
1042
+ aggregate.reasoningTokens += event.reasoningTokens;
1043
+ aggregate.totalTokens += event.totalTokens;
1044
+ aggregate.toolCalls += event.toolCalls;
1045
+ aggregate.eventCount += 1;
1046
+ if (event.breakdownAvailable) {
1047
+ aggregate.detailedTokens += event.totalTokens;
1048
+ } else {
1049
+ aggregate.unknownBreakdownTokens += event.totalTokens;
1050
+ }
1051
+ if (event.rateCardCredits !== null) {
1052
+ aggregate.rateCardCredits += event.rateCardCredits;
1053
+ aggregate.ratedTokens += event.totalTokens;
1054
+ }
1055
+ }
1056
+
711
1057
  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
- });
1058
+ context.spool.finishWrites();
1059
+ const sinceMs = options.since ? options.since.getTime() : -Infinity;
1060
+ const lastEventKeyByTurn = new Map();
1061
+ let earliestEventAt = null;
1062
+ let latestEventAt = null;
1063
+
1064
+ // The spool is ordered on disk. This lightweight pass finds the event that
1065
+ // receives each turn's tool calls without retaining the usage rows.
1066
+ for (const token of context.spool.tokenRows()) {
1067
+ const { threadId, timestamp } = resolvedOccurrence(token);
1068
+ if (Date.parse(timestamp) < sinceMs) continue;
1069
+ earliestEventAt ||= timestamp;
1070
+ latestEventAt = timestamp;
1071
+ const key = token.turnId || `thread:${threadId}`;
1072
+ lastEventKeyByTurn.set(key, token.eventKey);
764
1073
  }
765
- events.sort(
766
- (left, right) =>
767
- new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
768
- );
769
1074
 
770
1075
  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}`;
1076
+ for (const call of context.spool.callRows()) {
1077
+ const key = call.turnId || `thread:${call.threadId}`;
775
1078
  toolCounts.set(key, (toolCounts.get(key) || 0) + 1);
776
1079
  }
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
1080
 
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);
1081
+ const threadAggregates = new Map();
1082
+ let observedTokens = 0;
1083
+ let detailedTokens = 0;
1084
+ let legacyHeuristicEvents = 0;
1085
+ let observedModelCalls = 0;
1086
+
1087
+ function* compactableEvents() {
1088
+ for (const token of context.spool.tokenRows()) {
1089
+ const { origin, occurrence, threadId, timestamp } = resolvedOccurrence(token);
1090
+ if (Date.parse(timestamp) < sinceMs) continue;
1091
+ const metadata = threadMetadata(
1092
+ threadId,
1093
+ context.stateRows,
1094
+ titles,
1095
+ context.parents,
1096
+ origin || occurrence,
1097
+ );
1098
+ const usage = {
1099
+ inputTokens: Number(token.inputTokens),
1100
+ cachedInputTokens: Number(token.cachedInputTokens),
1101
+ cacheWriteInputTokens: Number(token.cacheWriteInputTokens),
1102
+ outputTokens: Number(token.outputTokens),
1103
+ reasoningTokens: Number(token.reasoningTokens),
1104
+ totalTokens: Number(token.totalTokens),
1105
+ };
1106
+ const breakdownAvailable = hasDetailedBreakdown(usage);
1107
+ const serviceTier = occurrence.serviceTier || null;
1108
+ const baseCredits = creditsForUsage(metadata.model, usage);
1109
+ const turnKey = token.turnId || `thread:${threadId}`;
1110
+ const event = {
1111
+ ...usage,
1112
+ timestamp,
1113
+ threadId,
1114
+ project: metadata.project,
1115
+ model: metadata.model,
1116
+ effort: metadata.effort,
1117
+ source: metadata.source,
1118
+ useType: metadata.useType,
1119
+ toolCalls:
1120
+ lastEventKeyByTurn.get(turnKey) === token.eventKey
1121
+ ? toolCounts.get(turnKey) || 0
1122
+ : 0,
1123
+ serviceTier,
1124
+ rateCardCredits:
1125
+ baseCredits === null
1126
+ ? null
1127
+ : serviceTier === "priority"
1128
+ ? baseCredits * FAST_MODE_MULTIPLIER
1129
+ : baseCredits,
1130
+ breakdownAvailable,
1131
+ };
1132
+
1133
+ let aggregate = threadAggregates.get(threadId);
1134
+ if (!aggregate) {
1135
+ aggregate = newThreadAggregate(metadata, timestamp);
1136
+ threadAggregates.set(threadId, aggregate);
1137
+ }
1138
+ addToThreadAggregate(aggregate, event);
1139
+ observedTokens += event.totalTokens;
1140
+ if (breakdownAvailable) detailedTokens += event.totalTokens;
1141
+ if (!token.turnId) legacyHeuristicEvents += 1;
1142
+ observedModelCalls += 1;
1143
+ yield event;
1144
+ }
792
1145
  }
793
1146
 
794
- const allThreadIds = new Set([
795
- ...context.stateRows.keys(),
796
- ...grouped.keys(),
797
- ]);
1147
+ const usageBuckets = buildUsageBuckets(compactableEvents(), {
1148
+ latestTimestampMs: latestEventAt ? Date.parse(latestEventAt) : 0,
1149
+ });
1150
+ const usageStats = usageBucketStats(usageBuckets);
1151
+ toolCounts.clear();
1152
+ lastEventKeyByTurn.clear();
1153
+
1154
+ const allThreadIds = new Set(context.stateRows.keys());
1155
+ for (const threadId of threadAggregates.keys()) allThreadIds.add(threadId);
798
1156
  const threads = [];
799
1157
  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(
1158
+ const aggregate = threadAggregates.get(threadId);
1159
+ const metadata = aggregate?.metadata || threadMetadata(
807
1160
  threadId,
808
1161
  context.stateRows,
809
1162
  titles,
810
1163
  context.parents,
811
- origin || first || {},
812
- );
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
1164
  );
842
- if (rows.length === 0 && !(metadata.reportedCumulativeTokens > 0)) continue;
843
- const coverage =
844
- rows.length === 0
1165
+ if (!aggregate && !(metadata.reportedCumulativeTokens > 0)) continue;
1166
+ const eventCount = aggregate?.eventCount || 0;
1167
+ const threadCoverage =
1168
+ eventCount === 0
845
1169
  ? "unresolved"
846
- : totals.detailedTokens === totals.totalTokens
1170
+ : aggregate.detailedTokens === aggregate.totalTokens
847
1171
  ? "complete"
848
- : totals.detailedTokens > 0
1172
+ : aggregate.detailedTokens > 0
849
1173
  ? "partial"
850
1174
  : "total-only";
851
1175
  threads.push({
@@ -857,27 +1181,32 @@ function buildSnapshot(context, options, titles) {
857
1181
  source: metadata.source,
858
1182
  useType: metadata.useType,
859
1183
  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,
1184
+ firstActiveAt: aggregate?.firstActiveAt || metadata.createdAt,
1185
+ lastActiveAt: aggregate?.lastActiveAt || metadata.updatedAt,
1186
+ totalTokens: aggregate?.totalTokens || 0,
1187
+ detailedTokens: aggregate?.detailedTokens || 0,
1188
+ unknownBreakdownTokens: aggregate?.unknownBreakdownTokens || 0,
865
1189
  reportedCumulativeTokens: metadata.reportedCumulativeTokens,
866
- inputTokens: totals.inputTokens,
867
- cachedInputTokens: totals.cachedInputTokens,
868
- outputTokens: totals.outputTokens,
869
- reasoningTokens: totals.reasoningTokens,
1190
+ inputTokens: aggregate?.inputTokens || 0,
1191
+ cachedInputTokens: aggregate?.cachedInputTokens || 0,
1192
+ outputTokens: aggregate?.outputTokens || 0,
1193
+ reasoningTokens: aggregate?.reasoningTokens || 0,
870
1194
  rateCardCredits:
871
- totals.totalTokens > 0 && totals.ratedTokens === totals.totalTokens
872
- ? totals.rateCardCredits
1195
+ aggregate?.totalTokens > 0 &&
1196
+ aggregate.ratedTokens === aggregate.totalTokens
1197
+ ? aggregate.rateCardCredits
873
1198
  : null,
874
- ratedTokens: totals.ratedTokens,
875
- toolCalls: totals.toolCalls,
876
- eventCount: rows.length,
877
- coverage,
1199
+ ratedTokens: aggregate?.ratedTokens || 0,
1200
+ toolCalls: aggregate?.toolCalls || 0,
1201
+ eventCount,
1202
+ coverage: threadCoverage,
878
1203
  });
879
1204
  }
880
1205
  threads.sort((left, right) => right.totalTokens - left.totalTokens);
1206
+ threadAggregates.clear();
1207
+ allThreadIds.clear();
1208
+ context.stateRows.clear();
1209
+ context.parents.clear();
881
1210
 
882
1211
  const quotas = [...context.quotas.values()]
883
1212
  .map((quota) => {
@@ -888,21 +1217,12 @@ function buildSnapshot(context, options, titles) {
888
1217
  })
889
1218
  .filter((quota) => {
890
1219
  if (!options.since) return true;
891
- return new Date(quota.timestamp).getTime() >= options.since.getTime();
1220
+ return Date.parse(quota.lastSeenAt) >= sinceMs;
892
1221
  })
893
1222
  .sort(
894
- (left, right) =>
895
- new Date(left.timestamp).getTime() -
896
- new Date(right.timestamp).getTime(),
1223
+ (left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
897
1224
  );
898
1225
 
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
1226
  const unknownBreakdownTokens = observedTokens - detailedTokens;
907
1227
  const stateCounterSumNonAdditive = threads.reduce(
908
1228
  (sum, thread) => sum + (thread.reportedCumulativeTokens || 0),
@@ -913,8 +1233,6 @@ function buildSnapshot(context, options, titles) {
913
1233
  thread.coverage === "unresolved" &&
914
1234
  (thread.reportedCumulativeTokens || 0) > 0,
915
1235
  ).length;
916
- const earliestEventAt = events[0]?.timestamp || null;
917
- const latestEventAt = events.at(-1)?.timestamp || null;
918
1236
  const weeklyCandidates = quotas.filter(
919
1237
  (quota) => quota.windowMinutes === WEEK_MINUTES,
920
1238
  );
@@ -925,9 +1243,7 @@ function buildSnapshot(context, options, titles) {
925
1243
  ...(accountWideWeekly.length ? accountWideWeekly : weeklyCandidates),
926
1244
  ]
927
1245
  .sort(
928
- (left, right) =>
929
- new Date(right.timestamp).getTime() -
930
- new Date(left.timestamp).getTime(),
1246
+ (left, right) => Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt),
931
1247
  )[0];
932
1248
  const weeklyStart = weekly
933
1249
  ? (weekly.resetsAt - weekly.windowMinutes * 60) * 1_000
@@ -935,19 +1251,20 @@ function buildSnapshot(context, options, titles) {
935
1251
  const completeSinceWindowStart = Boolean(
936
1252
  weeklyStart &&
937
1253
  earliestEventAt &&
938
- new Date(earliestEventAt).getTime() <= weeklyStart &&
1254
+ Date.parse(earliestEventAt) <= weeklyStart &&
939
1255
  context.parseErrors === 0,
940
1256
  );
941
1257
 
942
1258
  const notes = [
943
1259
  "Observed totals sum globally de-duplicated last_token_usage model-call events.",
1260
+ "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
1261
  "Codex thread counters are retained only as non-additive reference values because forks and subagents inherit cumulative history.",
945
1262
  "Historical rollout files can be pruned; a state-only counter cannot reveal that thread's unique token contribution.",
946
1263
  "Legacy events without turn IDs use a high-specificity usage-signature heuristic and are labeled in the ledger.",
947
1264
  ];
948
1265
 
949
1266
  return {
950
- schemaVersion: SCHEMA_VERSION,
1267
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
951
1268
  generatedAt: new Date().toISOString(),
952
1269
  label: "Local Codex snapshot",
953
1270
  provenance: {
@@ -968,9 +1285,10 @@ function buildSnapshot(context, options, titles) {
968
1285
  unknownBreakdownTokens,
969
1286
  stateCounterSumNonAdditive,
970
1287
  unresolvedThreadCounters,
971
- legacyHeuristicEvents: events.filter(
972
- (event) => event.dedupeQuality === "legacy-heuristic",
973
- ).length,
1288
+ legacyHeuristicEvents,
1289
+ observedModelCalls,
1290
+ usageBucketCount: usageStats.bucketCount,
1291
+ maximumUsageResolutionSeconds: usageStats.maximumResolutionSeconds,
974
1292
  detailedPercent:
975
1293
  observedTokens > 0 ? (detailedTokens / observedTokens) * 100 : 100,
976
1294
  earliestEventAt,
@@ -980,7 +1298,7 @@ function buildSnapshot(context, options, titles) {
980
1298
  },
981
1299
  quotaObservations: quotas,
982
1300
  threads,
983
- events,
1301
+ events: usageBuckets,
984
1302
  };
985
1303
  }
986
1304
 
@@ -999,14 +1317,13 @@ export async function collectUsage(options, onProgress = () => {}) {
999
1317
  .flat()
1000
1318
  .sort();
1001
1319
  const sizes = await Promise.all(files.map((path) => stat(path)));
1320
+ const spool = await createUsageSpool();
1002
1321
 
1003
1322
  const context = {
1004
1323
  stateRows: state.rows,
1005
1324
  parents: state.parents,
1006
- origins: new Map(),
1007
- tokens: new Map(),
1008
1325
  quotas: new Map(),
1009
- calls: new Map(),
1326
+ spool,
1010
1327
  filesScanned: 0,
1011
1328
  bytesScanned: 0,
1012
1329
  parseErrors: 0,
@@ -1014,24 +1331,28 @@ export async function collectUsage(options, onProgress = () => {}) {
1014
1331
  correctionIntervals: 0,
1015
1332
  };
1016
1333
 
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
- });
1334
+ try {
1335
+ for (let index = 0; index < files.length; index += 1) {
1336
+ await scanRollout(files[index], context);
1337
+ context.filesScanned += 1;
1338
+ context.bytesScanned += sizes[index].size;
1339
+ if (
1340
+ index === 0 ||
1341
+ index === files.length - 1 ||
1342
+ (index + 1) % 10 === 0
1343
+ ) {
1344
+ onProgress({
1345
+ current: index + 1,
1346
+ total: files.length,
1347
+ path: files[index],
1348
+ });
1349
+ }
1031
1350
  }
1351
+ return buildSnapshot(context, options, titles);
1352
+ } finally {
1353
+ spool.close();
1354
+ await rm(spool.directory, { recursive: true, force: true });
1032
1355
  }
1033
-
1034
- return buildSnapshot(context, options, titles);
1035
1356
  }
1036
1357
 
1037
1358
  async function main() {
@@ -1058,16 +1379,18 @@ async function main() {
1058
1379
  );
1059
1380
  });
1060
1381
  process.stdout.write("\nToken Ledger: writing privacy-reduced snapshot…\n");
1061
- await writePrivateSnapshot(options.output, snapshot);
1062
- const outputSize = (await stat(options.output)).size;
1382
+ const writeResult = await writePrivateSnapshot(options.output, snapshot);
1383
+ const storedSnapshot = writeResult.snapshot;
1063
1384
  process.stdout.write(
1064
1385
  [
1065
1386
  `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`,
1387
+ `Observed model-call tokens: ${storedSnapshot.coverage.observedTokens.toLocaleString()}`,
1388
+ `Unique model calls: ${storedSnapshot.coverage.observedModelCalls.toLocaleString()}`,
1389
+ `Stored usage buckets: ${storedSnapshot.events.length.toLocaleString()}`,
1390
+ `Duplicate/copied events skipped: ${storedSnapshot.coverage.duplicateEventsSkipped.toLocaleString()}`,
1391
+ `Threads with unresolved state-only counters: ${storedSnapshot.coverage.unresolvedThreadCounters.toLocaleString()}`,
1392
+ `Snapshot size: ${(writeResult.bytesWritten / 1_000_000).toFixed(1)} MB (${writeResult.encoding}; ${(writeResult.jsonBytes / 1_000_000).toFixed(1)} MB JSON before encoding)`,
1393
+ `Snapshot safety limit: ${(writeResult.maxBytes / 1_000_000).toFixed(1)} MB`,
1071
1394
  ].join("\n") + "\n",
1072
1395
  );
1073
1396
  }