tledger 0.3.0 → 0.4.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.
@@ -15,7 +15,10 @@ import { createHash } from "node:crypto";
15
15
  import { createReadStream } from "node:fs";
16
16
  import {
17
17
  access,
18
+ chmod,
19
+ lstat,
18
20
  mkdtemp,
21
+ open,
19
22
  readdir,
20
23
  rm,
21
24
  stat,
@@ -24,25 +27,94 @@ import { homedir, tmpdir } from "node:os";
24
27
  import {
25
28
  basename,
26
29
  extname,
30
+ relative,
27
31
  resolve,
28
32
  } from "node:path";
29
33
  import { pathToFileURL } from "node:url";
30
34
  import { createInterface } from "node:readline";
31
35
  import { DatabaseSync } from "node:sqlite";
36
+ import { Readable, Transform } from "node:stream";
37
+ import {
38
+ isMainThread,
39
+ parentPort,
40
+ Worker,
41
+ workerData,
42
+ } from "node:worker_threads";
32
43
 
33
- import { writePrivateSnapshot } from "./token-ledger-snapshot.mjs";
44
+ import { stagePrivateSnapshot } from "./token-ledger-snapshot.mjs";
45
+ import {
46
+ containsLocalPath,
47
+ LOCAL_LABEL,
48
+ safeExportLabel,
49
+ } from "./token-ledger-labels.mjs";
50
+ import {
51
+ DURABLE_LEDGER_COMPACTED_RETENTION_DAYS,
52
+ DURABLE_LEDGER_RETENTION_DAYS,
53
+ DURABLE_LEDGER_SCHEMA_VERSION,
54
+ codexHomeFingerprint,
55
+ durableSourceId,
56
+ durableQuotaObservationKey,
57
+ normalizeQuotaObservationFields,
58
+ readDurableSourceContinuity,
59
+ resolveDurableLedgerPath,
60
+ updateDurableLedger,
61
+ } from "./token-ledger-ledger.mjs";
62
+ import {
63
+ QUOTA_IDENTITY_CONTRACT_VERSION,
64
+ } from "./token-ledger-quota-contract.mjs";
65
+ import {
66
+ calculateCodexPurchasedCredits,
67
+ CODEX_CREDIT_RATE_CARD_AS_OF,
68
+ CODEX_CREDIT_RATE_CARD_KIND,
69
+ CODEX_CREDIT_RATE_CARD_SCOPE,
70
+ CODEX_CREDIT_RATE_CARD_URL,
71
+ hasDetailedTokenBreakdown,
72
+ normalizeCodexCreditModel,
73
+ } from "./token-ledger-rates.mjs";
74
+ import {
75
+ collectionScope,
76
+ normalizeCollectionSince,
77
+ } from "./token-ledger-collection.mjs";
34
78
  import {
35
79
  buildUsageBuckets,
80
+ checkedFiniteAdd,
81
+ checkedTokenPartitionAdd,
82
+ checkedTokenAdd,
83
+ isValidTokenValue,
84
+ MAX_SAFE_TOKEN_COUNT,
36
85
  SNAPSHOT_SCHEMA_VERSION,
86
+ splitUsageBucketsAtBoundaries,
87
+ tokenValue,
37
88
  usageBucketStats,
38
89
  } from "./token-ledger-usage.mjs";
39
-
40
- const RATE_CARD_AS_OF = "2026-08-17";
41
- const FAST_MODE_MULTIPLIER = 1.5;
42
- const RATE_CARD_URL = "https://help.openai.com/en/articles/20001106";
43
90
  const WEEK_MINUTES = 10_080;
91
+ const SCAN_CONCURRENCY = 4;
92
+ const USAGE_SPOOL_DIRECTORY = /^token-ledger-import-([1-9]\d*)-[A-Za-z0-9]{6}$/;
44
93
  const UUID_AT_END =
45
94
  /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
95
+ const RELEVANT_EVENT_TYPES = new Set([
96
+ "task_started",
97
+ "thread_settings_applied",
98
+ "token_count",
99
+ ]);
100
+ const RELEVANT_CALL_TYPES = new Set([
101
+ "function_call",
102
+ "custom_tool_call",
103
+ "tool_search_call",
104
+ "web_search_call",
105
+ "image_generation_call",
106
+ ]);
107
+ export const SOURCE_WATERMARK_VERSION = 1;
108
+ export const SOURCE_COLLECTION_MAX_ATTEMPTS = 3;
109
+ const SOURCE_MUTATION_ERROR_CODES = Object.freeze([
110
+ "ENOENT",
111
+ "ENOTDIR",
112
+ "EISDIR",
113
+ "ERR_SOURCE_IDENTITY_CHANGED",
114
+ "ERR_SOURCE_SIZE_CHANGED",
115
+ "ERR_SOURCE_CONTENT_CHANGED",
116
+ ]);
117
+ const MAX_TRAILING_SCAN_BYTES = 1 * 1024 * 1024;
46
118
 
47
119
  function spoolText(value, maximumLength = 2_000) {
48
120
  try {
@@ -55,13 +127,119 @@ function spoolText(value, maximumLength = 2_000) {
55
127
  function spoolSource(value) {
56
128
  const descriptor = sourceDescriptor(value);
57
129
  if (descriptor.subagent) return '{"subagent":true}';
58
- return descriptor.label || "";
130
+ if (containsLocalPath(descriptor.label)) return "/";
131
+ return safeExportLabel(descriptor.label, 80, "");
132
+ }
133
+
134
+ function spoolCwd(value) {
135
+ const path = spoolText(value).replaceAll("\\", "/").replace(/\/+$/, "");
136
+ return safeExportLabel(basename(path), 160, "");
137
+ }
138
+
139
+ function spoolGitOrigin(value) {
140
+ const origin = spoolText(value).trim();
141
+ return origin ? projectLabel("", origin) : "";
142
+ }
143
+
144
+ function processIsAlive(pid) {
145
+ try {
146
+ process.kill(pid, 0);
147
+ return true;
148
+ } catch (error) {
149
+ // EPERM still proves that the PID exists. Only ESRCH is authoritative
150
+ // evidence that no process currently owns the spool namespace.
151
+ return error?.code !== "ESRCH";
152
+ }
153
+ }
154
+
155
+ export async function cleanupOrphanedUsageSpools({
156
+ tempDirectory = tmpdir(),
157
+ currentPid = process.pid,
158
+ currentUid = process.getuid?.(),
159
+ beforeRevalidate = null,
160
+ } = {}) {
161
+ if (!Number.isSafeInteger(currentUid) || currentUid < 0) return 0;
162
+ let entries;
163
+ try {
164
+ entries = await readdir(tempDirectory, { withFileTypes: true });
165
+ } catch {
166
+ return 0;
167
+ }
168
+ let removed = 0;
169
+ for (const entry of entries) {
170
+ const match = String(entry.name).match(USAGE_SPOOL_DIRECTORY);
171
+ if (!match) continue;
172
+ const ownerPid = Number(match[1]);
173
+ if (
174
+ !Number.isSafeInteger(ownerPid) ||
175
+ ownerPid <= 0 ||
176
+ ownerPid === currentPid ||
177
+ processIsAlive(ownerPid)
178
+ ) continue;
179
+ const candidate = resolve(tempDirectory, entry.name);
180
+ let initial;
181
+ try {
182
+ initial = await lstat(candidate);
183
+ } catch {
184
+ continue;
185
+ }
186
+ if (
187
+ !initial.isDirectory() ||
188
+ initial.isSymbolicLink() ||
189
+ Number(initial.uid) !== currentUid
190
+ ) continue;
191
+
192
+ if (beforeRevalidate !== null) await beforeRevalidate(candidate);
193
+ // Revalidate the exact directory immediately before removal. Node's
194
+ // recursive rm unlinks symlinks rather than traversing them, and the
195
+ // inode check also refuses a root-path swap between inspection steps.
196
+ let current;
197
+ try {
198
+ current = await lstat(candidate);
199
+ } catch {
200
+ continue;
201
+ }
202
+ if (
203
+ !current.isDirectory() ||
204
+ current.isSymbolicLink() ||
205
+ Number(current.uid) !== currentUid ||
206
+ Number(current.dev) !== Number(initial.dev) ||
207
+ Number(current.ino) !== Number(initial.ino) ||
208
+ processIsAlive(ownerPid)
209
+ ) continue;
210
+ try {
211
+ await rm(candidate, { recursive: true, force: false });
212
+ removed += 1;
213
+ } catch {
214
+ // Cleanup is best-effort. The new private spool remains usable even if
215
+ // an orphan races away or local permissions prevent reclamation.
216
+ }
217
+ }
218
+ return removed;
59
219
  }
60
220
 
61
221
  async function createUsageSpool() {
62
- const directory = await mkdtemp(resolve(tmpdir(), "token-ledger-import-"));
222
+ await cleanupOrphanedUsageSpools();
223
+ const directory = await mkdtemp(
224
+ resolve(tmpdir(), `token-ledger-import-${process.pid}-`),
225
+ );
63
226
  const path = resolve(directory, "usage.sqlite");
64
227
  const database = new DatabaseSync(path);
228
+ try {
229
+ await chmod(path, 0o600);
230
+ } catch (error) {
231
+ try {
232
+ database.close();
233
+ } catch {
234
+ // The chmod failure remains authoritative.
235
+ }
236
+ try {
237
+ await rm(directory, { recursive: true, force: true });
238
+ } catch {
239
+ // The chmod failure remains authoritative.
240
+ }
241
+ throw error;
242
+ }
65
243
  database.exec(`
66
244
  PRAGMA journal_mode = OFF;
67
245
  PRAGMA synchronous = OFF;
@@ -75,6 +253,11 @@ async function createUsageSpool() {
75
253
  output_tokens REAL NOT NULL,
76
254
  reasoning_tokens REAL NOT NULL,
77
255
  total_tokens REAL NOT NULL,
256
+ tool_calls REAL NOT NULL DEFAULT 0,
257
+ call_count REAL NOT NULL DEFAULT 1,
258
+ detailed_call_count REAL NOT NULL DEFAULT 1,
259
+ input_call_count REAL NOT NULL DEFAULT 1,
260
+ components_valid INTEGER NOT NULL,
78
261
  timestamp TEXT NOT NULL,
79
262
  thread_id TEXT NOT NULL,
80
263
  model TEXT NOT NULL,
@@ -83,7 +266,16 @@ async function createUsageSpool() {
83
266
  git_origin TEXT,
84
267
  raw_source TEXT,
85
268
  service_tier TEXT,
86
- original_likely INTEGER NOT NULL
269
+ original_likely INTEGER NOT NULL,
270
+ project TEXT,
271
+ display_model TEXT,
272
+ source_label TEXT,
273
+ use_type TEXT,
274
+ rate_card_model TEXT,
275
+ rate_card_credits REAL,
276
+ identity_kind TEXT NOT NULL DEFAULT 'exact',
277
+ range_allocation_estimated INTEGER NOT NULL DEFAULT 0,
278
+ range_allocation_origin TEXT
87
279
  ) WITHOUT ROWID;
88
280
  CREATE TABLE tool_calls (
89
281
  call_key TEXT PRIMARY KEY,
@@ -109,9 +301,19 @@ async function createUsageSpool() {
109
301
  INSERT OR IGNORE INTO token_events (
110
302
  event_key, turn_id, input_tokens, cached_input_tokens,
111
303
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
304
+ total_tokens, tool_calls, call_count, detailed_call_count,
305
+ input_call_count, components_valid, timestamp, thread_id, model, effort,
306
+ cwd, git_origin, raw_source, service_tier, original_likely, project,
307
+ display_model, source_label, use_type, rate_card_model, rate_card_credits,
308
+ identity_kind, range_allocation_estimated, range_allocation_origin
309
+ ) VALUES (
310
+ ?, ?, ?, ?, ?,
311
+ ?, ?, ?, ?, ?,
312
+ ?, ?, ?, ?, ?,
313
+ ?, ?, ?, ?, ?,
314
+ ?, ?, ?, ?, ?,
315
+ ?, ?, ?, ?, ?, ?
316
+ )
115
317
  `);
116
318
  const promoteToken = database.prepare(`
117
319
  UPDATE token_events
@@ -120,6 +322,28 @@ async function createUsageSpool() {
120
322
  original_likely = 1
121
323
  WHERE event_key = ? AND original_likely = 0
122
324
  `);
325
+ const restoreTokenPlacement = database.prepare(`
326
+ UPDATE token_events
327
+ SET timestamp = ?, range_allocation_estimated = ?,
328
+ range_allocation_origin = ?
329
+ WHERE event_key = ?
330
+ `);
331
+ const enrichToken = database.prepare(`
332
+ UPDATE token_events
333
+ SET tool_calls = MAX(tool_calls, COALESCE(?, 0)),
334
+ project = COALESCE(project, ?),
335
+ display_model = COALESCE(display_model, ?),
336
+ source_label = COALESCE(source_label, ?),
337
+ use_type = COALESCE(use_type, ?),
338
+ rate_card_model = COALESCE(rate_card_model, ?),
339
+ rate_card_credits = COALESCE(rate_card_credits, ?),
340
+ identity_kind = COALESCE(identity_kind, ?),
341
+ range_allocation_estimated = MAX(
342
+ range_allocation_estimated, COALESCE(?, 0)
343
+ ),
344
+ range_allocation_origin = COALESCE(range_allocation_origin, ?)
345
+ WHERE event_key = ?
346
+ `);
123
347
  const insertCall = database.prepare(`
124
348
  INSERT OR IGNORE INTO tool_calls (
125
349
  call_key, turn_id, thread_id, original_likely
@@ -154,6 +378,7 @@ async function createUsageSpool() {
154
378
  WHERE turn_id = ?
155
379
  `);
156
380
  let writing = true;
381
+ const activeReadStatements = new Set();
157
382
 
158
383
  function originValues(candidate) {
159
384
  return [
@@ -165,8 +390,8 @@ async function createUsageSpool() {
165
390
  : Number.MAX_SAFE_INTEGER,
166
391
  spoolText(candidate.model, 200),
167
392
  spoolText(candidate.effort, 80),
168
- spoolText(candidate.cwd),
169
- spoolText(candidate.gitOrigin),
393
+ spoolCwd(candidate.cwd),
394
+ spoolGitOrigin(candidate.gitOrigin),
170
395
  spoolSource(candidate.rawSource),
171
396
  candidate.serviceTier == null
172
397
  ? null
@@ -193,7 +418,14 @@ async function createUsageSpool() {
193
418
  values[0],
194
419
  );
195
420
  },
196
- insertToken(eventKey, turnId, usage, occurrence, originalLikely) {
421
+ insertToken(
422
+ eventKey,
423
+ turnId,
424
+ usage,
425
+ occurrence,
426
+ originalLikely,
427
+ metadata = {},
428
+ ) {
197
429
  const values = [
198
430
  eventKey,
199
431
  turnId,
@@ -203,17 +435,35 @@ async function createUsageSpool() {
203
435
  usage.outputTokens,
204
436
  usage.reasoningTokens,
205
437
  usage.totalTokens,
438
+ usage.toolCalls ?? 0,
439
+ metadata.callCount ?? 1,
440
+ metadata.detailedCallCount ?? (
441
+ hasDetailedTokenBreakdown(usage) ? 1 : 0
442
+ ),
443
+ metadata.inputCallCount ?? (usage.inputTokens > 0 ? 1 : 0),
444
+ usage.componentsValid ? 1 : 0,
206
445
  occurrence.timestamp,
207
446
  occurrence.threadId,
208
447
  spoolText(occurrence.model, 200),
209
448
  spoolText(occurrence.effort, 80),
210
- spoolText(occurrence.cwd),
211
- spoolText(occurrence.gitOrigin),
449
+ spoolCwd(occurrence.cwd),
450
+ spoolGitOrigin(occurrence.gitOrigin),
212
451
  spoolSource(occurrence.rawSource),
213
452
  occurrence.serviceTier == null
214
453
  ? null
215
454
  : spoolText(occurrence.serviceTier, 80),
216
455
  originalLikely ? 1 : 0,
456
+ metadata.project ?? null,
457
+ metadata.displayModel ?? null,
458
+ metadata.source ?? null,
459
+ metadata.useType ?? null,
460
+ metadata.rateCardModel ?? null,
461
+ metadata.rateCardCredits ?? null,
462
+ metadata.identityKind ?? "exact",
463
+ metadata.rangeAllocationEstimated ? 1 : 0,
464
+ metadata.rangeAllocationOrigin == null
465
+ ? null
466
+ : JSON.stringify(metadata.rangeAllocationOrigin),
217
467
  ];
218
468
  const inserted = insertToken.run(...values).changes > 0;
219
469
  if (!inserted && originalLikely) {
@@ -223,8 +473,8 @@ async function createUsageSpool() {
223
473
  occurrence.threadId,
224
474
  spoolText(occurrence.model, 200),
225
475
  spoolText(occurrence.effort, 80),
226
- spoolText(occurrence.cwd),
227
- spoolText(occurrence.gitOrigin),
476
+ spoolCwd(occurrence.cwd),
477
+ spoolGitOrigin(occurrence.gitOrigin),
228
478
  spoolSource(occurrence.rawSource),
229
479
  occurrence.serviceTier == null
230
480
  ? null
@@ -232,8 +482,35 @@ async function createUsageSpool() {
232
482
  eventKey,
233
483
  );
234
484
  }
485
+ if (!inserted && metadata && Object.keys(metadata).length > 0) {
486
+ enrichToken.run(
487
+ usage.toolCalls ?? 0,
488
+ metadata.project ?? null,
489
+ metadata.displayModel ?? null,
490
+ metadata.source ?? null,
491
+ metadata.useType ?? null,
492
+ metadata.rateCardModel ?? null,
493
+ metadata.rateCardCredits ?? null,
494
+ metadata.identityKind ?? null,
495
+ metadata.rangeAllocationEstimated ? 1 : 0,
496
+ metadata.rangeAllocationOrigin == null
497
+ ? null
498
+ : JSON.stringify(metadata.rangeAllocationOrigin),
499
+ eventKey,
500
+ );
501
+ }
235
502
  return inserted;
236
503
  },
504
+ restoreTokenPlacement(eventKey, occurrence, metadata = {}) {
505
+ return restoreTokenPlacement.run(
506
+ occurrence.timestamp,
507
+ metadata.rangeAllocationEstimated ? 1 : 0,
508
+ metadata.rangeAllocationOrigin == null
509
+ ? null
510
+ : JSON.stringify(metadata.rangeAllocationOrigin),
511
+ eventKey,
512
+ ).changes > 0;
513
+ },
237
514
  insertCall(callKey, turnId, threadId, originalLikely) {
238
515
  const inserted = insertCall.run(
239
516
  callKey,
@@ -250,8 +527,11 @@ async function createUsageSpool() {
250
527
  database.exec("COMMIT");
251
528
  writing = false;
252
529
  },
253
- tokenRows() {
254
- return database.prepare(`
530
+ *tokenRows() {
531
+ // Node 22 does not keep StatementSync alive through a bare iterator.
532
+ // Keep an explicit heap reference until iteration has completed; a
533
+ // generator-local reference alone can still be finalized on long scans.
534
+ const statement = database.prepare(`
255
535
  SELECT token.event_key AS eventKey, token.turn_id AS turnId,
256
536
  token.input_tokens AS inputTokens,
257
537
  token.cached_input_tokens AS cachedInputTokens,
@@ -259,12 +539,26 @@ async function createUsageSpool() {
259
539
  token.output_tokens AS outputTokens,
260
540
  token.reasoning_tokens AS reasoningTokens,
261
541
  token.total_tokens AS totalTokens,
542
+ token.tool_calls AS toolCalls,
543
+ token.call_count AS callCount,
544
+ token.detailed_call_count AS detailedCallCount,
545
+ token.input_call_count AS inputCallCount,
546
+ token.components_valid AS componentsValid,
262
547
  token.timestamp, token.thread_id AS threadId,
263
548
  token.model, token.effort, token.cwd,
264
549
  token.git_origin AS gitOrigin,
265
550
  token.raw_source AS rawSource,
266
551
  token.service_tier AS serviceTier,
267
552
  token.original_likely AS originalLikely,
553
+ token.project,
554
+ token.display_model AS displayModel,
555
+ token.source_label AS sourceLabel,
556
+ token.use_type AS useType,
557
+ token.rate_card_model AS rateCardModel,
558
+ token.rate_card_credits AS rateCardCredits,
559
+ token.identity_kind AS identityKind,
560
+ token.range_allocation_estimated AS rangeAllocationEstimated,
561
+ token.range_allocation_origin AS rangeAllocationOrigin,
268
562
  origin.thread_id AS originThreadId,
269
563
  origin.timestamp AS originTimestamp,
270
564
  origin.model AS originModel,
@@ -276,15 +570,31 @@ async function createUsageSpool() {
276
570
  FROM token_events AS token
277
571
  LEFT JOIN turn_origins AS origin ON origin.turn_id = token.turn_id
278
572
  ORDER BY token.timestamp, token.event_key
279
- `).iterate();
573
+ `);
574
+ activeReadStatements.add(statement);
575
+ try {
576
+ for (const row of statement.iterate()) {
577
+ yield repairTokenTimestamp(row);
578
+ }
579
+ } finally {
580
+ activeReadStatements.delete(statement);
581
+ }
280
582
  },
281
- callRows() {
282
- return database.prepare(`
283
- SELECT call.turn_id AS turnId,
583
+ *callRows() {
584
+ const statement = database.prepare(`
585
+ SELECT call.call_key AS callKey,
586
+ call.turn_id AS turnId,
587
+ call.original_likely AS originalLikely,
284
588
  COALESCE(origin.thread_id, call.thread_id) AS threadId
285
589
  FROM tool_calls AS call
286
590
  LEFT JOIN turn_origins AS origin ON origin.turn_id = call.turn_id
287
- `).iterate();
591
+ `);
592
+ activeReadStatements.add(statement);
593
+ try {
594
+ yield* statement.iterate();
595
+ } finally {
596
+ activeReadStatements.delete(statement);
597
+ }
288
598
  },
289
599
  close() {
290
600
  if (writing) {
@@ -300,18 +610,6 @@ async function createUsageSpool() {
300
610
  };
301
611
  }
302
612
 
303
- const RATE_CARD = {
304
- "gpt-5.6-sol": { input: 125, cached: 12.5, output: 750 },
305
- "gpt-5.6-terra": { input: 50, cached: 5, output: 300 },
306
- "gpt-5.6-luna": { input: 5, cached: 0.5, output: 30 },
307
- "gpt-5.5": { input: 125, cached: 12.5, output: 750 },
308
- "gpt-5.5-cyber": { input: 500, cached: 50, output: 3_000 },
309
- "gpt-5.4": { input: 62.5, cached: 6.25, output: 375 },
310
- "gpt-5.4-mini": { input: 18.75, cached: 1.875, output: 113 },
311
- "gpt-5.3-codex": { input: 43.75, cached: 4.375, output: 350 },
312
- "gpt-5.2": { input: 43.75, cached: 4.375, output: 350 },
313
- };
314
-
315
613
  function usage() {
316
614
  return `Token Ledger local collector
317
615
 
@@ -319,21 +617,25 @@ Usage:
319
617
  node token-ledger-importer.mjs [options]
320
618
 
321
619
  Options:
322
- --output <file> Snapshot destination (default: token-ledger-snapshot-v2.json.gz)
620
+ --output <file> Snapshot destination (default: token-ledger-snapshot-v3.json.gz)
323
621
  --codex-home <dir> Codex data root (default: CODEX_HOME or ~/.codex)
324
- --since <ISO date> Ignore model calls before this timestamp
622
+ --since <ISO timestamp> Ignore model calls before this timestamp
325
623
  --no-archived Skip archived_sessions
326
624
  --help Show this help
327
625
 
328
626
  The snapshot contains usage metadata and Codex display titles only. It never
329
627
  contains message bodies, tool payloads, reasoning text, credential fields, or
330
- full local paths in its output. Codex display titles may contain user-written
331
- text.`;
628
+ full local paths in its output. Path-like source labels are categorized, and
629
+ local path tokens in other labels are redacted. Codex display titles may still
630
+ contain unrelated user-written text. Refreshes also update the app-owned
631
+ durable ledger in ~/.token-ledger. Selecting another snapshot output never
632
+ relocates SQLite state.
633
+ The ledger retains committed observations when source files later disappear.`;
332
634
  }
333
635
 
334
636
  function parseArgs(argv) {
335
637
  const options = {
336
- output: resolve("token-ledger-snapshot-v2.json.gz"),
638
+ output: resolve("token-ledger-snapshot-v3.json.gz"),
337
639
  codexHome: resolve(process.env.CODEX_HOME || `${homedir()}/.codex`),
338
640
  includeArchived: true,
339
641
  since: null,
@@ -355,10 +657,8 @@ function parseArgs(argv) {
355
657
  options.codexHome = resolve(value);
356
658
  } else if (argument === "--since") {
357
659
  const value = argv[++index];
358
- if (!value || Number.isNaN(new Date(value).getTime())) {
359
- throw new Error("--since requires a valid ISO date.");
360
- }
361
- options.since = new Date(value);
660
+ if (!value) throw new Error("--since requires a valid ISO timestamp.");
661
+ options.since = new Date(normalizeCollectionSince(value));
362
662
  } else {
363
663
  throw new Error(`Unknown option: ${argument}`);
364
664
  }
@@ -389,9 +689,10 @@ function safeIso(value, fallback = null) {
389
689
  }
390
690
 
391
691
  function tokenTuple(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.
692
+ // token_count payloads arrive from untrusted JSONL. Token counts are kept
693
+ // only when they are primitive, non-negative safe integers. Invalid
694
+ // components become unknown breakdown values; the total is validated by the
695
+ // caller before the record enters the usage spool.
395
696
  const {
396
697
  input_tokens: inputTokens,
397
698
  cached_input_tokens: cachedInputTokens,
@@ -400,76 +701,40 @@ function tokenTuple(value) {
400
701
  reasoning_output_tokens: reasoningOutputTokens,
401
702
  total_tokens: totalTokens,
402
703
  } = value ?? {};
403
- return [
404
- asFiniteNumber(inputTokens),
405
- asFiniteNumber(cachedInputTokens),
406
- asFiniteNumber(cacheWriteInputTokens),
407
- asFiniteNumber(outputTokens),
408
- asFiniteNumber(reasoningOutputTokens),
409
- asFiniteNumber(totalTokens),
704
+ const rawValues = [
705
+ inputTokens,
706
+ cachedInputTokens,
707
+ cacheWriteInputTokens,
708
+ outputTokens,
709
+ reasoningOutputTokens,
710
+ totalTokens,
410
711
  ];
712
+ return {
713
+ values: rawValues.map((value) => tokenValue(value)),
714
+ valid: rawValues.map((value, index) =>
715
+ index < 5 && value === undefined
716
+ ? true
717
+ : isValidTokenValue(value)),
718
+ };
411
719
  }
412
720
 
413
721
  function usageFromTuple(tuple) {
414
722
  // Cached input is a subset of input and reasoning is a subset of output.
415
723
  // Clamp at export so one out-of-range source record cannot skew subset
416
724
  // math downstream.
417
- const inputTokens = tuple[0];
418
- const outputTokens = tuple[3];
725
+ const inputTokens = tuple.values[0];
726
+ const outputTokens = tuple.values[3];
419
727
  return {
420
728
  inputTokens,
421
- cachedInputTokens: Math.min(inputTokens, tuple[1]),
422
- cacheWriteInputTokens: tuple[2],
729
+ cachedInputTokens: Math.min(inputTokens, tuple.values[1]),
730
+ cacheWriteInputTokens: tuple.values[2],
423
731
  outputTokens,
424
- reasoningTokens: Math.min(outputTokens, tuple[4]),
425
- totalTokens: tuple[5],
732
+ reasoningTokens: Math.min(outputTokens, tuple.values[4]),
733
+ totalTokens: tuple.values[5],
734
+ componentsValid: tuple.valid.slice(0, -1).every(Boolean),
426
735
  };
427
736
  }
428
737
 
429
- function hasDetailedBreakdown(usage) {
430
- if (usage.totalTokens === 0) return true;
431
- return (
432
- usage.inputTokens + usage.outputTokens === usage.totalTokens &&
433
- (usage.inputTokens > 0 || usage.outputTokens > 0)
434
- );
435
- }
436
-
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.
442
- const value = String(model || "unknown")
443
- .trim()
444
- .toLowerCase()
445
- .replace(/[\s_]+/g, "-");
446
- if (RATE_CARD[value]) return value;
447
- if (value.startsWith("gpt-5.6-sol")) return "gpt-5.6-sol";
448
- if (value.startsWith("gpt-5.6-terra")) return "gpt-5.6-terra";
449
- if (value.startsWith("gpt-5.6-luna")) return "gpt-5.6-luna";
450
- if (value.startsWith("gpt-5.5-cyber")) return "gpt-5.5-cyber";
451
- if (value.startsWith("gpt-5.5")) return "gpt-5.5";
452
- if (value.startsWith("gpt-5.4-mini")) return "gpt-5.4-mini";
453
- if (value.startsWith("gpt-5.4")) return "gpt-5.4";
454
- if (value.startsWith("gpt-5.3-codex")) return "gpt-5.3-codex";
455
- if (value.startsWith("gpt-5.2")) return "gpt-5.2";
456
- return value || "unknown";
457
- }
458
-
459
- function creditsForUsage(model, usage) {
460
- if (!hasDetailedBreakdown(usage)) return null;
461
- const rate = RATE_CARD[normalizeModel(model)];
462
- if (!rate) return null;
463
- const cached = Math.min(usage.inputTokens, usage.cachedInputTokens);
464
- const uncached = Math.max(0, usage.inputTokens - cached);
465
- return (
466
- (uncached * rate.input +
467
- cached * rate.cached +
468
- usage.outputTokens * rate.output) /
469
- 1_000_000
470
- );
471
- }
472
-
473
738
  function primitiveString(value) {
474
739
  try {
475
740
  const text = String.prototype.valueOf.call(value);
@@ -479,6 +744,12 @@ function primitiveString(value) {
479
744
  }
480
745
  }
481
746
 
747
+ function liveRecordTimestamp(value) {
748
+ const source = primitiveString(value);
749
+ if (source === null || source.length === 0) return null;
750
+ return safeIso(source, null);
751
+ }
752
+
482
753
  function sourceDescriptor(value) {
483
754
  // Thread sources reach us in two representations: rollout session_meta
484
755
  // lines carry parsed JSON (a label string or an object with subagent
@@ -503,6 +774,9 @@ function sourceDescriptor(value) {
503
774
 
504
775
  function sourceLabels(threadSource, rawSource) {
505
776
  const source = sourceDescriptor(rawSource);
777
+ const safeThreadSource = safeExportLabel(threadSource, 40, "");
778
+ const localThreadSource = containsLocalPath(threadSource);
779
+ const localRawSource = containsLocalPath(source.label);
506
780
  if (threadSource === "subagent" || source.subagent) {
507
781
  return { source: "subagent", useType: "subagent" };
508
782
  }
@@ -516,13 +790,21 @@ function sourceLabels(threadSource, rawSource) {
516
790
  if (source.label === "vscode") {
517
791
  return { source: "desktop", useType: "interactive" };
518
792
  }
793
+ if (localThreadSource || localRawSource) {
794
+ return {
795
+ source: LOCAL_LABEL,
796
+ useType: localThreadSource
797
+ ? LOCAL_LABEL
798
+ : safeThreadSource || "interactive",
799
+ };
800
+ }
519
801
  if (source.label) {
520
802
  return {
521
- source: source.label.slice(0, 40),
522
- useType: threadSource || "interactive",
803
+ source: safeExportLabel(source.label, 40, "unknown"),
804
+ useType: safeThreadSource || "interactive",
523
805
  };
524
806
  }
525
- return { source: "unknown", useType: threadSource || "unknown" };
807
+ return { source: "unknown", useType: safeThreadSource || "unknown" };
526
808
  }
527
809
 
528
810
  function cleanRemote(value) {
@@ -542,17 +824,40 @@ function cleanRemote(value) {
542
824
  }
543
825
  }
544
826
 
827
+ function isRemoteOrigin(value) {
828
+ const remote = spoolText(value).trim();
829
+ return (
830
+ /^(?:https?|git|ssh|git\+ssh):\/\//i.test(remote) ||
831
+ /^[^@\s/:]+@[^:\s/]+:.+$/.test(remote)
832
+ );
833
+ }
834
+
545
835
  function projectLabel(cwd, gitOrigin) {
546
836
  const remote = cleanRemote(gitOrigin);
837
+ if (remote && isRemoteOrigin(gitOrigin)) {
838
+ const parts = remote.split("/").filter(Boolean);
839
+ return safeExportLabel(
840
+ parts.slice(-2).join("/"),
841
+ 160,
842
+ "Unknown project",
843
+ );
844
+ }
845
+ if (containsLocalPath(gitOrigin)) return LOCAL_LABEL;
547
846
  if (remote) {
548
847
  const parts = remote.split("/").filter(Boolean);
549
- return parts.slice(-2).join("/") || "Unknown project";
848
+ return safeExportLabel(
849
+ parts.slice(-2).join("/"),
850
+ 160,
851
+ "Unknown project",
852
+ );
550
853
  }
551
- const path = String(cwd || "").replaceAll("\\", "/").replace(/\/+$/, "");
854
+ const path = spoolText(cwd).replaceAll("\\", "/").replace(/\/+$/, "");
552
855
  const worktree = path.match(/\/\.codex\/worktrees\/[^/]+\/([^/]+)$/);
553
- if (worktree) return worktree[1];
856
+ if (worktree) return safeExportLabel(worktree[1], 160, "Unknown project");
554
857
  const name = basename(path);
555
- return name && name !== "." && name !== "/" ? name : "Unknown project";
858
+ return name && name !== "." && name !== "/"
859
+ ? safeExportLabel(name, 160, "Unknown project")
860
+ : "Unknown project";
556
861
  }
557
862
 
558
863
  function safeTitle(row, sessionTitle) {
@@ -565,18 +870,16 @@ function safeTitle(row, sessionTitle) {
565
870
  candidate.includes("<codex_delegation>") ||
566
871
  (subagent && candidate.length > 120)
567
872
  ) {
568
- if (row?.agent_nickname) return `Subagent · ${row.agent_nickname}`;
873
+ if (row?.agent_nickname) {
874
+ return safeExportLabel(
875
+ `Subagent · ${row.agent_nickname}`,
876
+ 180,
877
+ "Subagent",
878
+ );
879
+ }
569
880
  return subagent ? `Subagent · ${String(row?.id || "").slice(0, 8)}` : "Untitled task";
570
881
  }
571
- return candidate
572
- .replace(/\/Users\/[^\s"'`]+/g, "[local path]")
573
- .replace(/\/(?:private\/)?tmp\/[^\s"'`]+/g, "[temporary path]")
574
- .replace(
575
- /\b(?:sk-[A-Za-z0-9_-]{16,}|lin_api_[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_-]{16,})\b/g,
576
- "[redacted credential-like text]",
577
- )
578
- .replace(/\s+/g, " ")
579
- .slice(0, 180);
882
+ return safeExportLabel(candidate, 180, "Untitled task");
580
883
  }
581
884
 
582
885
  async function pathExists(path) {
@@ -604,26 +907,425 @@ export async function listJsonlFiles(root) {
604
907
  return found;
605
908
  }
606
909
 
607
- export async function latestSourceModifiedAt(codexHome, includeArchived = true) {
608
- const roots = [resolve(codexHome, "sessions")];
609
- if (includeArchived) {
610
- roots.push(resolve(codexHome, "archived_sessions"));
910
+ function sourceManifestEntry(codexHome, path, sourceStat) {
911
+ return [
912
+ hash(relative(resolve(codexHome), path), 64),
913
+ sourceStat.size,
914
+ sourceStat.mtimeMs,
915
+ sourceStat.ctimeMs,
916
+ sourceStat.dev ?? null,
917
+ sourceStat.ino ?? null,
918
+ ];
919
+ }
920
+
921
+ function normalizeSourcePath(value) {
922
+ const normalized = String(value).replaceAll("\\", "/");
923
+ return normalized.replace(/\/{2,}/g, "/").replace(/\/$/, "") || "/";
924
+ }
925
+
926
+ export function sourceLocationForPath(codexHome, path) {
927
+ const root = normalizeSourcePath(codexHome);
928
+ const candidate = normalizeSourcePath(path);
929
+ const archiveRoot = `${root}/archived_sessions`;
930
+ return candidate === archiveRoot || candidate.startsWith(`${archiveRoot}/`)
931
+ ? "archived"
932
+ : "active";
933
+ }
934
+
935
+ function sourceIdsForRolloutPaths(codexHome, entries) {
936
+ const root = resolve(codexHome);
937
+ const sourceIds = new Map();
938
+ for (const { path, sourceStat } of entries) {
939
+ const baseId = durableSourceId(root, path);
940
+ const fileIdentity = sourceStat.dev != null && sourceStat.ino != null
941
+ ? `inode:${sourceStat.dev}:${sourceStat.ino}`
942
+ : `path:${relative(root, path)}`;
943
+ sourceIds.set(path, `${baseId}:file:${hash(fileIdentity, 16)}`);
611
944
  }
612
- const files = (await Promise.all(roots.map((root) => listJsonlFiles(root))))
613
- .flat();
614
- const metadataFiles = [
615
- resolve(codexHome, "session_index.jsonl"),
945
+ return sourceIds;
946
+ }
947
+
948
+ function sourceWatermarkFromEntries(entries) {
949
+ const serialized = JSON.stringify(entries);
950
+ return {
951
+ version: SOURCE_WATERMARK_VERSION,
952
+ fingerprint: hash(serialized, 64),
953
+ sourceCount: entries.length,
954
+ latestModifiedAt: entries.reduce(
955
+ (latest, entry) => Math.max(latest, entry[2]),
956
+ 0,
957
+ ),
958
+ };
959
+ }
960
+
961
+ export function sourceWatermarksEqual(left, right) {
962
+ return Boolean(
963
+ left &&
964
+ right &&
965
+ left.version === SOURCE_WATERMARK_VERSION &&
966
+ right.version === SOURCE_WATERMARK_VERSION &&
967
+ left.fingerprint === right.fingerprint &&
968
+ left.sourceCount === right.sourceCount &&
969
+ left.latestModifiedAt === right.latestModifiedAt,
970
+ );
971
+ }
972
+
973
+ async function prefixFingerprint(path, byteLength) {
974
+ const fingerprint = createHash("sha256");
975
+ if (byteLength === 0) return fingerprint.digest("hex");
976
+ const input = createReadStream(path, {
977
+ start: 0,
978
+ end: byteLength - 1,
979
+ });
980
+ for await (const chunk of input) fingerprint.update(chunk);
981
+ return fingerprint.digest("hex");
982
+ }
983
+
984
+ async function descriptorPrefixFingerprint(handle, byteLength) {
985
+ const fingerprint = createHash("sha256");
986
+ let offset = 0;
987
+ while (offset < byteLength) {
988
+ const chunkLength = Math.min(MAX_TRAILING_SCAN_BYTES, byteLength - offset);
989
+ const chunk = Buffer.allocUnsafe(chunkLength);
990
+ const { bytesRead } = await handle.read(
991
+ chunk,
992
+ 0,
993
+ chunkLength,
994
+ offset,
995
+ );
996
+ if (bytesRead !== chunkLength) return null;
997
+ fingerprint.update(chunk.subarray(0, bytesRead));
998
+ offset += bytesRead;
999
+ }
1000
+ return fingerprint.digest("hex");
1001
+ }
1002
+
1003
+ function sourceIdentityChanged(sourceStat, expectedIdentity) {
1004
+ return expectedIdentity?.device != null &&
1005
+ expectedIdentity?.inode != null &&
1006
+ (
1007
+ Number(sourceStat.dev) !== Number(expectedIdentity.device) ||
1008
+ Number(sourceStat.ino) !== Number(expectedIdentity.inode)
1009
+ );
1010
+ }
1011
+
1012
+ function sourceIdentityDiffers(left, right) {
1013
+ const leftHasIdentity = left?.dev != null && left?.ino != null;
1014
+ const rightHasIdentity = right?.dev != null && right?.ino != null;
1015
+ return leftHasIdentity !== rightHasIdentity ||
1016
+ (leftHasIdentity &&
1017
+ (
1018
+ Number(left.dev) !== Number(right.dev) ||
1019
+ Number(left.ino) !== Number(right.ino)
1020
+ ));
1021
+ }
1022
+
1023
+ function sourceRevisionChanged(left, right) {
1024
+ return (
1025
+ Number(left.size) !== Number(right.size) ||
1026
+ Number(left.mtimeMs) !== Number(right.mtimeMs) ||
1027
+ Number(left.ctimeMs) !== Number(right.ctimeMs) ||
1028
+ sourceIdentityDiffers(left, right)
1029
+ );
1030
+ }
1031
+
1032
+ async function revalidateScannedRollout(
1033
+ path,
1034
+ handle,
1035
+ opened,
1036
+ expectedIdentity,
1037
+ maximumBytes,
1038
+ cursorFingerprint,
1039
+ ) {
1040
+ const bounded = Number.isSafeInteger(maximumBytes) && maximumBytes >= 0;
1041
+ const descriptor = await handle.stat();
1042
+ if (bounded && Number(descriptor.size) < maximumBytes) {
1043
+ const error = new Error(
1044
+ "Rollout became shorter while its bounded prefix was being read.",
1045
+ );
1046
+ error.code = "ERR_SOURCE_SIZE_CHANGED";
1047
+ throw error;
1048
+ }
1049
+ if (
1050
+ bounded &&
1051
+ Number(descriptor.size) === Number(opened.size) &&
1052
+ (
1053
+ Number(descriptor.mtimeMs) !== Number(opened.mtimeMs) ||
1054
+ Number(descriptor.ctimeMs) !== Number(opened.ctimeMs)
1055
+ )
1056
+ ) {
1057
+ const error = new Error(
1058
+ "Rollout revision changed while its bounded prefix was being read.",
1059
+ );
1060
+ error.code = "ERR_SOURCE_CONTENT_CHANGED";
1061
+ throw error;
1062
+ }
1063
+ if (sourceIdentityChanged(descriptor, expectedIdentity)) {
1064
+ const error = new Error(
1065
+ "Rollout identity changed while its bounded prefix was being read.",
1066
+ );
1067
+ error.code = "ERR_SOURCE_IDENTITY_CHANGED";
1068
+ throw error;
1069
+ }
1070
+
1071
+ const pathBefore = await stat(path);
1072
+ if (
1073
+ sourceIdentityChanged(pathBefore, expectedIdentity) ||
1074
+ sourceIdentityDiffers(pathBefore, opened)
1075
+ ) {
1076
+ const error = new Error(
1077
+ "Rollout identity changed while its bounded prefix was being read.",
1078
+ );
1079
+ error.code = "ERR_SOURCE_IDENTITY_CHANGED";
1080
+ throw error;
1081
+ }
1082
+
1083
+ if (bounded && Number(pathBefore.size) < maximumBytes) {
1084
+ const error = new Error(
1085
+ "Rollout became shorter while its bounded prefix was being read.",
1086
+ );
1087
+ error.code = "ERR_SOURCE_SIZE_CHANGED";
1088
+ throw error;
1089
+ }
1090
+
1091
+ const verifyPrefix = async () => {
1092
+ const rereadFingerprint = await descriptorPrefixFingerprint(
1093
+ handle,
1094
+ maximumBytes,
1095
+ );
1096
+ if (rereadFingerprint !== cursorFingerprint) {
1097
+ const error = new Error(
1098
+ "Rollout content changed while its bounded prefix was being read.",
1099
+ );
1100
+ error.code = "ERR_SOURCE_CONTENT_CHANGED";
1101
+ throw error;
1102
+ }
1103
+ };
1104
+
1105
+ // The streaming scan already hashed this exact prefix. Re-read it only if
1106
+ // the descriptor or path stat changed while the scan was in flight; stable
1107
+ // size, timestamps, and identity are the normal case and need no second
1108
+ // pass over the file.
1109
+ if (
1110
+ bounded &&
1111
+ (
1112
+ sourceRevisionChanged(descriptor, opened) ||
1113
+ sourceRevisionChanged(pathBefore, opened)
1114
+ )
1115
+ ) {
1116
+ await verifyPrefix();
1117
+ }
1118
+
1119
+ const pathAfter = await stat(path);
1120
+ if (
1121
+ sourceIdentityChanged(pathAfter, expectedIdentity) ||
1122
+ sourceIdentityDiffers(pathAfter, opened)
1123
+ ) {
1124
+ const error = new Error(
1125
+ "Rollout identity changed while its bounded prefix was being read.",
1126
+ );
1127
+ error.code = "ERR_SOURCE_IDENTITY_CHANGED";
1128
+ throw error;
1129
+ }
1130
+ if (bounded && Number(pathAfter.size) < maximumBytes) {
1131
+ const error = new Error(
1132
+ "Rollout became shorter while its bounded prefix was being read.",
1133
+ );
1134
+ error.code = "ERR_SOURCE_SIZE_CHANGED";
1135
+ throw error;
1136
+ }
1137
+ if (bounded && sourceRevisionChanged(pathAfter, pathBefore)) {
1138
+ await verifyPrefix();
1139
+ }
1140
+ }
1141
+
1142
+ async function scanCutoffForFile(entry) {
1143
+ const size = Number(entry.size);
1144
+ if (!Number.isSafeInteger(size) || size <= 0) return Math.max(0, size || 0);
1145
+
1146
+ const handle = await open(entry.path, "r");
1147
+ try {
1148
+ // Start with the bounded trailing window, then continue backwards if an
1149
+ // unterminated record is larger than that window. A zero cutoff would
1150
+ // discard every complete record before the oversized partial record.
1151
+ let chunkEnd = size;
1152
+ while (chunkEnd > 0) {
1153
+ const chunkLength = Math.min(chunkEnd, MAX_TRAILING_SCAN_BYTES);
1154
+ const chunk = Buffer.allocUnsafe(chunkLength);
1155
+ const chunkStart = chunkEnd - chunkLength;
1156
+ const { bytesRead } = await handle.read(
1157
+ chunk,
1158
+ 0,
1159
+ chunkLength,
1160
+ chunkStart,
1161
+ );
1162
+ if (bytesRead <= 0) return 0;
1163
+ const lastNewline = chunk.subarray(0, bytesRead).lastIndexOf(0x0a);
1164
+ if (lastNewline >= 0) return chunkStart + lastNewline + 1;
1165
+ chunkEnd = chunkStart;
1166
+ }
1167
+ return 0;
1168
+ } finally {
1169
+ await handle.close();
1170
+ }
1171
+ }
1172
+
1173
+ async function sourceInventoryPreservesCutoff(before, after) {
1174
+ const initial = new Map(
1175
+ before.files.map((entry) => [String(entry.sourceId), entry]),
1176
+ );
1177
+ const current = new Map(
1178
+ after.files.map((entry) => [String(entry.sourceId), entry]),
1179
+ );
1180
+
1181
+ for (const [sourceId, cutoff] of initial) {
1182
+ const observed = current.get(sourceId);
1183
+ const scanBytes = Number.isSafeInteger(Number(cutoff.scanBytes)) &&
1184
+ Number(cutoff.scanBytes) >= 0
1185
+ ? Number(cutoff.scanBytes)
1186
+ : cutoff.size;
1187
+ if (!observed || observed.size < scanBytes) return false;
1188
+ const unchanged =
1189
+ observed.path === cutoff.path &&
1190
+ observed.size === cutoff.size &&
1191
+ observed.mtimeMs === cutoff.mtimeMs &&
1192
+ observed.ctimeMs === cutoff.ctimeMs &&
1193
+ observed.dev === cutoff.dev &&
1194
+ observed.ino === cutoff.ino;
1195
+ if (unchanged) continue;
1196
+ const cutoffFingerprint = primitiveString(cutoff.cursorFingerprint);
1197
+ if (
1198
+ cutoffFingerprint === null ||
1199
+ cutoffFingerprint.length !== 64 ||
1200
+ await prefixFingerprint(observed.path, scanBytes) !==
1201
+ cutoffFingerprint
1202
+ ) {
1203
+ return false;
1204
+ }
1205
+ }
1206
+ return true;
1207
+ }
1208
+
1209
+ function acceptedSourceWatermark(before, after) {
1210
+ const initialRolloutPaths = new Set(
1211
+ before.lifecycleFiles.map((entry) => entry.path),
1212
+ );
1213
+ const rolloutPaths = new Set([
1214
+ ...initialRolloutPaths,
1215
+ ...after.lifecycleFiles.map((entry) => entry.path),
1216
+ ]);
1217
+ const entries = [
1218
+ ...before.watermarkEntries.filter(({ path }) =>
1219
+ initialRolloutPaths.has(path)),
1220
+ ...after.watermarkEntries.filter(({ path }) => !rolloutPaths.has(path)),
1221
+ ]
1222
+ .sort((left, right) => left.path.localeCompare(right.path))
1223
+ .map(({ manifest }) => manifest);
1224
+ return sourceWatermarkFromEntries(entries);
1225
+ }
1226
+
1227
+ export async function sourceInventory(codexHome, includeArchived = true) {
1228
+ const root = resolve(codexHome);
1229
+ const activeRolloutFiles = await listJsonlFiles(
1230
+ resolve(codexHome, "sessions"),
1231
+ );
1232
+ // Active-only reports must not parse archived rollouts, but they still need
1233
+ // archive identities to distinguish a move from an unexplained deletion.
1234
+ // Tracking stat-only lifecycle entries prevents the first --no-archived
1235
+ // refresh after a move from continuing to attribute the source as active.
1236
+ const archivedRolloutFiles = await listJsonlFiles(
1237
+ resolve(codexHome, "archived_sessions"),
1238
+ );
1239
+ const lifecycleRolloutFiles = [
1240
+ ...activeRolloutFiles,
1241
+ ...archivedRolloutFiles,
1242
+ ];
1243
+ const rolloutFiles = includeArchived
1244
+ ? lifecycleRolloutFiles
1245
+ : activeRolloutFiles;
1246
+ const sqliteFiles = [
616
1247
  resolve(codexHome, "state_5.sqlite"),
617
1248
  resolve(codexHome, "sqlite", "state_5.sqlite"),
618
1249
  ];
1250
+ const metadataFiles = [
1251
+ resolve(codexHome, "session_index.jsonl"),
1252
+ ...sqliteFiles.flatMap((path) => [path, `${path}-wal`]),
1253
+ ];
619
1254
  const existingMetadataFiles = [];
620
1255
  for (const path of metadataFiles) {
621
1256
  if (await pathExists(path)) existingMetadataFiles.push(path);
622
1257
  }
623
- const sourceFiles = [...files, ...existingMetadataFiles];
624
- if (!sourceFiles.length) return 0;
625
- const stats = await Promise.all(sourceFiles.map((path) => stat(path)));
626
- return Math.max(...stats.map((entry) => entry.mtimeMs));
1258
+ const sourceFiles = [
1259
+ ...new Set([...lifecycleRolloutFiles, ...existingMetadataFiles]),
1260
+ ]
1261
+ .sort();
1262
+ const sourceStats = await Promise.all(
1263
+ sourceFiles.map(async (path) => ({
1264
+ path,
1265
+ sourceStat: await stat(path),
1266
+ })),
1267
+ );
1268
+ const sourceStatByPath = new Map(
1269
+ sourceStats.map((entry) => [entry.path, entry.sourceStat]),
1270
+ );
1271
+ const entries = sourceStats.map(({ path, sourceStat }) =>
1272
+ sourceManifestEntry(root, path, sourceStat),
1273
+ );
1274
+ const rolloutSourceIds = sourceIdsForRolloutPaths(
1275
+ root,
1276
+ lifecycleRolloutFiles.map((path) => ({
1277
+ path,
1278
+ sourceStat: sourceStatByPath.get(path),
1279
+ })),
1280
+ );
1281
+ const entryForPath = (path) => ({
1282
+ path,
1283
+ size: sourceStatByPath.get(path).size,
1284
+ mtimeMs: sourceStatByPath.get(path).mtimeMs,
1285
+ ctimeMs: sourceStatByPath.get(path).ctimeMs,
1286
+ dev: sourceStatByPath.get(path).dev ?? null,
1287
+ ino: sourceStatByPath.get(path).ino ?? null,
1288
+ sourceId: rolloutSourceIds.get(path),
1289
+ location: sourceLocationForPath(root, path),
1290
+ });
1291
+ const lifecycleFiles = lifecycleRolloutFiles.sort().map(entryForPath);
1292
+ const lifecycleByPath = new Map(
1293
+ lifecycleFiles.map((entry) => [entry.path, entry]),
1294
+ );
1295
+ const lifecyclePathSet = new Set(lifecycleRolloutFiles);
1296
+ const watermarkEntries = sourceStats.map(({ path, sourceStat }) => ({
1297
+ path,
1298
+ manifest: sourceManifestEntry(root, path, sourceStat),
1299
+ }));
1300
+ return {
1301
+ files: rolloutFiles.sort().map((path) => lifecycleByPath.get(path)),
1302
+ lifecycleFiles,
1303
+ watermarkEntries,
1304
+ metadataWatermark: sourceWatermarkFromEntries(
1305
+ watermarkEntries
1306
+ .filter(({ path }) => !lifecyclePathSet.has(path))
1307
+ .map(({ manifest }) => manifest),
1308
+ ),
1309
+ watermark: sourceWatermarkFromEntries(entries),
1310
+ cutoffAt: new Date().toISOString(),
1311
+ };
1312
+ }
1313
+
1314
+ export async function latestSourceModifiedAt(codexHome, includeArchived = true) {
1315
+ return (
1316
+ await sourceInventory(codexHome, includeArchived)
1317
+ ).watermark.latestModifiedAt;
1318
+ }
1319
+
1320
+ function metadataTimestampMs(value) {
1321
+ if (value == null) return null;
1322
+ if (String(value).trim().length === 0) return null;
1323
+ const numeric = Number(value);
1324
+ if (Number.isFinite(numeric)) {
1325
+ return numeric < 10_000_000_000 ? numeric * 1_000 : numeric;
1326
+ }
1327
+ const parsed = Date.parse(String(value));
1328
+ return Number.isFinite(parsed) ? parsed : null;
627
1329
  }
628
1330
 
629
1331
  async function readSessionTitles(path) {
@@ -636,11 +1338,16 @@ async function readSessionTitles(path) {
636
1338
  try {
637
1339
  const record = JSON.parse(line);
638
1340
  if (!record?.id || !record?.thread_name) continue;
639
- const timestamp = new Date(record.updated_at || 0).getTime();
1341
+ const timestamp = metadataTimestampMs(record.updated_at);
640
1342
  const current = titles.get(record.id);
641
- if (!current || timestamp >= current.timestamp) {
1343
+ if (
1344
+ !current ||
1345
+ timestamp === null ||
1346
+ current.timestamp === null ||
1347
+ timestamp >= current.timestamp
1348
+ ) {
642
1349
  titles.set(record.id, {
643
- title: String(record.thread_name).replace(/\s+/g, " ").slice(0, 180),
1350
+ title: spoolText(record.thread_name).replace(/\s+/g, " "),
644
1351
  timestamp,
645
1352
  });
646
1353
  }
@@ -651,48 +1358,269 @@ async function readSessionTitles(path) {
651
1358
  return titles;
652
1359
  }
653
1360
 
654
- async function readState(codexHome) {
655
- const preferred = resolve(codexHome, "state_5.sqlite");
656
- const legacy = resolve(codexHome, "sqlite", "state_5.sqlite");
657
- const path = (await pathExists(preferred))
658
- ? preferred
659
- : (await pathExists(legacy))
660
- ? legacy
661
- : null;
662
- const rows = new Map();
663
- const parents = new Map();
664
- if (!path) return { path: null, rows, parents };
1361
+ const STATE_DATABASE_BUSY_TIMEOUT_MS = 250;
1362
+ const STATE_THREAD_COLUMNS = [
1363
+ "id",
1364
+ "created_at",
1365
+ "updated_at",
1366
+ "source",
1367
+ "cwd",
1368
+ "title",
1369
+ "name",
1370
+ "tokens_used",
1371
+ "git_sha",
1372
+ "git_branch",
1373
+ "git_origin_url",
1374
+ "agent_nickname",
1375
+ "agent_role",
1376
+ "model",
1377
+ "reasoning_effort",
1378
+ "thread_source",
1379
+ ];
1380
+ const ROLLOUT_METADATA_COLUMNS = [
1381
+ "source",
1382
+ "cwd",
1383
+ "git_origin_url",
1384
+ "model",
1385
+ "reasoning_effort",
1386
+ "thread_source",
1387
+ ];
665
1388
 
666
- const database = new DatabaseSync(path, { readOnly: true });
667
- try {
668
- const threadRows = database
669
- .prepare(
670
- `SELECT id, created_at, updated_at, source, cwd, title, name,
671
- tokens_used, git_sha, git_branch, git_origin_url,
672
- agent_nickname, agent_role, model, reasoning_effort,
673
- thread_source
674
- FROM threads`,
675
- )
676
- .all();
677
- for (const row of threadRows) rows.set(String(row.id), row);
1389
+ function currentThreadMetadataFingerprints(state) {
1390
+ const threadIds = new Set(state.rows.keys());
1391
+ const fingerprints = new Map();
1392
+ for (const threadId of threadIds) {
1393
+ const row = state.rows.get(threadId);
1394
+ fingerprints.set(
1395
+ String(threadId),
1396
+ hash(JSON.stringify(
1397
+ ROLLOUT_METADATA_COLUMNS.map((column) => row?.[column] ?? null),
1398
+ ), 64),
1399
+ );
1400
+ }
1401
+ return fingerprints;
1402
+ }
678
1403
 
679
- const edgeRows = database
680
- .prepare(
681
- "SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges",
682
- )
683
- .all();
684
- for (const edge of edgeRows) {
685
- parents.set(String(edge.child_thread_id), String(edge.parent_thread_id));
686
- }
687
- } finally {
688
- database.close();
1404
+ function threadMetadataRecordChanged(current, previous, fields) {
1405
+ if (!previous) return true;
1406
+ return fields.some((field) =>
1407
+ (current?.[field] ?? null) !== (previous?.[field] ?? null)
1408
+ );
1409
+ }
1410
+
1411
+ function providedThreadMetadataFields(threadId, state) {
1412
+ const row = state.rows.get(threadId);
1413
+ const fields = [];
1414
+ if (row?.cwd != null || row?.git_origin_url != null) fields.push("project");
1415
+ if (row?.model != null) fields.push("model");
1416
+ if (row?.reasoning_effort != null) fields.push("effort");
1417
+ if (row?.thread_source != null || row?.source != null) {
1418
+ fields.push("source", "useType");
689
1419
  }
690
- return { path, rows, parents };
1420
+ return fields;
691
1421
  }
692
1422
 
693
- function taskStartCandidate(record, threadId, stateRow, fileContext) {
694
- const outerMs = new Date(record.timestamp).getTime();
695
- const started = asFiniteNumber(record.payload?.started_at);
1423
+ function metadataChangedThreadIds({
1424
+ state,
1425
+ titles,
1426
+ currentFingerprints,
1427
+ previousFingerprints,
1428
+ persistedThreads,
1429
+ }) {
1430
+ if (previousFingerprints instanceof Map) {
1431
+ const threadIds = new Set([
1432
+ ...currentFingerprints.keys(),
1433
+ ...previousFingerprints.keys(),
1434
+ ]);
1435
+ return new Set([...threadIds].filter((threadId) =>
1436
+ currentFingerprints.get(threadId) !== previousFingerprints.get(threadId)
1437
+ ));
1438
+ }
1439
+ if (!(persistedThreads instanceof Map)) return null;
1440
+
1441
+ // Existing v3 ledgers predate per-thread fingerprints. Seed them without a
1442
+ // one-time full rescan by comparing today's normalized metadata with the
1443
+ // private thread records already stored in the ledger. Ambiguous rollout
1444
+ // names still use the conservative global fallback below.
1445
+ const changed = new Set();
1446
+ for (const threadId of currentFingerprints.keys()) {
1447
+ const current = threadMetadata(
1448
+ threadId,
1449
+ state.rows,
1450
+ titles,
1451
+ state.parents,
1452
+ {},
1453
+ persistedThreads,
1454
+ );
1455
+ const providedFields = providedThreadMetadataFields(threadId, state);
1456
+ if (
1457
+ threadMetadataRecordChanged(
1458
+ current,
1459
+ persistedThreads.get(threadId),
1460
+ providedFields,
1461
+ )
1462
+ ) {
1463
+ changed.add(threadId);
1464
+ }
1465
+ }
1466
+ return changed;
1467
+ }
1468
+
1469
+ function stateDatabaseResult(status, rows, parents, reason = null) {
1470
+ return {
1471
+ rows,
1472
+ parents,
1473
+ metadata: {
1474
+ status,
1475
+ reason,
1476
+ threadRows: rows.size,
1477
+ parentEdges: parents.size,
1478
+ },
1479
+ };
1480
+ }
1481
+
1482
+ function stateDatabaseFailureReason(error) {
1483
+ const errorNumber = Number(error?.errcode);
1484
+ if (errorNumber === 5 || errorNumber === 6) return "busy";
1485
+ if (errorNumber === 11 || errorNumber === 26) return "corrupt";
1486
+ const message = String(error?.message || "").toLowerCase();
1487
+ if (message.includes("locked") || message.includes("busy")) return "busy";
1488
+ if (
1489
+ message.includes("corrupt") ||
1490
+ message.includes("malformed") ||
1491
+ message.includes("not a database")
1492
+ ) {
1493
+ return "corrupt";
1494
+ }
1495
+ if (message.includes("no such table") || message.includes("no such column")) {
1496
+ return "schema-mismatch";
1497
+ }
1498
+ return "read-error";
1499
+ }
1500
+
1501
+ function expectedStateDatabaseFailure(error) {
1502
+ const code = String(error?.code || "");
1503
+ return code.startsWith("ERR_SQLITE") ||
1504
+ ["EACCES", "EISDIR", "ENOENT", "ENOTDIR", "EPERM"].includes(code);
1505
+ }
1506
+
1507
+ function stateTableExists(database, table) {
1508
+ return Boolean(
1509
+ database
1510
+ .prepare(
1511
+ "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?",
1512
+ )
1513
+ .get(table),
1514
+ );
1515
+ }
1516
+
1517
+ function stateTableColumns(database, table) {
1518
+ return new Set(
1519
+ database
1520
+ .prepare(`PRAGMA table_info("${table}")`)
1521
+ .all()
1522
+ .map((column) => String(column.name)),
1523
+ );
1524
+ }
1525
+
1526
+ async function readState(codexHome) {
1527
+ const preferred = resolve(codexHome, "state_5.sqlite");
1528
+ const legacy = resolve(codexHome, "sqlite", "state_5.sqlite");
1529
+ const path = (await pathExists(preferred))
1530
+ ? preferred
1531
+ : (await pathExists(legacy))
1532
+ ? legacy
1533
+ : null;
1534
+ const rows = new Map();
1535
+ const parents = new Map();
1536
+ if (!path) return stateDatabaseResult("missing", rows, parents);
1537
+
1538
+ let database;
1539
+ try {
1540
+ database = new DatabaseSync(path, { readOnly: true });
1541
+ database.exec(`PRAGMA busy_timeout = ${STATE_DATABASE_BUSY_TIMEOUT_MS}`);
1542
+ if (!stateTableExists(database, "threads")) {
1543
+ return stateDatabaseResult(
1544
+ "unavailable",
1545
+ rows,
1546
+ parents,
1547
+ "schema-mismatch",
1548
+ );
1549
+ }
1550
+ const threadColumns = stateTableColumns(database, "threads");
1551
+ if (!threadColumns.has("id")) {
1552
+ return stateDatabaseResult(
1553
+ "unavailable",
1554
+ rows,
1555
+ parents,
1556
+ "schema-mismatch",
1557
+ );
1558
+ }
1559
+ const projection = STATE_THREAD_COLUMNS.map((column) =>
1560
+ threadColumns.has(column)
1561
+ ? `"${column}"`
1562
+ : `NULL AS "${column}"`,
1563
+ ).join(", ");
1564
+ const threadRows = database
1565
+ .prepare(`SELECT ${projection} FROM threads`)
1566
+ .all();
1567
+ for (const row of threadRows) rows.set(String(row.id), row);
1568
+
1569
+ if (!stateTableExists(database, "thread_spawn_edges")) {
1570
+ return stateDatabaseResult("available", rows, parents);
1571
+ }
1572
+ const edgeColumns = stateTableColumns(database, "thread_spawn_edges");
1573
+ if (
1574
+ !edgeColumns.has("parent_thread_id") ||
1575
+ !edgeColumns.has("child_thread_id")
1576
+ ) {
1577
+ return stateDatabaseResult(
1578
+ "partial",
1579
+ rows,
1580
+ parents,
1581
+ "schema-mismatch",
1582
+ );
1583
+ }
1584
+ try {
1585
+ const edgeRows = database
1586
+ .prepare(
1587
+ "SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges",
1588
+ )
1589
+ .all();
1590
+ for (const edge of edgeRows) {
1591
+ parents.set(String(edge.child_thread_id), String(edge.parent_thread_id));
1592
+ }
1593
+ } catch (error) {
1594
+ if (!expectedStateDatabaseFailure(error)) throw error;
1595
+ return stateDatabaseResult(
1596
+ "partial",
1597
+ rows,
1598
+ parents,
1599
+ stateDatabaseFailureReason(error),
1600
+ );
1601
+ }
1602
+ return stateDatabaseResult("available", rows, parents);
1603
+ } catch (error) {
1604
+ if (!expectedStateDatabaseFailure(error)) throw error;
1605
+ return stateDatabaseResult(
1606
+ "unavailable",
1607
+ new Map(),
1608
+ new Map(),
1609
+ stateDatabaseFailureReason(error),
1610
+ );
1611
+ } finally {
1612
+ try {
1613
+ database?.close();
1614
+ } catch {
1615
+ // State metadata is optional enrichment; close failures cannot erase
1616
+ // additive usage recovered from rollout JSONL.
1617
+ }
1618
+ }
1619
+ }
1620
+
1621
+ function taskStartCandidate(record, threadId, stateRow, fileContext) {
1622
+ const outerMs = new Date(record.timestamp).getTime();
1623
+ const started = asFiniteNumber(record.payload?.started_at);
696
1624
  const startedMs = started > 10_000_000_000 ? started : started * 1_000;
697
1625
  const timestamp = isoFromEpoch(started, safeIso(record.timestamp, new Date(0).toISOString()));
698
1626
  const deltaMs =
@@ -714,65 +1642,144 @@ function taskStartCandidate(record, threadId, stateRow, fileContext) {
714
1642
  };
715
1643
  }
716
1644
 
717
- function rememberQuota(quotaMap, rateLimits, occurrence) {
718
- // rate_limits payloads come straight from JSONL. Read the named fields
719
- // once; records without usable buckets drop out via the filter below.
1645
+ function quotaCandidates(rateLimits, occurrence) {
1646
+ if (rateLimits == null) return { candidates: [], invalidRecords: 0 };
1647
+ if (Object(rateLimits) !== rateLimits || Array.isArray(rateLimits)) {
1648
+ return { candidates: [], invalidRecords: 1 };
1649
+ }
720
1650
  const {
721
1651
  primary,
722
1652
  secondary,
723
1653
  limit_id: limitId,
724
1654
  limit_name: limitName,
725
1655
  plan_type: planType,
726
- } = rateLimits ?? {};
727
- const buckets = [primary, secondary].filter(Boolean);
728
- for (const bucket of buckets) {
729
- const windowMinutes = asFiniteNumber(bucket.window_minutes);
730
- const usedPercent = asFiniteNumber(bucket.used_percent);
731
- const resetsAt = asFiniteNumber(bucket.resets_at);
732
- if (!windowMinutes || !resetsAt) continue;
733
- const limitKey = String(limitId || limitName || "anonymous");
734
- const key = [
735
- hash(limitKey, 16),
1656
+ } = rateLimits;
1657
+ const limitIdText = limitId == null ? null : primitiveString(limitId);
1658
+ const limitNameText = limitName == null ? null : primitiveString(limitName);
1659
+ const planTypeText = planType == null ? null : primitiveString(planType);
1660
+ if (
1661
+ (limitId != null && limitIdText == null) ||
1662
+ (limitName != null && limitNameText == null) ||
1663
+ (planType != null && planTypeText == null)
1664
+ ) {
1665
+ return { candidates: [], invalidRecords: 1 };
1666
+ }
1667
+ // Codex treats an omitted limit id as the default `codex` bucket. Keep the
1668
+ // provider id authoritative: a human label is optional display metadata and
1669
+ // must never create a second durable identity for the same quota pool.
1670
+ const canonicalLimitId = (limitIdText ?? "")
1671
+ .trim()
1672
+ .replace(/[A-Z-]/g, (character) =>
1673
+ character === "-" ? "_" : character.toLowerCase()) || "codex";
1674
+ const normalizedLimitName = limitNameText == null
1675
+ ? null
1676
+ : safeExportLabel(limitNameText, 80, null);
1677
+ const normalizedPlanType = planTypeText == null
1678
+ ? "unknown"
1679
+ : safeExportLabel(planTypeText, 80, null);
1680
+ if (normalizedPlanType == null) {
1681
+ return { candidates: [], invalidRecords: 1 };
1682
+ }
1683
+ const scope = canonicalLimitId === "codex" ? "account" : "named";
1684
+ const candidates = [];
1685
+ let invalidRecords = 0;
1686
+ for (const bucket of [primary, secondary]) {
1687
+ // Providers use null for an unavailable secondary window.
1688
+ if (bucket == null) continue;
1689
+ if (Object(bucket) !== bucket || Array.isArray(bucket)) {
1690
+ invalidRecords += 1;
1691
+ continue;
1692
+ }
1693
+ const normalized = normalizeQuotaObservationFields({
1694
+ windowMinutes: bucket.window_minutes,
1695
+ usedPercent: bucket.used_percent,
1696
+ resetsAt: bucket.resets_at,
1697
+ });
1698
+ if (!normalized) {
1699
+ invalidRecords += 1;
1700
+ continue;
1701
+ }
1702
+ const { windowMinutes, usedPercent, resetsAt } = normalized;
1703
+ const stableLimitKey = hash(canonicalLimitId, 16);
1704
+ const observationKey = durableQuotaObservationKey({
1705
+ limitKey: stableLimitKey,
736
1706
  windowMinutes,
737
1707
  resetsAt,
738
1708
  usedPercent,
739
- ].join("|");
740
- const candidate = {
741
- id: `quota-${hash(key)}`,
1709
+ });
1710
+ candidates.push({
1711
+ observationKey,
1712
+ id: `quota-${hash(observationKey)}`,
742
1713
  timestamp: occurrence.timestamp,
1714
+ lastSeenAt: occurrence.timestamp,
743
1715
  usedPercent,
744
1716
  windowMinutes,
745
1717
  resetsAt,
746
- planType: String(planType || "unknown"),
747
- limitKey: hash(limitKey, 16),
748
- limitName: limitName ? String(limitName).slice(0, 80) : null,
1718
+ planType: normalizedPlanType,
1719
+ limitKey: stableLimitKey,
1720
+ limitName: normalizedLimitName,
1721
+ scope,
749
1722
  source: "log",
750
1723
  turnId: occurrence.turnId || null,
751
1724
  originalLikely: occurrence.originalLikely,
752
- };
753
- const current = quotaMap.get(key);
754
- if (
755
- !current ||
756
- (!current.originalLikely && candidate.originalLikely) ||
757
- (current.originalLikely === candidate.originalLikely &&
758
- candidate.timestamp < current.timestamp)
759
- ) {
760
- quotaMap.set(key, candidate);
761
- }
1725
+ });
1726
+ }
1727
+ return { candidates, invalidRecords };
1728
+ }
1729
+
1730
+ function rememberQuotaLabel(quotaLabels, sourceId, candidate) {
1731
+ if (candidate.limitName == null) return;
1732
+ const observedAt = Date.parse(candidate.timestamp);
1733
+ const key = JSON.stringify([String(sourceId), candidate.limitKey]);
1734
+ const current = quotaLabels.get(key);
1735
+ if (
1736
+ !current ||
1737
+ observedAt > current.observedAtMs ||
1738
+ (
1739
+ observedAt === current.observedAtMs &&
1740
+ candidate.limitName > current.limitName
1741
+ )
1742
+ ) {
1743
+ quotaLabels.set(key, {
1744
+ sourceId: String(sourceId),
1745
+ limitKey: candidate.limitKey,
1746
+ limitName: candidate.limitName,
1747
+ observedAt: candidate.timestamp,
1748
+ observedAtMs: observedAt,
1749
+ });
1750
+ }
1751
+ }
1752
+
1753
+ function rememberQuota(quotaMap, candidate, sinceMs = -Infinity) {
1754
+ if (Date.parse(candidate.timestamp) < sinceMs) return;
1755
+ const { observationKey, ...storedCandidate } = candidate;
1756
+ const current = quotaMap.get(observationKey);
1757
+ if (
1758
+ !current ||
1759
+ (!current.originalLikely && storedCandidate.originalLikely)
1760
+ ) {
1761
+ quotaMap.set(observationKey, storedCandidate);
1762
+ } else if (current.originalLikely === storedCandidate.originalLikely) {
1763
+ // Keep the first and last occurrence of an unchanged reading without
1764
+ // storing every repeated provider sample in the snapshot.
1765
+ quotaMap.set(observationKey, {
1766
+ ...current,
1767
+ timestamp:
1768
+ storedCandidate.timestamp < current.timestamp
1769
+ ? storedCandidate.timestamp
1770
+ : current.timestamp,
1771
+ lastSeenAt:
1772
+ storedCandidate.timestamp > current.lastSeenAt
1773
+ ? storedCandidate.timestamp
1774
+ : current.lastSeenAt,
1775
+ });
762
1776
  }
763
1777
  }
764
1778
 
765
1779
  function responseCall(record) {
766
1780
  if (record.type !== "response_item") return null;
767
1781
  const payload = record.payload;
768
- const allowed = new Set([
769
- "function_call",
770
- "custom_tool_call",
771
- "tool_search_call",
772
- "web_search_call",
773
- "image_generation_call",
774
- ]);
775
- if (!payload || !allowed.has(payload.type)) return null;
1782
+ if (!payload || !RELEVANT_CALL_TYPES.has(payload.type)) return null;
776
1783
  return {
777
1784
  type: payload.type,
778
1785
  name: String(payload.name || payload.namespace || payload.type).slice(0, 80),
@@ -780,10 +1787,249 @@ function responseCall(record) {
780
1787
  };
781
1788
  }
782
1789
 
783
- async function scanRollout(path, context) {
1790
+ function inspectJsonLine(line) {
1791
+ let index = 0;
1792
+ let topType = { kind: "absent" };
1793
+ let payloadType = { kind: "absent" };
1794
+
1795
+ function skipWhitespace() {
1796
+ while (
1797
+ line[index] === " " ||
1798
+ line[index] === "\t" ||
1799
+ line[index] === "\n" ||
1800
+ line[index] === "\r"
1801
+ ) {
1802
+ index += 1;
1803
+ }
1804
+ }
1805
+
1806
+ function parseString() {
1807
+ if (line[index] !== '"') return { valid: false };
1808
+ index += 1;
1809
+ const start = index;
1810
+ let simple = true;
1811
+ while (index < line.length) {
1812
+ const code = line.charCodeAt(index);
1813
+ if (code === 34) {
1814
+ const value = simple ? line.slice(start, index) : null;
1815
+ index += 1;
1816
+ return { valid: true, simple, value };
1817
+ }
1818
+ if (code === 92) {
1819
+ simple = false;
1820
+ index += 1;
1821
+ if (index >= line.length) return { valid: false };
1822
+ if (line[index] === "u") {
1823
+ if (!/^[0-9a-f]{4}$/i.test(line.slice(index + 1, index + 5))) {
1824
+ return { valid: false };
1825
+ }
1826
+ index += 5;
1827
+ } else if ('"\\/bfnrt'.includes(line[index])) {
1828
+ index += 1;
1829
+ } else {
1830
+ return { valid: false };
1831
+ }
1832
+ continue;
1833
+ }
1834
+ if (code < 0x20) return { valid: false };
1835
+ index += 1;
1836
+ }
1837
+ return { valid: false };
1838
+ }
1839
+
1840
+ function parseNumber() {
1841
+ const start = index;
1842
+ if (line[index] === "-") index += 1;
1843
+ if (line[index] === "0") {
1844
+ index += 1;
1845
+ } else if (line[index] >= "1" && line[index] <= "9") {
1846
+ while (line[index] >= "0" && line[index] <= "9") index += 1;
1847
+ } else {
1848
+ return false;
1849
+ }
1850
+ if (line[index] === ".") {
1851
+ index += 1;
1852
+ const fractionStart = index;
1853
+ while (line[index] >= "0" && line[index] <= "9") index += 1;
1854
+ if (index === fractionStart) return false;
1855
+ }
1856
+ if (line[index] === "e" || line[index] === "E") {
1857
+ index += 1;
1858
+ if (line[index] === "+" || line[index] === "-") index += 1;
1859
+ const exponentStart = index;
1860
+ while (line[index] >= "0" && line[index] <= "9") index += 1;
1861
+ if (index === exponentStart) return false;
1862
+ }
1863
+ return index > start;
1864
+ }
1865
+
1866
+ function parseLiteral(value) {
1867
+ if (line.slice(index, index + value.length) !== value) return false;
1868
+ index += value.length;
1869
+ return true;
1870
+ }
1871
+
1872
+ function captureValue() {
1873
+ if (line[index] === '"') {
1874
+ const result = parseString();
1875
+ if (!result.valid) return { valid: false };
1876
+ return result.simple
1877
+ ? { valid: true, kind: "string", value: result.value }
1878
+ : { valid: true, kind: "unknown" };
1879
+ }
1880
+ return parseValue()
1881
+ ? { valid: true, kind: "other" }
1882
+ : { valid: false };
1883
+ }
1884
+
1885
+ function rememberType(current, captured) {
1886
+ if (current.kind === "unknown") return current;
1887
+ return captured.kind === "unknown"
1888
+ ? { kind: "unknown" }
1889
+ : captured.kind === "string"
1890
+ ? { kind: "string", value: captured.value }
1891
+ : { kind: "other" };
1892
+ }
1893
+
1894
+ function parseObject(scope = "generic") {
1895
+ if (line[index] !== "{") return false;
1896
+ index += 1;
1897
+ skipWhitespace();
1898
+ if (line[index] === "}") {
1899
+ index += 1;
1900
+ return true;
1901
+ }
1902
+ while (index < line.length) {
1903
+ const key = parseString();
1904
+ if (!key.valid) return false;
1905
+ skipWhitespace();
1906
+ if (line[index] !== ":") return false;
1907
+ index += 1;
1908
+ skipWhitespace();
1909
+
1910
+ if (scope === "top" && !key.simple) {
1911
+ topType = { kind: "unknown" };
1912
+ payloadType = { kind: "unknown" };
1913
+ }
1914
+ if (scope === "payload" && !key.simple) {
1915
+ payloadType = { kind: "unknown" };
1916
+ }
1917
+
1918
+ if (scope === "top" && key.simple && key.value === "type") {
1919
+ const captured = captureValue();
1920
+ if (!captured.valid) return false;
1921
+ topType = rememberType(topType, captured);
1922
+ } else if (scope === "top" && key.simple && key.value === "payload") {
1923
+ if (line[index] === "{") {
1924
+ if (!parseObject("payload")) return false;
1925
+ if (payloadType.kind === "absent") {
1926
+ payloadType = { kind: "other" };
1927
+ }
1928
+ } else {
1929
+ const captured = captureValue();
1930
+ if (!captured.valid) return false;
1931
+ payloadType = { kind: "other" };
1932
+ }
1933
+ } else if (scope === "payload" && key.simple && key.value === "type") {
1934
+ const captured = captureValue();
1935
+ if (!captured.valid) return false;
1936
+ payloadType = rememberType(payloadType, captured);
1937
+ } else if (!parseValue()) {
1938
+ return false;
1939
+ }
1940
+
1941
+ skipWhitespace();
1942
+ if (line[index] === "}") {
1943
+ index += 1;
1944
+ return true;
1945
+ }
1946
+ if (line[index] !== ",") return false;
1947
+ index += 1;
1948
+ skipWhitespace();
1949
+ }
1950
+ return false;
1951
+ }
1952
+
1953
+ function parseArray() {
1954
+ if (line[index] !== "[") return false;
1955
+ index += 1;
1956
+ skipWhitespace();
1957
+ if (line[index] === "]") {
1958
+ index += 1;
1959
+ return true;
1960
+ }
1961
+ while (index < line.length) {
1962
+ if (!parseValue()) return false;
1963
+ skipWhitespace();
1964
+ if (line[index] === "]") {
1965
+ index += 1;
1966
+ return true;
1967
+ }
1968
+ if (line[index] !== ",") return false;
1969
+ index += 1;
1970
+ skipWhitespace();
1971
+ }
1972
+ return false;
1973
+ }
1974
+
1975
+ function parseValue() {
1976
+ if (line[index] === '"') return parseString().valid;
1977
+ if (line[index] === "{") return parseObject();
1978
+ if (line[index] === "[") return parseArray();
1979
+ if (line[index] === "t") return parseLiteral("true");
1980
+ if (line[index] === "f") return parseLiteral("false");
1981
+ if (line[index] === "n") return parseLiteral("null");
1982
+ return parseNumber();
1983
+ }
1984
+
1985
+ skipWhitespace();
1986
+ try {
1987
+ if (line[index] !== "{" || !parseObject("top")) return null;
1988
+ } catch {
1989
+ // Deeply nested values can exhaust the call stack before JSON.parse
1990
+ // would; treat that as inconclusive and let the real parser decide.
1991
+ return null;
1992
+ }
1993
+ skipWhitespace();
1994
+ if (index !== line.length) return null;
1995
+ return { topType, payloadType };
1996
+ }
1997
+
1998
+ function lineMayAffectUsage(line) {
1999
+ const inspected = inspectJsonLine(line);
2000
+ if (!inspected) return true;
2001
+ if (inspected.topType.kind === "unknown") return true;
2002
+ if (inspected.topType.kind !== "string") return false;
2003
+ if (inspected.topType.value === "session_meta") return true;
2004
+ if (inspected.topType.value === "turn_context") return true;
2005
+ if (inspected.topType.value === "event_msg") {
2006
+ return (
2007
+ inspected.payloadType.kind === "unknown" ||
2008
+ (inspected.payloadType.kind === "string" &&
2009
+ RELEVANT_EVENT_TYPES.has(inspected.payloadType.value))
2010
+ );
2011
+ }
2012
+ if (inspected.topType.value === "response_item") {
2013
+ return (
2014
+ inspected.payloadType.kind === "unknown" ||
2015
+ (inspected.payloadType.kind === "string" &&
2016
+ RELEVANT_CALL_TYPES.has(inspected.payloadType.value))
2017
+ );
2018
+ }
2019
+ return false;
2020
+ }
2021
+
2022
+ async function scanRollout(
2023
+ path,
2024
+ stateRows,
2025
+ signal,
2026
+ continuityBytes = null,
2027
+ maximumBytes = null,
2028
+ expectedIdentity = null,
2029
+ ) {
784
2030
  const match = path.match(UUID_AT_END);
785
2031
  const threadId = match?.[1] || `file-${hash(path)}`;
786
- const stateRow = context.stateRows.get(threadId);
2032
+ const stateRow = stateRows.get(threadId);
787
2033
  const fileContext = {
788
2034
  model: stateRow?.model || "unknown",
789
2035
  effort: stateRow?.reasoning_effort || "unknown",
@@ -793,215 +2039,507 @@ async function scanRollout(path, context) {
793
2039
  serviceTier: null,
794
2040
  };
795
2041
  const callOrdinals = new Map();
2042
+ const tokenSignatures = new Map();
2043
+ let nextTokenOrdinal = 0;
796
2044
  let currentTurnId = "";
797
2045
  let currentCandidate = null;
798
- let currentCandidateSelected = false;
799
2046
  let previousCumulative = null;
2047
+ const operations = [];
2048
+ let parseErrors = 0;
2049
+ let correctionIntervals = 0;
2050
+ let invalidTokenRecords = 0;
2051
+ let invalidQuotaRecords = 0;
800
2052
 
801
- const input = createReadStream(path, { encoding: "utf8" });
802
- const lines = createInterface({ input, crlfDelay: Infinity });
803
- for await (const line of lines) {
804
- if (!line.trim()) continue;
805
- let record;
806
- try {
807
- record = JSON.parse(line);
808
- } catch {
809
- context.parseErrors += 1;
810
- continue;
2053
+ const contentHash = createHash("sha256");
2054
+ const continuityHash = Number.isSafeInteger(continuityBytes) &&
2055
+ continuityBytes >= 0
2056
+ ? createHash("sha256")
2057
+ : null;
2058
+ let continuityRemaining = continuityHash ? continuityBytes : 0;
2059
+ const bounded = Number.isSafeInteger(maximumBytes) && maximumBytes >= 0;
2060
+ const sourceOptions = { signal, autoClose: false };
2061
+ if (bounded) {
2062
+ sourceOptions.start = 0;
2063
+ sourceOptions.end = maximumBytes - 1;
2064
+ }
2065
+ const handle = await open(path, "r");
2066
+ let source;
2067
+ let opened;
2068
+ try {
2069
+ opened = await handle.stat();
2070
+ if (bounded && Number(opened.size) < maximumBytes) {
2071
+ const error = new Error(
2072
+ "Rollout became shorter between inventory and collection.",
2073
+ );
2074
+ error.code = "ERR_SOURCE_SIZE_CHANGED";
2075
+ throw error;
811
2076
  }
2077
+ if (
2078
+ expectedIdentity?.device != null &&
2079
+ expectedIdentity?.inode != null &&
2080
+ (
2081
+ Number(opened.dev) !== Number(expectedIdentity.device) ||
2082
+ Number(opened.ino) !== Number(expectedIdentity.inode)
2083
+ )
2084
+ ) {
2085
+ const error = new Error(
2086
+ "Rollout identity changed between inventory and collection.",
2087
+ );
2088
+ error.code = "ERR_SOURCE_IDENTITY_CHANGED";
2089
+ throw error;
2090
+ }
2091
+ if (bounded && maximumBytes === 0) {
2092
+ source = Readable.from([]);
2093
+ } else {
2094
+ source = handle.createReadStream(sourceOptions);
2095
+ }
2096
+ } catch (error) {
2097
+ await handle.close().catch(() => {});
2098
+ throw error;
2099
+ }
2100
+ const input = new Transform({
2101
+ transform(chunk, encoding, callback) {
2102
+ contentHash.update(chunk);
2103
+ if (continuityHash && continuityRemaining > 0) {
2104
+ const included = chunk.subarray(
2105
+ 0,
2106
+ Math.min(chunk.byteLength, continuityRemaining),
2107
+ );
2108
+ continuityHash.update(included);
2109
+ continuityRemaining -= included.byteLength;
2110
+ }
2111
+ callback(null, chunk);
2112
+ },
2113
+ });
2114
+ source.on("error", (error) => input.destroy(error));
2115
+ source.pipe(input);
2116
+ const lines = createInterface({ input, crlfDelay: Infinity });
2117
+ try {
2118
+ for await (const line of lines) {
2119
+ if (!line.trim() || !lineMayAffectUsage(line)) continue;
2120
+ let record;
2121
+ try {
2122
+ record = JSON.parse(line);
2123
+ } catch {
2124
+ parseErrors += 1;
2125
+ continue;
2126
+ }
812
2127
 
813
- if (record.type === "session_meta") {
814
- const payload = record.payload;
815
- if (payload?.id === threadId) {
816
- fileContext.cwd = payload.cwd || fileContext.cwd;
817
- fileContext.gitOrigin =
818
- payload.git?.repository_url || fileContext.gitOrigin;
819
- fileContext.rawSource = payload.source || fileContext.rawSource;
820
- if (payload.parent_thread_id || payload.forked_from_id) {
821
- context.parents.set(
2128
+ if (record.type === "session_meta") {
2129
+ const payload = record.payload;
2130
+ if (payload?.id === threadId) {
2131
+ fileContext.cwd = payload.cwd || fileContext.cwd;
2132
+ fileContext.gitOrigin =
2133
+ payload.git?.repository_url || fileContext.gitOrigin;
2134
+ fileContext.rawSource = payload.source || fileContext.rawSource;
2135
+ if (payload.parent_thread_id || payload.forked_from_id) {
2136
+ operations.push({
2137
+ kind: "parent",
2138
+ threadId,
2139
+ parentThreadId: String(
2140
+ payload.parent_thread_id || payload.forked_from_id,
2141
+ ),
2142
+ });
2143
+ }
2144
+ const spawnedParent =
2145
+ payload.source?.subagent?.thread_spawn?.parent_thread_id;
2146
+ if (spawnedParent) {
2147
+ operations.push({
2148
+ kind: "parent",
2149
+ threadId,
2150
+ parentThreadId: String(spawnedParent),
2151
+ });
2152
+ }
2153
+ }
2154
+ continue;
2155
+ }
2156
+
2157
+ if (record.type === "event_msg" && record.payload?.type === "task_started") {
2158
+ currentTurnId = String(record.payload.turn_id || currentTurnId || "");
2159
+ if (currentTurnId) {
2160
+ currentCandidate = taskStartCandidate(
2161
+ record,
822
2162
  threadId,
823
- String(payload.parent_thread_id || payload.forked_from_id),
2163
+ stateRow,
2164
+ fileContext,
824
2165
  );
2166
+ operations.push({
2167
+ kind: "origin",
2168
+ candidate: { ...currentCandidate },
2169
+ });
825
2170
  }
826
- const spawnedParent =
827
- payload.source?.subagent?.thread_spawn?.parent_thread_id;
828
- if (spawnedParent) context.parents.set(threadId, String(spawnedParent));
2171
+ continue;
829
2172
  }
830
- continue;
831
- }
832
2173
 
833
- if (record.type === "event_msg" && record.payload?.type === "task_started") {
834
- currentTurnId = String(record.payload.turn_id || currentTurnId || "");
835
- if (currentTurnId) {
836
- currentCandidate = taskStartCandidate(
837
- record,
838
- threadId,
839
- stateRow,
840
- fileContext,
841
- );
842
- currentCandidateSelected = context.spool.insertOrigin(currentCandidate);
2174
+ if (record.type === "turn_context") {
2175
+ currentTurnId = String(record.payload?.turn_id || currentTurnId || "");
2176
+ fileContext.model = record.payload?.model || fileContext.model;
2177
+ fileContext.effort = record.payload?.effort || fileContext.effort;
2178
+ fileContext.cwd = record.payload?.cwd || fileContext.cwd;
2179
+ if (currentCandidate?.turnId === currentTurnId) {
2180
+ currentCandidate.model = fileContext.model;
2181
+ currentCandidate.effort = fileContext.effort;
2182
+ currentCandidate.cwd = fileContext.cwd;
2183
+ operations.push({
2184
+ kind: "origin_update",
2185
+ turnId: currentTurnId,
2186
+ model: fileContext.model,
2187
+ effort: fileContext.effort,
2188
+ cwd: fileContext.cwd,
2189
+ });
2190
+ }
2191
+ continue;
843
2192
  }
844
- continue;
845
- }
846
2193
 
847
- if (record.type === "turn_context") {
848
- currentTurnId = String(record.payload?.turn_id || currentTurnId || "");
849
- fileContext.model = record.payload?.model || fileContext.model;
850
- fileContext.effort = record.payload?.effort || fileContext.effort;
851
- fileContext.cwd = record.payload?.cwd || fileContext.cwd;
852
- if (currentCandidate?.turnId === currentTurnId) {
853
- currentCandidate.model = fileContext.model;
854
- currentCandidate.effort = fileContext.effort;
855
- currentCandidate.cwd = fileContext.cwd;
856
- if (currentCandidateSelected) {
857
- context.spool.updateOrigin(currentCandidate);
858
- }
2194
+ if (
2195
+ record.type === "event_msg" &&
2196
+ record.payload?.type === "thread_settings_applied"
2197
+ ) {
2198
+ const settings = record.payload?.thread_settings;
2199
+ fileContext.model = settings?.model || fileContext.model;
2200
+ fileContext.effort = settings?.reasoning_effort || fileContext.effort;
2201
+ const serviceTier = String(settings?.service_tier ?? "").trim();
2202
+ fileContext.serviceTier = serviceTier
2203
+ ? serviceTier.slice(0, 40)
2204
+ : null;
2205
+ continue;
859
2206
  }
860
- continue;
861
- }
862
2207
 
863
- if (
864
- record.type === "event_msg" &&
865
- record.payload?.type === "thread_settings_applied"
866
- ) {
867
- const settings = record.payload?.thread_settings;
868
- fileContext.model = settings?.model || fileContext.model;
869
- fileContext.effort = settings?.reasoning_effort || fileContext.effort;
870
- const serviceTier = String(settings?.service_tier ?? "").trim();
871
- fileContext.serviceTier = serviceTier
872
- ? serviceTier.slice(0, 40)
2208
+ const tokenCountRecord =
2209
+ record.type === "event_msg" && record.payload?.type === "token_count";
2210
+ const tokenTimestamp = tokenCountRecord
2211
+ ? liveRecordTimestamp(record.timestamp)
873
2212
  : null;
874
- continue;
875
- }
876
-
877
- const originalLikely = Boolean(currentCandidate?.deltaMs <= 2_000);
878
- const occurrence = {
879
- threadId,
880
- turnId: currentTurnId,
881
- timestamp: safeIso(
882
- record.timestamp,
883
- currentCandidate?.timestamp || new Date(0).toISOString(),
884
- ),
885
- originalLikely,
886
- model: fileContext.model,
887
- effort: fileContext.effort,
888
- cwd: fileContext.cwd,
889
- gitOrigin: fileContext.gitOrigin,
890
- rawSource: fileContext.rawSource,
891
- serviceTier: fileContext.serviceTier,
892
- };
2213
+ if (tokenCountRecord && tokenTimestamp === null) {
2214
+ if (record.payload?.info?.last_token_usage !== undefined) {
2215
+ invalidTokenRecords += 1;
2216
+ }
2217
+ if (record.payload?.rate_limits != null) invalidQuotaRecords += 1;
2218
+ continue;
2219
+ }
893
2220
 
894
- const call = responseCall(record);
895
- if (call) {
896
- const ordinalBase = `${currentTurnId}|${call.type}|${call.name}`;
897
- const ordinal = (callOrdinals.get(ordinalBase) || 0) + 1;
898
- callOrdinals.set(ordinalBase, ordinal);
899
- const callKey = call.stableId
900
- ? `id|${call.stableId}`
901
- : `ordinal|${ordinalBase}|${ordinal}`;
902
- context.spool.insertCall(
903
- callKey,
904
- currentTurnId,
2221
+ const originalLikely = Boolean(currentCandidate?.deltaMs <= 2_000);
2222
+ const occurrence = {
905
2223
  threadId,
2224
+ turnId: currentTurnId,
2225
+ timestamp: tokenTimestamp || safeIso(
2226
+ record.timestamp,
2227
+ currentCandidate?.timestamp || new Date(0).toISOString(),
2228
+ ),
906
2229
  originalLikely,
907
- );
908
- continue;
909
- }
2230
+ model: fileContext.model,
2231
+ effort: fileContext.effort,
2232
+ cwd: fileContext.cwd,
2233
+ gitOrigin: fileContext.gitOrigin,
2234
+ rawSource: fileContext.rawSource,
2235
+ serviceTier: fileContext.serviceTier,
2236
+ };
910
2237
 
911
- if (record.type !== "event_msg" || record.payload?.type !== "token_count") {
912
- continue;
913
- }
2238
+ const call = responseCall(record);
2239
+ if (call) {
2240
+ const ordinalBase = `${currentTurnId}|${call.type}|${call.name}`;
2241
+ const ordinal = (callOrdinals.get(ordinalBase) || 0) + 1;
2242
+ callOrdinals.set(ordinalBase, ordinal);
2243
+ const callKey = call.stableId
2244
+ ? `id|${call.stableId}`
2245
+ : `ordinal|${ordinalBase}|${ordinal}`;
2246
+ operations.push({
2247
+ kind: "call",
2248
+ callKey,
2249
+ turnId: currentTurnId,
2250
+ threadId,
2251
+ originalLikely,
2252
+ });
2253
+ continue;
2254
+ }
914
2255
 
915
- rememberQuota(context.quotas, record.payload.rate_limits, occurrence);
916
- const info = record.payload.info;
917
- if (!info?.last_token_usage) continue;
2256
+ if (!tokenCountRecord) continue;
918
2257
 
919
- const totalTuple = tokenTuple(info.total_token_usage);
920
- const lastTuple = tokenTuple(info.last_token_usage);
921
- if (lastTuple[5] <= 0) continue;
922
- const contextWindow = asFiniteNumber(info.model_context_window);
923
- const eventKey = currentTurnId
924
- ? JSON.stringify([
925
- currentTurnId,
926
- totalTuple,
927
- lastTuple,
928
- contextWindow,
929
- ])
930
- : JSON.stringify(["legacy", totalTuple, lastTuple, contextWindow]);
2258
+ const info = record.payload.info;
2259
+ const quotaResult = quotaCandidates(
2260
+ record.payload.rate_limits,
2261
+ occurrence,
2262
+ );
2263
+ invalidQuotaRecords += quotaResult.invalidRecords;
2264
+ if (quotaResult.candidates.length > 0) {
2265
+ operations.push({
2266
+ kind: "quota",
2267
+ candidates: quotaResult.candidates,
2268
+ });
2269
+ }
2270
+ if (info?.last_token_usage === undefined) continue;
931
2271
 
932
- if (
933
- previousCumulative !== null &&
934
- totalTuple[5] < previousCumulative
935
- ) {
936
- context.correctionIntervals += 1;
937
- }
938
- previousCumulative = totalTuple[5];
2272
+ const totalTuple = tokenTuple(info.total_token_usage);
2273
+ const lastTuple = tokenTuple(info.last_token_usage);
2274
+ if (!lastTuple.valid[5]) {
2275
+ invalidTokenRecords += 1;
2276
+ continue;
2277
+ }
2278
+ if (lastTuple.values[5] <= 0) continue;
2279
+ const contextWindow = asFiniteNumber(info.model_context_window);
2280
+ const tokenSignature = JSON.stringify([
2281
+ totalTuple.values,
2282
+ totalTuple.valid,
2283
+ lastTuple.values,
2284
+ lastTuple.valid,
2285
+ contextWindow,
2286
+ ]);
2287
+ const signatureScope = currentTurnId || "legacy";
2288
+ const signatures = tokenSignatures.get(signatureScope) || new Map();
2289
+ let eventOrdinal = signatures.get(tokenSignature);
2290
+ if (!eventOrdinal) {
2291
+ nextTokenOrdinal += 1;
2292
+ const ordinal = nextTokenOrdinal;
2293
+ eventOrdinal = ordinal;
2294
+ signatures.set(tokenSignature, eventOrdinal);
2295
+ tokenSignatures.set(signatureScope, signatures);
2296
+ }
2297
+ const eventKey = currentTurnId
2298
+ ? JSON.stringify([
2299
+ currentTurnId,
2300
+ totalTuple.values,
2301
+ totalTuple.valid,
2302
+ lastTuple.values,
2303
+ lastTuple.valid,
2304
+ contextWindow,
2305
+ ])
2306
+ : JSON.stringify([
2307
+ "legacy",
2308
+ totalTuple.values,
2309
+ totalTuple.valid,
2310
+ lastTuple.values,
2311
+ lastTuple.valid,
2312
+ contextWindow,
2313
+ ]);
939
2314
 
940
- const inserted = context.spool.insertToken(
941
- eventKey,
942
- currentTurnId,
943
- usageFromTuple(lastTuple),
944
- occurrence,
945
- originalLikely,
946
- );
947
- if (!inserted) {
948
- context.duplicateEventsSkipped += 1;
2315
+ if (
2316
+ totalTuple.valid[5] &&
2317
+ previousCumulative !== null &&
2318
+ totalTuple.values[5] < previousCumulative
2319
+ ) {
2320
+ correctionIntervals += 1;
2321
+ }
2322
+ previousCumulative = totalTuple.valid[5] ? totalTuple.values[5] : null;
2323
+
2324
+ operations.push({
2325
+ kind: "token",
2326
+ eventKey,
2327
+ turnId: currentTurnId,
2328
+ usage: usageFromTuple(lastTuple),
2329
+ occurrence,
2330
+ eventOrdinal,
2331
+ originalLikely,
2332
+ });
949
2333
  }
2334
+ } finally {
2335
+ lines.close();
950
2336
  }
951
- }
952
-
953
- function threadMetadata(threadId, stateRows, titles, parents, fallback = {}) {
954
- const row = stateRows.get(threadId);
955
- const sessionTitle = titles.get(threadId)?.title;
956
- const labels = sourceLabels(
957
- row?.thread_source,
958
- fallback.rawSource || row?.source,
2337
+ try {
2338
+ const cursorFingerprint = contentHash.digest("hex");
2339
+ await revalidateScannedRollout(
2340
+ path,
2341
+ handle,
2342
+ opened,
2343
+ expectedIdentity,
2344
+ maximumBytes,
2345
+ cursorFingerprint,
2346
+ );
2347
+ return {
2348
+ path,
2349
+ cursorFingerprint,
2350
+ continuityBytes,
2351
+ continuityFingerprint: continuityHash && continuityRemaining === 0
2352
+ ? continuityHash.digest("hex")
2353
+ : null,
2354
+ operations,
2355
+ parseErrors,
2356
+ correctionIntervals,
2357
+ invalidTokenRecords,
2358
+ invalidQuotaRecords,
2359
+ };
2360
+ } finally {
2361
+ await handle.close().catch(() => {});
2362
+ }
2363
+ }
2364
+
2365
+ function threadMetadata(
2366
+ threadId,
2367
+ stateRows,
2368
+ titles,
2369
+ parents,
2370
+ fallback = {},
2371
+ persistedRows = new Map(),
2372
+ ) {
2373
+ const row = stateRows.get(threadId);
2374
+ const persisted = persistedRows.get(threadId);
2375
+ const metadataRow = row || persisted;
2376
+ const sessionTitle = titles.get(threadId)?.title;
2377
+ const labels = sourceLabels(
2378
+ fallback.source || metadataRow?.thread_source || metadataRow?.source,
2379
+ fallback.rawSource || metadataRow?.source,
959
2380
  );
960
2381
  return {
961
2382
  id: threadId,
962
- title: safeTitle(row, sessionTitle),
963
- project: projectLabel(
964
- fallback.cwd || row?.cwd,
965
- fallback.gitOrigin || row?.git_origin_url,
2383
+ title: safeExportLabel(
2384
+ fallback.title || sessionTitle || metadataRow?.title || metadataRow?.name,
2385
+ 180,
2386
+ safeTitle(metadataRow, sessionTitle),
2387
+ ),
2388
+ project: safeExportLabel(
2389
+ fallback.project || metadataRow?.project || projectLabel(
2390
+ fallback.cwd || metadataRow?.cwd,
2391
+ fallback.gitOrigin || metadataRow?.git_origin_url,
2392
+ ),
2393
+ 160,
2394
+ "Unknown project",
2395
+ ),
2396
+ model: safeExportLabel(
2397
+ normalizeCodexCreditModel(
2398
+ fallback.model || metadataRow?.model || "unknown",
2399
+ ),
2400
+ 80,
2401
+ "unknown",
2402
+ ),
2403
+ effort: safeExportLabel(
2404
+ fallback.effort || metadataRow?.reasoning_effort || metadataRow?.effort ||
2405
+ "unknown",
2406
+ 40,
2407
+ "unknown",
966
2408
  ),
967
- model: normalizeModel(fallback.model || row?.model || "unknown"),
968
- effort: String(
969
- fallback.effort || row?.reasoning_effort || "unknown",
970
- ).slice(0, 40),
971
2409
  source: labels.source,
972
2410
  useType: labels.useType,
973
- parentThreadId: parents.get(threadId) || null,
2411
+ parentThreadId: safeExportLabel(
2412
+ fallback.parentThreadId ||
2413
+ parents.get(threadId) ||
2414
+ metadataRow?.parentThreadId,
2415
+ 80,
2416
+ null,
2417
+ ),
974
2418
  reportedCumulativeTokens:
975
- row && row.tokens_used !== null
976
- ? asFiniteNumber(row.tokens_used)
2419
+ metadataRow && isValidTokenValue(
2420
+ fallback.reportedCumulativeTokens ?? metadataRow.tokens_used ??
2421
+ metadataRow.reportedCumulativeTokens,
2422
+ )
2423
+ ? fallback.reportedCumulativeTokens ?? metadataRow.tokens_used ??
2424
+ metadataRow.reportedCumulativeTokens
977
2425
  : null,
978
- createdAt: isoFromEpoch(row?.created_at),
979
- updatedAt: isoFromEpoch(row?.updated_at),
2426
+ createdAt: fallback.createdAt || isoFromEpoch(
2427
+ metadataRow?.created_at || metadataRow?.createdAt,
2428
+ ),
2429
+ updatedAt: fallback.updatedAt || isoFromEpoch(
2430
+ metadataRow?.updated_at || metadataRow?.updatedAt,
2431
+ ),
2432
+ };
2433
+ }
2434
+
2435
+ const TIMESTAMP_CORRECTION_KIND = "before-own-turn";
2436
+
2437
+ function timestampCorrectionOrigin(value, originalTimestamp, fallbackTimestamp) {
2438
+ const parsed = parsedRangeAllocationOrigin(value);
2439
+ const origin = parsed && !Array.isArray(parsed) ? { ...parsed } : {};
2440
+ const current = origin.timestampCorrection;
2441
+ if (
2442
+ current &&
2443
+ !Array.isArray(current) &&
2444
+ Object(current) === current &&
2445
+ current.kind === TIMESTAMP_CORRECTION_KIND &&
2446
+ current.originalTimestamp === originalTimestamp &&
2447
+ current.fallbackTimestamp === fallbackTimestamp
2448
+ ) {
2449
+ return origin;
2450
+ }
2451
+ origin.timestampCorrection = {
2452
+ kind: TIMESTAMP_CORRECTION_KIND,
2453
+ originalTimestamp,
2454
+ fallbackTimestamp,
2455
+ };
2456
+ return origin;
2457
+ }
2458
+
2459
+ function timestampRepairForToken(token) {
2460
+ if (!token || typeof token !== "object") return null;
2461
+ if (
2462
+ token.identityKind != null &&
2463
+ primitiveString(token.identityKind) !== "exact"
2464
+ ) return null;
2465
+ const turnId = primitiveString(token.turnId)?.trim();
2466
+ const threadId = primitiveString(token.threadId)?.trim();
2467
+ const originThreadId = primitiveString(token.originThreadId)?.trim();
2468
+ if (!turnId || !threadId || !originThreadId || threadId !== originThreadId) {
2469
+ return null;
2470
+ }
2471
+ const tokenTimestamp = token.timestamp == null
2472
+ ? null
2473
+ : safeIso(token.timestamp, null);
2474
+ const originTimestamp = token.originTimestamp == null
2475
+ ? null
2476
+ : safeIso(token.originTimestamp, null);
2477
+ if (!tokenTimestamp || !originTimestamp) return null;
2478
+ const tokenMs = Date.parse(tokenTimestamp);
2479
+ const originMs = Date.parse(originTimestamp);
2480
+ if (!Number.isFinite(tokenMs) || !Number.isFinite(originMs)) return null;
2481
+ // There is no clock-skew tolerance here: an event before its own known turn
2482
+ // is impossible, and the turn origin is the only defensible fallback.
2483
+ if (tokenMs >= originMs) return null;
2484
+ return {
2485
+ timestamp: originTimestamp,
2486
+ rangeAllocationEstimated: true,
2487
+ rangeAllocationOrigin: timestampCorrectionOrigin(
2488
+ token.rangeAllocationOrigin,
2489
+ tokenTimestamp,
2490
+ originTimestamp,
2491
+ ),
2492
+ };
2493
+ }
2494
+
2495
+ function repairTokenTimestamp(token) {
2496
+ const repair = timestampRepairForToken(token);
2497
+ if (!repair) return token;
2498
+ return {
2499
+ ...token,
2500
+ timestamp: repair.timestamp,
2501
+ rangeAllocationEstimated: repair.rangeAllocationEstimated,
2502
+ rangeAllocationOrigin: repair.rangeAllocationOrigin,
980
2503
  };
981
2504
  }
982
2505
 
983
2506
  function resolvedOccurrence(token) {
984
- const origin = token.originThreadId == null
2507
+ const resolvedToken = repairTokenTimestamp(token);
2508
+ const origin = resolvedToken.originThreadId == null
985
2509
  ? null
986
2510
  : {
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,
2511
+ threadId: resolvedToken.originThreadId,
2512
+ timestamp: resolvedToken.originTimestamp,
2513
+ model: resolvedToken.originModel,
2514
+ effort: resolvedToken.originEffort,
2515
+ cwd: resolvedToken.originCwd,
2516
+ gitOrigin: resolvedToken.originGitOrigin,
2517
+ rawSource: resolvedToken.originRawSource,
2518
+ serviceTier: resolvedToken.originServiceTier,
995
2519
  };
996
- const occurrence = token.originalLikely || !origin ? token : origin;
2520
+ const occurrence = resolvedToken.originalLikely || !origin
2521
+ ? resolvedToken
2522
+ : origin;
997
2523
  return {
998
2524
  origin,
999
2525
  occurrence,
1000
2526
  threadId: origin?.threadId || occurrence.threadId,
1001
- timestamp: token.timestamp || origin?.timestamp,
2527
+ timestamp: resolvedToken.timestamp || origin?.timestamp,
1002
2528
  };
1003
2529
  }
1004
2530
 
2531
+ function parsedRangeAllocationOrigin(value) {
2532
+ if (value && Object(value) === value) return value;
2533
+ const source = primitiveString(value);
2534
+ if (source === null || source.length === 0) return null;
2535
+ try {
2536
+ const parsed = JSON.parse(source);
2537
+ return parsed && Object(parsed) === parsed ? parsed : null;
2538
+ } catch {
2539
+ return null;
2540
+ }
2541
+ }
2542
+
1005
2543
  function newThreadAggregate(metadata, timestamp) {
1006
2544
  return {
1007
2545
  metadata,
@@ -1017,33 +2555,131 @@ function newThreadAggregate(metadata, timestamp) {
1017
2555
  unknownBreakdownTokens: 0,
1018
2556
  rateCardCredits: 0,
1019
2557
  ratedTokens: 0,
2558
+ hasPositiveUnrated: false,
1020
2559
  eventCount: 0,
1021
2560
  };
1022
2561
  }
1023
2562
 
1024
2563
  function addToThreadAggregate(aggregate, event) {
2564
+ const allowFractional = event.rangeAllocationEstimated === true;
1025
2565
  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;
2566
+ aggregate.inputTokens = checkedTokenAdd(
2567
+ aggregate.inputTokens,
2568
+ event.inputTokens,
2569
+ { allowFractional },
2570
+ );
2571
+ aggregate.cachedInputTokens = checkedTokenAdd(
2572
+ aggregate.cachedInputTokens,
2573
+ event.cachedInputTokens,
2574
+ { allowFractional },
2575
+ );
2576
+ aggregate.outputTokens = checkedTokenAdd(
2577
+ aggregate.outputTokens,
2578
+ event.outputTokens,
2579
+ { allowFractional },
2580
+ );
2581
+ aggregate.reasoningTokens = checkedTokenAdd(
2582
+ aggregate.reasoningTokens,
2583
+ event.reasoningTokens,
2584
+ { allowFractional },
2585
+ );
2586
+ aggregate.totalTokens = checkedTokenAdd(
2587
+ aggregate.totalTokens,
2588
+ event.totalTokens,
2589
+ { allowFractional },
2590
+ );
2591
+ aggregate.toolCalls = checkedTokenAdd(
2592
+ aggregate.toolCalls,
2593
+ event.toolCalls,
2594
+ { allowFractional },
2595
+ );
2596
+ aggregate.cachedInputTokens = Math.min(
2597
+ aggregate.inputTokens,
2598
+ aggregate.cachedInputTokens,
2599
+ );
2600
+ aggregate.reasoningTokens = Math.min(
2601
+ aggregate.outputTokens,
2602
+ aggregate.reasoningTokens,
2603
+ );
2604
+ aggregate.eventCount = checkedTokenAdd(
2605
+ aggregate.eventCount,
2606
+ Number.isFinite(event.callCount) && event.callCount > 0
2607
+ ? event.callCount
2608
+ : 1,
2609
+ { allowFractional: true },
2610
+ );
1033
2611
  if (event.breakdownAvailable) {
1034
- aggregate.detailedTokens += event.totalTokens;
2612
+ aggregate.detailedTokens = checkedTokenAdd(
2613
+ aggregate.detailedTokens,
2614
+ event.totalTokens,
2615
+ { allowFractional },
2616
+ );
1035
2617
  } else {
1036
- aggregate.unknownBreakdownTokens += event.totalTokens;
2618
+ aggregate.unknownBreakdownTokens = checkedTokenAdd(
2619
+ aggregate.unknownBreakdownTokens,
2620
+ event.totalTokens,
2621
+ { allowFractional },
2622
+ );
1037
2623
  }
1038
2624
  if (event.rateCardCredits !== null) {
1039
- aggregate.rateCardCredits += event.rateCardCredits;
1040
- aggregate.ratedTokens += event.totalTokens;
2625
+ aggregate.rateCardCredits = checkedFiniteAdd(
2626
+ aggregate.rateCardCredits,
2627
+ event.rateCardCredits,
2628
+ );
2629
+ aggregate.ratedTokens = checkedTokenAdd(
2630
+ aggregate.ratedTokens,
2631
+ event.totalTokens,
2632
+ );
2633
+ } else if (event.totalTokens > 0) {
2634
+ aggregate.hasPositiveUnrated = true;
1041
2635
  }
1042
2636
  }
1043
2637
 
2638
+ function representableUnknownBreakdownTokens(
2639
+ observedTokens,
2640
+ detailedTokens,
2641
+ unknownBreakdownTokens,
2642
+ ) {
2643
+ if (
2644
+ observedTokens >= MAX_SAFE_TOKEN_COUNT &&
2645
+ detailedTokens >= MAX_SAFE_TOKEN_COUNT
2646
+ ) {
2647
+ return unknownBreakdownTokens;
2648
+ }
2649
+ return Math.min(
2650
+ unknownBreakdownTokens,
2651
+ Math.max(0, observedTokens - detailedTokens),
2652
+ );
2653
+ }
2654
+
1044
2655
  function buildSnapshot(context, options, titles) {
1045
2656
  context.spool.finishWrites();
1046
- const sinceMs = options.since ? options.since.getTime() : -Infinity;
2657
+ const scope = collectionScope(options);
2658
+ const sinceMs = scope.since === null ? -Infinity : Date.parse(scope.since);
2659
+
2660
+ function scopedTokenTimestamp(token, timestamp) {
2661
+ const timestampMs = Date.parse(timestamp);
2662
+ if (!Number.isFinite(timestampMs)) return null;
2663
+ if (sinceMs === -Infinity || timestampMs >= sinceMs) return timestamp;
2664
+ const range = parsedRangeAllocationOrigin(token.rangeAllocationOrigin);
2665
+ const bounds = [timestampMs, range?.startAt, range?.endAt]
2666
+ .map((value) => Date.parse(String(value ?? "")))
2667
+ .filter(Number.isFinite);
2668
+ const endExclusive = Math.max(...bounds) + 1;
2669
+ if (endExclusive <= sinceMs) return null;
2670
+ const startMs = Math.max(sinceMs, Math.min(...bounds));
2671
+ return new Date(Math.round(
2672
+ startMs + (endExclusive - startMs - 1) / 2,
2673
+ )).toISOString();
2674
+ }
2675
+
2676
+ function scopedEventFragments(event) {
2677
+ if (sinceMs === -Infinity) return [event];
2678
+ return splitUsageBucketsAtBoundaries([event], [sinceMs]).filter(
2679
+ (fragment) => Date.parse(fragment.timestamp) >= sinceMs,
2680
+ );
2681
+ }
2682
+
1047
2683
  const lastEventKeyByTurn = new Map();
1048
2684
  let earliestEventAt = null;
1049
2685
  let latestEventAt = null;
@@ -1052,9 +2688,20 @@ function buildSnapshot(context, options, titles) {
1052
2688
  // receives each turn's tool calls without retaining the usage rows.
1053
2689
  for (const token of context.spool.tokenRows()) {
1054
2690
  const { threadId, timestamp } = resolvedOccurrence(token);
1055
- if (Date.parse(timestamp) < sinceMs) continue;
1056
- earliestEventAt ||= timestamp;
1057
- latestEventAt = timestamp;
2691
+ const scopedTimestamp = scopedTokenTimestamp(token, timestamp);
2692
+ if (!scopedTimestamp) continue;
2693
+ if (
2694
+ earliestEventAt === null ||
2695
+ Date.parse(scopedTimestamp) < Date.parse(earliestEventAt)
2696
+ ) {
2697
+ earliestEventAt = scopedTimestamp;
2698
+ }
2699
+ if (
2700
+ latestEventAt === null ||
2701
+ Date.parse(scopedTimestamp) > Date.parse(latestEventAt)
2702
+ ) {
2703
+ latestEventAt = scopedTimestamp;
2704
+ }
1058
2705
  const key = token.turnId || `thread:${threadId}`;
1059
2706
  lastEventKeyByTurn.set(key, token.eventKey);
1060
2707
  }
@@ -1066,22 +2713,30 @@ function buildSnapshot(context, options, titles) {
1066
2713
  }
1067
2714
 
1068
2715
  const threadAggregates = new Map();
2716
+ const coverage = {
2717
+ detailedTokens: 0,
2718
+ unknownBreakdownTokens: 0,
2719
+ };
1069
2720
  let observedTokens = 0;
1070
- let detailedTokens = 0;
1071
2721
  let legacyHeuristicEvents = 0;
1072
2722
  let observedModelCalls = 0;
2723
+ let exactObservedModelCalls = 0;
2724
+ let migratedCompactedCalls = 0;
2725
+ let migratedCompactedTokens = 0;
1073
2726
 
1074
2727
  function* compactableEvents() {
1075
2728
  for (const token of context.spool.tokenRows()) {
1076
2729
  const { origin, occurrence, threadId, timestamp } = resolvedOccurrence(token);
1077
- if (Date.parse(timestamp) < sinceMs) continue;
1078
2730
  const metadata = threadMetadata(
1079
2731
  threadId,
1080
2732
  context.stateRows,
1081
2733
  titles,
1082
2734
  context.parents,
1083
2735
  origin || occurrence,
2736
+ context.persistedThreads,
1084
2737
  );
2738
+ const rangeAllocationEstimated =
2739
+ Number(token.rangeAllocationEstimated) === 1;
1085
2740
  const usage = {
1086
2741
  inputTokens: Number(token.inputTokens),
1087
2742
  cachedInputTokens: Number(token.cachedInputTokens),
@@ -1089,45 +2744,106 @@ function buildSnapshot(context, options, titles) {
1089
2744
  outputTokens: Number(token.outputTokens),
1090
2745
  reasoningTokens: Number(token.reasoningTokens),
1091
2746
  totalTokens: Number(token.totalTokens),
2747
+ componentsValid: Number(token.componentsValid) === 1,
2748
+ toolCalls: Number(token.toolCalls),
2749
+ callCount: Number(token.callCount),
2750
+ detailedCallCount: Number(token.detailedCallCount),
2751
+ inputCallCount: Number(token.inputCallCount),
2752
+ rangeAllocationEstimated,
1092
2753
  };
1093
- const breakdownAvailable = hasDetailedBreakdown(usage);
1094
- const serviceTier = occurrence.serviceTier || null;
1095
- const baseCredits = creditsForUsage(metadata.model, usage);
2754
+ const rangeAllocationOrigin = parsedRangeAllocationOrigin(
2755
+ token.rangeAllocationOrigin,
2756
+ );
2757
+ const storedRateCardCredits = token.rateCardCredits == null
2758
+ ? null
2759
+ : Number(token.rateCardCredits);
2760
+ const breakdownAvailable = hasDetailedTokenBreakdown(usage);
2761
+ const serviceTier = safeExportLabel(occurrence.serviceTier, 40, null);
1096
2762
  const turnKey = token.turnId || `thread:${threadId}`;
2763
+ const sourceToolCalls = lastEventKeyByTurn.get(turnKey) === token.eventKey
2764
+ ? toolCounts.get(turnKey) || 0
2765
+ : 0;
2766
+ const persistedToolCalls = Number.isFinite(Number(token.toolCalls))
2767
+ ? Math.max(0, Number(token.toolCalls))
2768
+ : 0;
1097
2769
  const event = {
1098
2770
  ...usage,
1099
2771
  timestamp,
2772
+ startAt: rangeAllocationOrigin?.startAt || timestamp,
2773
+ endAt: rangeAllocationOrigin?.endAt || timestamp,
1100
2774
  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,
2775
+ project: token.project || metadata.project,
2776
+ model: token.displayModel || metadata.model,
2777
+ rateCardModel: token.rateCardModel || occurrence.model || metadata.model,
2778
+ effort: token.effort || metadata.effort,
2779
+ source: token.sourceLabel || metadata.source,
2780
+ useType: token.useType || metadata.useType,
2781
+ toolCalls: Math.max(sourceToolCalls, persistedToolCalls),
1110
2782
  serviceTier,
1111
- rateCardCredits:
1112
- baseCredits === null
1113
- ? null
1114
- : serviceTier === "priority"
1115
- ? baseCredits * FAST_MODE_MULTIPLIER
1116
- : baseCredits,
2783
+ // Keep the canonical model for snapshot display, but preserve the raw
2784
+ // identifier for multiplier selection so unsupported aliases do not
2785
+ // inherit a canonical Daybreak multiplier.
2786
+ rateCardCredits: Number.isFinite(storedRateCardCredits)
2787
+ ? storedRateCardCredits
2788
+ : calculateCodexPurchasedCredits({
2789
+ model: occurrence.model || metadata.model,
2790
+ serviceTier,
2791
+ usage,
2792
+ }),
1117
2793
  breakdownAvailable,
2794
+ rangeAllocationEstimated,
2795
+ rangeAllocationOrigin,
1118
2796
  };
1119
2797
 
1120
- let aggregate = threadAggregates.get(threadId);
1121
- if (!aggregate) {
1122
- aggregate = newThreadAggregate(metadata, timestamp);
1123
- threadAggregates.set(threadId, aggregate);
2798
+ for (const scopedEvent of scopedEventFragments(event)) {
2799
+ let aggregate = threadAggregates.get(threadId);
2800
+ if (!aggregate) {
2801
+ aggregate = newThreadAggregate(metadata, scopedEvent.timestamp);
2802
+ threadAggregates.set(threadId, aggregate);
2803
+ }
2804
+ addToThreadAggregate(aggregate, scopedEvent);
2805
+ observedTokens = checkedTokenAdd(
2806
+ observedTokens,
2807
+ scopedEvent.totalTokens,
2808
+ { allowFractional: scopedEvent.rangeAllocationEstimated === true },
2809
+ );
2810
+ checkedTokenPartitionAdd(coverage, scopedEvent.totalTokens, {
2811
+ detailed: scopedEvent.breakdownAvailable,
2812
+ });
2813
+ if (token.identityKind === "exact" && !token.turnId) {
2814
+ legacyHeuristicEvents += 1;
2815
+ }
2816
+ observedModelCalls = checkedTokenAdd(
2817
+ observedModelCalls,
2818
+ Number.isFinite(scopedEvent.callCount) && scopedEvent.callCount > 0
2819
+ ? scopedEvent.callCount
2820
+ : 1,
2821
+ { allowFractional: true },
2822
+ );
2823
+ if (token.identityKind === "migrated_compacted") {
2824
+ migratedCompactedCalls = checkedTokenAdd(
2825
+ migratedCompactedCalls,
2826
+ Number.isFinite(scopedEvent.callCount) && scopedEvent.callCount > 0
2827
+ ? scopedEvent.callCount
2828
+ : 1,
2829
+ { allowFractional: true },
2830
+ );
2831
+ migratedCompactedTokens = checkedTokenAdd(
2832
+ migratedCompactedTokens,
2833
+ scopedEvent.totalTokens,
2834
+ { allowFractional: scopedEvent.rangeAllocationEstimated === true },
2835
+ );
2836
+ } else {
2837
+ exactObservedModelCalls = checkedTokenAdd(
2838
+ exactObservedModelCalls,
2839
+ Number.isFinite(scopedEvent.callCount) && scopedEvent.callCount > 0
2840
+ ? scopedEvent.callCount
2841
+ : 1,
2842
+ { allowFractional: true },
2843
+ );
2844
+ }
2845
+ yield scopedEvent;
1124
2846
  }
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
2847
  }
1132
2848
  }
1133
2849
 
@@ -1138,8 +2854,15 @@ function buildSnapshot(context, options, titles) {
1138
2854
  toolCounts.clear();
1139
2855
  lastEventKeyByTurn.clear();
1140
2856
 
1141
- const allThreadIds = new Set(context.stateRows.keys());
2857
+ const allThreadIds = new Set(
2858
+ scope.since === null ? context.stateRows.keys() : threadAggregates.keys(),
2859
+ );
1142
2860
  for (const threadId of threadAggregates.keys()) allThreadIds.add(threadId);
2861
+ if (scope.since === null) {
2862
+ for (const threadId of context.persistedThreads.keys()) {
2863
+ allThreadIds.add(threadId);
2864
+ }
2865
+ }
1143
2866
  const threads = [];
1144
2867
  for (const threadId of allThreadIds) {
1145
2868
  const aggregate = threadAggregates.get(threadId);
@@ -1148,13 +2871,25 @@ function buildSnapshot(context, options, titles) {
1148
2871
  context.stateRows,
1149
2872
  titles,
1150
2873
  context.parents,
2874
+ {},
2875
+ context.persistedThreads,
1151
2876
  );
1152
- if (!aggregate && !(metadata.reportedCumulativeTokens > 0)) continue;
2877
+ if (
2878
+ !aggregate &&
2879
+ (scope.since !== null || !(metadata.reportedCumulativeTokens > 0))
2880
+ ) continue;
1153
2881
  const eventCount = aggregate?.eventCount || 0;
2882
+ const unknownBreakdownTokens = aggregate
2883
+ ? representableUnknownBreakdownTokens(
2884
+ aggregate.totalTokens,
2885
+ aggregate.detailedTokens,
2886
+ aggregate.unknownBreakdownTokens,
2887
+ )
2888
+ : 0;
1154
2889
  const threadCoverage =
1155
2890
  eventCount === 0
1156
2891
  ? "unresolved"
1157
- : aggregate.detailedTokens === aggregate.totalTokens
2892
+ : aggregate.unknownBreakdownTokens === 0
1158
2893
  ? "complete"
1159
2894
  : aggregate.detailedTokens > 0
1160
2895
  ? "partial"
@@ -1172,7 +2907,7 @@ function buildSnapshot(context, options, titles) {
1172
2907
  lastActiveAt: aggregate?.lastActiveAt || metadata.updatedAt,
1173
2908
  totalTokens: aggregate?.totalTokens || 0,
1174
2909
  detailedTokens: aggregate?.detailedTokens || 0,
1175
- unknownBreakdownTokens: aggregate?.unknownBreakdownTokens || 0,
2910
+ unknownBreakdownTokens,
1176
2911
  reportedCumulativeTokens: metadata.reportedCumulativeTokens,
1177
2912
  inputTokens: aggregate?.inputTokens || 0,
1178
2913
  cachedInputTokens: aggregate?.cachedInputTokens || 0,
@@ -1180,6 +2915,7 @@ function buildSnapshot(context, options, titles) {
1180
2915
  reasoningTokens: aggregate?.reasoningTokens || 0,
1181
2916
  rateCardCredits:
1182
2917
  aggregate?.totalTokens > 0 &&
2918
+ !aggregate.hasPositiveUnrated &&
1183
2919
  aggregate.ratedTokens === aggregate.totalTokens
1184
2920
  ? aggregate.rateCardCredits
1185
2921
  : null,
@@ -1200,19 +2936,29 @@ function buildSnapshot(context, options, titles) {
1200
2936
  const exported = { ...quota };
1201
2937
  delete exported.turnId;
1202
2938
  delete exported.originalLikely;
2939
+ if (
2940
+ sinceMs !== -Infinity &&
2941
+ Date.parse(exported.timestamp) < sinceMs &&
2942
+ Date.parse(exported.lastSeenAt) >= sinceMs
2943
+ ) {
2944
+ exported.timestamp = new Date(sinceMs).toISOString();
2945
+ }
1203
2946
  return exported;
1204
2947
  })
1205
2948
  .filter((quota) => {
1206
- if (!options.since) return true;
1207
- return Date.parse(quota.timestamp) >= sinceMs;
2949
+ return Date.parse(quota.lastSeenAt) >= sinceMs;
1208
2950
  })
1209
2951
  .sort(
1210
2952
  (left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
1211
2953
  );
1212
2954
 
1213
- const unknownBreakdownTokens = observedTokens - detailedTokens;
2955
+ const { detailedTokens, unknownBreakdownTokens } = coverage;
2956
+ const coverageTokens = detailedTokens + unknownBreakdownTokens;
1214
2957
  const stateCounterSumNonAdditive = threads.reduce(
1215
- (sum, thread) => sum + (thread.reportedCumulativeTokens || 0),
2958
+ (sum, thread) => checkedTokenAdd(
2959
+ sum,
2960
+ thread.reportedCumulativeTokens || 0,
2961
+ ),
1216
2962
  0,
1217
2963
  );
1218
2964
  const unresolvedThreadCounters = threads.filter(
@@ -1224,30 +2970,36 @@ function buildSnapshot(context, options, titles) {
1224
2970
  (quota) => quota.windowMinutes === WEEK_MINUTES,
1225
2971
  );
1226
2972
  const accountWideWeekly = weeklyCandidates.filter(
1227
- (quota) => !quota.limitName,
2973
+ (quota) => quota.scope === "account",
1228
2974
  );
1229
- const weekly = [
1230
- ...(accountWideWeekly.length ? accountWideWeekly : weeklyCandidates),
1231
- ]
2975
+ const weekly = accountWideWeekly
1232
2976
  .sort(
1233
- (left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp),
2977
+ (left, right) => Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt),
1234
2978
  )[0];
1235
2979
  const weeklyStart = weekly
1236
2980
  ? (weekly.resetsAt - weekly.windowMinutes * 60) * 1_000
1237
2981
  : null;
1238
2982
  const completeSinceWindowStart = Boolean(
1239
- weeklyStart &&
2983
+ scope.since === null &&
2984
+ scope.includeArchived &&
2985
+ weeklyStart &&
1240
2986
  earliestEventAt &&
1241
2987
  Date.parse(earliestEventAt) <= weeklyStart &&
1242
- context.parseErrors === 0,
2988
+ context.parseErrors === 0 &&
2989
+ context.invalidTokenRecords === 0 &&
2990
+ context.invalidQuotaRecords === 0 &&
2991
+ !context.durableLedger?.sourceSummary?.sourceIncomplete &&
2992
+ migratedCompactedCalls === 0,
1243
2993
  );
1244
2994
 
1245
2995
  const notes = [
1246
2996
  "Observed totals sum globally de-duplicated last_token_usage model-call events.",
1247
2997
  "The snapshot keeps exact recent calls and compacts older usage into time buckets; token, cache, model, project, tool-call, and thread totals remain additive.",
1248
2998
  "Codex thread counters are retained only as non-additive reference values because forks and subagents inherit cumulative history.",
1249
- "Historical rollout files can be pruned; a state-only counter cannot reveal that thread's unique token contribution.",
2999
+ "Historical rollout files can be pruned; the private durable ledger keeps committed observations without making a state-only counter additive.",
1250
3000
  "Legacy events without turn IDs use a high-specificity usage-signature heuristic and are labeled in the ledger.",
3001
+ "Source files are mutable inputs. Missing or tombstoned sources never delete committed observations and are surfaced in coverage.",
3002
+ "Migrated compacted rows preserve totals but do not invent exact event or turn identities; estimated values remain marked.",
1251
3003
  ];
1252
3004
 
1253
3005
  return {
@@ -1256,16 +3008,48 @@ function buildSnapshot(context, options, titles) {
1256
3008
  label: "Local Codex snapshot",
1257
3009
  provenance: {
1258
3010
  kind: "codex-local-metadata",
3011
+ collection: scope,
3012
+ sourceCutoffAt: context.sourceInventory.cutoffAt,
1259
3013
  privacy:
1260
- "Contains token metadata and Codex display titles only; credential fields, message bodies, reasoning text, tool payloads, and full local paths are not exported. Display titles may contain user-written text.",
1261
- rateCardAsOf: RATE_CARD_AS_OF,
1262
- rateCardUrl: RATE_CARD_URL,
3014
+ "Contains token metadata and Codex display titles only; credential fields, message bodies, reasoning text, tool payloads, and full local paths are not exported. Path-like source labels are categorized and local path tokens in other labels are redacted; unrelated user-written title text may remain.",
3015
+ rateCardKind: CODEX_CREDIT_RATE_CARD_KIND,
3016
+ rateCardAsOf: CODEX_CREDIT_RATE_CARD_AS_OF,
3017
+ rateCardUrl: CODEX_CREDIT_RATE_CARD_URL,
3018
+ rateCardScope: CODEX_CREDIT_RATE_CARD_SCOPE,
3019
+ },
3020
+ metadata: {
3021
+ stateDatabase: context.stateDatabase,
3022
+ durableLedger: context.durableLedger
3023
+ ? {
3024
+ schemaVersion: DURABLE_LEDGER_SCHEMA_VERSION,
3025
+ revision: context.durableLedger.revision,
3026
+ quotaIdentityContract: QUOTA_IDENTITY_CONTRACT_VERSION,
3027
+ codexHomeFingerprint: codexHomeFingerprint(context.codexHome),
3028
+ retentionDays: DURABLE_LEDGER_RETENTION_DAYS,
3029
+ compactedRetentionDays: DURABLE_LEDGER_COMPACTED_RETENTION_DAYS,
3030
+ legacySnapshotStatus:
3031
+ context.durableLedger.legacySnapshotStatus || null,
3032
+ legacyQuotaStatus:
3033
+ context.durableLedger.legacyQuotaStatus || null,
3034
+ legacyQuotaRowsSkipped:
3035
+ context.durableLedger.legacyQuotaRowsSkipped || 0,
3036
+ quotaContractUpgradeStatus:
3037
+ context.durableLedger.quotaContractUpgradeStatus || null,
3038
+ quotaContractRowsDiscarded:
3039
+ (context.durableLedger.quotaContractMigratedRowsDiscarded || 0) +
3040
+ (context.durableLedger.quotaContractExactRowsDiscarded || 0),
3041
+ }
3042
+ : null,
1263
3043
  },
1264
3044
  coverage: {
1265
3045
  filesScanned: context.filesScanned,
1266
3046
  bytesScanned: context.bytesScanned,
3047
+ filesReused: context.filesReused,
3048
+ bytesReused: context.bytesReused,
1267
3049
  parseErrors: context.parseErrors,
1268
3050
  duplicateEventsSkipped: context.duplicateEventsSkipped,
3051
+ invalidTokenRecords: context.invalidTokenRecords,
3052
+ invalidQuotaRecords: context.invalidQuotaRecords,
1269
3053
  correctionIntervals: context.correctionIntervals,
1270
3054
  observedTokens,
1271
3055
  detailedTokens,
@@ -1274,10 +3058,35 @@ function buildSnapshot(context, options, titles) {
1274
3058
  unresolvedThreadCounters,
1275
3059
  legacyHeuristicEvents,
1276
3060
  observedModelCalls,
3061
+ exactObservedModelCalls,
3062
+ migratedCompactedCalls,
3063
+ migratedCompactedTokens,
3064
+ migratedCompactedBuckets:
3065
+ context.durableLedger?.migratedUsageRows || 0,
3066
+ migratedQuotaObservations:
3067
+ context.durableLedger?.migratedQuotaRows || 0,
3068
+ compactedUsageBuckets:
3069
+ context.durableLedger?.compactedUsageRows || 0,
3070
+ legacySnapshotStatus:
3071
+ context.durableLedger?.legacySnapshotStatus || null,
3072
+ legacyQuotaStatus:
3073
+ context.durableLedger?.legacyQuotaStatus || null,
3074
+ legacyQuotaRowsSkipped:
3075
+ context.durableLedger?.legacyQuotaRowsSkipped || 0,
3076
+ sourceIncomplete: Boolean(
3077
+ context.durableLedger?.sourceSummary?.sourceIncomplete,
3078
+ ),
3079
+ sourceStates: context.durableLedger?.sourceSummary?.counts || {
3080
+ active: 0,
3081
+ archived: 0,
3082
+ missing: 0,
3083
+ tombstoned: 0,
3084
+ changed: 0,
3085
+ },
1277
3086
  usageBucketCount: usageStats.bucketCount,
1278
3087
  maximumUsageResolutionSeconds: usageStats.maximumResolutionSeconds,
1279
3088
  detailedPercent:
1280
- observedTokens > 0 ? (detailedTokens / observedTokens) * 100 : 100,
3089
+ coverageTokens > 0 ? (detailedTokens / coverageTokens) * 100 : 100,
1281
3090
  earliestEventAt,
1282
3091
  latestEventAt,
1283
3092
  completeSinceWindowStart,
@@ -1289,65 +3098,1062 @@ function buildSnapshot(context, options, titles) {
1289
3098
  };
1290
3099
  }
1291
3100
 
1292
- export async function collectUsage(options, onProgress = () => {}) {
1293
- const state = await readState(options.codexHome);
1294
- const titles = await readSessionTitles(
1295
- resolve(options.codexHome, "session_index.jsonl"),
1296
- );
1297
- const roots = [resolve(options.codexHome, "sessions")];
1298
- if (options.includeArchived) {
1299
- roots.push(resolve(options.codexHome, "archived_sessions"));
1300
- }
1301
- const files = (
1302
- await Promise.all(roots.map((root) => listJsonlFiles(root)))
1303
- )
1304
- .flat()
1305
- .sort();
1306
- const sizes = await Promise.all(files.map((path) => stat(path)));
1307
- const spool = await createUsageSpool();
1308
-
1309
- const context = {
3101
+ function createCollectionContext(state, spool, options) {
3102
+ return {
3103
+ codexHome: options.codexHome,
1310
3104
  stateRows: state.rows,
1311
3105
  parents: state.parents,
3106
+ stateDatabase: state.metadata,
1312
3107
  quotas: new Map(),
3108
+ quotaLabels: new Map(),
3109
+ eventSources: new Map(),
3110
+ eventPositions: [],
3111
+ callSources: new Map(),
3112
+ quotaSources: new Map(),
3113
+ quotaSourceBounds: new Map(),
3114
+ persistedThreads: new Map(),
3115
+ trustedScannedSourceIds: new Set(),
3116
+ durableLedger: null,
1313
3117
  spool,
1314
3118
  filesScanned: 0,
1315
3119
  bytesScanned: 0,
3120
+ filesReused: 0,
3121
+ bytesReused: 0,
1316
3122
  parseErrors: 0,
1317
3123
  duplicateEventsSkipped: 0,
3124
+ invalidTokenRecords: 0,
3125
+ invalidQuotaRecords: 0,
1318
3126
  correctionIntervals: 0,
3127
+ uncertainSourceIds: new Set(),
3128
+ quotaUncertainSourceIds: new Set(),
3129
+ // Capture the complete observed ledger even when the requested snapshot
3130
+ // is filtered. buildSnapshot applies the requested scope on materialize.
3131
+ sinceMs: -Infinity,
1319
3132
  };
3133
+ }
1320
3134
 
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],
3135
+ function sourceMatchesDurableCursor(entry, prior) {
3136
+ if (
3137
+ !prior ||
3138
+ prior.reconciliationPending ||
3139
+ prior.quotaReconciliationPending
3140
+ ) return false;
3141
+ const size = Number(entry.size);
3142
+ const cursorBytes = Number(prior.cursorBytes);
3143
+ const device = entry.dev == null ? null : Number(entry.dev);
3144
+ const inode = entry.ino == null ? null : Number(entry.ino);
3145
+ return (
3146
+ Number.isSafeInteger(size) &&
3147
+ size >= 0 &&
3148
+ Number.isSafeInteger(cursorBytes) &&
3149
+ cursorBytes >= 0 &&
3150
+ cursorBytes <= size &&
3151
+ size === Number(prior.sizeBytes) &&
3152
+ Number(entry.mtimeMs) === Number(prior.mtimeMs) &&
3153
+ Number(entry.ctimeMs) === Number(prior.ctimeMs) &&
3154
+ device === prior.device &&
3155
+ inode === prior.inode &&
3156
+ prior.cursorFingerprint.length === 64
3157
+ );
3158
+ }
3159
+
3160
+ function durableLedgerRelatedPaths(options) {
3161
+ const ledgerPath = resolveDurableLedgerPath(options);
3162
+ return [
3163
+ ledgerPath,
3164
+ `${ledgerPath}.writer-lock.sqlite`,
3165
+ `${ledgerPath}-journal`,
3166
+ `${ledgerPath}-wal`,
3167
+ `${ledgerPath}-shm`,
3168
+ ];
3169
+ }
3170
+
3171
+ function addSourceAssociation(map, key, sourceId) {
3172
+ if (!sourceId) return;
3173
+ const sourceIds = map.get(key) || new Set();
3174
+ sourceIds.add(sourceId);
3175
+ map.set(key, sourceIds);
3176
+ }
3177
+
3178
+ function addQuotaSourceBounds(map, key, sourceId, timestamp) {
3179
+ if (!sourceId) return;
3180
+ const bySource = map.get(key) || new Map();
3181
+ const current = bySource.get(sourceId);
3182
+ bySource.set(sourceId, {
3183
+ firstSeenAt: current && current.firstSeenAt < timestamp
3184
+ ? current.firstSeenAt
3185
+ : timestamp,
3186
+ lastSeenAt: current && current.lastSeenAt > timestamp
3187
+ ? current.lastSeenAt
3188
+ : timestamp,
3189
+ });
3190
+ map.set(key, bySource);
3191
+ }
3192
+
3193
+ function eventMetadataForToken(token, context, titles) {
3194
+ const { origin, occurrence, threadId } = resolvedOccurrence(token);
3195
+ const metadata = threadMetadata(
3196
+ threadId,
3197
+ context.stateRows,
3198
+ titles,
3199
+ context.parents,
3200
+ origin || occurrence,
3201
+ context.persistedThreads,
3202
+ );
3203
+ return {
3204
+ project: token.project || metadata.project,
3205
+ model: token.displayModel || metadata.model,
3206
+ source: token.sourceLabel || metadata.source,
3207
+ useType: token.useType || metadata.useType,
3208
+ rateCardModel: token.rateCardModel || occurrence.model || metadata.model,
3209
+ };
3210
+ }
3211
+
3212
+ function threadRecordsForLedger(context, titles) {
3213
+ const records = new Map();
3214
+ const remember = (threadId, fallback = {}) => {
3215
+ if (!threadId) return;
3216
+ const metadata = threadMetadata(
3217
+ threadId,
3218
+ context.stateRows,
3219
+ titles,
3220
+ context.parents,
3221
+ fallback,
3222
+ context.persistedThreads,
3223
+ );
3224
+ records.set(threadId, metadata);
3225
+ };
3226
+ for (const threadId of context.stateRows.keys()) remember(threadId);
3227
+ for (const threadId of context.parents.keys()) remember(threadId);
3228
+ for (const [threadId, persisted] of context.persistedThreads) {
3229
+ const sessionTitle = titles.get(threadId)?.title;
3230
+ // A title-only index change is a real metadata observation. Untouched
3231
+ // persisted rows remain read-only fallbacks so retention can expire them.
3232
+ if (sessionTitle == null) continue;
3233
+ const current = threadMetadata(
3234
+ threadId,
3235
+ context.stateRows,
3236
+ titles,
3237
+ context.parents,
3238
+ {},
3239
+ context.persistedThreads,
3240
+ );
3241
+ if (current.title === persisted.title) continue;
3242
+ remember(threadId);
3243
+ }
3244
+ for (const token of context.spool.tokenRows()) {
3245
+ const { origin, occurrence, threadId } = resolvedOccurrence(token);
3246
+ remember(threadId, origin || occurrence);
3247
+ }
3248
+ return [...records.values()];
3249
+ }
3250
+
3251
+ const DURABLE_USAGE_FIELDS = Object.freeze([
3252
+ "inputTokens",
3253
+ "cachedInputTokens",
3254
+ "cacheWriteInputTokens",
3255
+ "outputTokens",
3256
+ "reasoningTokens",
3257
+ "totalTokens",
3258
+ "toolCalls",
3259
+ "rateCardCredits",
3260
+ ]);
3261
+ const DURABLE_COUNT_FIELDS = Object.freeze([
3262
+ "callCount",
3263
+ "detailedCallCount",
3264
+ "inputCallCount",
3265
+ ]);
3266
+
3267
+ function subtractCompactedRows(
3268
+ row,
3269
+ currentRowsByEventKey,
3270
+ currentCallKeys,
3271
+ ) {
3272
+ const compactedEventKeys = row.compactedEventKeys;
3273
+ if (
3274
+ compactedEventKeys == null ||
3275
+ !compactedEventKeys[Symbol.iterator]
3276
+ ) {
3277
+ return row;
3278
+ }
3279
+ const matches = [];
3280
+ for (const eventKey of compactedEventKeys) {
3281
+ const current = currentRowsByEventKey.get(String(eventKey));
3282
+ if (current) matches.push(current);
3283
+ }
3284
+ const ownedToolCallKeys = Array.isArray(row.toolCallKeys)
3285
+ ? row.toolCallKeys.map((callKey) => String(callKey))
3286
+ : null;
3287
+ const activeToolCallKeys = ownedToolCallKeys
3288
+ ? ownedToolCallKeys.filter((callKey) => currentCallKeys.has(callKey))
3289
+ : [];
3290
+ if (!matches.length && !activeToolCallKeys.length) return row;
3291
+ const residual = { ...row };
3292
+ for (const field of DURABLE_USAGE_FIELDS) {
3293
+ if (field === "rateCardCredits") {
3294
+ const known = matches.every((match) => match.rateCardCredits != null);
3295
+ residual[field] = known
3296
+ ? Math.max(
3297
+ 0,
3298
+ Number(row[field] || 0) - matches.reduce(
3299
+ (sum, match) => sum + Number(match.rateCardCredits || 0),
3300
+ 0,
3301
+ ),
3302
+ )
3303
+ : row[field];
3304
+ continue;
3305
+ }
3306
+ residual[field] = Math.max(
3307
+ 0,
3308
+ Number(row[field] || 0) - matches.reduce(
3309
+ (sum, match) => sum + Number(match[field] || 0),
3310
+ 0,
3311
+ ),
3312
+ );
3313
+ }
3314
+ if (ownedToolCallKeys) {
3315
+ residual.toolCalls = Math.max(
3316
+ 0,
3317
+ Number(row.toolCalls || 0) - activeToolCallKeys.length,
3318
+ );
3319
+ residual.toolCallKeys = ownedToolCallKeys.filter(
3320
+ (callKey) => !currentCallKeys.has(callKey),
3321
+ );
3322
+ }
3323
+ for (const field of DURABLE_COUNT_FIELDS) {
3324
+ residual[field] = Math.max(
3325
+ 0,
3326
+ Number(row[field] || 0) - matches.reduce(
3327
+ (sum, match) => sum + Number(match[field] || 0),
3328
+ 0,
3329
+ ),
3330
+ );
3331
+ }
3332
+ if (residual.totalTokens <= 0 && residual.callCount <= 0) return null;
3333
+ const origin = row.rangeAllocationOrigin &&
3334
+ Object(row.rangeAllocationOrigin) === row.rangeAllocationOrigin
3335
+ ? { ...row.rangeAllocationOrigin }
3336
+ : null;
3337
+ if (origin) {
3338
+ origin.inputTokens = residual.inputTokens;
3339
+ origin.totalTokens = residual.totalTokens;
3340
+ origin.callCount = residual.callCount;
3341
+ residual.rangeAllocationOrigin = origin;
3342
+ }
3343
+ residual.compactedEventKeys = [];
3344
+ return residual;
3345
+ }
3346
+
3347
+ function createDurableSpoolSink(context) {
3348
+ // Keep the durable read independent from the ingest metadata map. These
3349
+ // lookup sets are allocated only after ingest has finished and only when a
3350
+ // compacted or tool row actually needs them.
3351
+ let currentRowsByEventKey = null;
3352
+ let currentCallKeys = null;
3353
+ const ensureCurrentCallKeys = () => {
3354
+ if (currentCallKeys) return;
3355
+ currentCallKeys = new Set();
3356
+ for (const call of context.spool.callRows()) {
3357
+ currentCallKeys.add(String(call.callKey));
3358
+ }
3359
+ };
3360
+ const ensureCurrentRows = () => {
3361
+ if (currentRowsByEventKey) return;
3362
+ currentRowsByEventKey = new Map();
3363
+ for (const current of context.spool.tokenRows()) {
3364
+ currentRowsByEventKey.set(String(current.eventKey), current);
3365
+ }
3366
+ ensureCurrentCallKeys();
3367
+ }
3368
+
3369
+ function consumeUsage(storedRow) {
3370
+ if (storedRow.identityKind === "compacted") ensureCurrentRows();
3371
+ const row = storedRow.identityKind === "compacted"
3372
+ ? subtractCompactedRows(
3373
+ storedRow,
3374
+ currentRowsByEventKey,
3375
+ currentCallKeys,
3376
+ )
3377
+ : repairTokenTimestamp(storedRow);
3378
+ if (!row) return;
3379
+ if (row.originThreadId) {
3380
+ context.spool.insertOrigin({
3381
+ turnId: row.turnId,
3382
+ threadId: row.originThreadId,
3383
+ timestamp: row.originTimestamp || row.timestamp,
3384
+ deltaMs: 0,
3385
+ model: row.originModel || row.model,
3386
+ effort: row.originEffort || row.effort,
3387
+ cwd: row.originCwd || row.cwd,
3388
+ gitOrigin: row.originGitOrigin || row.gitOrigin,
3389
+ rawSource: row.originRawSource || row.rawSource,
3390
+ serviceTier: row.originServiceTier || row.serviceTier,
3391
+ });
3392
+ }
3393
+ const occurrence = {
3394
+ threadId: row.threadId,
3395
+ timestamp: row.timestamp,
3396
+ model: row.model,
3397
+ effort: row.effort,
3398
+ cwd: row.cwd,
3399
+ gitOrigin: row.gitOrigin,
3400
+ rawSource: row.rawSource,
3401
+ serviceTier: row.serviceTier,
3402
+ };
3403
+ context.spool.insertToken(
3404
+ row.eventKey || row.observationId,
3405
+ row.turnId,
3406
+ {
3407
+ inputTokens: row.inputTokens,
3408
+ cachedInputTokens: row.cachedInputTokens,
3409
+ cacheWriteInputTokens: row.cacheWriteInputTokens,
3410
+ outputTokens: row.outputTokens,
3411
+ reasoningTokens: row.reasoningTokens,
3412
+ totalTokens: row.totalTokens,
3413
+ toolCalls: row.identityKind === "exact" ? 0 : row.toolCalls,
3414
+ componentsValid: row.componentsValid,
3415
+ },
3416
+ occurrence,
3417
+ true,
3418
+ {
3419
+ project: row.project,
3420
+ displayModel: row.displayModel,
3421
+ source: row.source,
3422
+ useType: row.useType,
3423
+ rateCardModel: row.rateCardModel,
3424
+ rateCardCredits: row.rateCardCredits,
3425
+ identityKind: row.identityKind,
3426
+ rangeAllocationEstimated: row.rangeAllocationEstimated,
3427
+ rangeAllocationOrigin: row.rangeAllocationOrigin,
3428
+ callCount: row.callCount,
3429
+ detailedCallCount: row.detailedCallCount,
3430
+ inputCallCount: row.inputCallCount,
3431
+ },
3432
+ );
3433
+ if (row.identityKind === "exact") {
3434
+ context.spool.restoreTokenPlacement(
3435
+ row.eventKey || row.observationId,
3436
+ occurrence,
3437
+ {
3438
+ rangeAllocationEstimated: row.rangeAllocationEstimated,
3439
+ rangeAllocationOrigin: row.rangeAllocationOrigin,
3440
+ },
3441
+ );
3442
+ }
3443
+ }
3444
+
3445
+ function consumeTool(call) {
3446
+ ensureCurrentCallKeys();
3447
+ if (!call.usageOwned) return;
3448
+ const hasUnscannedSource = [...call.sourceIds].some(
3449
+ (sourceId) => !context.trustedScannedSourceIds.has(String(sourceId)),
3450
+ );
3451
+ if (!hasUnscannedSource) return;
3452
+ context.spool.insertCall(
3453
+ call.callKey,
3454
+ call.turnId,
3455
+ call.threadId,
3456
+ call.originalLikely,
3457
+ );
3458
+ }
3459
+
3460
+ function addMetadata(ledger) {
3461
+ for (const quota of ledger.quotaRows) {
3462
+ const key = durableQuotaObservationKey({
3463
+ limitKey: quota.limitKey,
3464
+ windowMinutes: quota.windowMinutes,
3465
+ resetsAt: quota.resetsAt,
3466
+ usedPercent: quota.usedPercent,
3467
+ });
3468
+ context.quotas.set(key, quota);
3469
+ }
3470
+ for (const row of ledger.threadRows) {
3471
+ context.persistedThreads.set(row.id, row);
3472
+ if (row.parentThreadId && !context.parents.has(row.id)) {
3473
+ context.parents.set(row.id, row.parentThreadId);
3474
+ }
3475
+ }
3476
+ context.durableLedger = ledger;
3477
+ }
3478
+
3479
+ return { consumeUsage, consumeTool, addMetadata };
3480
+ }
3481
+
3482
+ function reduceScanResult(result, context, inventorySourceId = null) {
3483
+ const sourceId = inventorySourceId ||
3484
+ durableSourceId(context.codexHome, result.path);
3485
+ const uncertain = result.parseErrors > 0 || result.invalidTokenRecords > 0;
3486
+ const quotaUncertain = uncertain || result.invalidQuotaRecords > 0;
3487
+ if (quotaUncertain) {
3488
+ context.quotaUncertainSourceIds.add(String(sourceId));
3489
+ }
3490
+ // A source-local omission makes every operation from that scan unsafe: a
3491
+ // shifted ordinal can overwrite an older observation, and partial calls can
3492
+ // reassign ownership even when membership pruning is disabled. Retain the
3493
+ // coverage evidence and source watermark, but let the durable ledger keep
3494
+ // the last complete interpretation until this source scans cleanly.
3495
+ if (uncertain) {
3496
+ context.uncertainSourceIds.add(String(sourceId));
3497
+ context.parseErrors += result.parseErrors;
3498
+ context.invalidTokenRecords += result.invalidTokenRecords || 0;
3499
+ context.invalidQuotaRecords += result.invalidQuotaRecords || 0;
3500
+ context.correctionIntervals += result.correctionIntervals;
3501
+ return;
3502
+ }
3503
+ context.trustedScannedSourceIds.add(String(sourceId));
3504
+ let currentCandidate = null;
3505
+ let currentCandidateSelected = false;
3506
+ for (const operation of result.operations) {
3507
+ if (operation.kind === "parent") {
3508
+ context.parents.set(operation.threadId, operation.parentThreadId);
3509
+ continue;
3510
+ }
3511
+ if (operation.kind === "origin") {
3512
+ currentCandidate = operation.candidate;
3513
+ currentCandidateSelected = context.spool.insertOrigin(currentCandidate);
3514
+ continue;
3515
+ }
3516
+ if (operation.kind === "origin_update") {
3517
+ if (currentCandidate?.turnId === operation.turnId) {
3518
+ currentCandidate.model = operation.model;
3519
+ currentCandidate.effort = operation.effort;
3520
+ currentCandidate.cwd = operation.cwd;
3521
+ if (currentCandidateSelected) {
3522
+ context.spool.updateOrigin(currentCandidate);
3523
+ }
3524
+ }
3525
+ continue;
3526
+ }
3527
+ if (operation.kind === "quota") {
3528
+ for (const candidate of operation.candidates) {
3529
+ if (!quotaUncertain) {
3530
+ rememberQuotaLabel(context.quotaLabels, sourceId, candidate);
3531
+ }
3532
+ rememberQuota(context.quotas, candidate, context.sinceMs);
3533
+ addSourceAssociation(
3534
+ context.quotaSources,
3535
+ candidate.observationKey,
3536
+ sourceId,
3537
+ );
3538
+ addQuotaSourceBounds(
3539
+ context.quotaSourceBounds,
3540
+ candidate.observationKey,
3541
+ sourceId,
3542
+ candidate.timestamp,
3543
+ );
3544
+ }
3545
+ continue;
3546
+ }
3547
+ if (operation.kind === "call") {
3548
+ context.spool.insertCall(
3549
+ operation.callKey,
3550
+ operation.turnId,
3551
+ operation.threadId,
3552
+ operation.originalLikely,
3553
+ );
3554
+ addSourceAssociation(context.callSources, operation.callKey, sourceId);
3555
+ continue;
3556
+ }
3557
+ if (operation.kind === "token") {
3558
+ const inserted = context.spool.insertToken(
3559
+ operation.eventKey,
3560
+ operation.turnId,
3561
+ operation.usage,
3562
+ operation.occurrence,
3563
+ operation.originalLikely,
3564
+ );
3565
+ if (!inserted) context.duplicateEventsSkipped += 1;
3566
+ addSourceAssociation(context.eventSources, operation.eventKey, sourceId);
3567
+ if (operation.eventOrdinal !== null && operation.eventOrdinal !== undefined) {
3568
+ context.eventPositions.push({
3569
+ sourceId,
3570
+ ordinal: operation.eventOrdinal,
3571
+ eventKey: operation.eventKey,
1335
3572
  });
1336
3573
  }
3574
+ continue;
3575
+ }
3576
+ throw new Error(`Unknown rollout operation: ${operation.kind}`);
3577
+ }
3578
+ context.parseErrors += result.parseErrors;
3579
+ context.invalidTokenRecords += result.invalidTokenRecords || 0;
3580
+ context.invalidQuotaRecords += result.invalidQuotaRecords || 0;
3581
+ context.correctionIntervals += result.correctionIntervals;
3582
+ }
3583
+
3584
+ function createProgressReporter(files, onProgress) {
3585
+ let completed = 0;
3586
+ return async (index) => {
3587
+ completed += 1;
3588
+ if (
3589
+ completed === 1 ||
3590
+ completed === files.length ||
3591
+ completed % 10 === 0
3592
+ ) {
3593
+ await onProgress({
3594
+ current: completed,
3595
+ total: files.length,
3596
+ path: files[index],
3597
+ });
3598
+ }
3599
+ };
3600
+ }
3601
+
3602
+ function workerError(error, terminal) {
3603
+ const result = new Error(error?.message || "Rollout worker failed.");
3604
+ result.name = error?.name || "Error";
3605
+ if (terminal) result.rolloutWorkerFailure = true;
3606
+ if (error?.code) result.code = error.code;
3607
+ if (error?.stack) result.stack = error.stack;
3608
+ return result;
3609
+ }
3610
+
3611
+ async function scanFilesSequential(
3612
+ files,
3613
+ stateRows,
3614
+ continuityBytes,
3615
+ maximumBytes,
3616
+ sourceIdentities,
3617
+ onProgress,
3618
+ onResult,
3619
+ ) {
3620
+ const reportCompleted = createProgressReporter(files, onProgress);
3621
+ for (let index = 0; index < files.length; index += 1) {
3622
+ const result = await scanRollout(
3623
+ files[index],
3624
+ stateRows,
3625
+ undefined,
3626
+ continuityBytes[index],
3627
+ maximumBytes[index],
3628
+ sourceIdentities[index],
3629
+ );
3630
+ await onResult(result, index);
3631
+ await reportCompleted(index);
3632
+ }
3633
+ }
3634
+
3635
+ export function rolloutWorkerStateEntry(path, stateRows) {
3636
+ const threadId = String(path).match(UUID_AT_END)?.[1];
3637
+ if (!threadId) return null;
3638
+ const row = stateRows.get(threadId);
3639
+ if (!row) return null;
3640
+ // A rollout scan needs only these five enrichment fields. In particular,
3641
+ // do not clone titles (which may contain large user-authored text) or the
3642
+ // rest of the state database into every worker isolate.
3643
+ return [threadId, {
3644
+ model: row.model,
3645
+ reasoning_effort: row.reasoning_effort,
3646
+ cwd: row.cwd,
3647
+ git_origin_url: row.git_origin_url,
3648
+ source: row.source,
3649
+ }];
3650
+ }
3651
+
3652
+ async function scanFilesWithWorkers(
3653
+ files,
3654
+ stateRows,
3655
+ continuityBytes,
3656
+ maximumBytes,
3657
+ sourceIdentities,
3658
+ concurrency,
3659
+ onProgress,
3660
+ onResult,
3661
+ ) {
3662
+ const reportCompleted = createProgressReporter(files, onProgress);
3663
+ const workerCount = Math.min(concurrency, files.length);
3664
+ const maxReorderWindow = Math.max(1, concurrency * 2);
3665
+ const workers = [];
3666
+ try {
3667
+ for (let index = 0; index < workerCount; index += 1) {
3668
+ workers.push(
3669
+ new Worker(new URL(import.meta.url), {
3670
+ type: "module",
3671
+ workerData: {
3672
+ tokenLedgerImporterWorker: true,
3673
+ },
3674
+ execArgv: ["--no-warnings"],
3675
+ }),
3676
+ );
1337
3677
  }
1338
- return buildSnapshot(context, options, titles);
3678
+ } catch (error) {
3679
+ await Promise.all(workers.map((worker) => worker.terminate()));
3680
+ throw error;
3681
+ }
3682
+
3683
+ let nextIndex = 0;
3684
+ let completedCount = 0;
3685
+ let closed = false;
3686
+ const assignments = new Map();
3687
+ const pendingResults = new Map();
3688
+ let nextResultIndex = 0;
3689
+ let resolveDone;
3690
+ let rejectDone;
3691
+ const done = new Promise((resolve, reject) => {
3692
+ resolveDone = resolve;
3693
+ rejectDone = reject;
3694
+ });
3695
+
3696
+ // Progress callbacks may be async; serialize them, dispatch further files
3697
+ // only after each settles, and hold completion open until the queue drains,
3698
+ // so a rejection fails the scan instead of becoming an unhandled rejection.
3699
+ let progressChain = Promise.resolve();
3700
+ function queueProgress(index) {
3701
+ progressChain = progressChain
3702
+ .then(() => reportCompleted(index))
3703
+ .then(() => {
3704
+ if (!closed) dispatchAvailable();
3705
+ });
3706
+ progressChain.catch((error) => close(error));
3707
+ }
3708
+
3709
+ function close(error = null) {
3710
+ if (closed) return;
3711
+ closed = true;
3712
+ Promise.all([
3713
+ Promise.all(workers.map((worker) => worker.terminate())),
3714
+ progressChain,
3715
+ ]).then(
3716
+ () => {
3717
+ if (error) rejectDone(error);
3718
+ else resolveDone();
3719
+ },
3720
+ (cleanupError) => rejectDone(error || cleanupError),
3721
+ );
3722
+ }
3723
+
3724
+ function dispatch(worker) {
3725
+ if (closed || nextIndex >= files.length) return;
3726
+ const index = nextIndex;
3727
+ nextIndex += 1;
3728
+ assignments.set(worker, { index });
3729
+ try {
3730
+ worker.postMessage({
3731
+ index,
3732
+ path: files[index],
3733
+ continuityBytes: continuityBytes[index],
3734
+ maximumBytes: maximumBytes[index],
3735
+ expectedIdentity: sourceIdentities[index],
3736
+ stateEntry: rolloutWorkerStateEntry(files[index], stateRows),
3737
+ });
3738
+ } catch (error) {
3739
+ close(error);
3740
+ }
3741
+ }
3742
+
3743
+ function flushResults() {
3744
+ while (pendingResults.has(nextResultIndex)) {
3745
+ const result = pendingResults.get(nextResultIndex);
3746
+ pendingResults.delete(nextResultIndex);
3747
+ onResult(result, nextResultIndex);
3748
+ nextResultIndex += 1;
3749
+ }
3750
+ }
3751
+
3752
+ function dispatchAvailable() {
3753
+ while (
3754
+ !closed &&
3755
+ nextIndex < files.length &&
3756
+ nextIndex - nextResultIndex < maxReorderWindow
3757
+ ) {
3758
+ const worker = workers.find((candidate) => !assignments.has(candidate));
3759
+ if (!worker) return;
3760
+ dispatch(worker);
3761
+ }
3762
+ }
3763
+
3764
+ for (const worker of workers) {
3765
+ worker.on("message", (message) => {
3766
+ if (closed) return;
3767
+ const assignment = assignments.get(worker);
3768
+ if (assignment?.index !== message.index) {
3769
+ close(new Error("Rollout worker returned an unexpected file index."));
3770
+ return;
3771
+ }
3772
+ assignments.delete(worker);
3773
+ if (message.error) {
3774
+ // Filesystem mutation errors stay retryable regardless of how much
3775
+ // of the queue already completed, so pruning mid-scan re-inventories
3776
+ // instead of aborting the refresh.
3777
+ close(
3778
+ workerError(
3779
+ message.error,
3780
+ !SOURCE_MUTATION_ERROR_CODES.includes(message.error?.code),
3781
+ ),
3782
+ );
3783
+ return;
3784
+ }
3785
+ pendingResults.set(message.index, message.result);
3786
+ try {
3787
+ flushResults();
3788
+ } catch (error) {
3789
+ close(error);
3790
+ return;
3791
+ }
3792
+ queueProgress(message.index);
3793
+ completedCount += 1;
3794
+ if (completedCount === files.length) close();
3795
+ });
3796
+ worker.on("error", (error) => close(error));
3797
+ worker.on("exit", (code) => {
3798
+ if (!closed) {
3799
+ close(
3800
+ new Error(
3801
+ `Rollout worker exited before completing its scan (code ${code}).`,
3802
+ ),
3803
+ );
3804
+ }
3805
+ });
3806
+ }
3807
+ dispatchAvailable();
3808
+ return done;
3809
+ }
3810
+
3811
+ async function scanFiles(
3812
+ files,
3813
+ stateRows,
3814
+ continuityBytes,
3815
+ maximumBytes,
3816
+ sourceIdentities,
3817
+ concurrency,
3818
+ onProgress,
3819
+ onResult,
3820
+ ) {
3821
+ if (!files.length) return;
3822
+ if (concurrency <= 1) {
3823
+ return scanFilesSequential(
3824
+ files,
3825
+ stateRows,
3826
+ continuityBytes,
3827
+ maximumBytes,
3828
+ sourceIdentities,
3829
+ onProgress,
3830
+ onResult,
3831
+ );
3832
+ }
3833
+ return scanFilesWithWorkers(
3834
+ files,
3835
+ stateRows,
3836
+ continuityBytes,
3837
+ maximumBytes,
3838
+ sourceIdentities,
3839
+ concurrency,
3840
+ onProgress,
3841
+ onResult,
3842
+ );
3843
+ }
3844
+
3845
+ async function collectUsageWithConcurrency(
3846
+ options,
3847
+ inventory,
3848
+ onProgress,
3849
+ concurrency,
3850
+ reuseUnchanged = true,
3851
+ ) {
3852
+ const state = await readState(options.codexHome);
3853
+ const titles = await readSessionTitles(
3854
+ resolve(options.codexHome, "session_index.jsonl"),
3855
+ );
3856
+ const spool = await createUsageSpool();
3857
+ const context = createCollectionContext(state, spool, options);
3858
+ context.sourceInventory = inventory;
3859
+ const durableContinuity = await readDurableSourceContinuity(
3860
+ resolveDurableLedgerPath(options),
3861
+ {
3862
+ codexHome: options.codexHome,
3863
+ inventory,
3864
+ },
3865
+ );
3866
+ const priorContinuity = durableContinuity.sources;
3867
+ if (durableContinuity.threadRecords instanceof Map) {
3868
+ for (const [threadId, row] of durableContinuity.threadRecords) {
3869
+ context.persistedThreads.set(threadId, row);
3870
+ }
3871
+ }
3872
+ const threadMetadataFingerprints = currentThreadMetadataFingerprints(state);
3873
+ const metadataChanged = !sourceWatermarksEqual(
3874
+ durableContinuity.metadataWatermark,
3875
+ inventory.metadataWatermark,
3876
+ );
3877
+ const changedMetadataThreadIds = metadataChanged
3878
+ ? metadataChangedThreadIds({
3879
+ state,
3880
+ titles,
3881
+ currentFingerprints: threadMetadataFingerprints,
3882
+ previousFingerprints: durableContinuity.threadMetadataFingerprints,
3883
+ persistedThreads: durableContinuity.threadRecords,
3884
+ })
3885
+ : new Set();
3886
+ const scanIndexes = [];
3887
+ for (let index = 0; index < inventory.files.length; index += 1) {
3888
+ const entry = inventory.files[index];
3889
+ const prior = priorContinuity.get(String(entry.sourceId));
3890
+ const threadId = entry.path.match(UUID_AT_END)?.[1] || null;
3891
+ const metadataRequiresScan = metadataChanged && (
3892
+ changedMetadataThreadIds === null ||
3893
+ threadId === null ||
3894
+ changedMetadataThreadIds.has(threadId)
3895
+ );
3896
+ if (
3897
+ reuseUnchanged &&
3898
+ sourceMatchesDurableCursor(entry, prior) &&
3899
+ !metadataRequiresScan
3900
+ ) {
3901
+ entry.cursorFingerprint = prior.cursorFingerprint;
3902
+ entry.scanBytes = prior.cursorBytes;
3903
+ entry.continuityBytes = prior.cursorBytes;
3904
+ entry.continuityFingerprint = prior.cursorFingerprint;
3905
+ context.filesReused += 1;
3906
+ context.bytesReused += entry.size;
3907
+ } else {
3908
+ scanIndexes.push(index);
3909
+ }
3910
+ }
3911
+ const files = scanIndexes.map((index) => inventory.files[index].path);
3912
+ const continuityBytes = scanIndexes.map((index) =>
3913
+ priorContinuity.get(String(inventory.files[index].sourceId))?.cursorBytes ?? null
3914
+ );
3915
+ const maximumBytes = [];
3916
+ for (const index of scanIndexes) {
3917
+ const entry = inventory.files[index];
3918
+ const scanBytes = await scanCutoffForFile(entry);
3919
+ entry.scanBytes = scanBytes;
3920
+ maximumBytes.push(scanBytes);
3921
+ }
3922
+ const sourceIdentities = scanIndexes.map((index) => ({
3923
+ device: inventory.files[index].dev,
3924
+ inode: inventory.files[index].ino,
3925
+ }));
3926
+ let publicationWatermark = inventory.watermark;
3927
+ let stagedWatermark = null;
3928
+
3929
+ try {
3930
+ await scanFiles(
3931
+ files,
3932
+ state.rows,
3933
+ continuityBytes,
3934
+ maximumBytes,
3935
+ sourceIdentities,
3936
+ concurrency,
3937
+ onProgress,
3938
+ (result, index) => {
3939
+ const inventoryIndex = scanIndexes[index];
3940
+ inventory.files[inventoryIndex].cursorFingerprint = result.cursorFingerprint;
3941
+ inventory.files[inventoryIndex].continuityBytes = result.continuityBytes;
3942
+ inventory.files[inventoryIndex].continuityFingerprint =
3943
+ result.continuityFingerprint;
3944
+ reduceScanResult(
3945
+ result,
3946
+ context,
3947
+ inventory.files[inventoryIndex]?.sourceId,
3948
+ );
3949
+ context.filesScanned += 1;
3950
+ context.bytesScanned += inventory.files[inventoryIndex].scanBytes ??
3951
+ inventory.files[inventoryIndex].size;
3952
+ },
3953
+ );
3954
+ const after = await sourceInventory(
3955
+ options.codexHome,
3956
+ options.includeArchived,
3957
+ );
3958
+ // Every rollout worker read a bounded byte range captured by `inventory`.
3959
+ // Once all reads complete, that dataset is a valid point-in-time cutoff;
3960
+ // source changes observed afterward belong to the next refresh and must
3961
+ // not force a complete rescan of the already-captured files.
3962
+ publicationWatermark = acceptedSourceWatermark(inventory, after);
3963
+ context.spool.finishWrites();
3964
+ // Durable reconciliation makes several sequential passes. Give each pass
3965
+ // a fresh anchored SQLite iterator instead of retaining every spool row in
3966
+ // the parent JavaScript heap for the lifetime of the refresh.
3967
+ const tokenRows = {
3968
+ [Symbol.iterator]: () => context.spool.tokenRows(),
3969
+ };
3970
+ const callRows = {
3971
+ [Symbol.iterator]: () => context.spool.callRows(),
3972
+ };
3973
+ const durableSpool = createDurableSpoolSink(context);
3974
+ const eventMetadata = new Map();
3975
+ for (const token of tokenRows) {
3976
+ eventMetadata.set(
3977
+ String(token.eventKey),
3978
+ eventMetadataForToken(token, context, titles),
3979
+ );
3980
+ }
3981
+ const validateSources = async () => {
3982
+ // A completed bounded scan is the staged CLI candidate's point-in-time
3983
+ // cutoff. Source activity after those reads cannot change its contents;
3984
+ // publish once and reconcile later activity on the next refresh.
3985
+ if (options.stageSnapshot instanceof Function) {
3986
+ publicationWatermark = stagedWatermark ?? publicationWatermark;
3987
+ return;
3988
+ }
3989
+ const finalInventory = await sourceInventory(
3990
+ options.codexHome,
3991
+ options.includeArchived,
3992
+ );
3993
+ if (!(await sourceInventoryPreservesCutoff(
3994
+ inventory,
3995
+ finalInventory,
3996
+ ))) {
3997
+ const error = new Error(
3998
+ "Local Codex sources changed during collection; the candidate snapshot was not published.",
3999
+ );
4000
+ error.code = "ERR_SOURCE_CHANGED_DURING_COLLECTION";
4001
+ throw error;
4002
+ }
4003
+ const acceptedWatermark = acceptedSourceWatermark(
4004
+ inventory,
4005
+ finalInventory,
4006
+ );
4007
+ // Rollout integrity is checked against the captured cutoff above. Once a
4008
+ // snapshot is staged, retain that accepted watermark: Codex may keep
4009
+ // appending advisory SQLite/session metadata while the ledger commits,
4010
+ // and that activity must not discard an otherwise valid snapshot.
4011
+ publicationWatermark = stagedWatermark ?? acceptedWatermark;
4012
+ };
4013
+ let stagedSnapshot = null;
4014
+ const stageCandidate = async ({ ledger }) => {
4015
+ if (!(options.stageSnapshot instanceof Function)) return null;
4016
+ durableSpool.addMetadata(ledger);
4017
+ const candidate = {
4018
+ ...buildSnapshot(context, options, titles),
4019
+ sourceWatermark: publicationWatermark,
4020
+ };
4021
+ stagedWatermark = publicationWatermark;
4022
+ stagedSnapshot = await options.stageSnapshot(candidate);
4023
+ return stagedSnapshot;
4024
+ };
4025
+ const ledger = await updateDurableLedger({
4026
+ options,
4027
+ codexHome: options.codexHome,
4028
+ inventory,
4029
+ includeArchived: options.includeArchived,
4030
+ tokenRows,
4031
+ callRows,
4032
+ quotas: [...context.quotas.values()],
4033
+ eventSources: context.eventSources,
4034
+ eventPositions: context.eventPositions,
4035
+ callSources: context.callSources,
4036
+ quotaSources: context.quotaSources,
4037
+ quotaSourceBounds: context.quotaSourceBounds,
4038
+ quotaLabelEvidence: [...context.quotaLabels.values()].map((evidence) => ({
4039
+ sourceId: evidence.sourceId,
4040
+ limitKey: evidence.limitKey,
4041
+ limitName: evidence.limitName,
4042
+ observedAt: evidence.observedAt,
4043
+ })),
4044
+ uncertainSourceIds: context.uncertainSourceIds,
4045
+ quotaUncertainSourceIds: context.quotaUncertainSourceIds,
4046
+ eventMetadata,
4047
+ threadRecords: threadRecordsForLedger(context, titles),
4048
+ threadMetadataFingerprints,
4049
+ nowMs: Date.now(),
4050
+ faultInjector: options.faultInjector,
4051
+ validateBeforeCommit: validateSources,
4052
+ validateAfterCommit: validateSources,
4053
+ stageBeforeCommit: stageCandidate,
4054
+ onMaterializedRow: ({ kind, row }) => {
4055
+ if (kind === "usage") durableSpool.consumeUsage(row);
4056
+ else if (kind === "tool") durableSpool.consumeTool(row);
4057
+ },
4058
+ });
4059
+ if (stagedSnapshot) return stagedSnapshot.snapshot;
4060
+ durableSpool.addMetadata(ledger);
4061
+ return {
4062
+ ...buildSnapshot(context, options, titles),
4063
+ sourceWatermark: publicationWatermark,
4064
+ };
1339
4065
  } finally {
1340
4066
  spool.close();
1341
4067
  await rm(spool.directory, { recursive: true, force: true });
1342
4068
  }
1343
4069
  }
1344
4070
 
4071
+ async function collectUsageAttempt(options, inventory, onProgress) {
4072
+ return collectUsageWithConcurrency(
4073
+ options,
4074
+ inventory,
4075
+ onProgress,
4076
+ SCAN_CONCURRENCY,
4077
+ );
4078
+ }
4079
+
4080
+ function isSourceMutationError(error) {
4081
+ return (
4082
+ !error?.rolloutWorkerFailure &&
4083
+ (
4084
+ SOURCE_MUTATION_ERROR_CODES.includes(error?.code) ||
4085
+ error?.code === "ERR_SOURCE_CHANGED_DURING_COLLECTION"
4086
+ )
4087
+ );
4088
+ }
4089
+
4090
+ export async function collectUsage(options, onProgress = () => {}) {
4091
+ for (let attempt = 1; attempt <= SOURCE_COLLECTION_MAX_ATTEMPTS; attempt += 1) {
4092
+ try {
4093
+ const before = await sourceInventory(
4094
+ options.codexHome,
4095
+ options.includeArchived,
4096
+ );
4097
+ const snapshot = await collectUsageAttempt(options, before, onProgress);
4098
+ return snapshot;
4099
+ } catch (error) {
4100
+ if (!isSourceMutationError(error)) {
4101
+ throw error;
4102
+ }
4103
+ if (attempt === SOURCE_COLLECTION_MAX_ATTEMPTS) {
4104
+ const exhausted = new Error(
4105
+ `Local Codex sources changed during collection after ${SOURCE_COLLECTION_MAX_ATTEMPTS} attempts; no snapshot was published.`,
4106
+ { cause: error },
4107
+ );
4108
+ exhausted.code = "ERR_SOURCE_CHANGED_DURING_COLLECTION";
4109
+ throw exhausted;
4110
+ }
4111
+ }
4112
+ }
4113
+
4114
+ const error = new Error(
4115
+ `Local Codex sources changed during collection after ${SOURCE_COLLECTION_MAX_ATTEMPTS} attempts; no snapshot was published.`,
4116
+ );
4117
+ error.code = "ERR_SOURCE_CHANGED_DURING_COLLECTION";
4118
+ throw error;
4119
+ }
4120
+
4121
+ export async function collectUsageSequential(options, onProgress = () => {}) {
4122
+ for (let attempt = 1; attempt <= SOURCE_COLLECTION_MAX_ATTEMPTS; attempt += 1) {
4123
+ const inventory = await sourceInventory(
4124
+ options.codexHome,
4125
+ options.includeArchived,
4126
+ );
4127
+ try {
4128
+ const snapshot = await collectUsageWithConcurrency(
4129
+ options,
4130
+ inventory,
4131
+ onProgress,
4132
+ 1,
4133
+ false,
4134
+ );
4135
+ return snapshot;
4136
+ } catch (error) {
4137
+ if (!isSourceMutationError(error)) throw error;
4138
+ if (attempt === SOURCE_COLLECTION_MAX_ATTEMPTS) {
4139
+ const exhausted = new Error(
4140
+ `Local Codex sources changed during collection after ${SOURCE_COLLECTION_MAX_ATTEMPTS} attempts; no snapshot was published.`,
4141
+ { cause: error },
4142
+ );
4143
+ exhausted.code = "ERR_SOURCE_CHANGED_DURING_COLLECTION";
4144
+ throw exhausted;
4145
+ }
4146
+ }
4147
+ }
4148
+ throw new Error("Unreachable sequential collection state.");
4149
+ }
4150
+
1345
4151
  async function main() {
1346
4152
  let options;
1347
4153
  try {
1348
4154
  options = parseArgs(process.argv.slice(2));
1349
4155
  } catch (error) {
1350
- process.stderr.write(`${error.message}\n\n${usage()}\n`);
4156
+ process.stderr.write(`${safeExportLabel(error.message, 2_000, "Invalid collector arguments")}\n\n${usage()}\n`);
1351
4157
  process.exitCode = 1;
1352
4158
  return;
1353
4159
  }
@@ -1360,14 +4166,23 @@ async function main() {
1360
4166
  }
1361
4167
 
1362
4168
  process.stdout.write("Token Ledger: scanning local Codex metadata…\n");
1363
- const snapshot = await collectUsage(options, ({ current, total }) => {
4169
+ let writeResult = null;
4170
+ const snapshot = await collectUsage({
4171
+ ...options,
4172
+ stageSnapshot: async (candidate) => {
4173
+ process.stdout.write("\nToken Ledger: writing privacy-reduced snapshot…\n");
4174
+ writeResult = await stagePrivateSnapshot(options.output, candidate, {
4175
+ reservedPaths: durableLedgerRelatedPaths(options),
4176
+ });
4177
+ return writeResult;
4178
+ },
4179
+ }, ({ current, total }) => {
1364
4180
  process.stdout.write(
1365
4181
  `\rToken Ledger: scanned ${current.toLocaleString()}/${total.toLocaleString()} rollout files`,
1366
4182
  );
1367
4183
  });
1368
- process.stdout.write("\nToken Ledger: writing privacy-reduced snapshot…\n");
1369
- const writeResult = await writePrivateSnapshot(options.output, snapshot);
1370
- const storedSnapshot = writeResult.snapshot;
4184
+ process.stdout.write("\n");
4185
+ const storedSnapshot = snapshot;
1371
4186
  process.stdout.write(
1372
4187
  [
1373
4188
  `Snapshot: ${options.output}`,
@@ -1375,6 +4190,8 @@ async function main() {
1375
4190
  `Unique model calls: ${storedSnapshot.coverage.observedModelCalls.toLocaleString()}`,
1376
4191
  `Stored usage buckets: ${storedSnapshot.events.length.toLocaleString()}`,
1377
4192
  `Duplicate/copied events skipped: ${storedSnapshot.coverage.duplicateEventsSkipped.toLocaleString()}`,
4193
+ `Invalid token records excluded: ${storedSnapshot.coverage.invalidTokenRecords.toLocaleString()}`,
4194
+ `Invalid quota records excluded: ${storedSnapshot.coverage.invalidQuotaRecords.toLocaleString()}`,
1378
4195
  `Threads with unresolved state-only counters: ${storedSnapshot.coverage.unresolvedThreadCounters.toLocaleString()}`,
1379
4196
  `Snapshot size: ${(writeResult.bytesWritten / 1_000_000).toFixed(1)} MB (${writeResult.encoding}; ${(writeResult.jsonBytes / 1_000_000).toFixed(1)} MB JSON before encoding)`,
1380
4197
  `Snapshot safety limit: ${(writeResult.maxBytes / 1_000_000).toFixed(1)} MB`,
@@ -1382,12 +4199,49 @@ async function main() {
1382
4199
  );
1383
4200
  }
1384
4201
 
4202
+ if (!isMainThread && workerData?.tokenLedgerImporterWorker && parentPort) {
4203
+ parentPort.on("message", async ({
4204
+ index,
4205
+ path,
4206
+ continuityBytes,
4207
+ maximumBytes,
4208
+ expectedIdentity,
4209
+ stateEntry,
4210
+ }) => {
4211
+ try {
4212
+ const result = await scanRollout(
4213
+ path,
4214
+ stateEntry ? new Map([stateEntry]) : new Map(),
4215
+ undefined,
4216
+ continuityBytes,
4217
+ maximumBytes,
4218
+ expectedIdentity,
4219
+ );
4220
+ parentPort.postMessage({ index, result });
4221
+ } catch (error) {
4222
+ parentPort.postMessage({
4223
+ index,
4224
+ error: {
4225
+ name: error?.name,
4226
+ message: error?.message || String(error),
4227
+ stack: error?.stack,
4228
+ code: error?.code,
4229
+ },
4230
+ });
4231
+ }
4232
+ });
4233
+ }
4234
+
1385
4235
  const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
1386
- if (import.meta.url === invokedPath) {
4236
+ if (isMainThread && import.meta.url === invokedPath) {
1387
4237
  main().catch((error) => {
1388
4238
  process.stderr.write(
1389
4239
  `Token Ledger collector failed: ${
1390
- error instanceof Error ? error.message : String(error)
4240
+ safeExportLabel(
4241
+ error instanceof Error ? error.message : String(error),
4242
+ 2_000,
4243
+ "Unknown collector error",
4244
+ )
1391
4245
  }\n`,
1392
4246
  );
1393
4247
  process.exitCode = 1;