@nyte-ai/host 0.0.0-stage → 0.0.18

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 (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/THIRD-PARTY-NOTICES.md +800 -0
  4. package/dist/chunks/canvas-preview-0cmbkp2j.js +65 -0
  5. package/dist/chunks/canvas-preview-0nq75h9k.js +7 -0
  6. package/dist/chunks/canvas-preview-0nrnbt76.js +497 -0
  7. package/dist/chunks/canvas-preview-1sq7z9jq.js +17 -0
  8. package/dist/chunks/canvas-preview-1ta3avah.js +56 -0
  9. package/dist/chunks/canvas-preview-2zdyh9ny.js +26 -0
  10. package/dist/chunks/canvas-preview-31w7xm8z.js +34 -0
  11. package/dist/chunks/canvas-preview-4b6sayhg.js +295 -0
  12. package/dist/chunks/canvas-preview-4e0pxa32.js +63 -0
  13. package/dist/chunks/canvas-preview-518dh8by.js +169 -0
  14. package/dist/chunks/canvas-preview-5hxat7hh.js +239 -0
  15. package/dist/chunks/canvas-preview-5mex725c.js +14 -0
  16. package/dist/chunks/canvas-preview-5z63am63.js +295 -0
  17. package/dist/chunks/canvas-preview-65xqjw7f.js +20 -0
  18. package/dist/chunks/canvas-preview-69fkk9yn.js +95 -0
  19. package/dist/chunks/canvas-preview-75db60xy.js +457 -0
  20. package/dist/chunks/canvas-preview-76fwvd6e.js +272 -0
  21. package/dist/chunks/canvas-preview-8xgt67ka.js +598 -0
  22. package/dist/chunks/canvas-preview-98aj5p34.js +176 -0
  23. package/dist/chunks/canvas-preview-9aqczacs.js +73 -0
  24. package/dist/chunks/canvas-preview-a3yw0j70.js +72 -0
  25. package/dist/chunks/canvas-preview-azg56359.js +68 -0
  26. package/dist/chunks/canvas-preview-d9wwhbtz.js +240 -0
  27. package/dist/chunks/canvas-preview-ehf3vp31.js +411 -0
  28. package/dist/chunks/canvas-preview-evh92ywd.js +369 -0
  29. package/dist/chunks/canvas-preview-ewgspmsd.js +81 -0
  30. package/dist/chunks/canvas-preview-g3qj3y3k.js +156 -0
  31. package/dist/chunks/canvas-preview-h8hshnff.js +63 -0
  32. package/dist/chunks/canvas-preview-j1tnf51w.js +36 -0
  33. package/dist/chunks/canvas-preview-kdqgrstc.js +194 -0
  34. package/dist/chunks/canvas-preview-kn7kvbfj.js +12 -0
  35. package/dist/chunks/canvas-preview-m02v4yy6.js +386 -0
  36. package/dist/chunks/canvas-preview-mgcx2b2p.js +271 -0
  37. package/dist/chunks/canvas-preview-rp4x6y1h.js +144 -0
  38. package/dist/chunks/canvas-preview-rp6fz0w5.js +130 -0
  39. package/dist/chunks/canvas-preview-s94ebwf5.js +112 -0
  40. package/dist/chunks/canvas-preview-senpjwep.js +518 -0
  41. package/dist/chunks/canvas-preview-skzwvw4d.js +23 -0
  42. package/dist/chunks/canvas-preview-srgfpdmf.js +60 -0
  43. package/dist/chunks/canvas-preview-tjhabynd.js +252 -0
  44. package/dist/chunks/canvas-preview-vaatfznm.js +189 -0
  45. package/dist/chunks/canvas-preview-vax9ne4e.js +341 -0
  46. package/dist/chunks/canvas-preview-wg80rbc2.js +46 -0
  47. package/dist/chunks/canvas-preview-xn10d4f8.js +608 -0
  48. package/dist/chunks/canvas-preview-yjz0f7n4.js +50 -0
  49. package/dist/chunks/canvas-preview-ymgz0937.js +587 -0
  50. package/dist/chunks/canvas-preview-yrfz2ak6.js +134 -0
  51. package/dist/chunks/canvas-preview-zaaj99wd.js +244 -0
  52. package/dist/chunks/canvas-preview-zn2d0k9d.js +133 -0
  53. package/dist/src/canvas-preview.d.ts +29 -0
  54. package/dist/src/canvas-preview.js +267 -0
  55. package/dist/src/credentials.d.ts +44 -0
  56. package/dist/src/credentials.js +10 -0
  57. package/dist/src/deploy/cloudflare/worker.d.ts +50 -0
  58. package/dist/src/deploy/cloudflare/worker.js +196 -0
  59. package/dist/src/deploy/vercel/nitro.config.d.ts +2 -0
  60. package/dist/src/deploy/vercel/nitro.config.js +30 -0
  61. package/dist/src/deploy/vercel/server/routes/[...].d.ts +2 -0
  62. package/dist/src/deploy/vercel/server/routes/[...].js +18 -0
  63. package/dist/src/deploy/vercel/server/utils/host.d.ts +13 -0
  64. package/dist/src/deploy/vercel/server/utils/host.js +7 -0
  65. package/dist/src/deploy/vercel/server/utils/repair.d.ts +1 -0
  66. package/dist/src/deploy/vercel/server/utils/repair.js +6 -0
  67. package/dist/src/deploy/vercel/server/utils/step.d.ts +10 -0
  68. package/dist/src/deploy/vercel/server/utils/step.js +6 -0
  69. package/dist/src/deploy/vercel/server/workflows/drive.d.ts +14 -0
  70. package/dist/src/deploy/vercel/server/workflows/drive.js +13 -0
  71. package/dist/src/durable-object.d.ts +14 -0
  72. package/dist/src/durable-object.js +231 -0
  73. package/dist/src/hash.d.ts +1 -0
  74. package/dist/src/hash.js +6 -0
  75. package/dist/src/identity-key.d.ts +11 -0
  76. package/dist/src/identity-key.js +9 -0
  77. package/dist/src/identity.d.ts +12 -0
  78. package/dist/src/identity.js +6 -0
  79. package/dist/src/index.d.ts +21 -0
  80. package/dist/src/index.js +120 -0
  81. package/dist/src/key-file.d.ts +1 -0
  82. package/dist/src/key-file.js +7 -0
  83. package/dist/src/listen-fetch.d.ts +8 -0
  84. package/dist/src/listen-fetch.js +9 -0
  85. package/dist/src/listen.d.ts +4 -0
  86. package/dist/src/listen.js +10 -0
  87. package/dist/src/local/command.d.ts +15 -0
  88. package/dist/src/local/command.js +8 -0
  89. package/dist/src/local/feed.d.ts +7 -0
  90. package/dist/src/local/feed.js +6 -0
  91. package/dist/src/local/files.d.ts +18 -0
  92. package/dist/src/local/files.js +13 -0
  93. package/dist/src/local/git-watch.d.ts +15 -0
  94. package/dist/src/local/git-watch.js +7 -0
  95. package/dist/src/local/git.d.ts +5 -0
  96. package/dist/src/local/git.js +13 -0
  97. package/dist/src/local/github.d.ts +30 -0
  98. package/dist/src/local/github.js +9 -0
  99. package/dist/src/local/identity.d.ts +3 -0
  100. package/dist/src/local/identity.js +7 -0
  101. package/dist/src/local/jobs.d.ts +7 -0
  102. package/dist/src/local/jobs.js +6 -0
  103. package/dist/src/local/legacy-trust.d.ts +10 -0
  104. package/dist/src/local/legacy-trust.js +6 -0
  105. package/dist/src/local/patch.d.ts +16 -0
  106. package/dist/src/local/patch.js +17 -0
  107. package/dist/src/local/provider.d.ts +11 -0
  108. package/dist/src/local/provider.js +20 -0
  109. package/dist/src/local/snapshots.d.ts +7 -0
  110. package/dist/src/local/snapshots.js +11 -0
  111. package/dist/src/local/text.d.ts +1 -0
  112. package/dist/src/local/text.js +6 -0
  113. package/dist/src/local/tree.d.ts +50 -0
  114. package/dist/src/local/tree.js +16 -0
  115. package/dist/src/local/workspace-migration.d.ts +2 -0
  116. package/dist/src/local/workspace-migration.js +7 -0
  117. package/dist/src/local/worktrees.d.ts +46 -0
  118. package/dist/src/local/worktrees.js +14 -0
  119. package/dist/src/mcp/config.d.ts +75 -0
  120. package/dist/src/mcp/config.js +12 -0
  121. package/dist/src/mcp/connect.d.ts +28 -0
  122. package/dist/src/mcp/connect.js +9 -0
  123. package/dist/src/mcp/hub.d.ts +19 -0
  124. package/dist/src/mcp/hub.js +11 -0
  125. package/dist/src/mcp/oauth.d.ts +46 -0
  126. package/dist/src/mcp/oauth.js +12 -0
  127. package/dist/src/mcp/tools.d.ts +29 -0
  128. package/dist/src/mcp/tools.js +12 -0
  129. package/dist/src/open.d.ts +42 -0
  130. package/dist/src/open.js +44 -0
  131. package/dist/src/plugins/loader.d.ts +21 -0
  132. package/dist/src/plugins/loader.js +11 -0
  133. package/dist/src/postgres.d.ts +19 -0
  134. package/dist/src/postgres.js +8 -0
  135. package/dist/src/providers.d.ts +16 -0
  136. package/dist/src/providers.js +112 -0
  137. package/dist/src/secrets.d.ts +12 -0
  138. package/dist/src/secrets.js +8 -0
  139. package/dist/src/serve/binding.d.ts +39 -0
  140. package/dist/src/serve/binding.js +6 -0
  141. package/dist/src/serve/handler.d.ts +15 -0
  142. package/dist/src/serve/handler.js +8 -0
  143. package/dist/src/serve/stream-server.d.ts +6 -0
  144. package/dist/src/serve/stream-server.js +6 -0
  145. package/dist/src/session-index.d.ts +11 -0
  146. package/dist/src/session-index.js +11 -0
  147. package/dist/src/shell-env.d.ts +14 -0
  148. package/dist/src/shell-env.js +108 -0
  149. package/dist/src/sqlite-workers.d.ts +6 -0
  150. package/dist/src/sqlite-workers.js +6 -0
  151. package/dist/src/sqlite.d.ts +7 -0
  152. package/dist/src/sqlite.js +10 -0
  153. package/dist/src/store-owner.d.ts +15 -0
  154. package/dist/src/store-owner.js +9 -0
  155. package/dist/src/store-rows.d.ts +13 -0
  156. package/dist/src/store-rows.js +26 -0
  157. package/dist/src/store-usage.d.ts +25 -0
  158. package/dist/src/store-usage.js +16 -0
  159. package/dist/src/usage-scan.d.ts +24 -0
  160. package/dist/src/usage-scan.js +65 -0
  161. package/dist/src/usage-service.d.ts +22 -0
  162. package/dist/src/usage-service.js +12 -0
  163. package/dist/src/usage-summary.d.ts +9 -0
  164. package/dist/src/usage-summary.js +16 -0
  165. package/dist/src/usage-types.d.ts +86 -0
  166. package/dist/src/usage-types.js +0 -0
  167. package/dist/src/usage-worker.d.ts +1 -0
  168. package/dist/src/usage-worker.js +25 -0
  169. package/dist/src/usage.d.ts +72 -0
  170. package/dist/src/usage.js +13 -0
  171. package/package.json +132 -3
  172. package/src/canvas-preview.ts +402 -0
  173. package/src/credentials.ts +294 -0
  174. package/src/deploy/cloudflare/README.md +3 -0
  175. package/src/deploy/cloudflare/worker.ts +306 -0
  176. package/src/deploy/cloudflare/wrangler.jsonc +12 -0
  177. package/src/deploy/vercel/README.md +3 -0
  178. package/src/deploy/vercel/nitro.config.ts +29 -0
  179. package/src/deploy/vercel/server/routes/[...].ts +10 -0
  180. package/src/deploy/vercel/server/utils/host.ts +104 -0
  181. package/src/deploy/vercel/server/utils/repair.ts +35 -0
  182. package/src/deploy/vercel/server/utils/step.ts +25 -0
  183. package/src/deploy/vercel/server/workflows/drive.ts +46 -0
  184. package/src/deploy/vercel/tsconfig.json +9 -0
  185. package/src/deploy/vercel/types/bun.d.ts +2 -0
  186. package/src/durable-object.ts +386 -0
  187. package/src/hash.ts +5 -0
  188. package/src/identity-key.ts +20 -0
  189. package/src/identity.ts +66 -0
  190. package/src/index.ts +21 -0
  191. package/src/key-file.ts +53 -0
  192. package/src/listen-fetch.ts +409 -0
  193. package/src/listen.ts +228 -0
  194. package/src/local/command.ts +109 -0
  195. package/src/local/feed.ts +70 -0
  196. package/src/local/files.ts +812 -0
  197. package/src/local/git-watch.ts +189 -0
  198. package/src/local/git.ts +914 -0
  199. package/src/local/github.ts +692 -0
  200. package/src/local/identity.ts +21 -0
  201. package/src/local/jobs.ts +587 -0
  202. package/src/local/legacy-trust.ts +105 -0
  203. package/src/local/patch.ts +334 -0
  204. package/src/local/provider.ts +184 -0
  205. package/src/local/snapshots.ts +776 -0
  206. package/src/local/text.ts +9 -0
  207. package/src/local/tree.ts +398 -0
  208. package/src/local/workspace-migration.ts +95 -0
  209. package/src/local/worktrees.ts +239 -0
  210. package/src/mcp/config.ts +330 -0
  211. package/src/mcp/connect.ts +105 -0
  212. package/src/mcp/hub.ts +836 -0
  213. package/src/mcp/oauth.ts +436 -0
  214. package/src/mcp/tools.ts +333 -0
  215. package/src/open.ts +469 -0
  216. package/src/plugins/loader.ts +186 -0
  217. package/src/postgres.ts +492 -0
  218. package/src/providers.ts +135 -0
  219. package/src/secrets.ts +31 -0
  220. package/src/serve/binding.ts +628 -0
  221. package/src/serve/handler.ts +362 -0
  222. package/src/serve/stream-server.ts +176 -0
  223. package/src/session-index.ts +99 -0
  224. package/src/shell-env.ts +153 -0
  225. package/src/sqlite-workers.ts +283 -0
  226. package/src/sqlite.ts +528 -0
  227. package/src/store-owner.ts +98 -0
  228. package/src/store-rows.ts +110 -0
  229. package/src/store-usage.ts +344 -0
  230. package/src/usage-scan.ts +81 -0
  231. package/src/usage-service.ts +73 -0
  232. package/src/usage-summary.ts +79 -0
  233. package/src/usage-types.ts +87 -0
  234. package/src/usage-worker.ts +24 -0
  235. package/src/usage.ts +701 -0
package/src/usage.ts ADDED
@@ -0,0 +1,701 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { constants, existsSync } from "node:fs";
3
+ import { lstat, open, opendir, stat } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import process from "node:process";
7
+ import { StringDecoder } from "node:string_decoder";
8
+ import { Type, type Static } from "typebox";
9
+ import { Value } from "typebox/value";
10
+ import { calculateCost, type Models } from "@nyte-ai/ai";
11
+ import { summarizeModels, emptyModelUsage, recordedUsage } from "./usage-summary.ts";
12
+ import type { LocalHistoryUsage, ModelUsage } from "./usage-types.ts";
13
+ import type { Usage } from "@nyte-ai/schema/model";
14
+
15
+ export interface ClaudeCodeUsageOptions {
16
+ readonly models: Pick<Models, "getModels">;
17
+ readonly configDir?: string;
18
+ readonly signal?: AbortSignal;
19
+ readonly cache?: UsageScanCache<ClaudeCodeFileScan>;
20
+ }
21
+
22
+ export type UsageScanCache<T> = Map<string, FileScan<T>>;
23
+
24
+ export interface FileScan<T> {
25
+ readonly size: number;
26
+ readonly mtimeMs: number;
27
+ readonly scan: T;
28
+ }
29
+
30
+ export interface ClaudeCodeFileScan {
31
+ readonly keyed: readonly (readonly [string, Snapshot])[];
32
+ readonly anonymous: readonly Snapshot[];
33
+ readonly malformedRecords: number;
34
+ }
35
+
36
+ export type ClaudeCodeUsage = LocalHistoryUsage;
37
+
38
+ const tokens = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
39
+
40
+ const envelope = Type.Object({ type: Type.String() });
41
+
42
+ const assistantRecord = Type.Object({
43
+ type: Type.Literal("assistant"),
44
+ requestId: Type.Optional(Type.String()),
45
+ costUSD: Type.Optional(Type.Unknown()),
46
+ message: Type.Object({
47
+ id: Type.Optional(Type.String()),
48
+ model: Type.String({ minLength: 1, pattern: "\\S" }),
49
+ usage: Type.Object({
50
+ input_tokens: tokens,
51
+ output_tokens: tokens,
52
+ cache_read_input_tokens: Type.Optional(tokens),
53
+ cache_creation_input_tokens: Type.Optional(tokens),
54
+ cache_creation: Type.Optional(
55
+ Type.Object({
56
+ ephemeral_5m_input_tokens: Type.Optional(tokens),
57
+ ephemeral_1h_input_tokens: Type.Optional(tokens),
58
+ }),
59
+ ),
60
+ }),
61
+ }),
62
+ });
63
+
64
+ const reportedCost = Type.Number({ minimum: 0, maximum: Number.MAX_VALUE });
65
+
66
+ export interface Snapshot {
67
+ readonly model: string;
68
+ readonly usage: Usage;
69
+ readonly costUSD: number | undefined;
70
+ }
71
+
72
+ function betterSnapshot(previous: Snapshot | undefined, next: Snapshot): boolean {
73
+ if (previous === undefined) return true;
74
+ const usage = next.usage;
75
+
76
+ return (
77
+ usage.totalTokens > previous.usage.totalTokens ||
78
+ (usage.totalTokens === previous.usage.totalTokens &&
79
+ (usage.output > previous.usage.output ||
80
+ (usage.output === previous.usage.output &&
81
+ previous.costUSD === undefined &&
82
+ next.costUSD !== undefined)))
83
+ );
84
+ }
85
+
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;
94
+
95
+ for await (const line of historyLines(path, signal)) {
96
+ signal?.throwIfAborted();
97
+
98
+ if (!line.text.trim()) continue;
99
+ let value: unknown;
100
+
101
+ try {
102
+ value = JSON.parse(line.text);
103
+ } catch {
104
+ if (line.terminated) malformedRecords++;
105
+ continue;
106
+ }
107
+
108
+ if (!Value.Check(envelope, value)) {
109
+ malformedRecords++;
110
+ continue;
111
+ }
112
+
113
+ if (value.type !== "assistant") continue;
114
+
115
+ if (!Value.Check(assistantRecord, value)) {
116
+ malformedRecords++;
117
+ continue;
118
+ }
119
+
120
+ const raw = value.message.usage;
121
+ const cacheWrite = raw.cache_creation_input_tokens ?? 0;
122
+ const cacheWrite1h = raw.cache_creation?.ephemeral_1h_input_tokens;
123
+
124
+ if ((cacheWrite1h ?? 0) + (raw.cache_creation?.ephemeral_5m_input_tokens ?? 0) > cacheWrite) {
125
+ malformedRecords++;
126
+ continue;
127
+ }
128
+
129
+ const usage: Usage = {
130
+ ...emptyModelUsage(),
131
+ input: raw.input_tokens,
132
+ output: raw.output_tokens,
133
+ cacheRead: raw.cache_read_input_tokens ?? 0,
134
+ cacheWrite,
135
+ totalTokens:
136
+ raw.input_tokens + raw.output_tokens + (raw.cache_read_input_tokens ?? 0) + cacheWrite,
137
+ };
138
+
139
+ if (cacheWrite1h !== undefined) usage.cacheWrite1h = cacheWrite1h;
140
+
141
+ const snapshot: Snapshot = {
142
+ model: value.message.model,
143
+ usage,
144
+ costUSD: Value.Check(reportedCost, value.costUSD) ? value.costUSD : undefined,
145
+ };
146
+
147
+ const messageId = value.message.id?.trim() || null;
148
+ const requestId = value.requestId?.trim() || null;
149
+
150
+ if (messageId === null && requestId === null) {
151
+ anonymous.push(snapshot);
152
+ continue;
153
+ }
154
+
155
+ const identity = JSON.stringify([messageId, requestId]);
156
+
157
+ if (betterSnapshot(keyed.get(identity), snapshot)) keyed.set(identity, snapshot);
158
+ }
159
+
160
+ return { keyed: [...keyed], anonymous, malformedRecords };
161
+ }
162
+
163
+ async function scanCached<T>(
164
+ 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();
172
+ const info = await lstat(path);
173
+
174
+ 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
+
181
+ return scan;
182
+ }
183
+
184
+ function pruneCache<T>(cache: UsageScanCache<T> | undefined, seen: Set<string>): void {
185
+ if (cache === undefined) return;
186
+
187
+ for (const path of cache.keys()) if (!seen.has(path)) cache.delete(path);
188
+ }
189
+
190
+ /**
191
+ * Reads Claude Code's local usage under `<configDir>/projects`. The configured root may be a
192
+ * symlink (as may a system ancestor such as macOS `/tmp`), but links below it are skipped, to
193
+ * avoid loops and duplicate history. Every read prices cached snapshots again on a copy, since
194
+ * the catalog can change between reads while a transcript does not; a snapshot that records its
195
+ * own cost keeps that total, with no invented breakdown.
196
+ */
197
+ export async function readClaudeCodeUsage(
198
+ options: ClaudeCodeUsageOptions,
199
+ ): Promise<ClaudeCodeUsage> {
200
+ options.signal?.throwIfAborted();
201
+
202
+ const projects = join(
203
+ resolve(
204
+ options.configDir ?? (process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude")),
205
+ ),
206
+ "projects",
207
+ );
208
+
209
+ if (!existsSync(projects)) return { kind: "missing" };
210
+
211
+ try {
212
+ const root = await stat(projects);
213
+ options.signal?.throwIfAborted();
214
+
215
+ if (!root.isDirectory())
216
+ return { kind: "failed", message: "Claude Code history path is not a directory." };
217
+ } catch {
218
+ options.signal?.throwIfAborted();
219
+
220
+ return { kind: "failed", message: "Could not open Claude Code local history." };
221
+ }
222
+
223
+ const keyed = new Map<string, Snapshot>();
224
+ const anonymous: Snapshot[] = [];
225
+ const seen = new Set<string>();
226
+ let malformedRecords = 0;
227
+ let unreadableFiles = 0;
228
+
229
+ try {
230
+ const directories = [projects];
231
+
232
+ for (const directory of directories) {
233
+ options.signal?.throwIfAborted();
234
+
235
+ try {
236
+ if (directory !== projects && (await lstat(directory)).isSymbolicLink()) continue;
237
+ const entries = await opendir(directory);
238
+
239
+ for await (const entry of entries) {
240
+ options.signal?.throwIfAborted();
241
+ const path = join(directory, entry.name);
242
+
243
+ if (entry.isDirectory()) {
244
+ directories.push(path);
245
+ continue;
246
+ }
247
+
248
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
249
+
250
+ try {
251
+ const scan = await scanCached(path, options.cache, seen, () =>
252
+ scanClaudeCodeFile(path, options.signal),
253
+ );
254
+
255
+ malformedRecords += scan.malformedRecords;
256
+ anonymous.push(...scan.anonymous);
257
+
258
+ for (const [identity, snapshot] of scan.keyed) {
259
+ if (betterSnapshot(keyed.get(identity), snapshot)) keyed.set(identity, snapshot);
260
+ }
261
+ } catch {
262
+ options.signal?.throwIfAborted();
263
+ unreadableFiles++;
264
+ }
265
+ }
266
+ } catch (error) {
267
+ options.signal?.throwIfAborted();
268
+
269
+ if (directory === projects) throw error;
270
+ unreadableFiles++;
271
+ }
272
+ }
273
+
274
+ options.signal?.throwIfAborted();
275
+ pruneCache(options.cache, seen);
276
+
277
+ const catalog = options.models
278
+ .getModels("anthropic")
279
+ .filter((model) => model.provider === "anthropic");
280
+
281
+ const rows: ModelUsage[] = [];
282
+ let unpricedRecords = 0;
283
+
284
+ for (const snapshot of [...keyed.values(), ...anonymous]) {
285
+ options.signal?.throwIfAborted();
286
+ const usage: Usage = { ...snapshot.usage, cost: { ...snapshot.usage.cost } };
287
+
288
+ if (snapshot.costUSD !== undefined) {
289
+ usage.cost.total = snapshot.costUSD;
290
+ } else {
291
+ const matches = catalog.filter((model) => model.id === snapshot.model);
292
+ const model = matches.length === 1 ? matches[0] : undefined;
293
+
294
+ if (model) calculateCost(model, usage);
295
+ else unpricedRecords++;
296
+ }
297
+
298
+ rows.push({
299
+ provider: "anthropic",
300
+ model: snapshot.model,
301
+ turns: 1,
302
+ usage: recordedUsage(usage),
303
+ });
304
+ }
305
+
306
+ options.signal?.throwIfAborted();
307
+
308
+ return {
309
+ kind: "ready",
310
+ summary: summarizeModels(rows),
311
+ unpricedRecords,
312
+ malformedRecords,
313
+ unreadableFiles,
314
+ };
315
+ } catch {
316
+ options.signal?.throwIfAborted();
317
+
318
+ return { kind: "failed", message: "Could not read Claude Code local history." };
319
+ }
320
+ }
321
+
322
+ export type CodexUsage = LocalHistoryUsage;
323
+
324
+ export interface CodexUsageOptions {
325
+ readonly models: Pick<Models, "getModels">;
326
+ readonly homeDir?: string;
327
+ readonly signal?: AbortSignal;
328
+ readonly cache?: UsageScanCache<CodexFileScan>;
329
+ }
330
+
331
+ export interface CodexFileScan {
332
+ readonly modern: readonly (readonly [string, CodexRow])[];
333
+ readonly legacy: readonly CodexRow[];
334
+ readonly malformedRecords: number;
335
+ }
336
+
337
+ export interface CodexRow {
338
+ readonly model: string;
339
+ readonly usage: Usage;
340
+ }
341
+
342
+ const codexTokens = Type.Object({
343
+ input_tokens: Type.Optional(tokens),
344
+ cached_input_tokens: Type.Optional(tokens),
345
+ cache_write_input_tokens: Type.Optional(tokens),
346
+ output_tokens: Type.Optional(tokens),
347
+ reasoning_output_tokens: Type.Optional(tokens),
348
+ });
349
+
350
+ const usageRecord = Type.Object({
351
+ type: Type.Literal("token_usage_record"),
352
+ payload: Type.Object({
353
+ response_id: Type.Optional(Type.String()),
354
+ usage: codexTokens,
355
+ }),
356
+ });
357
+
358
+ const turnContext = Type.Object({
359
+ type: Type.Literal("turn_context"),
360
+ payload: Type.Object({ model: Type.Optional(Type.String({ minLength: 1 })) }),
361
+ });
362
+
363
+ const sessionMeta = Type.Object({
364
+ type: Type.Literal("session_meta"),
365
+ timestamp: Type.Optional(Type.String()),
366
+ payload: Type.Object({
367
+ forked_from_id: Type.Optional(Type.String()),
368
+ parent_thread_id: Type.Optional(Type.String()),
369
+ }),
370
+ });
371
+
372
+ const tokenCount = Type.Object({
373
+ type: Type.Literal("event_msg"),
374
+ timestamp: Type.Optional(Type.String()),
375
+ payload: Type.Object({
376
+ type: Type.Literal("token_count"),
377
+ info: Type.Object({ last_token_usage: Type.Optional(codexTokens) }),
378
+ }),
379
+ });
380
+
381
+ type CodexTokens = Static<typeof codexTokens>;
382
+
383
+ function codexUsage(raw: CodexTokens): Usage {
384
+ const input = raw.input_tokens ?? 0;
385
+ const cacheRead = raw.cached_input_tokens ?? 0;
386
+ const cacheWrite = raw.cache_write_input_tokens ?? 0;
387
+ const output = raw.output_tokens ?? 0;
388
+ const uncached = Math.max(0, input - cacheRead - cacheWrite);
389
+
390
+ return {
391
+ ...emptyModelUsage(),
392
+ input: uncached,
393
+ output,
394
+ cacheRead,
395
+ cacheWrite,
396
+ reasoning: Math.min(output, raw.reasoning_output_tokens ?? 0),
397
+ totalTokens: uncached + output + cacheRead + cacheWrite,
398
+ };
399
+ }
400
+
401
+ const FORK_COPY_MAX_GAP_MS = 1000;
402
+
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
+ /**
411
+ * Reads Codex's local usage from `sessions` and `archived_sessions` (where the legacy format
412
+ * lives). A `response_id` is unique across files, so one map dedups a forked rollout's repeated
413
+ * parent responses as well as repeats within a file; a file with modern records counts only
414
+ * those, since the legacy event is written beside them. Rows are priced on a copy against the
415
+ * `openai-codex` catalog, the ChatGPT-backend model ids Codex writes.
416
+ */
417
+ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexUsage> {
418
+ options.signal?.throwIfAborted();
419
+
420
+ const home = resolve(
421
+ options.homeDir ?? (process.env.CODEX_HOME?.trim() || join(homedir(), ".codex")),
422
+ );
423
+
424
+ const roots = [join(home, "sessions"), join(home, "archived_sessions")];
425
+ const present: string[] = [];
426
+
427
+ for (const root of roots) {
428
+ if (!existsSync(root)) continue;
429
+
430
+ try {
431
+ if ((await stat(root)).isDirectory()) present.push(root);
432
+ } catch {
433
+ options.signal?.throwIfAborted();
434
+
435
+ return { kind: "failed", message: "Could not open Codex local history." };
436
+ }
437
+ }
438
+
439
+ if (present.length === 0) return { kind: "missing" };
440
+
441
+ const responses = new Map<string, CodexRow>();
442
+ const legacy: CodexRow[] = [];
443
+ const seen = new Set<string>();
444
+ let malformedRecords = 0;
445
+ let unreadableFiles = 0;
446
+
447
+ try {
448
+ for (const root of present) {
449
+ for await (const path of jsonlFiles(root, options.signal)) {
450
+ try {
451
+ const scan = await scanCached(path, options.cache, seen, () =>
452
+ scanCodexRollout(path, options.signal),
453
+ );
454
+
455
+ malformedRecords += scan.malformedRecords;
456
+
457
+ if (scan.modern.length > 0) {
458
+ for (const [id, row] of scan.modern) if (!responses.has(id)) responses.set(id, row);
459
+ } else {
460
+ legacy.push(...scan.legacy);
461
+ }
462
+ } catch {
463
+ options.signal?.throwIfAborted();
464
+ unreadableFiles++;
465
+ }
466
+ }
467
+ }
468
+
469
+ options.signal?.throwIfAborted();
470
+ pruneCache(options.cache, seen);
471
+
472
+ const catalog = options.models
473
+ .getModels("openai-codex")
474
+ .filter((model) => model.provider === "openai-codex");
475
+
476
+ const rows: ModelUsage[] = [];
477
+ let unpricedRecords = 0;
478
+
479
+ for (const row of [...responses.values(), ...legacy]) {
480
+ options.signal?.throwIfAborted();
481
+ const usage: Usage = { ...row.usage, cost: { ...row.usage.cost } };
482
+ const matches = catalog.filter((model) => model.id === row.model);
483
+ const model = matches.length === 1 ? matches[0] : undefined;
484
+
485
+ if (model) calculateCost(model, usage);
486
+ else unpricedRecords++;
487
+ rows.push({
488
+ provider: "openai-codex",
489
+ model: row.model,
490
+ turns: 1,
491
+ usage: recordedUsage(usage),
492
+ });
493
+ }
494
+
495
+ return {
496
+ kind: "ready",
497
+ summary: summarizeModels(rows),
498
+ unpricedRecords,
499
+ malformedRecords,
500
+ unreadableFiles,
501
+ };
502
+ } catch {
503
+ options.signal?.throwIfAborted();
504
+
505
+ return { kind: "failed", message: "Could not read Codex local history." };
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Reads one Codex rollout. A `token_count` names no model, so the last `turn_context` does; only
511
+ * the file's first session meta counts, since a fork repeats its ancestors' metas straight after
512
+ * 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.
514
+ */
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;
531
+ let value: unknown;
532
+
533
+ try {
534
+ value = JSON.parse(line.text);
535
+ } catch {
536
+ if (line.terminated) malformedRecords++;
537
+ continue;
538
+ }
539
+
540
+ if (!Value.Check(envelope, value)) {
541
+ malformedRecords++;
542
+ continue;
543
+ }
544
+
545
+ if (Value.Check(turnContext, value)) {
546
+ if (value.payload.model !== undefined) model = value.payload.model;
547
+ continue;
548
+ }
549
+
550
+ if (Value.Check(sessionMeta, value)) {
551
+ if (sawMeta) continue;
552
+ sawMeta = true;
553
+
554
+ const forked =
555
+ value.payload.forked_from_id !== undefined || value.payload.parent_thread_id !== undefined;
556
+
557
+ if (forked) forkAnchorMs = parseMs(value.timestamp);
558
+ continue;
559
+ }
560
+
561
+ if (Value.Check(usageRecord, value)) {
562
+ const id = value.payload.response_id;
563
+
564
+ if (id === undefined || model === "") continue;
565
+ const usage = codexUsage(value.payload.usage);
566
+
567
+ if (usage.totalTokens > 0 && !modern.has(id)) modern.set(id, { model, usage });
568
+ continue;
569
+ }
570
+
571
+ if (!Value.Check(tokenCount, value)) continue;
572
+ const last = value.payload.info.last_token_usage;
573
+
574
+ if (last === undefined || model === "") continue;
575
+ const next = JSON.stringify(last);
576
+
577
+ if (next === signature) continue;
578
+ signature = next;
579
+
580
+ if (forkAnchorMs !== undefined) {
581
+ const at = parseMs(value.timestamp);
582
+
583
+ if (at !== undefined && at - forkAnchorMs < FORK_COPY_MAX_GAP_MS) {
584
+ forkAnchorMs = at;
585
+ continue;
586
+ }
587
+
588
+ forkAnchorMs = undefined;
589
+ }
590
+
591
+ const usage = codexUsage(last);
592
+
593
+ if (usage.totalTokens > 0) legacy.push({ model, usage });
594
+ }
595
+
596
+ return { modern: [...modern], legacy, malformedRecords };
597
+ }
598
+
599
+ async function* jsonlFiles(root: string, signal: AbortSignal | undefined): AsyncGenerator<string> {
600
+ const directories = [root];
601
+
602
+ for (const directory of directories) {
603
+ signal?.throwIfAborted();
604
+
605
+ try {
606
+ if (directory !== root && (await lstat(directory)).isSymbolicLink()) continue;
607
+
608
+ for await (const entry of await opendir(directory)) {
609
+ signal?.throwIfAborted();
610
+ const path = join(directory, entry.name);
611
+
612
+ if (entry.isDirectory()) directories.push(path);
613
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) yield path;
614
+ }
615
+ } catch (error) {
616
+ if (directory === root) throw error;
617
+ }
618
+ }
619
+ }
620
+
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
+ export interface UsageScanCaches {
661
+ readonly claudeCode: UsageScanCache<ClaudeCodeFileScan>;
662
+ readonly codex: UsageScanCache<CodexFileScan>;
663
+ }
664
+
665
+ export function createUsageScanCaches(): UsageScanCaches {
666
+ return { claudeCode: new Map(), codex: new Map() };
667
+ }
668
+
669
+ export interface LocalUsage {
670
+ readonly claudeCode: LocalHistoryUsage;
671
+ readonly codex: LocalHistoryUsage;
672
+ }
673
+
674
+ export async function readLocalUsage(options: {
675
+ readonly models: Pick<Models, "getModels">;
676
+ readonly signal?: AbortSignal;
677
+ readonly caches?: UsageScanCaches;
678
+ }): Promise<LocalUsage> {
679
+ const failed = (message: string): LocalHistoryUsage => {
680
+ options.signal?.throwIfAborted();
681
+
682
+ return { kind: "failed", message };
683
+ };
684
+
685
+ const [claudeCode, codex] = await Promise.all([
686
+ readClaudeCodeUsage({
687
+ models: options.models,
688
+ signal: options.signal,
689
+ cache: options.caches?.claudeCode,
690
+ }).catch(() => failed("Could not read Claude Code history.")),
691
+ readCodexUsage({
692
+ models: options.models,
693
+ signal: options.signal,
694
+ cache: options.caches?.codex,
695
+ }).catch(() => failed("Could not read Codex history.")),
696
+ ]);
697
+
698
+ options.signal?.throwIfAborted();
699
+
700
+ return { claudeCode, codex };
701
+ }