@nyte-ai/host 0.0.18 → 0.0.19

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.
Files changed (102) hide show
  1. package/THIRD-PARTY-NOTICES.md +66 -0
  2. package/dist/chunks/{canvas-preview-5hxat7hh.js → canvas-preview-11nadadc.js} +8 -6
  3. package/dist/chunks/{canvas-preview-rp6fz0w5.js → canvas-preview-2jwbe14k.js} +5 -5
  4. package/dist/chunks/{canvas-preview-m02v4yy6.js → canvas-preview-55s0y68g.js} +3 -2
  5. package/dist/chunks/{canvas-preview-xn10d4f8.js → canvas-preview-5ekjx6sw.js} +9 -4
  6. package/dist/chunks/{canvas-preview-75db60xy.js → canvas-preview-8xc0vn7v.js} +5 -4
  7. package/dist/chunks/{canvas-preview-zn2d0k9d.js → canvas-preview-af86jz8t.js} +14 -6
  8. package/dist/chunks/{canvas-preview-d9wwhbtz.js → canvas-preview-aq5j4p25.js} +1 -1
  9. package/dist/chunks/{canvas-preview-j1tnf51w.js → canvas-preview-b2v8wfnz.js} +1 -1
  10. package/dist/chunks/canvas-preview-befkjea2.js +60 -0
  11. package/dist/chunks/canvas-preview-bxnqt8cd.js +103 -0
  12. package/dist/chunks/{canvas-preview-vax9ne4e.js → canvas-preview-cawy6vbz.js} +22 -11
  13. package/dist/chunks/{canvas-preview-senpjwep.js → canvas-preview-crmzxxt8.js} +3 -3
  14. package/dist/chunks/{canvas-preview-ymgz0937.js → canvas-preview-drctegz4.js} +8 -4
  15. package/dist/chunks/{canvas-preview-yrfz2ak6.js → canvas-preview-e3vnkkc5.js} +6 -6
  16. package/dist/chunks/canvas-preview-ec2mt7es.js +108 -0
  17. package/dist/chunks/{canvas-preview-1ta3avah.js → canvas-preview-fxa0rfb0.js} +10 -5
  18. package/dist/chunks/{canvas-preview-8xgt67ka.js → canvas-preview-g5fkjs34.js} +1 -1
  19. package/dist/chunks/{canvas-preview-g3qj3y3k.js → canvas-preview-jeddjex8.js} +13 -3
  20. package/dist/chunks/{canvas-preview-0nrnbt76.js → canvas-preview-k1yg4fgp.js} +199 -125
  21. package/dist/chunks/canvas-preview-kp75gb63.js +141 -0
  22. package/dist/chunks/{canvas-preview-ewgspmsd.js → canvas-preview-n2a35zv5.js} +1 -1
  23. package/dist/chunks/{canvas-preview-h8hshnff.js → canvas-preview-pkzqg96v.js} +1 -1
  24. package/dist/chunks/{canvas-preview-evh92ywd.js → canvas-preview-tnqe7k0b.js} +15 -10
  25. package/dist/chunks/{canvas-preview-ehf3vp31.js → canvas-preview-vm9zdp2t.js} +45 -58
  26. package/dist/chunks/{canvas-preview-76fwvd6e.js → canvas-preview-w54ymtb3.js} +1 -1
  27. package/dist/chunks/canvas-preview-y5y6ghaw.js +125 -0
  28. package/dist/chunks/{canvas-preview-98aj5p34.js → canvas-preview-ycepb05s.js} +1 -1
  29. package/dist/src/canvas-preview.js +7 -3
  30. package/dist/src/deploy/cloudflare/worker.js +1 -1
  31. package/dist/src/deploy/vercel/server/routes/[...].js +2 -2
  32. package/dist/src/deploy/vercel/server/utils/host.js +1 -1
  33. package/dist/src/deploy/vercel/server/workflows/drive.js +3 -3
  34. package/dist/src/images/exif.d.ts +1 -0
  35. package/dist/src/images/exif.js +6 -0
  36. package/dist/src/images/node.d.ts +37 -0
  37. package/dist/src/images/node.js +12 -0
  38. package/dist/src/images/photon.d.ts +3 -0
  39. package/dist/src/images/photon.js +7 -0
  40. package/dist/src/images/worker.d.ts +1 -0
  41. package/dist/src/images/worker.js +23 -0
  42. package/dist/src/index.d.ts +0 -1
  43. package/dist/src/index.js +23 -22
  44. package/dist/src/listen-fetch.js +3 -3
  45. package/dist/src/listen.js +4 -4
  46. package/dist/src/local/command.d.ts +5 -0
  47. package/dist/src/local/command.js +6 -2
  48. package/dist/src/local/files.js +2 -2
  49. package/dist/src/local/git-watch.d.ts +1 -1
  50. package/dist/src/local/git-watch.js +1 -1
  51. package/dist/src/local/git.js +3 -3
  52. package/dist/src/local/github.js +2 -2
  53. package/dist/src/local/jobs.d.ts +1 -1
  54. package/dist/src/local/jobs.js +2 -1
  55. package/dist/src/local/provider.js +9 -9
  56. package/dist/src/local/snapshots.js +2 -2
  57. package/dist/src/local/worktrees.js +2 -2
  58. package/dist/src/mcp/connect.d.ts +0 -13
  59. package/dist/src/mcp/connect.js +1 -1
  60. package/dist/src/mcp/hub.js +2 -2
  61. package/dist/src/open.d.ts +2 -30
  62. package/dist/src/open.js +20 -18
  63. package/dist/src/serve/binding.js +1 -1
  64. package/dist/src/serve/handler.js +2 -2
  65. package/dist/src/sqlite.d.ts +2 -1
  66. package/dist/src/sqlite.js +4 -3
  67. package/dist/src/store-usage.js +4 -1
  68. package/dist/src/usage-history.d.ts +54 -0
  69. package/dist/src/usage-history.js +14 -0
  70. package/dist/src/usage-service.d.ts +2 -1
  71. package/dist/src/usage-service.js +7 -3
  72. package/dist/src/usage-worker.js +8 -4
  73. package/dist/src/usage.d.ts +150 -27
  74. package/dist/src/usage.js +12 -2
  75. package/package.json +16 -8
  76. package/src/canvas-preview.ts +4 -3
  77. package/src/images/exif.ts +69 -0
  78. package/src/images/node.ts +163 -0
  79. package/src/images/photon.ts +141 -0
  80. package/src/images/worker.ts +29 -0
  81. package/src/index.ts +19 -1
  82. package/src/local/command.ts +64 -12
  83. package/src/local/files.ts +8 -3
  84. package/src/local/git-watch.ts +16 -6
  85. package/src/local/git.ts +7 -2
  86. package/src/local/github.ts +3 -4
  87. package/src/local/jobs.ts +55 -69
  88. package/src/local/snapshots.ts +4 -2
  89. package/src/local/worktrees.ts +4 -4
  90. package/src/mcp/connect.ts +1 -14
  91. package/src/open.ts +16 -36
  92. package/src/serve/binding.ts +4 -3
  93. package/src/sqlite.ts +33 -8
  94. package/src/store-usage.ts +5 -2
  95. package/src/usage-history.ts +219 -0
  96. package/src/usage-service.ts +12 -3
  97. package/src/usage.ts +251 -176
  98. package/dist/chunks/canvas-preview-a3yw0j70.js +0 -72
  99. package/dist/chunks/canvas-preview-azg56359.js +0 -68
  100. package/dist/src/store-owner.d.ts +0 -15
  101. package/dist/src/store-owner.js +0 -9
  102. package/src/store-owner.ts +0 -98
package/src/usage.ts CHANGED
@@ -1,46 +1,82 @@
1
- import { Buffer } from "node:buffer";
2
- import { constants, existsSync } from "node:fs";
3
- import { lstat, open, opendir, stat } from "node:fs/promises";
1
+ import { existsSync } from "node:fs";
2
+ import { lstat, opendir, stat } from "node:fs/promises";
4
3
  import { homedir } from "node:os";
5
4
  import { join, resolve } from "node:path";
6
5
  import process from "node:process";
7
- import { StringDecoder } from "node:string_decoder";
8
6
  import { Type, type Static } from "typebox";
7
+ import { Compile } from "typebox/compile";
9
8
  import { Value } from "typebox/value";
10
9
  import { calculateCost, type Models } from "@nyte-ai/ai";
10
+ import { localDay } from "./store-usage.ts";
11
+ import {
12
+ fileScanSchema,
13
+ readCacheDocument,
14
+ readHistory,
15
+ UsageScanCache,
16
+ writeCacheDocument,
17
+ type HistoryLine,
18
+ type HistoryParser,
19
+ } from "./usage-history.ts";
11
20
  import { summarizeModels, emptyModelUsage, recordedUsage } from "./usage-summary.ts";
12
- import type { LocalHistoryUsage, ModelUsage } from "./usage-types.ts";
21
+ import type { LocalHistoryUsage, ModelUsage, UsageWindow } from "./usage-types.ts";
13
22
  import type { Usage } from "@nyte-ai/schema/model";
14
23
 
15
24
  export interface ClaudeCodeUsageOptions {
16
25
  readonly models: Pick<Models, "getModels">;
26
+ readonly window: UsageWindow;
17
27
  readonly configDir?: string;
18
28
  readonly signal?: AbortSignal;
19
29
  readonly cache?: UsageScanCache<ClaudeCodeFileScan>;
20
30
  }
21
31
 
22
- export type UsageScanCache<T> = Map<string, FileScan<T>>;
32
+ export type ClaudeCodeUsage = LocalHistoryUsage;
23
33
 
24
- export interface FileScan<T> {
25
- readonly size: number;
26
- readonly mtimeMs: number;
27
- readonly scan: T;
28
- }
34
+ const tokens = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
29
35
 
30
- export interface ClaudeCodeFileScan {
31
- readonly keyed: readonly (readonly [string, Snapshot])[];
32
- readonly anonymous: readonly Snapshot[];
33
- readonly malformedRecords: number;
34
- }
36
+ const count = Type.Number({ minimum: 0 });
37
+
38
+ const modelUsage = Type.Object({
39
+ input: count,
40
+ output: count,
41
+ cacheRead: count,
42
+ cacheWrite: count,
43
+ cacheWrite1h: Type.Optional(count),
44
+ reasoning: Type.Optional(count),
45
+ totalTokens: count,
46
+ cost: Type.Object({
47
+ input: Type.Number(),
48
+ output: Type.Number(),
49
+ cacheRead: Type.Number(),
50
+ cacheWrite: Type.Number(),
51
+ total: Type.Number(),
52
+ }),
53
+ });
35
54
 
36
- export type ClaudeCodeUsage = LocalHistoryUsage;
55
+ /** When a record was written, in epoch ms; `null` when its line carries no readable timestamp. */
56
+ const recordedAt = Type.Union([Type.Number(), Type.Null()]);
37
57
 
38
- const tokens = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
58
+ const snapshot = Type.Object({
59
+ model: Type.String(),
60
+ usage: modelUsage,
61
+ costUSD: Type.Optional(Type.Number()),
62
+ at: recordedAt,
63
+ });
64
+
65
+ export type Snapshot = Static<typeof snapshot>;
66
+
67
+ const claudeCodeFileScan = Type.Object({
68
+ keyed: Type.Array(Type.Tuple([Type.String(), snapshot])),
69
+ anonymous: Type.Array(snapshot),
70
+ malformedRecords: Type.Integer({ minimum: 0 }),
71
+ });
72
+
73
+ export type ClaudeCodeFileScan = Static<typeof claudeCodeFileScan>;
39
74
 
40
75
  const envelope = Type.Object({ type: Type.String() });
41
76
 
42
77
  const assistantRecord = Type.Object({
43
78
  type: Type.Literal("assistant"),
79
+ timestamp: Type.Optional(Type.String()),
44
80
  requestId: Type.Optional(Type.String()),
45
81
  costUSD: Type.Optional(Type.Unknown()),
46
82
  message: Type.Object({
@@ -63,12 +99,6 @@ const assistantRecord = Type.Object({
63
99
 
64
100
  const reportedCost = Type.Number({ minimum: 0, maximum: Number.MAX_VALUE });
65
101
 
66
- export interface Snapshot {
67
- readonly model: string;
68
- readonly usage: Usage;
69
- readonly costUSD: number | undefined;
70
- }
71
-
72
102
  function betterSnapshot(previous: Snapshot | undefined, next: Snapshot): boolean {
73
103
  if (previous === undefined) return true;
74
104
  const usage = next.usage;
@@ -83,38 +113,51 @@ function betterSnapshot(previous: Snapshot | undefined, next: Snapshot): boolean
83
113
  );
84
114
  }
85
115
 
86
- /** Reads one Claude Code transcript's usage snapshots. An unparsable last line without its newline is not counted malformed: a writer may still be appending it. */
87
- async function scanClaudeCodeFile(
88
- path: string,
89
- signal: AbortSignal | undefined,
90
- ): Promise<ClaudeCodeFileScan> {
91
- const keyed = new Map<string, Snapshot>();
92
- const anonymous: Snapshot[] = [];
93
- let malformedRecords = 0;
116
+ function parseRecordedAt(value: string | undefined): number | null {
117
+ if (value === undefined) return null;
118
+ const parsed = Date.parse(value);
94
119
 
95
- for await (const line of historyLines(path, signal)) {
96
- signal?.throwIfAborted();
120
+ return Number.isNaN(parsed) ? null : parsed;
121
+ }
122
+
123
+ /** Whether a record falls in `window`, by the host's local day. An undated record counts only toward all time. */
124
+ function recordedIn(window: UsageWindow, at: number | null): boolean {
125
+ if (at === null) return window.sinceDay === null;
126
+ const day = localDay(at);
97
127
 
98
- if (!line.text.trim()) continue;
128
+ return day <= window.untilDay && (window.sinceDay === null || day >= window.sinceDay);
129
+ }
130
+
131
+ /** Folds Claude Code transcript lines into usage snapshots. An unparsable last line without its newline is not counted malformed: a writer may still be appending it. */
132
+ const claudeCodeParser: HistoryParser<ClaudeCodeFileScan> = (from) => {
133
+ const keyed = new Map(from?.keyed);
134
+ const anonymous = [...(from?.anonymous ?? [])];
135
+ let malformedRecords = from?.malformedRecords ?? 0;
136
+
137
+ const line = ({ text, terminated }: HistoryLine): void => {
138
+ if (!text.trim()) return;
99
139
  let value: unknown;
100
140
 
101
141
  try {
102
- value = JSON.parse(line.text);
142
+ value = JSON.parse(text);
103
143
  } catch {
104
- if (line.terminated) malformedRecords++;
105
- continue;
144
+ if (terminated) malformedRecords++;
145
+
146
+ return;
106
147
  }
107
148
 
108
149
  if (!Value.Check(envelope, value)) {
109
150
  malformedRecords++;
110
- continue;
151
+
152
+ return;
111
153
  }
112
154
 
113
- if (value.type !== "assistant") continue;
155
+ if (value.type !== "assistant") return;
114
156
 
115
157
  if (!Value.Check(assistantRecord, value)) {
116
158
  malformedRecords++;
117
- continue;
159
+
160
+ return;
118
161
  }
119
162
 
120
163
  const raw = value.message.usage;
@@ -123,7 +166,8 @@ async function scanClaudeCodeFile(
123
166
 
124
167
  if ((cacheWrite1h ?? 0) + (raw.cache_creation?.ephemeral_5m_input_tokens ?? 0) > cacheWrite) {
125
168
  malformedRecords++;
126
- continue;
169
+
170
+ return;
127
171
  }
128
172
 
129
173
  const usage: Usage = {
@@ -141,50 +185,53 @@ async function scanClaudeCodeFile(
141
185
  const snapshot: Snapshot = {
142
186
  model: value.message.model,
143
187
  usage,
144
- costUSD: Value.Check(reportedCost, value.costUSD) ? value.costUSD : undefined,
188
+ at: parseRecordedAt(value.timestamp),
145
189
  };
146
190
 
191
+ if (Value.Check(reportedCost, value.costUSD)) snapshot.costUSD = value.costUSD;
192
+
147
193
  const messageId = value.message.id?.trim() || null;
148
194
  const requestId = value.requestId?.trim() || null;
149
195
 
150
196
  if (messageId === null && requestId === null) {
151
197
  anonymous.push(snapshot);
152
- continue;
198
+
199
+ return;
153
200
  }
154
201
 
155
202
  const identity = JSON.stringify([messageId, requestId]);
156
203
 
157
204
  if (betterSnapshot(keyed.get(identity), snapshot)) keyed.set(identity, snapshot);
158
- }
205
+ };
159
206
 
160
- return { keyed: [...keyed], anonymous, malformedRecords };
161
- }
207
+ return { line, scan: () => ({ keyed: [...keyed], anonymous: [...anonymous], malformedRecords }) };
208
+ };
162
209
 
163
- async function scanCached<T>(
210
+ /**
211
+ * Reads one history file unless it was last written before `window` opens: no record is dated
212
+ * after its file's last write. The path counts as seen either way, so the cache keeps a file the
213
+ * window passes over.
214
+ */
215
+ async function readHistoryInWindow<Scan>(
164
216
  path: string,
165
- cache: UsageScanCache<T> | undefined,
166
- seen: Set<string>,
167
- parse: () => Promise<T>,
168
- ): Promise<T> {
169
- seen.add(path);
170
-
171
- if (cache === undefined) return parse();
217
+ window: UsageWindow,
218
+ read: {
219
+ readonly parser: HistoryParser<Scan>;
220
+ readonly cache: UsageScanCache<Scan> | undefined;
221
+ readonly seen: Set<string>;
222
+ readonly signal: AbortSignal | undefined;
223
+ },
224
+ ): Promise<Scan | undefined> {
225
+ read.seen.add(path);
172
226
  const info = await lstat(path);
173
227
 
174
228
  if (!info.isFile()) throw new Error("History is not a regular file.");
175
- const hit = cache.get(path);
176
-
177
- if (hit !== undefined && hit.size === info.size && hit.mtimeMs === info.mtimeMs) return hit.scan;
178
- const scan = await parse();
179
- cache.set(path, { size: info.size, mtimeMs: info.mtimeMs, scan });
180
229
 
181
- return scan;
182
- }
230
+ const opens = window.sinceDay === null ? 0 : new Date(`${window.sinceDay}T00:00:00`).getTime();
183
231
 
184
- function pruneCache<T>(cache: UsageScanCache<T> | undefined, seen: Set<string>): void {
185
- if (cache === undefined) return;
232
+ if (info.mtimeMs < opens) return undefined;
186
233
 
187
- for (const path of cache.keys()) if (!seen.has(path)) cache.delete(path);
234
+ return readHistory(path, info, read.parser, read.cache, read.signal);
188
235
  }
189
236
 
190
237
  /**
@@ -248,10 +295,14 @@ export async function readClaudeCodeUsage(
248
295
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
249
296
 
250
297
  try {
251
- const scan = await scanCached(path, options.cache, seen, () =>
252
- scanClaudeCodeFile(path, options.signal),
253
- );
254
-
298
+ const scan = await readHistoryInWindow(path, options.window, {
299
+ parser: claudeCodeParser,
300
+ cache: options.cache,
301
+ seen,
302
+ signal: options.signal,
303
+ });
304
+
305
+ if (scan === undefined) continue;
255
306
  malformedRecords += scan.malformedRecords;
256
307
  anonymous.push(...scan.anonymous);
257
308
 
@@ -272,7 +323,7 @@ export async function readClaudeCodeUsage(
272
323
  }
273
324
 
274
325
  options.signal?.throwIfAborted();
275
- pruneCache(options.cache, seen);
326
+ options.cache?.keepOnly(seen);
276
327
 
277
328
  const catalog = options.models
278
329
  .getModels("anthropic")
@@ -283,6 +334,8 @@ export async function readClaudeCodeUsage(
283
334
 
284
335
  for (const snapshot of [...keyed.values(), ...anonymous]) {
285
336
  options.signal?.throwIfAborted();
337
+
338
+ if (!recordedIn(options.window, snapshot.at)) continue;
286
339
  const usage: Usage = { ...snapshot.usage, cost: { ...snapshot.usage.cost } };
287
340
 
288
341
  if (snapshot.costUSD !== undefined) {
@@ -323,21 +376,32 @@ export type CodexUsage = LocalHistoryUsage;
323
376
 
324
377
  export interface CodexUsageOptions {
325
378
  readonly models: Pick<Models, "getModels">;
379
+ readonly window: UsageWindow;
326
380
  readonly homeDir?: string;
327
381
  readonly signal?: AbortSignal;
328
382
  readonly cache?: UsageScanCache<CodexFileScan>;
329
383
  }
330
384
 
331
- export interface CodexFileScan {
332
- readonly modern: readonly (readonly [string, CodexRow])[];
333
- readonly legacy: readonly CodexRow[];
334
- readonly malformedRecords: number;
335
- }
385
+ const codexRow = Type.Object({ model: Type.String(), usage: modelUsage, at: recordedAt });
336
386
 
337
- export interface CodexRow {
338
- readonly model: string;
339
- readonly usage: Usage;
340
- }
387
+ export type CodexRow = Static<typeof codexRow>;
388
+
389
+ /** What a rollout's next line is read against: see {@link codexParser}. */
390
+ const codexCursor = Type.Object({
391
+ model: Type.String(),
392
+ sawMeta: Type.Boolean(),
393
+ signature: Type.Optional(Type.String()),
394
+ forkAnchorMs: Type.Optional(Type.Number()),
395
+ });
396
+
397
+ const codexFileScan = Type.Object({
398
+ modern: Type.Array(Type.Tuple([Type.String(), codexRow])),
399
+ legacy: Type.Array(codexRow),
400
+ malformedRecords: Type.Integer({ minimum: 0 }),
401
+ cursor: codexCursor,
402
+ });
403
+
404
+ export type CodexFileScan = Static<typeof codexFileScan>;
341
405
 
342
406
  const codexTokens = Type.Object({
343
407
  input_tokens: Type.Optional(tokens),
@@ -349,6 +413,7 @@ const codexTokens = Type.Object({
349
413
 
350
414
  const usageRecord = Type.Object({
351
415
  type: Type.Literal("token_usage_record"),
416
+ timestamp: Type.Optional(Type.String()),
352
417
  payload: Type.Object({
353
418
  response_id: Type.Optional(Type.String()),
354
419
  usage: codexTokens,
@@ -400,13 +465,6 @@ function codexUsage(raw: CodexTokens): Usage {
400
465
 
401
466
  const FORK_COPY_MAX_GAP_MS = 1000;
402
467
 
403
- function parseMs(value: string | undefined): number | undefined {
404
- if (value === undefined) return undefined;
405
- const parsed = Date.parse(value);
406
-
407
- return Number.isNaN(parsed) ? undefined : parsed;
408
- }
409
-
410
468
  /**
411
469
  * Reads Codex's local usage from `sessions` and `archived_sessions` (where the legacy format
412
470
  * lives). A `response_id` is unique across files, so one map dedups a forked rollout's repeated
@@ -448,10 +506,14 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
448
506
  for (const root of present) {
449
507
  for await (const path of jsonlFiles(root, options.signal)) {
450
508
  try {
451
- const scan = await scanCached(path, options.cache, seen, () =>
452
- scanCodexRollout(path, options.signal),
453
- );
454
-
509
+ const scan = await readHistoryInWindow(path, options.window, {
510
+ parser: codexParser,
511
+ cache: options.cache,
512
+ seen,
513
+ signal: options.signal,
514
+ });
515
+
516
+ if (scan === undefined) continue;
455
517
  malformedRecords += scan.malformedRecords;
456
518
 
457
519
  if (scan.modern.length > 0) {
@@ -467,7 +529,7 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
467
529
  }
468
530
 
469
531
  options.signal?.throwIfAborted();
470
- pruneCache(options.cache, seen);
532
+ options.cache?.keepOnly(seen);
471
533
 
472
534
  const catalog = options.models
473
535
  .getModels("openai-codex")
@@ -478,6 +540,8 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
478
540
 
479
541
  for (const row of [...responses.values(), ...legacy]) {
480
542
  options.signal?.throwIfAborted();
543
+
544
+ if (!recordedIn(options.window, row.at)) continue;
481
545
  const usage: Usage = { ...row.usage, cost: { ...row.usage.cost } };
482
546
  const matches = catalog.filter((model) => model.id === row.model);
483
547
  const model = matches.length === 1 ? matches[0] : undefined;
@@ -507,94 +571,103 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
507
571
  }
508
572
 
509
573
  /**
510
- * Reads one Codex rollout. A `token_count` names no model, so the last `turn_context` does; only
574
+ * Folds one Codex rollout. A `token_count` names no model, so the last `turn_context` does; only
511
575
  * the file's first session meta counts, since a fork repeats its ancestors' metas straight after
512
576
  * and would move the fork anchor; and a `token_count` equal to the one before is a re-emission
513
- * at a stream boundary, not new usage.
577
+ * at a stream boundary, not new usage. That reading state is the scan's cursor, so a grown
578
+ * rollout resumes against it.
514
579
  */
515
- async function scanCodexRollout(
516
- path: string,
517
- signal: AbortSignal | undefined,
518
- ): Promise<CodexFileScan> {
519
- const modern = new Map<string, CodexRow>();
520
- const legacy: CodexRow[] = [];
521
- let malformedRecords = 0;
522
- let model = "";
523
- let sawMeta = false;
524
- let signature: string | undefined;
525
- let forkAnchorMs: number | undefined;
526
-
527
- for await (const line of historyLines(path, signal)) {
528
- signal?.throwIfAborted();
529
-
530
- if (!line.text.trim()) continue;
580
+ const codexParser: HistoryParser<CodexFileScan> = (from) => {
581
+ const modern = new Map(from?.modern);
582
+ const legacy = [...(from?.legacy ?? [])];
583
+ let malformedRecords = from?.malformedRecords ?? 0;
584
+ const cursor: CodexFileScan["cursor"] = { ...(from?.cursor ?? { model: "", sawMeta: false }) };
585
+
586
+ const line = ({ text, terminated }: HistoryLine): void => {
587
+ if (!text.trim()) return;
531
588
  let value: unknown;
532
589
 
533
590
  try {
534
- value = JSON.parse(line.text);
591
+ value = JSON.parse(text);
535
592
  } catch {
536
- if (line.terminated) malformedRecords++;
537
- continue;
593
+ if (terminated) malformedRecords++;
594
+
595
+ return;
538
596
  }
539
597
 
540
598
  if (!Value.Check(envelope, value)) {
541
599
  malformedRecords++;
542
- continue;
600
+
601
+ return;
543
602
  }
544
603
 
545
604
  if (Value.Check(turnContext, value)) {
546
- if (value.payload.model !== undefined) model = value.payload.model;
547
- continue;
605
+ if (value.payload.model !== undefined) cursor.model = value.payload.model;
606
+
607
+ return;
548
608
  }
549
609
 
550
610
  if (Value.Check(sessionMeta, value)) {
551
- if (sawMeta) continue;
552
- sawMeta = true;
611
+ if (cursor.sawMeta) return;
612
+ cursor.sawMeta = true;
553
613
 
554
614
  const forked =
555
615
  value.payload.forked_from_id !== undefined || value.payload.parent_thread_id !== undefined;
556
616
 
557
- if (forked) forkAnchorMs = parseMs(value.timestamp);
558
- continue;
617
+ const anchor = parseRecordedAt(value.timestamp);
618
+
619
+ if (forked && anchor !== null) cursor.forkAnchorMs = anchor;
620
+
621
+ return;
559
622
  }
560
623
 
561
624
  if (Value.Check(usageRecord, value)) {
562
625
  const id = value.payload.response_id;
563
626
 
564
- if (id === undefined || model === "") continue;
627
+ if (id === undefined || cursor.model === "") return;
565
628
  const usage = codexUsage(value.payload.usage);
566
629
 
567
- if (usage.totalTokens > 0 && !modern.has(id)) modern.set(id, { model, usage });
568
- continue;
630
+ if (usage.totalTokens > 0 && !modern.has(id))
631
+ modern.set(id, { model: cursor.model, usage, at: parseRecordedAt(value.timestamp) });
632
+
633
+ return;
569
634
  }
570
635
 
571
- if (!Value.Check(tokenCount, value)) continue;
636
+ if (!Value.Check(tokenCount, value)) return;
572
637
  const last = value.payload.info.last_token_usage;
573
638
 
574
- if (last === undefined || model === "") continue;
639
+ if (last === undefined || cursor.model === "") return;
575
640
  const next = JSON.stringify(last);
576
641
 
577
- if (next === signature) continue;
578
- signature = next;
642
+ if (next === cursor.signature) return;
643
+ cursor.signature = next;
644
+ const at = parseRecordedAt(value.timestamp);
579
645
 
580
- if (forkAnchorMs !== undefined) {
581
- const at = parseMs(value.timestamp);
646
+ if (cursor.forkAnchorMs !== undefined) {
647
+ if (at !== null && at - cursor.forkAnchorMs < FORK_COPY_MAX_GAP_MS) {
648
+ cursor.forkAnchorMs = at;
582
649
 
583
- if (at !== undefined && at - forkAnchorMs < FORK_COPY_MAX_GAP_MS) {
584
- forkAnchorMs = at;
585
- continue;
650
+ return;
586
651
  }
587
652
 
588
- forkAnchorMs = undefined;
653
+ delete cursor.forkAnchorMs;
589
654
  }
590
655
 
591
656
  const usage = codexUsage(last);
592
657
 
593
- if (usage.totalTokens > 0) legacy.push({ model, usage });
594
- }
658
+ if (usage.totalTokens > 0) legacy.push({ model: cursor.model, usage, at });
659
+ };
595
660
 
596
- return { modern: [...modern], legacy, malformedRecords };
597
- }
661
+ return {
662
+ line,
663
+ scan: () => ({
664
+ modern: [...modern],
665
+ legacy: [...legacy],
666
+ malformedRecords,
667
+ cursor: { ...cursor },
668
+ }),
669
+ };
670
+ };
598
671
 
599
672
  async function* jsonlFiles(root: string, signal: AbortSignal | undefined): AsyncGenerator<string> {
600
673
  const directories = [root];
@@ -618,52 +691,51 @@ async function* jsonlFiles(root: string, signal: AbortSignal | undefined): Async
618
691
  }
619
692
  }
620
693
 
621
- /** Yields a history file's lines up to the size it had when opened, though its writer may keep appending; the last line is `terminated: false` without its newline. */
622
- async function* historyLines(path: string, signal: AbortSignal | undefined) {
623
- const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
624
-
625
- try {
626
- const stat = await file.stat();
627
-
628
- if (!stat.isFile()) throw new Error("History is not a regular file.");
629
- const buffer = Buffer.alloc(64 * 1024);
630
- const decoder = new StringDecoder("utf8");
631
- let pending = "";
632
- let position = 0;
633
-
634
- while (position < stat.size) {
635
- signal?.throwIfAborted();
636
-
637
- const { bytesRead } = await file.read(
638
- buffer,
639
- 0,
640
- Math.min(buffer.length, stat.size - position),
641
- position,
642
- );
643
-
644
- if (bytesRead === 0) break;
645
- position += bytesRead;
646
- const lines = `${pending}${decoder.write(buffer.subarray(0, bytesRead))}`.split("\n");
647
- pending = lines.pop() ?? "";
648
-
649
- for (const text of lines) yield { text, terminated: true };
650
- }
651
-
652
- pending += decoder.end();
653
-
654
- if (pending) yield { text: pending, terminated: false };
655
- } finally {
656
- await file.close();
657
- }
658
- }
659
-
660
694
  export interface UsageScanCaches {
661
695
  readonly claudeCode: UsageScanCache<ClaudeCodeFileScan>;
662
696
  readonly codex: UsageScanCache<CodexFileScan>;
663
697
  }
664
698
 
665
699
  export function createUsageScanCaches(): UsageScanCaches {
666
- return { claudeCode: new Map(), codex: new Map() };
700
+ return { claudeCode: new UsageScanCache(), codex: new UsageScanCache() };
701
+ }
702
+
703
+ /** Bump when a parser changes what it keeps, so an older document is dropped instead of misread. */
704
+ const USAGE_SCAN_CACHE_VERSION = 1;
705
+
706
+ const usageScanCacheDocument = Type.Object({
707
+ version: Type.Literal(USAGE_SCAN_CACHE_VERSION),
708
+ claudeCode: Type.Array(Type.Tuple([Type.String(), fileScanSchema(claudeCodeFileScan)])),
709
+ codex: Type.Array(Type.Tuple([Type.String(), fileScanSchema(codexFileScan)])),
710
+ });
711
+
712
+ const usageScanCacheValidator = Compile(usageScanCacheDocument);
713
+
714
+ /** Restores the scans {@link saveUsageScanCaches} wrote to `path`; each still answers only while its file is unchanged. */
715
+ export async function loadUsageScanCaches(path: string): Promise<UsageScanCaches> {
716
+ const document = await readCacheDocument(path, usageScanCacheValidator);
717
+
718
+ if (document === undefined) return createUsageScanCaches();
719
+
720
+ return {
721
+ claudeCode: new UsageScanCache(document.claudeCode),
722
+ codex: new UsageScanCache(document.codex),
723
+ };
724
+ }
725
+
726
+ /** Writes `caches` to `path` when a read changed them since they were last saved; a failed write is tried again on the next save. */
727
+ export async function saveUsageScanCaches(path: string, caches: UsageScanCaches): Promise<void> {
728
+ if (!caches.claudeCode.changed && !caches.codex.changed) return;
729
+
730
+ const saved = await writeCacheDocument(path, {
731
+ version: USAGE_SCAN_CACHE_VERSION,
732
+ claudeCode: [...caches.claudeCode.entries()],
733
+ codex: [...caches.codex.entries()],
734
+ } satisfies Static<typeof usageScanCacheDocument>);
735
+
736
+ if (!saved) return;
737
+ caches.claudeCode.markSaved();
738
+ caches.codex.markSaved();
667
739
  }
668
740
 
669
741
  export interface LocalUsage {
@@ -673,6 +745,7 @@ export interface LocalUsage {
673
745
 
674
746
  export async function readLocalUsage(options: {
675
747
  readonly models: Pick<Models, "getModels">;
748
+ readonly window: UsageWindow;
676
749
  readonly signal?: AbortSignal;
677
750
  readonly caches?: UsageScanCaches;
678
751
  }): Promise<LocalUsage> {
@@ -685,11 +758,13 @@ export async function readLocalUsage(options: {
685
758
  const [claudeCode, codex] = await Promise.all([
686
759
  readClaudeCodeUsage({
687
760
  models: options.models,
761
+ window: options.window,
688
762
  signal: options.signal,
689
763
  cache: options.caches?.claudeCode,
690
764
  }).catch(() => failed("Could not read Claude Code history.")),
691
765
  readCodexUsage({
692
766
  models: options.models,
767
+ window: options.window,
693
768
  signal: options.signal,
694
769
  cache: options.caches?.codex,
695
770
  }).catch(() => failed("Could not read Codex history.")),