@adecore/agents 0.31.5 → 0.31.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ /*
2
+ * An ACP agent that answers with the frames a real one sent, recorded in a capture: its `initialize`, its
3
+ * `session/new`, and every update of one prompt before the response. Every prompt gets the same turn, except
4
+ * that a `session_info_update` carries the prompt's own text as its title, as Copilot CLI names a session.
5
+ */
6
+ import type { FakeCli } from './fake-cli.ts';
7
+
8
+ type Frame = Record<string, unknown>;
9
+
10
+ export interface AcpCapture {
11
+ sessionId: string;
12
+ initialize: unknown;
13
+ sessionNew: unknown;
14
+ // The `available_commands_update` the agent sends before it answers `session/new`.
15
+ commands: unknown[];
16
+ setConfigOption: unknown;
17
+ promptUpdates: unknown[];
18
+ promptResponse: unknown;
19
+ }
20
+
21
+ export function fakeAcpCapture(capture: AcpCapture): FakeCli {
22
+ return (io) => {
23
+ const send = (frame: Frame): void => {
24
+ io.out({ jsonrpc: '2.0', ...frame });
25
+ };
26
+ const update = (fields: unknown): void => {
27
+ send({ method: 'session/update', params: { sessionId: capture.sessionId, update: fields } });
28
+ };
29
+ return {
30
+ onLine: (line) => {
31
+ const frame = JSON.parse(line) as Frame;
32
+ const id = frame.id;
33
+ switch (frame.method) {
34
+ case undefined:
35
+ return;
36
+ case 'initialize':
37
+ send({ id, result: capture.initialize });
38
+ return;
39
+ case 'session/new':
40
+ update({ sessionUpdate: 'available_commands_update', availableCommands: capture.commands });
41
+ send({ id, result: capture.sessionNew });
42
+ return;
43
+ case 'session/set_config_option':
44
+ send({ id, result: capture.setConfigOption });
45
+ return;
46
+ case 'session/set_mode':
47
+ case 'session/close':
48
+ send({ id, result: {} });
49
+ return;
50
+ case 'session/prompt': {
51
+ const blocks = Array.isArray((frame.params as Frame | undefined)?.prompt) ? ((frame.params as Frame).prompt as Frame[]) : [];
52
+ const text = blocks.find((block) => block.type === 'text')?.text;
53
+ for (const fields of capture.promptUpdates) {
54
+ const named = (fields as Frame).sessionUpdate === 'session_info_update';
55
+ update(named ? { ...(fields as Frame), title: text } : fields);
56
+ }
57
+ send({ id, result: capture.promptResponse });
58
+ return;
59
+ }
60
+ case 'session/cancel':
61
+ return;
62
+ default:
63
+ if (id !== undefined) {
64
+ send({ id, error: { code: -32601, message: `Method not found: ${String(frame.method)}` } });
65
+ }
66
+ }
67
+ }
68
+ };
69
+ };
70
+ }
@@ -186,7 +186,7 @@ export async function aggregate(
186
186
  if (day < payload.from || day > payload.to) {
187
187
  continue;
188
188
  }
189
- const lookup = prices.look(record.model);
189
+ const lookup = prices.look(record.model, record.serviceTier, record.timestampMs);
190
190
  const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
191
191
  foldBucket(buckets, slot, record, account, lookup.price, cost);
192
192
  foldModel(models, record, account, lookup, cost);
@@ -2,8 +2,8 @@ import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contract
2
2
  import type { UsageRecord } from './record.ts';
3
3
  import type { CodexParserState } from './readers/codex.ts';
4
4
 
5
- /* Another version is thrown away rather than migrated, since rebuilding costs one cold scan; version 1 is read as it is. */
6
- const INDEX_VERSION = 2;
5
+ /* Old records survive deleted transcripts; the scanner reparses existing Codex files with its current reader. */
6
+ const INDEX_VERSION = 3;
7
7
  /* Before accounts: every record was the default account's, so it reads as that without a scan. */
8
8
  const ACCOUNTLESS_VERSION = 1;
9
9
 
@@ -21,18 +21,14 @@ export interface IndexedFile {
21
21
  codex: CodexParserState | null;
22
22
  /* A versioned hash of all bytes before the offset; absent in indexes that cannot verify an append. */
23
23
  head: string | null;
24
+ parserVersion?: number;
25
+ compressed?: boolean;
24
26
  }
25
27
 
26
28
  export type UsageIndex = Map<string, IndexedFile>;
27
29
 
28
- /*
29
- * The index is a few hundred thousand records of seven numbers each, and the same model, session,
30
- * directory and account strings over and over. Interning those and writing a record as a tuple is
31
- * what keeps the file at a few megabytes instead of tens of them. The account is one past its place
32
- * in its table, and 0 for the default account, which is also what a version 1 row without it means.
33
- * A cost the CLI reported itself rides last, only on a row that has one, so every other row keeps its length.
34
- */
35
- type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?];
30
+ /* Interned names keep the index compact; optional cost and tier fields preserve per-call pricing. */
31
+ type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?, string?];
36
32
 
37
33
  interface FileRow {
38
34
  p: UsageProvider;
@@ -43,6 +39,8 @@ interface FileRow {
43
39
  t: RecordRow[];
44
40
  c: CodexParserState | null;
45
41
  h?: string;
42
+ v?: number;
43
+ z?: boolean;
46
44
  }
47
45
 
48
46
  interface IndexFile {
@@ -92,8 +90,11 @@ export function encodeIndex(index: UsageIndex): string {
92
90
  record.dedupeKey ?? 0,
93
91
  record.account === undefined ? 0 : accounts.index(record.account) + 1
94
92
  ];
95
- if (record.costUsd !== undefined) {
96
- counted.push(record.costUsd);
93
+ if (record.costUsd !== undefined || record.serviceTier !== undefined) {
94
+ counted[13] = record.costUsd;
95
+ }
96
+ if (record.serviceTier !== undefined) {
97
+ counted[14] = record.serviceTier;
97
98
  }
98
99
  return counted;
99
100
  };
@@ -106,7 +107,9 @@ export function encodeIndex(index: UsageIndex): string {
106
107
  c: file.codex,
107
108
  r: file.records.map(row),
108
109
  t: file.tail.map(row),
109
- ...(file.head === null ? {} : { h: file.head })
110
+ ...(file.head === null ? {} : { h: file.head }),
111
+ ...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
112
+ ...(file.compressed === undefined ? {} : { z: file.compressed })
110
113
  };
111
114
  }
112
115
  const document: IndexFile = {
@@ -123,11 +126,12 @@ export function encodeIndex(index: UsageIndex): string {
123
126
  function isRow(row: unknown, version: number): row is RecordRow {
124
127
  return (
125
128
  Array.isArray(row) &&
126
- (version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length === 13 || row.length === 14) &&
129
+ (version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length >= 13 && row.length <= (version === INDEX_VERSION ? 15 : 14)) &&
127
130
  row.slice(0, 11).every((value) => typeof value === 'number') &&
128
131
  (typeof row[11] === 'string' || row[11] === 0) &&
129
132
  (version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
130
- (row.length < 14 || typeof row[13] === 'number')
133
+ (row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
134
+ (row.length < 15 || typeof row[14] === 'string')
131
135
  );
132
136
  }
133
137
 
@@ -145,7 +149,7 @@ export function decodeIndex(text: string): UsageIndex {
145
149
  }
146
150
  const version = document?.version;
147
151
  if (
148
- (version !== INDEX_VERSION && version !== ACCOUNTLESS_VERSION) ||
152
+ (version !== INDEX_VERSION && version !== 2 && version !== ACCOUNTLESS_VERSION) ||
149
153
  !Array.isArray(document.models) ||
150
154
  !Array.isArray(document.sessions) ||
151
155
  !Array.isArray(document.folders)
@@ -184,7 +188,8 @@ export function decodeIndex(text: string): UsageIndex {
184
188
  cwd,
185
189
  ...(account === undefined ? {} : { account }),
186
190
  totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
187
- ...(row[13] === undefined ? {} : { costUsd: row[13] }),
191
+ ...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
192
+ ...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
188
193
  dedupeKey: row[11] === 0 ? null : row[11]
189
194
  });
190
195
  }
@@ -201,7 +206,9 @@ export function decodeIndex(text: string): UsageIndex {
201
206
  records,
202
207
  tail,
203
208
  codex: file.c ?? null,
204
- head: typeof file.h === 'string' ? file.h : null
209
+ head: typeof file.h === 'string' ? file.h : null,
210
+ ...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
211
+ ...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
205
212
  });
206
213
  }
207
214
  }
@@ -22,6 +22,7 @@ export interface ModelPrice {
22
22
  cacheRead: number;
23
23
  cacheWrite: number;
24
24
  cacheWrite1h: number;
25
+ tiers?: Partial<Record<string, ModelPrice>>;
25
26
  }
26
27
 
27
28
  export type PriceTable = Map<string, ModelPrice>;
@@ -40,6 +41,7 @@ const PROVIDERS = new Set(['anthropic', 'openai']);
40
41
  const MODES = new Set(['chat', 'responses']);
41
42
 
42
43
  interface LiteLlmEntry {
44
+ [key: string]: unknown;
43
45
  input_cost_per_token?: unknown;
44
46
  output_cost_per_token?: unknown;
45
47
  cache_read_input_token_cost?: unknown;
@@ -84,6 +86,23 @@ export function parsePriceTable(document: unknown): PriceTable {
84
86
  cacheWrite: rate(value.cache_creation_input_token_cost) ?? input * CACHE_WRITE_SHARE,
85
87
  cacheWrite1h: input * CACHE_WRITE_1H_SHARE
86
88
  };
89
+ const tiers: Partial<Record<string, ModelPrice>> = {};
90
+ for (const [tier, multiplier] of [
91
+ ['priority', 2],
92
+ ['flex', 0.5],
93
+ ['ultrafast', 6]
94
+ ] as const) {
95
+ tiers[tier] = {
96
+ input: rate(value[`input_cost_per_token_${tier}`]) ?? input * multiplier,
97
+ output: rate(value[`output_cost_per_token_${tier}`]) ?? output * multiplier,
98
+ cacheRead: rate(value[`cache_read_input_token_cost_${tier}`]) ?? price.cacheRead * multiplier,
99
+ cacheWrite: rate(value[`cache_creation_input_token_cost_${tier}`]) ?? price.cacheWrite * multiplier,
100
+ cacheWrite1h: price.cacheWrite1h * multiplier
101
+ };
102
+ }
103
+ if (value.litellm_provider === 'openai') {
104
+ price.tiers = tiers;
105
+ }
87
106
  const key = name.trim().toLowerCase();
88
107
  table.set(key, price);
89
108
  const bare = key.slice(key.lastIndexOf('/') + 1);
@@ -100,12 +119,25 @@ function withoutDate(key: string): string | null {
100
119
  return match === null ? null : match[1]!;
101
120
  }
102
121
 
103
- /*
104
- * Exact first, then the same name without its date, then the longest family the table knows. A name
105
- * nobody prices is left unpriced rather than guessed at: `$0.00` for a model that costs money is a
106
- * worse answer than a question mark.
107
- */
108
- export function lookupPrice(table: PriceTable, model: string): PriceLookup {
122
+ // The internal review alias has no published price; expose ccusage's dated estimate as a family match.
123
+ // https://github.com/ccusage/ccusage/blob/main/rust/adapters/codex/src/codex-auto-review-fallbacks.json
124
+ const REVIEW_MODELS = [
125
+ ['2026-07-30', 'gpt-5.6-luna'],
126
+ ['2026-03-05', 'gpt-5.4'],
127
+ ['2026-02-05', 'gpt-5.3-codex'],
128
+ ['2025-12-11', 'gpt-5.2-codex'],
129
+ ['2025-11-13', 'gpt-5.1-codex'],
130
+ ['2025-09-15', 'gpt-5-codex'],
131
+ ['2025-08-07', 'gpt-5']
132
+ ] as const;
133
+
134
+ export function lookupPrice(table: PriceTable, model: string, serviceTier?: string, timestampMs?: number): PriceLookup {
135
+ const found = lookupBasePrice(table, model, timestampMs);
136
+ const tier = serviceTier === 'fast' ? 'priority' : serviceTier;
137
+ return { ...found, price: (tier === undefined ? undefined : found.price?.tiers?.[tier]) ?? found.price };
138
+ }
139
+
140
+ function lookupBasePrice(table: PriceTable, model: string, timestampMs?: number): PriceLookup {
109
141
  const key = model.trim().toLowerCase().split('[')[0]!;
110
142
  const bare = key.slice(key.lastIndexOf('/') + 1);
111
143
  if (bare === '' || AMBIGUOUS.has(bare)) {
@@ -115,6 +147,12 @@ export function lookupPrice(table: PriceTable, model: string): PriceLookup {
115
147
  if (exact !== undefined) {
116
148
  return { price: exact, basis: 'exact', pricedAs: null };
117
149
  }
150
+ if (bare === 'codex-auto-review' && timestampMs !== undefined && Number.isFinite(timestampMs)) {
151
+ const date = new Date(timestampMs).toISOString().slice(0, 10);
152
+ const fallback = REVIEW_MODELS.find(([released]) => date >= released)?.[1] ?? 'gpt-5';
153
+ const found = lookupBasePrice(table, fallback);
154
+ return { price: found.price, basis: found.price === null ? 'unknown' : 'family', pricedAs: found.price === null ? null : fallback };
155
+ }
118
156
  const dated = withoutDate(bare);
119
157
  const undated = dated === null ? undefined : table.get(dated);
120
158
  if (undated !== undefined && dated !== null) {
@@ -177,8 +215,8 @@ export class PriceBook {
177
215
  return { source: this.table.size === 0 ? 'none' : this.source, fetchedAt: this.fetchedAt, models: this.table.size };
178
216
  }
179
217
 
180
- look(model: string): PriceLookup {
181
- return lookupPrice(this.table, model);
218
+ look(model: string, serviceTier?: string, timestampMs?: number): PriceLookup {
219
+ return lookupPrice(this.table, model, serviceTier, timestampMs);
182
220
  }
183
221
 
184
222
  /* Reads the table an earlier run stored, once; asking LiteLLM is left to `ensure`. */
@@ -1,8 +1,7 @@
1
1
  import { asObject, asString, int, type UsageRecord } from '../record.ts';
2
2
 
3
- /* A `token_count` line carries the counts; the other two carry the model and the directory they belong to. */
4
3
  export function codexMightCarryUsage(line: string): boolean {
5
- return line.includes('token_count') || line.includes('turn_context') || line.includes('session_meta');
4
+ return ['token_count', 'turn_context', 'session_meta', 'thread_settings_applied', 'token_usage_record', 'compacted'].some((type) => line.includes(type));
6
5
  }
7
6
 
8
7
  /* A fork replays its ancestors' events into the new file the moment it is created, so anything this
@@ -34,6 +33,11 @@ export interface CodexParserState {
34
33
  anchorMs: number;
35
34
  /* The last counts as they were written, so a line repeated verbatim is not counted twice. */
36
35
  lastSignature: string | null;
36
+ serviceTier: string | null;
37
+ pendingCompactions: Record<string, { record: UsageRecord; counts: CodexCounts; threadTotal: CodexCounts | null }>;
38
+ compacted: string[];
39
+ handledResponses: string[];
40
+ latestResponse: string | null;
37
41
  }
38
42
 
39
43
  export function createCodexState(): CodexParserState {
@@ -45,12 +49,17 @@ export function createCodexState(): CodexParserState {
45
49
  sawSessionMeta: false,
46
50
  suppressing: false,
47
51
  anchorMs: 0,
48
- lastSignature: null
52
+ lastSignature: null,
53
+ serviceTier: null,
54
+ pendingCompactions: {},
55
+ compacted: [],
56
+ handledResponses: [],
57
+ latestResponse: null
49
58
  };
50
59
  }
51
60
 
52
61
  export function cloneCodexState(state: CodexParserState): CodexParserState {
53
- return { ...state, total: state.total === null ? null : { ...state.total } };
62
+ return structuredClone(state);
54
63
  }
55
64
 
56
65
  function countsOf(usage: Record<string, unknown>): CodexCounts {
@@ -87,13 +96,8 @@ export function isForkOrSubagent(payload: Record<string, unknown>): boolean {
87
96
  return typeof asObject(spawn)?.parent_thread_id === 'string';
88
97
  }
89
98
 
90
- /*
91
- * One line of `~/.codex/sessions/**\/*.jsonl`, against the state of the file it came from. Codex
92
- * reports a cumulative total per turn, so the call is the difference with the line before it; when
93
- * that difference falls the run started over and `last_token_usage` is the turn on its own. Codex
94
- * counts cache reads and writes inside `input_tokens`, unlike Anthropic, so what is left after
95
- * taking both out is the input that was actually paid for at the full rate.
96
- */
99
+ /* Codex includes cache reads and writes in input_tokens. Compaction can appear separately from
100
+ cumulative token_count events, so response identities prevent charging the same request twice. */
97
101
  export function parseCodexLine(line: string, state: CodexParserState): UsageRecord | null {
98
102
  let parsed: unknown;
99
103
  try {
@@ -134,6 +138,18 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
134
138
  return null;
135
139
  }
136
140
 
141
+ if (payload.type === 'thread_settings_applied') {
142
+ const settings = asObject(payload.thread_settings);
143
+ if (settings !== null && typeof settings.service_tier === 'string') {
144
+ state.serviceTier = settings.service_tier;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ if (record.type === 'token_usage_record' || record.type === 'compacted') {
150
+ return parseCompaction(record, payload, state);
151
+ }
152
+
137
153
  if (payload.type !== 'token_count') {
138
154
  return null;
139
155
  }
@@ -170,6 +186,62 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
170
186
  state.suppressing = false;
171
187
  }
172
188
 
189
+ let dedupeKey: string | null = null;
190
+ const pending = state.latestResponse === null ? undefined : state.pendingCompactions[state.latestResponse];
191
+ if (pending !== undefined && (sameCounts(pending.counts, counts) || sameCounts(pending.threadTotal, cumulative))) {
192
+ dedupeKey = `codex:response:${state.latestResponse}`;
193
+ delete state.pendingCompactions[state.latestResponse!];
194
+ state.handledResponses.push(state.latestResponse!);
195
+ }
196
+ return usageRecord(counts, timestampMs, state.model, state, dedupeKey);
197
+ }
198
+
199
+ function sameCounts(first: CodexCounts | null, second: CodexCounts): boolean {
200
+ return (
201
+ first !== null &&
202
+ first.input === second.input &&
203
+ first.cached === second.cached &&
204
+ first.cacheWrite === second.cacheWrite &&
205
+ first.output === second.output &&
206
+ first.reasoning === second.reasoning
207
+ );
208
+ }
209
+
210
+ function parseCompaction(record: Record<string, unknown>, payload: Record<string, unknown>, state: CodexParserState): UsageRecord | null {
211
+ const id = asString(record.type === 'compacted' ? payload.compaction_response_id : payload.response_id);
212
+ if (id === '' || state.handledResponses.includes(id)) {
213
+ return null;
214
+ }
215
+ if (record.type === 'compacted') {
216
+ if (!state.compacted.includes(id)) {
217
+ state.compacted.push(id);
218
+ }
219
+ } else {
220
+ const usage = asObject(payload.usage);
221
+ const model = asString(payload.model) || state.model;
222
+ const timestampMs = Date.parse(asString(record.timestamp));
223
+ if (usage === null || model === null || !Number.isFinite(timestampMs)) {
224
+ return null;
225
+ }
226
+ const counts = countsOf(usage);
227
+ const parsed = usageRecord(counts, timestampMs, model, state, `codex:response:${id}`);
228
+ if (parsed === null) {
229
+ return null;
230
+ }
231
+ state.latestResponse = id;
232
+ const threadTotal = asObject(payload.thread_token_usage);
233
+ state.pendingCompactions[id] ??= { record: parsed, counts, threadTotal: threadTotal === null ? null : countsOf(threadTotal) };
234
+ }
235
+ const pending = state.pendingCompactions[id];
236
+ if (!state.compacted.includes(id) || pending === undefined) {
237
+ return null;
238
+ }
239
+ state.handledResponses.push(id);
240
+ delete state.pendingCompactions[id];
241
+ return pending.record;
242
+ }
243
+
244
+ function usageRecord(counts: CodexCounts, timestampMs: number, model: string, state: CodexParserState, dedupeKey: string | null): UsageRecord | null {
173
245
  const uncached = Math.max(0, counts.input - counts.cached - counts.cacheWrite);
174
246
  const totals = {
175
247
  calls: 1,
@@ -186,11 +258,11 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
186
258
  return {
187
259
  provider: 'codex',
188
260
  timestampMs,
189
- model: state.model,
261
+ model,
190
262
  sessionId: state.sessionId,
191
263
  cwd: state.cwd,
192
264
  totals,
193
- // Fork copies are gone by here, so every event that is left is a call of its own.
194
- dedupeKey: null
265
+ ...(state.serviceTier === null ? {} : { serviceTier: state.serviceTier }),
266
+ dedupeKey
195
267
  };
196
268
  }
@@ -13,6 +13,7 @@ export interface UsageRecord {
13
13
  totals: UsageTotals;
14
14
  /* What the CLI itself says the call cost, which wins over a price from the table; absent where it says nothing. */
15
15
  costUsd?: number;
16
+ serviceTier?: string;
16
17
  /* What identifies the message this line reports on, or null when nothing does. */
17
18
  dedupeKey: string | null;
18
19
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash, type Hash } from 'node:crypto';
2
2
  import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { zstdDecompressSync } from 'node:zlib';
4
5
  import type { UsageProvider, UsageRoot } from '@adecore/agent-contracts';
5
6
  import { errorText } from '../error-text.ts';
6
7
  import { isNotFound, writeAtomic } from '../fs.ts';
@@ -13,6 +14,7 @@ import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine
13
14
  import { usageRoots, type UsageRootPath } from './roots.ts';
14
15
 
15
16
  const HASH_CHUNK_BYTES = 65536;
17
+ const CODEX_PARSER_VERSION = 2;
16
18
 
17
19
  interface HashedRead {
18
20
  /* Everything from `from` to the end of the file, as it is at the read. */
@@ -82,7 +84,7 @@ function readsWhole(provider: UsageProvider): boolean {
82
84
  }
83
85
 
84
86
  function isUsageFile(provider: UsageProvider, name: string): boolean {
85
- return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
87
+ return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl') || (provider === 'codex' && name.endsWith('.jsonl.zst'));
86
88
  }
87
89
 
88
90
  async function listUsageFiles(dir: string, provider: UsageProvider, out: string[] = []): Promise<string[]> {
@@ -255,7 +257,12 @@ export class UsageScanner {
255
257
  }
256
258
  let failure: string | null = null;
257
259
  const accountOf = accountResolver(root, sessionAccounts);
258
- for (const path of await listUsageFiles(root.path, root.provider)) {
260
+ const paths = await listUsageFiles(root.path, root.provider);
261
+ const plain = new Set(paths);
262
+ for (const path of paths) {
263
+ if (root.provider === 'codex' && path.endsWith('.jsonl.zst') && plain.has(path.slice(0, -4))) {
264
+ continue;
265
+ }
259
266
  files += 1;
260
267
  try {
261
268
  if (await this.readFileInto(path, root.provider, accountOf)) {
@@ -288,15 +295,34 @@ export class UsageScanner {
288
295
 
289
296
  private async readFileInto(path: string, provider: UsageProvider, accountOf: AccountOf): Promise<boolean> {
290
297
  const info = await stat(path);
291
- const known = this.index.get(path);
292
- if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
298
+ const compressed = provider === 'codex' && path.endsWith('.jsonl.zst');
299
+ const key = compressed ? path.slice(0, -4) : path;
300
+ const known = this.index.get(key);
301
+ const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
302
+ if (
303
+ currentParser &&
304
+ known !== undefined &&
305
+ known.provider === provider &&
306
+ (known.compressed ?? false) === compressed &&
307
+ known.size === info.size &&
308
+ known.mtimeMs === info.mtimeMs
309
+ ) {
293
310
  return false;
294
311
  }
295
312
  if (readsWhole(provider)) {
296
313
  return this.readWholeInto(path, provider, accountOf, info);
297
314
  }
298
- const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
299
- let read = await readHashed(path, appended ? known.offset : 0);
315
+ const appended =
316
+ !compressed &&
317
+ currentParser &&
318
+ known?.compressed !== true &&
319
+ known !== undefined &&
320
+ known.provider === provider &&
321
+ info.size > known.size &&
322
+ known.head?.startsWith('sha256:') === true;
323
+ let read = compressed
324
+ ? { bytes: zstdDecompressSync(await readFile(path)), hash: createHash('sha256') }
325
+ : await readHashed(path, appended ? known.offset : 0);
300
326
  // A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
301
327
  const grown = appended && headOf(read.hash) === known.head;
302
328
  if (appended && !grown) {
@@ -315,9 +341,10 @@ export class UsageScanner {
315
341
  records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
316
342
  tail: withAccount(parsed.tail, accountOf),
317
343
  codex: parsed.codex,
318
- head: headOf(read.hash)
344
+ head: headOf(read.hash),
345
+ ...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
319
346
  };
320
- this.index.set(path, entry);
347
+ this.index.set(key, entry);
321
348
  this.dirty = true;
322
349
  return true;
323
350
  }