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