@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Tests for Claude Code transcript JSONL parser.
3
+ *
4
+ * Uses temp files with real-format JSONL data. No mocks.
5
+ * Philosophy: "never mock what you can use for real" (mx-252b16).
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtemp } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
13
+ import { estimateCost, parseTranscriptUsage } from "./transcript.ts";
14
+
15
+ let tempDir: string;
16
+
17
+ beforeEach(async () => {
18
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-transcript-test-"));
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await cleanupTempDir(tempDir);
23
+ });
24
+
25
+ /** Write a JSONL file with the given lines. */
26
+ async function writeJsonl(filename: string, lines: unknown[]): Promise<string> {
27
+ const path = join(tempDir, filename);
28
+ const content = `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`;
29
+ await Bun.write(path, content);
30
+ return path;
31
+ }
32
+
33
+ // === parseTranscriptUsage ===
34
+
35
+ describe("parseTranscriptUsage", () => {
36
+ test("parses a single assistant entry with all usage fields", async () => {
37
+ const path = await writeJsonl("single.jsonl", [
38
+ {
39
+ type: "assistant",
40
+ message: {
41
+ model: "claude-opus-4-6",
42
+ usage: {
43
+ input_tokens: 100,
44
+ output_tokens: 50,
45
+ cache_read_input_tokens: 1000,
46
+ cache_creation_input_tokens: 500,
47
+ },
48
+ },
49
+ },
50
+ ]);
51
+
52
+ const usage = await parseTranscriptUsage(path);
53
+
54
+ expect(usage.inputTokens).toBe(100);
55
+ expect(usage.outputTokens).toBe(50);
56
+ expect(usage.cacheReadTokens).toBe(1000);
57
+ expect(usage.cacheCreationTokens).toBe(500);
58
+ expect(usage.modelUsed).toBe("claude-opus-4-6");
59
+ });
60
+
61
+ test("aggregates usage across multiple assistant turns", async () => {
62
+ const path = await writeJsonl("multi.jsonl", [
63
+ {
64
+ type: "assistant",
65
+ message: {
66
+ model: "claude-sonnet-4-20250514",
67
+ usage: {
68
+ input_tokens: 100,
69
+ output_tokens: 50,
70
+ cache_read_input_tokens: 1000,
71
+ cache_creation_input_tokens: 500,
72
+ },
73
+ },
74
+ },
75
+ {
76
+ type: "human",
77
+ message: { content: "follow-up question" },
78
+ },
79
+ {
80
+ type: "assistant",
81
+ message: {
82
+ model: "claude-sonnet-4-20250514",
83
+ usage: {
84
+ input_tokens: 200,
85
+ output_tokens: 75,
86
+ cache_read_input_tokens: 2000,
87
+ cache_creation_input_tokens: 0,
88
+ },
89
+ },
90
+ },
91
+ ]);
92
+
93
+ const usage = await parseTranscriptUsage(path);
94
+
95
+ expect(usage.inputTokens).toBe(300);
96
+ expect(usage.outputTokens).toBe(125);
97
+ expect(usage.cacheReadTokens).toBe(3000);
98
+ expect(usage.cacheCreationTokens).toBe(500);
99
+ expect(usage.modelUsed).toBe("claude-sonnet-4-20250514");
100
+ });
101
+
102
+ test("skips non-assistant entries (human, system, tool_use, etc.)", async () => {
103
+ const path = await writeJsonl("mixed.jsonl", [
104
+ { type: "system", content: "system prompt" },
105
+ {
106
+ type: "assistant",
107
+ message: {
108
+ model: "claude-opus-4-6",
109
+ usage: {
110
+ input_tokens: 100,
111
+ output_tokens: 50,
112
+ cache_read_input_tokens: 0,
113
+ cache_creation_input_tokens: 0,
114
+ },
115
+ },
116
+ },
117
+ { type: "human", message: { content: "hello" } },
118
+ { type: "tool_result", content: "result" },
119
+ ]);
120
+
121
+ const usage = await parseTranscriptUsage(path);
122
+
123
+ expect(usage.inputTokens).toBe(100);
124
+ expect(usage.outputTokens).toBe(50);
125
+ });
126
+
127
+ test("returns zeros for empty file", async () => {
128
+ const path = join(tempDir, "empty.jsonl");
129
+ await Bun.write(path, "");
130
+
131
+ const usage = await parseTranscriptUsage(path);
132
+
133
+ expect(usage.inputTokens).toBe(0);
134
+ expect(usage.outputTokens).toBe(0);
135
+ expect(usage.cacheReadTokens).toBe(0);
136
+ expect(usage.cacheCreationTokens).toBe(0);
137
+ expect(usage.modelUsed).toBeNull();
138
+ });
139
+
140
+ test("returns zeros for file with no assistant entries", async () => {
141
+ const path = await writeJsonl("no-assistant.jsonl", [
142
+ { type: "human", message: { content: "hello" } },
143
+ { type: "system", content: "system prompt" },
144
+ ]);
145
+
146
+ const usage = await parseTranscriptUsage(path);
147
+
148
+ expect(usage.inputTokens).toBe(0);
149
+ expect(usage.outputTokens).toBe(0);
150
+ expect(usage.modelUsed).toBeNull();
151
+ });
152
+
153
+ test("gracefully handles malformed JSON lines", async () => {
154
+ const path = join(tempDir, "malformed.jsonl");
155
+ const content = [
156
+ '{"type":"assistant","message":{"model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}',
157
+ "this is not valid json",
158
+ "",
159
+ '{"type":"assistant","message":{"model":"claude-opus-4-6","usage":{"input_tokens":200,"output_tokens":75,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}',
160
+ ].join("\n");
161
+ await Bun.write(path, content);
162
+
163
+ const usage = await parseTranscriptUsage(path);
164
+
165
+ // Should parse the two valid assistant entries, skip the malformed line
166
+ expect(usage.inputTokens).toBe(300);
167
+ expect(usage.outputTokens).toBe(125);
168
+ });
169
+
170
+ test("handles assistant entries with missing usage fields (defaults to 0)", async () => {
171
+ const path = await writeJsonl("partial.jsonl", [
172
+ {
173
+ type: "assistant",
174
+ message: {
175
+ model: "claude-haiku-3-5-20241022",
176
+ usage: {
177
+ input_tokens: 100,
178
+ output_tokens: 50,
179
+ // No cache fields
180
+ },
181
+ },
182
+ },
183
+ ]);
184
+
185
+ const usage = await parseTranscriptUsage(path);
186
+
187
+ expect(usage.inputTokens).toBe(100);
188
+ expect(usage.outputTokens).toBe(50);
189
+ expect(usage.cacheReadTokens).toBe(0);
190
+ expect(usage.cacheCreationTokens).toBe(0);
191
+ });
192
+
193
+ test("handles assistant entries with no usage object", async () => {
194
+ const path = await writeJsonl("no-usage.jsonl", [
195
+ {
196
+ type: "assistant",
197
+ message: {
198
+ model: "claude-opus-4-6",
199
+ content: "response without usage",
200
+ },
201
+ },
202
+ ]);
203
+
204
+ const usage = await parseTranscriptUsage(path);
205
+
206
+ expect(usage.inputTokens).toBe(0);
207
+ expect(usage.outputTokens).toBe(0);
208
+ expect(usage.modelUsed).toBeNull();
209
+ });
210
+
211
+ test("captures model from first assistant turn only", async () => {
212
+ const path = await writeJsonl("model-change.jsonl", [
213
+ {
214
+ type: "assistant",
215
+ message: {
216
+ model: "claude-sonnet-4-20250514",
217
+ usage: {
218
+ input_tokens: 10,
219
+ output_tokens: 5,
220
+ cache_read_input_tokens: 0,
221
+ cache_creation_input_tokens: 0,
222
+ },
223
+ },
224
+ },
225
+ {
226
+ type: "assistant",
227
+ message: {
228
+ model: "claude-opus-4-6",
229
+ usage: {
230
+ input_tokens: 20,
231
+ output_tokens: 10,
232
+ cache_read_input_tokens: 0,
233
+ cache_creation_input_tokens: 0,
234
+ },
235
+ },
236
+ },
237
+ ]);
238
+
239
+ const usage = await parseTranscriptUsage(path);
240
+
241
+ expect(usage.modelUsed).toBe("claude-sonnet-4-20250514");
242
+ expect(usage.inputTokens).toBe(30);
243
+ });
244
+
245
+ test("handles real-world transcript format with trailing newlines", async () => {
246
+ const path = join(tempDir, "trailing.jsonl");
247
+ const content =
248
+ '{"type":"assistant","message":{"model":"claude-opus-4-6","usage":{"input_tokens":3,"output_tokens":9,"cache_read_input_tokens":19401,"cache_creation_input_tokens":9918}}}\n\n\n';
249
+ await Bun.write(path, content);
250
+
251
+ const usage = await parseTranscriptUsage(path);
252
+
253
+ expect(usage.inputTokens).toBe(3);
254
+ expect(usage.outputTokens).toBe(9);
255
+ expect(usage.cacheReadTokens).toBe(19401);
256
+ expect(usage.cacheCreationTokens).toBe(9918);
257
+ });
258
+ });
259
+
260
+ // === estimateCost ===
261
+
262
+ describe("estimateCost", () => {
263
+ test("calculates cost for opus model", () => {
264
+ const cost = estimateCost({
265
+ inputTokens: 1_000_000,
266
+ outputTokens: 1_000_000,
267
+ cacheReadTokens: 1_000_000,
268
+ cacheCreationTokens: 1_000_000,
269
+ modelUsed: "claude-opus-4-6",
270
+ });
271
+
272
+ // opus: input=$15, output=$75, cacheRead=$1.50, cacheCreation=$3.75
273
+ expect(cost).toBeCloseTo(95.25, 2);
274
+ });
275
+
276
+ test("calculates cost for sonnet model", () => {
277
+ const cost = estimateCost({
278
+ inputTokens: 1_000_000,
279
+ outputTokens: 1_000_000,
280
+ cacheReadTokens: 1_000_000,
281
+ cacheCreationTokens: 1_000_000,
282
+ modelUsed: "claude-sonnet-4-20250514",
283
+ });
284
+
285
+ // sonnet: input=$3, output=$15, cacheRead=$0.30, cacheCreation=$0.75
286
+ expect(cost).toBeCloseTo(19.05, 2);
287
+ });
288
+
289
+ test("calculates cost for haiku model", () => {
290
+ const cost = estimateCost({
291
+ inputTokens: 1_000_000,
292
+ outputTokens: 1_000_000,
293
+ cacheReadTokens: 1_000_000,
294
+ cacheCreationTokens: 1_000_000,
295
+ modelUsed: "claude-haiku-3-5-20241022",
296
+ });
297
+
298
+ // haiku: input=$0.80, output=$4, cacheRead=$0.08, cacheCreation=$0.20
299
+ expect(cost).toBeCloseTo(5.08, 2);
300
+ });
301
+
302
+ test("returns null for unknown model", () => {
303
+ const cost = estimateCost({
304
+ inputTokens: 1_000_000,
305
+ outputTokens: 1_000_000,
306
+ cacheReadTokens: 0,
307
+ cacheCreationTokens: 0,
308
+ modelUsed: "gpt-4o",
309
+ });
310
+
311
+ expect(cost).toBeNull();
312
+ });
313
+
314
+ test("returns null when modelUsed is null", () => {
315
+ const cost = estimateCost({
316
+ inputTokens: 1_000_000,
317
+ outputTokens: 1_000_000,
318
+ cacheReadTokens: 0,
319
+ cacheCreationTokens: 0,
320
+ modelUsed: null,
321
+ });
322
+
323
+ expect(cost).toBeNull();
324
+ });
325
+
326
+ test("zero tokens yields zero cost", () => {
327
+ const cost = estimateCost({
328
+ inputTokens: 0,
329
+ outputTokens: 0,
330
+ cacheReadTokens: 0,
331
+ cacheCreationTokens: 0,
332
+ modelUsed: "claude-opus-4-6",
333
+ });
334
+
335
+ expect(cost).toBe(0);
336
+ });
337
+
338
+ test("realistic session cost calculation", () => {
339
+ // A typical agent session: ~20K input, ~5K output, heavy cache reads
340
+ const cost = estimateCost({
341
+ inputTokens: 20_000,
342
+ outputTokens: 5_000,
343
+ cacheReadTokens: 100_000,
344
+ cacheCreationTokens: 15_000,
345
+ modelUsed: "claude-sonnet-4-20250514",
346
+ });
347
+
348
+ // sonnet: (20K/1M)*3 + (5K/1M)*15 + (100K/1M)*0.30 + (15K/1M)*0.75
349
+ // = 0.06 + 0.075 + 0.03 + 0.01125 = $0.17625
350
+ expect(cost).not.toBeNull();
351
+ if (cost !== null) {
352
+ expect(cost).toBeGreaterThan(0.1);
353
+ expect(cost).toBeLessThan(1.0);
354
+ }
355
+ });
356
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Parser for Claude Code transcript JSONL files.
3
+ *
4
+ * Extracts token usage data from assistant-type entries in transcript files
5
+ * at ~/.claude/projects/{project-slug}/{session-id}.jsonl.
6
+ *
7
+ * Each assistant entry contains per-turn usage:
8
+ * {
9
+ * "type": "assistant",
10
+ * "message": {
11
+ * "model": "claude-opus-4-6",
12
+ * "usage": {
13
+ * "input_tokens": 3,
14
+ * "output_tokens": 9,
15
+ * "cache_read_input_tokens": 19401,
16
+ * "cache_creation_input_tokens": 9918
17
+ * }
18
+ * }
19
+ * }
20
+ */
21
+
22
+ export interface TranscriptUsage {
23
+ inputTokens: number;
24
+ outputTokens: number;
25
+ cacheReadTokens: number;
26
+ cacheCreationTokens: number;
27
+ modelUsed: string | null;
28
+ }
29
+
30
+ /** Pricing per million tokens (USD). */
31
+ interface ModelPricing {
32
+ inputPerMTok: number;
33
+ outputPerMTok: number;
34
+ cacheReadPerMTok: number;
35
+ cacheCreationPerMTok: number;
36
+ }
37
+
38
+ /** Hardcoded pricing for known Claude models. */
39
+ const MODEL_PRICING: Record<string, ModelPricing> = {
40
+ opus: {
41
+ inputPerMTok: 15,
42
+ outputPerMTok: 75,
43
+ cacheReadPerMTok: 1.5, // 10% of input
44
+ cacheCreationPerMTok: 3.75, // 25% of input
45
+ },
46
+ sonnet: {
47
+ inputPerMTok: 3,
48
+ outputPerMTok: 15,
49
+ cacheReadPerMTok: 0.3, // 10% of input
50
+ cacheCreationPerMTok: 0.75, // 25% of input
51
+ },
52
+ haiku: {
53
+ inputPerMTok: 0.8,
54
+ outputPerMTok: 4,
55
+ cacheReadPerMTok: 0.08, // 10% of input
56
+ cacheCreationPerMTok: 0.2, // 25% of input
57
+ },
58
+ };
59
+
60
+ /**
61
+ * Determine the pricing tier for a given model string.
62
+ * Matches on substring: "opus" -> opus pricing, "sonnet" -> sonnet, "haiku" -> haiku.
63
+ * Returns null if unrecognized.
64
+ */
65
+ function getPricingForModel(model: string): ModelPricing | null {
66
+ const lower = model.toLowerCase();
67
+ if (lower.includes("opus")) return MODEL_PRICING.opus ?? null;
68
+ if (lower.includes("sonnet")) return MODEL_PRICING.sonnet ?? null;
69
+ if (lower.includes("haiku")) return MODEL_PRICING.haiku ?? null;
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Calculate the estimated cost in USD for a given usage and model.
75
+ * Returns null if the model is unrecognized.
76
+ */
77
+ export function estimateCost(usage: TranscriptUsage): number | null {
78
+ if (usage.modelUsed === null) return null;
79
+
80
+ const pricing = getPricingForModel(usage.modelUsed);
81
+ if (pricing === null) return null;
82
+
83
+ const inputCost = (usage.inputTokens / 1_000_000) * pricing.inputPerMTok;
84
+ const outputCost = (usage.outputTokens / 1_000_000) * pricing.outputPerMTok;
85
+ const cacheReadCost = (usage.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMTok;
86
+ const cacheCreationCost = (usage.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMTok;
87
+
88
+ return inputCost + outputCost + cacheReadCost + cacheCreationCost;
89
+ }
90
+
91
+ /**
92
+ * Narrow an unknown value to determine if it looks like a transcript assistant entry.
93
+ * Returns the usage fields if valid, or null otherwise.
94
+ */
95
+ function extractUsageFromEntry(entry: unknown): {
96
+ inputTokens: number;
97
+ outputTokens: number;
98
+ cacheReadTokens: number;
99
+ cacheCreationTokens: number;
100
+ model: string | undefined;
101
+ } | null {
102
+ if (typeof entry !== "object" || entry === null) return null;
103
+
104
+ const obj = entry as Record<string, unknown>;
105
+ if (obj.type !== "assistant") return null;
106
+
107
+ const message = obj.message;
108
+ if (typeof message !== "object" || message === null) return null;
109
+
110
+ const msg = message as Record<string, unknown>;
111
+ const usage = msg.usage;
112
+ if (typeof usage !== "object" || usage === null) return null;
113
+
114
+ const u = usage as Record<string, unknown>;
115
+
116
+ return {
117
+ inputTokens: typeof u.input_tokens === "number" ? u.input_tokens : 0,
118
+ outputTokens: typeof u.output_tokens === "number" ? u.output_tokens : 0,
119
+ cacheReadTokens: typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : 0,
120
+ cacheCreationTokens:
121
+ typeof u.cache_creation_input_tokens === "number" ? u.cache_creation_input_tokens : 0,
122
+ model: typeof msg.model === "string" ? msg.model : undefined,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Parse a Claude Code transcript JSONL file and aggregate token usage.
128
+ *
129
+ * Reads the file line by line, extracting usage data from each assistant
130
+ * entry. Returns aggregated totals and the model from the first assistant turn.
131
+ *
132
+ * @param transcriptPath - Absolute path to the transcript JSONL file
133
+ * @returns Aggregated usage data across all assistant turns
134
+ */
135
+ export async function parseTranscriptUsage(transcriptPath: string): Promise<TranscriptUsage> {
136
+ const file = Bun.file(transcriptPath);
137
+ const text = await file.text();
138
+ const lines = text.split("\n");
139
+
140
+ const result: TranscriptUsage = {
141
+ inputTokens: 0,
142
+ outputTokens: 0,
143
+ cacheReadTokens: 0,
144
+ cacheCreationTokens: 0,
145
+ modelUsed: null,
146
+ };
147
+
148
+ for (const line of lines) {
149
+ const trimmed = line.trim();
150
+ if (trimmed.length === 0) continue;
151
+
152
+ let parsed: unknown;
153
+ try {
154
+ parsed = JSON.parse(trimmed);
155
+ } catch {
156
+ // Skip malformed lines
157
+ continue;
158
+ }
159
+
160
+ const usage = extractUsageFromEntry(parsed);
161
+ if (usage === null) continue;
162
+
163
+ result.inputTokens += usage.inputTokens;
164
+ result.outputTokens += usage.outputTokens;
165
+ result.cacheReadTokens += usage.cacheReadTokens;
166
+ result.cacheCreationTokens += usage.cacheCreationTokens;
167
+
168
+ // Capture model from first assistant turn
169
+ if (result.modelUsed === null && usage.model !== undefined) {
170
+ result.modelUsed = usage.model;
171
+ }
172
+ }
173
+
174
+ return result;
175
+ }