tledger 0.3.1 → 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,78 +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)}`,
742
- // Keep the first and last occurrence of an unchanged reading without
743
- // storing every repeated provider sample in the snapshot.
1709
+ });
1710
+ candidates.push({
1711
+ observationKey,
1712
+ id: `quota-${hash(observationKey)}`,
744
1713
  timestamp: occurrence.timestamp,
745
1714
  lastSeenAt: occurrence.timestamp,
746
1715
  usedPercent,
747
1716
  windowMinutes,
748
1717
  resetsAt,
749
- planType: String(planType || "unknown"),
750
- limitKey: hash(limitKey, 16),
751
- limitName: limitName ? String(limitName).slice(0, 80) : null,
1718
+ planType: normalizedPlanType,
1719
+ limitKey: stableLimitKey,
1720
+ limitName: normalizedLimitName,
1721
+ scope,
752
1722
  source: "log",
753
1723
  turnId: occurrence.turnId || null,
754
1724
  originalLikely: occurrence.originalLikely,
755
- };
756
- const current = quotaMap.get(key);
757
- if (
758
- !current ||
759
- (!current.originalLikely && candidate.originalLikely)
760
- ) {
761
- quotaMap.set(key, candidate);
762
- } else if (current.originalLikely === candidate.originalLikely) {
763
- quotaMap.set(key, {
764
- ...current,
765
- timestamp:
766
- candidate.timestamp < current.timestamp
767
- ? candidate.timestamp
768
- : current.timestamp,
769
- lastSeenAt:
770
- candidate.timestamp > current.lastSeenAt
771
- ? candidate.timestamp
772
- : current.lastSeenAt,
773
- });
774
- }
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
+ });
775
1776
  }
776
1777
  }
777
1778
 
778
1779
  function responseCall(record) {
779
1780
  if (record.type !== "response_item") return null;
780
1781
  const payload = record.payload;
781
- const allowed = new Set([
782
- "function_call",
783
- "custom_tool_call",
784
- "tool_search_call",
785
- "web_search_call",
786
- "image_generation_call",
787
- ]);
788
- if (!payload || !allowed.has(payload.type)) return null;
1782
+ if (!payload || !RELEVANT_CALL_TYPES.has(payload.type)) return null;
789
1783
  return {
790
1784
  type: payload.type,
791
1785
  name: String(payload.name || payload.namespace || payload.type).slice(0, 80),
@@ -793,10 +1787,249 @@ function responseCall(record) {
793
1787
  };
794
1788
  }
795
1789
 
796
- 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
+ ) {
797
2030
  const match = path.match(UUID_AT_END);
798
2031
  const threadId = match?.[1] || `file-${hash(path)}`;
799
- const stateRow = context.stateRows.get(threadId);
2032
+ const stateRow = stateRows.get(threadId);
800
2033
  const fileContext = {
801
2034
  model: stateRow?.model || "unknown",
802
2035
  effort: stateRow?.reasoning_effort || "unknown",
@@ -806,215 +2039,507 @@ async function scanRollout(path, context) {
806
2039
  serviceTier: null,
807
2040
  };
808
2041
  const callOrdinals = new Map();
2042
+ const tokenSignatures = new Map();
2043
+ let nextTokenOrdinal = 0;
809
2044
  let currentTurnId = "";
810
2045
  let currentCandidate = null;
811
- let currentCandidateSelected = false;
812
2046
  let previousCumulative = null;
2047
+ const operations = [];
2048
+ let parseErrors = 0;
2049
+ let correctionIntervals = 0;
2050
+ let invalidTokenRecords = 0;
2051
+ let invalidQuotaRecords = 0;
813
2052
 
814
- const input = createReadStream(path, { encoding: "utf8" });
815
- const lines = createInterface({ input, crlfDelay: Infinity });
816
- for await (const line of lines) {
817
- if (!line.trim()) continue;
818
- let record;
819
- try {
820
- record = JSON.parse(line);
821
- } catch {
822
- context.parseErrors += 1;
823
- 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;
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);
824
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
+ }
2127
+
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
+ }
825
2156
 
826
- if (record.type === "session_meta") {
827
- const payload = record.payload;
828
- if (payload?.id === threadId) {
829
- fileContext.cwd = payload.cwd || fileContext.cwd;
830
- fileContext.gitOrigin =
831
- payload.git?.repository_url || fileContext.gitOrigin;
832
- fileContext.rawSource = payload.source || fileContext.rawSource;
833
- if (payload.parent_thread_id || payload.forked_from_id) {
834
- context.parents.set(
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,
835
2162
  threadId,
836
- String(payload.parent_thread_id || payload.forked_from_id),
2163
+ stateRow,
2164
+ fileContext,
837
2165
  );
2166
+ operations.push({
2167
+ kind: "origin",
2168
+ candidate: { ...currentCandidate },
2169
+ });
838
2170
  }
839
- const spawnedParent =
840
- payload.source?.subagent?.thread_spawn?.parent_thread_id;
841
- if (spawnedParent) context.parents.set(threadId, String(spawnedParent));
2171
+ continue;
842
2172
  }
843
- continue;
844
- }
845
2173
 
846
- if (record.type === "event_msg" && record.payload?.type === "task_started") {
847
- currentTurnId = String(record.payload.turn_id || currentTurnId || "");
848
- if (currentTurnId) {
849
- currentCandidate = taskStartCandidate(
850
- record,
851
- threadId,
852
- stateRow,
853
- fileContext,
854
- );
855
- 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;
856
2192
  }
857
- continue;
858
- }
859
2193
 
860
- if (record.type === "turn_context") {
861
- currentTurnId = String(record.payload?.turn_id || currentTurnId || "");
862
- fileContext.model = record.payload?.model || fileContext.model;
863
- fileContext.effort = record.payload?.effort || fileContext.effort;
864
- fileContext.cwd = record.payload?.cwd || fileContext.cwd;
865
- if (currentCandidate?.turnId === currentTurnId) {
866
- currentCandidate.model = fileContext.model;
867
- currentCandidate.effort = fileContext.effort;
868
- currentCandidate.cwd = fileContext.cwd;
869
- if (currentCandidateSelected) {
870
- context.spool.updateOrigin(currentCandidate);
871
- }
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;
872
2206
  }
873
- continue;
874
- }
875
2207
 
876
- if (
877
- record.type === "event_msg" &&
878
- record.payload?.type === "thread_settings_applied"
879
- ) {
880
- const settings = record.payload?.thread_settings;
881
- fileContext.model = settings?.model || fileContext.model;
882
- fileContext.effort = settings?.reasoning_effort || fileContext.effort;
883
- const serviceTier = String(settings?.service_tier ?? "").trim();
884
- fileContext.serviceTier = serviceTier
885
- ? 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)
886
2212
  : null;
887
- continue;
888
- }
889
-
890
- const originalLikely = Boolean(currentCandidate?.deltaMs <= 2_000);
891
- const occurrence = {
892
- threadId,
893
- turnId: currentTurnId,
894
- timestamp: safeIso(
895
- record.timestamp,
896
- currentCandidate?.timestamp || new Date(0).toISOString(),
897
- ),
898
- originalLikely,
899
- model: fileContext.model,
900
- effort: fileContext.effort,
901
- cwd: fileContext.cwd,
902
- gitOrigin: fileContext.gitOrigin,
903
- rawSource: fileContext.rawSource,
904
- serviceTier: fileContext.serviceTier,
905
- };
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
+ }
906
2220
 
907
- const call = responseCall(record);
908
- if (call) {
909
- const ordinalBase = `${currentTurnId}|${call.type}|${call.name}`;
910
- const ordinal = (callOrdinals.get(ordinalBase) || 0) + 1;
911
- callOrdinals.set(ordinalBase, ordinal);
912
- const callKey = call.stableId
913
- ? `id|${call.stableId}`
914
- : `ordinal|${ordinalBase}|${ordinal}`;
915
- context.spool.insertCall(
916
- callKey,
917
- currentTurnId,
2221
+ const originalLikely = Boolean(currentCandidate?.deltaMs <= 2_000);
2222
+ const occurrence = {
918
2223
  threadId,
2224
+ turnId: currentTurnId,
2225
+ timestamp: tokenTimestamp || safeIso(
2226
+ record.timestamp,
2227
+ currentCandidate?.timestamp || new Date(0).toISOString(),
2228
+ ),
919
2229
  originalLikely,
920
- );
921
- continue;
922
- }
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
+ };
923
2237
 
924
- if (record.type !== "event_msg" || record.payload?.type !== "token_count") {
925
- continue;
926
- }
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
+ }
927
2255
 
928
- rememberQuota(context.quotas, record.payload.rate_limits, occurrence);
929
- const info = record.payload.info;
930
- if (!info?.last_token_usage) continue;
2256
+ if (!tokenCountRecord) continue;
931
2257
 
932
- const totalTuple = tokenTuple(info.total_token_usage);
933
- const lastTuple = tokenTuple(info.last_token_usage);
934
- if (lastTuple[5] <= 0) continue;
935
- const contextWindow = asFiniteNumber(info.model_context_window);
936
- const eventKey = currentTurnId
937
- ? JSON.stringify([
938
- currentTurnId,
939
- totalTuple,
940
- lastTuple,
941
- contextWindow,
942
- ])
943
- : 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;
944
2271
 
945
- if (
946
- previousCumulative !== null &&
947
- totalTuple[5] < previousCumulative
948
- ) {
949
- context.correctionIntervals += 1;
950
- }
951
- 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
+ ]);
952
2314
 
953
- const inserted = context.spool.insertToken(
954
- eventKey,
955
- currentTurnId,
956
- usageFromTuple(lastTuple),
957
- occurrence,
958
- originalLikely,
959
- );
960
- if (!inserted) {
961
- 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
+ });
962
2333
  }
2334
+ } finally {
2335
+ lines.close();
963
2336
  }
964
- }
965
-
966
- function threadMetadata(threadId, stateRows, titles, parents, fallback = {}) {
967
- const row = stateRows.get(threadId);
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;
968
2376
  const sessionTitle = titles.get(threadId)?.title;
969
2377
  const labels = sourceLabels(
970
- row?.thread_source,
971
- fallback.rawSource || row?.source,
2378
+ fallback.source || metadataRow?.thread_source || metadataRow?.source,
2379
+ fallback.rawSource || metadataRow?.source,
972
2380
  );
973
2381
  return {
974
2382
  id: threadId,
975
- title: safeTitle(row, sessionTitle),
976
- project: projectLabel(
977
- fallback.cwd || row?.cwd,
978
- 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",
979
2408
  ),
980
- model: normalizeModel(fallback.model || row?.model || "unknown"),
981
- effort: String(
982
- fallback.effort || row?.reasoning_effort || "unknown",
983
- ).slice(0, 40),
984
2409
  source: labels.source,
985
2410
  useType: labels.useType,
986
- parentThreadId: parents.get(threadId) || null,
2411
+ parentThreadId: safeExportLabel(
2412
+ fallback.parentThreadId ||
2413
+ parents.get(threadId) ||
2414
+ metadataRow?.parentThreadId,
2415
+ 80,
2416
+ null,
2417
+ ),
987
2418
  reportedCumulativeTokens:
988
- row && row.tokens_used !== null
989
- ? 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
990
2425
  : null,
991
- createdAt: isoFromEpoch(row?.created_at),
992
- 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,
993
2503
  };
994
2504
  }
995
2505
 
996
2506
  function resolvedOccurrence(token) {
997
- const origin = token.originThreadId == null
2507
+ const resolvedToken = repairTokenTimestamp(token);
2508
+ const origin = resolvedToken.originThreadId == null
998
2509
  ? null
999
2510
  : {
1000
- threadId: token.originThreadId,
1001
- timestamp: token.originTimestamp,
1002
- model: token.originModel,
1003
- effort: token.originEffort,
1004
- cwd: token.originCwd,
1005
- gitOrigin: token.originGitOrigin,
1006
- rawSource: token.originRawSource,
1007
- serviceTier: token.originServiceTier,
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,
1008
2519
  };
1009
- const occurrence = token.originalLikely || !origin ? token : origin;
2520
+ const occurrence = resolvedToken.originalLikely || !origin
2521
+ ? resolvedToken
2522
+ : origin;
1010
2523
  return {
1011
2524
  origin,
1012
2525
  occurrence,
1013
2526
  threadId: origin?.threadId || occurrence.threadId,
1014
- timestamp: token.timestamp || origin?.timestamp,
2527
+ timestamp: resolvedToken.timestamp || origin?.timestamp,
1015
2528
  };
1016
2529
  }
1017
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
+
1018
2543
  function newThreadAggregate(metadata, timestamp) {
1019
2544
  return {
1020
2545
  metadata,
@@ -1030,33 +2555,131 @@ function newThreadAggregate(metadata, timestamp) {
1030
2555
  unknownBreakdownTokens: 0,
1031
2556
  rateCardCredits: 0,
1032
2557
  ratedTokens: 0,
2558
+ hasPositiveUnrated: false,
1033
2559
  eventCount: 0,
1034
2560
  };
1035
2561
  }
1036
2562
 
1037
2563
  function addToThreadAggregate(aggregate, event) {
2564
+ const allowFractional = event.rangeAllocationEstimated === true;
1038
2565
  aggregate.lastActiveAt = event.timestamp;
1039
- aggregate.inputTokens += event.inputTokens;
1040
- aggregate.cachedInputTokens += event.cachedInputTokens;
1041
- aggregate.outputTokens += event.outputTokens;
1042
- aggregate.reasoningTokens += event.reasoningTokens;
1043
- aggregate.totalTokens += event.totalTokens;
1044
- aggregate.toolCalls += event.toolCalls;
1045
- aggregate.eventCount += 1;
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
+ );
1046
2611
  if (event.breakdownAvailable) {
1047
- aggregate.detailedTokens += event.totalTokens;
2612
+ aggregate.detailedTokens = checkedTokenAdd(
2613
+ aggregate.detailedTokens,
2614
+ event.totalTokens,
2615
+ { allowFractional },
2616
+ );
1048
2617
  } else {
1049
- aggregate.unknownBreakdownTokens += event.totalTokens;
2618
+ aggregate.unknownBreakdownTokens = checkedTokenAdd(
2619
+ aggregate.unknownBreakdownTokens,
2620
+ event.totalTokens,
2621
+ { allowFractional },
2622
+ );
1050
2623
  }
1051
2624
  if (event.rateCardCredits !== null) {
1052
- aggregate.rateCardCredits += event.rateCardCredits;
1053
- 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;
1054
2635
  }
1055
2636
  }
1056
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
+
1057
2655
  function buildSnapshot(context, options, titles) {
1058
2656
  context.spool.finishWrites();
1059
- 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
+
1060
2683
  const lastEventKeyByTurn = new Map();
1061
2684
  let earliestEventAt = null;
1062
2685
  let latestEventAt = null;
@@ -1065,9 +2688,20 @@ function buildSnapshot(context, options, titles) {
1065
2688
  // receives each turn's tool calls without retaining the usage rows.
1066
2689
  for (const token of context.spool.tokenRows()) {
1067
2690
  const { threadId, timestamp } = resolvedOccurrence(token);
1068
- if (Date.parse(timestamp) < sinceMs) continue;
1069
- earliestEventAt ||= timestamp;
1070
- 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
+ }
1071
2705
  const key = token.turnId || `thread:${threadId}`;
1072
2706
  lastEventKeyByTurn.set(key, token.eventKey);
1073
2707
  }
@@ -1079,22 +2713,30 @@ function buildSnapshot(context, options, titles) {
1079
2713
  }
1080
2714
 
1081
2715
  const threadAggregates = new Map();
2716
+ const coverage = {
2717
+ detailedTokens: 0,
2718
+ unknownBreakdownTokens: 0,
2719
+ };
1082
2720
  let observedTokens = 0;
1083
- let detailedTokens = 0;
1084
2721
  let legacyHeuristicEvents = 0;
1085
2722
  let observedModelCalls = 0;
2723
+ let exactObservedModelCalls = 0;
2724
+ let migratedCompactedCalls = 0;
2725
+ let migratedCompactedTokens = 0;
1086
2726
 
1087
2727
  function* compactableEvents() {
1088
2728
  for (const token of context.spool.tokenRows()) {
1089
2729
  const { origin, occurrence, threadId, timestamp } = resolvedOccurrence(token);
1090
- if (Date.parse(timestamp) < sinceMs) continue;
1091
2730
  const metadata = threadMetadata(
1092
2731
  threadId,
1093
2732
  context.stateRows,
1094
2733
  titles,
1095
2734
  context.parents,
1096
2735
  origin || occurrence,
2736
+ context.persistedThreads,
1097
2737
  );
2738
+ const rangeAllocationEstimated =
2739
+ Number(token.rangeAllocationEstimated) === 1;
1098
2740
  const usage = {
1099
2741
  inputTokens: Number(token.inputTokens),
1100
2742
  cachedInputTokens: Number(token.cachedInputTokens),
@@ -1102,45 +2744,106 @@ function buildSnapshot(context, options, titles) {
1102
2744
  outputTokens: Number(token.outputTokens),
1103
2745
  reasoningTokens: Number(token.reasoningTokens),
1104
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,
1105
2753
  };
1106
- const breakdownAvailable = hasDetailedBreakdown(usage);
1107
- const serviceTier = occurrence.serviceTier || null;
1108
- 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);
1109
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;
1110
2769
  const event = {
1111
2770
  ...usage,
1112
2771
  timestamp,
2772
+ startAt: rangeAllocationOrigin?.startAt || timestamp,
2773
+ endAt: rangeAllocationOrigin?.endAt || timestamp,
1113
2774
  threadId,
1114
- project: metadata.project,
1115
- model: metadata.model,
1116
- effort: metadata.effort,
1117
- source: metadata.source,
1118
- useType: metadata.useType,
1119
- toolCalls:
1120
- lastEventKeyByTurn.get(turnKey) === token.eventKey
1121
- ? toolCounts.get(turnKey) || 0
1122
- : 0,
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),
1123
2782
  serviceTier,
1124
- rateCardCredits:
1125
- baseCredits === null
1126
- ? null
1127
- : serviceTier === "priority"
1128
- ? baseCredits * FAST_MODE_MULTIPLIER
1129
- : 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
+ }),
1130
2793
  breakdownAvailable,
2794
+ rangeAllocationEstimated,
2795
+ rangeAllocationOrigin,
1131
2796
  };
1132
2797
 
1133
- let aggregate = threadAggregates.get(threadId);
1134
- if (!aggregate) {
1135
- aggregate = newThreadAggregate(metadata, timestamp);
1136
- 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;
1137
2846
  }
1138
- addToThreadAggregate(aggregate, event);
1139
- observedTokens += event.totalTokens;
1140
- if (breakdownAvailable) detailedTokens += event.totalTokens;
1141
- if (!token.turnId) legacyHeuristicEvents += 1;
1142
- observedModelCalls += 1;
1143
- yield event;
1144
2847
  }
1145
2848
  }
1146
2849
 
@@ -1151,8 +2854,15 @@ function buildSnapshot(context, options, titles) {
1151
2854
  toolCounts.clear();
1152
2855
  lastEventKeyByTurn.clear();
1153
2856
 
1154
- const allThreadIds = new Set(context.stateRows.keys());
2857
+ const allThreadIds = new Set(
2858
+ scope.since === null ? context.stateRows.keys() : threadAggregates.keys(),
2859
+ );
1155
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
+ }
1156
2866
  const threads = [];
1157
2867
  for (const threadId of allThreadIds) {
1158
2868
  const aggregate = threadAggregates.get(threadId);
@@ -1161,13 +2871,25 @@ function buildSnapshot(context, options, titles) {
1161
2871
  context.stateRows,
1162
2872
  titles,
1163
2873
  context.parents,
2874
+ {},
2875
+ context.persistedThreads,
1164
2876
  );
1165
- if (!aggregate && !(metadata.reportedCumulativeTokens > 0)) continue;
2877
+ if (
2878
+ !aggregate &&
2879
+ (scope.since !== null || !(metadata.reportedCumulativeTokens > 0))
2880
+ ) continue;
1166
2881
  const eventCount = aggregate?.eventCount || 0;
2882
+ const unknownBreakdownTokens = aggregate
2883
+ ? representableUnknownBreakdownTokens(
2884
+ aggregate.totalTokens,
2885
+ aggregate.detailedTokens,
2886
+ aggregate.unknownBreakdownTokens,
2887
+ )
2888
+ : 0;
1167
2889
  const threadCoverage =
1168
2890
  eventCount === 0
1169
2891
  ? "unresolved"
1170
- : aggregate.detailedTokens === aggregate.totalTokens
2892
+ : aggregate.unknownBreakdownTokens === 0
1171
2893
  ? "complete"
1172
2894
  : aggregate.detailedTokens > 0
1173
2895
  ? "partial"
@@ -1185,7 +2907,7 @@ function buildSnapshot(context, options, titles) {
1185
2907
  lastActiveAt: aggregate?.lastActiveAt || metadata.updatedAt,
1186
2908
  totalTokens: aggregate?.totalTokens || 0,
1187
2909
  detailedTokens: aggregate?.detailedTokens || 0,
1188
- unknownBreakdownTokens: aggregate?.unknownBreakdownTokens || 0,
2910
+ unknownBreakdownTokens,
1189
2911
  reportedCumulativeTokens: metadata.reportedCumulativeTokens,
1190
2912
  inputTokens: aggregate?.inputTokens || 0,
1191
2913
  cachedInputTokens: aggregate?.cachedInputTokens || 0,
@@ -1193,6 +2915,7 @@ function buildSnapshot(context, options, titles) {
1193
2915
  reasoningTokens: aggregate?.reasoningTokens || 0,
1194
2916
  rateCardCredits:
1195
2917
  aggregate?.totalTokens > 0 &&
2918
+ !aggregate.hasPositiveUnrated &&
1196
2919
  aggregate.ratedTokens === aggregate.totalTokens
1197
2920
  ? aggregate.rateCardCredits
1198
2921
  : null,
@@ -1213,19 +2936,29 @@ function buildSnapshot(context, options, titles) {
1213
2936
  const exported = { ...quota };
1214
2937
  delete exported.turnId;
1215
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
+ }
1216
2946
  return exported;
1217
2947
  })
1218
2948
  .filter((quota) => {
1219
- if (!options.since) return true;
1220
2949
  return Date.parse(quota.lastSeenAt) >= sinceMs;
1221
2950
  })
1222
2951
  .sort(
1223
2952
  (left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
1224
2953
  );
1225
2954
 
1226
- const unknownBreakdownTokens = observedTokens - detailedTokens;
2955
+ const { detailedTokens, unknownBreakdownTokens } = coverage;
2956
+ const coverageTokens = detailedTokens + unknownBreakdownTokens;
1227
2957
  const stateCounterSumNonAdditive = threads.reduce(
1228
- (sum, thread) => sum + (thread.reportedCumulativeTokens || 0),
2958
+ (sum, thread) => checkedTokenAdd(
2959
+ sum,
2960
+ thread.reportedCumulativeTokens || 0,
2961
+ ),
1229
2962
  0,
1230
2963
  );
1231
2964
  const unresolvedThreadCounters = threads.filter(
@@ -1237,11 +2970,9 @@ function buildSnapshot(context, options, titles) {
1237
2970
  (quota) => quota.windowMinutes === WEEK_MINUTES,
1238
2971
  );
1239
2972
  const accountWideWeekly = weeklyCandidates.filter(
1240
- (quota) => !quota.limitName,
2973
+ (quota) => quota.scope === "account",
1241
2974
  );
1242
- const weekly = [
1243
- ...(accountWideWeekly.length ? accountWideWeekly : weeklyCandidates),
1244
- ]
2975
+ const weekly = accountWideWeekly
1245
2976
  .sort(
1246
2977
  (left, right) => Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt),
1247
2978
  )[0];
@@ -1249,18 +2980,26 @@ function buildSnapshot(context, options, titles) {
1249
2980
  ? (weekly.resetsAt - weekly.windowMinutes * 60) * 1_000
1250
2981
  : null;
1251
2982
  const completeSinceWindowStart = Boolean(
1252
- weeklyStart &&
2983
+ scope.since === null &&
2984
+ scope.includeArchived &&
2985
+ weeklyStart &&
1253
2986
  earliestEventAt &&
1254
2987
  Date.parse(earliestEventAt) <= weeklyStart &&
1255
- context.parseErrors === 0,
2988
+ context.parseErrors === 0 &&
2989
+ context.invalidTokenRecords === 0 &&
2990
+ context.invalidQuotaRecords === 0 &&
2991
+ !context.durableLedger?.sourceSummary?.sourceIncomplete &&
2992
+ migratedCompactedCalls === 0,
1256
2993
  );
1257
2994
 
1258
2995
  const notes = [
1259
2996
  "Observed totals sum globally de-duplicated last_token_usage model-call events.",
1260
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.",
1261
2998
  "Codex thread counters are retained only as non-additive reference values because forks and subagents inherit cumulative history.",
1262
- "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.",
1263
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.",
1264
3003
  ];
1265
3004
 
1266
3005
  return {
@@ -1269,16 +3008,48 @@ function buildSnapshot(context, options, titles) {
1269
3008
  label: "Local Codex snapshot",
1270
3009
  provenance: {
1271
3010
  kind: "codex-local-metadata",
3011
+ collection: scope,
3012
+ sourceCutoffAt: context.sourceInventory.cutoffAt,
1272
3013
  privacy:
1273
- "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.",
1274
- rateCardAsOf: RATE_CARD_AS_OF,
1275
- 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,
1276
3043
  },
1277
3044
  coverage: {
1278
3045
  filesScanned: context.filesScanned,
1279
3046
  bytesScanned: context.bytesScanned,
3047
+ filesReused: context.filesReused,
3048
+ bytesReused: context.bytesReused,
1280
3049
  parseErrors: context.parseErrors,
1281
3050
  duplicateEventsSkipped: context.duplicateEventsSkipped,
3051
+ invalidTokenRecords: context.invalidTokenRecords,
3052
+ invalidQuotaRecords: context.invalidQuotaRecords,
1282
3053
  correctionIntervals: context.correctionIntervals,
1283
3054
  observedTokens,
1284
3055
  detailedTokens,
@@ -1287,10 +3058,35 @@ function buildSnapshot(context, options, titles) {
1287
3058
  unresolvedThreadCounters,
1288
3059
  legacyHeuristicEvents,
1289
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
+ },
1290
3086
  usageBucketCount: usageStats.bucketCount,
1291
3087
  maximumUsageResolutionSeconds: usageStats.maximumResolutionSeconds,
1292
3088
  detailedPercent:
1293
- observedTokens > 0 ? (detailedTokens / observedTokens) * 100 : 100,
3089
+ coverageTokens > 0 ? (detailedTokens / coverageTokens) * 100 : 100,
1294
3090
  earliestEventAt,
1295
3091
  latestEventAt,
1296
3092
  completeSinceWindowStart,
@@ -1302,65 +3098,1062 @@ function buildSnapshot(context, options, titles) {
1302
3098
  };
1303
3099
  }
1304
3100
 
1305
- export async function collectUsage(options, onProgress = () => {}) {
1306
- const state = await readState(options.codexHome);
1307
- const titles = await readSessionTitles(
1308
- resolve(options.codexHome, "session_index.jsonl"),
1309
- );
1310
- const roots = [resolve(options.codexHome, "sessions")];
1311
- if (options.includeArchived) {
1312
- roots.push(resolve(options.codexHome, "archived_sessions"));
1313
- }
1314
- const files = (
1315
- await Promise.all(roots.map((root) => listJsonlFiles(root)))
1316
- )
1317
- .flat()
1318
- .sort();
1319
- const sizes = await Promise.all(files.map((path) => stat(path)));
1320
- const spool = await createUsageSpool();
1321
-
1322
- const context = {
3101
+ function createCollectionContext(state, spool, options) {
3102
+ return {
3103
+ codexHome: options.codexHome,
1323
3104
  stateRows: state.rows,
1324
3105
  parents: state.parents,
3106
+ stateDatabase: state.metadata,
1325
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,
1326
3117
  spool,
1327
3118
  filesScanned: 0,
1328
3119
  bytesScanned: 0,
3120
+ filesReused: 0,
3121
+ bytesReused: 0,
1329
3122
  parseErrors: 0,
1330
3123
  duplicateEventsSkipped: 0,
3124
+ invalidTokenRecords: 0,
3125
+ invalidQuotaRecords: 0,
1331
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,
3132
+ };
3133
+ }
3134
+
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,
1332
3209
  };
3210
+ }
1333
3211
 
1334
- try {
1335
- for (let index = 0; index < files.length; index += 1) {
1336
- await scanRollout(files[index], context);
1337
- context.filesScanned += 1;
1338
- context.bytesScanned += sizes[index].size;
1339
- if (
1340
- index === 0 ||
1341
- index === files.length - 1 ||
1342
- (index + 1) % 10 === 0
1343
- ) {
1344
- onProgress({
1345
- current: index + 1,
1346
- total: files.length,
1347
- path: files[index],
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,
1348
3572
  });
1349
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
+ );
3677
+ }
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);
1350
3870
  }
1351
- return buildSnapshot(context, options, titles);
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
+ };
1352
4065
  } finally {
1353
4066
  spool.close();
1354
4067
  await rm(spool.directory, { recursive: true, force: true });
1355
4068
  }
1356
4069
  }
1357
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
+
1358
4151
  async function main() {
1359
4152
  let options;
1360
4153
  try {
1361
4154
  options = parseArgs(process.argv.slice(2));
1362
4155
  } catch (error) {
1363
- 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`);
1364
4157
  process.exitCode = 1;
1365
4158
  return;
1366
4159
  }
@@ -1373,14 +4166,23 @@ async function main() {
1373
4166
  }
1374
4167
 
1375
4168
  process.stdout.write("Token Ledger: scanning local Codex metadata…\n");
1376
- 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 }) => {
1377
4180
  process.stdout.write(
1378
4181
  `\rToken Ledger: scanned ${current.toLocaleString()}/${total.toLocaleString()} rollout files`,
1379
4182
  );
1380
4183
  });
1381
- process.stdout.write("\nToken Ledger: writing privacy-reduced snapshot…\n");
1382
- const writeResult = await writePrivateSnapshot(options.output, snapshot);
1383
- const storedSnapshot = writeResult.snapshot;
4184
+ process.stdout.write("\n");
4185
+ const storedSnapshot = snapshot;
1384
4186
  process.stdout.write(
1385
4187
  [
1386
4188
  `Snapshot: ${options.output}`,
@@ -1388,6 +4190,8 @@ async function main() {
1388
4190
  `Unique model calls: ${storedSnapshot.coverage.observedModelCalls.toLocaleString()}`,
1389
4191
  `Stored usage buckets: ${storedSnapshot.events.length.toLocaleString()}`,
1390
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()}`,
1391
4195
  `Threads with unresolved state-only counters: ${storedSnapshot.coverage.unresolvedThreadCounters.toLocaleString()}`,
1392
4196
  `Snapshot size: ${(writeResult.bytesWritten / 1_000_000).toFixed(1)} MB (${writeResult.encoding}; ${(writeResult.jsonBytes / 1_000_000).toFixed(1)} MB JSON before encoding)`,
1393
4197
  `Snapshot safety limit: ${(writeResult.maxBytes / 1_000_000).toFixed(1)} MB`,
@@ -1395,12 +4199,49 @@ async function main() {
1395
4199
  );
1396
4200
  }
1397
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
+
1398
4235
  const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
1399
- if (import.meta.url === invokedPath) {
4236
+ if (isMainThread && import.meta.url === invokedPath) {
1400
4237
  main().catch((error) => {
1401
4238
  process.stderr.write(
1402
4239
  `Token Ledger collector failed: ${
1403
- 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
+ )
1404
4245
  }\n`,
1405
4246
  );
1406
4247
  process.exitCode = 1;