@mohndoe/pi-atlas 0.1.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 (66) hide show
  1. package/.pi/extensions/guardrails.json +10 -0
  2. package/.pi/extensions/guardrails.v0.json +8 -0
  3. package/AGENTS.md +13 -0
  4. package/CONTEXT.md +119 -0
  5. package/LICENSE +21 -0
  6. package/README.md +40 -0
  7. package/bun.lock +325 -0
  8. package/docs/ARCHITECTURE.md +66 -0
  9. package/docs/adr/0001-global-session-project-map.md +9 -0
  10. package/docs/adr/0002-precomputed-summaries.md +9 -0
  11. package/docs/agents/domain.md +42 -0
  12. package/docs/agents/issue-tracker.md +22 -0
  13. package/docs/agents/triage-labels.md +14 -0
  14. package/package.json +49 -0
  15. package/src/__tests__/cache.test.ts +388 -0
  16. package/src/__tests__/components.fixtures.ts +54 -0
  17. package/src/__tests__/compute.fixtures.ts +49 -0
  18. package/src/__tests__/compute.test.ts +336 -0
  19. package/src/__tests__/e2e.test.ts +182 -0
  20. package/src/__tests__/format.test.ts +232 -0
  21. package/src/__tests__/parser.test.ts +1396 -0
  22. package/src/cache.ts +178 -0
  23. package/src/colorPalette.ts +119 -0
  24. package/src/components/BarChart.ts +288 -0
  25. package/src/components/Dashboard.ts +222 -0
  26. package/src/components/Header.ts +40 -0
  27. package/src/components/KpiCards.ts +104 -0
  28. package/src/components/LoadingView.ts +38 -0
  29. package/src/components/MarqueeText.ts +79 -0
  30. package/src/components/RangeSelector.ts +63 -0
  31. package/src/components/RankedBarList.ts +71 -0
  32. package/src/components/SortedTable.ts +221 -0
  33. package/src/components/StatCard.ts +64 -0
  34. package/src/components/TabBar.ts +59 -0
  35. package/src/components/UsageRow.ts +55 -0
  36. package/src/components/__tests__/Bar.test.ts +66 -0
  37. package/src/components/__tests__/BarChart.test.ts +224 -0
  38. package/src/components/__tests__/Dashboard.test.ts +452 -0
  39. package/src/components/__tests__/KpiCards.test.ts +83 -0
  40. package/src/components/__tests__/LoadingView.test.ts +26 -0
  41. package/src/components/__tests__/MarqueeText.test.ts +75 -0
  42. package/src/components/__tests__/RangeSelector.test.ts +34 -0
  43. package/src/components/__tests__/RankedBarList.test.ts +110 -0
  44. package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
  45. package/src/components/__tests__/SortedTable.test.ts +723 -0
  46. package/src/components/__tests__/TabBar.test.ts +62 -0
  47. package/src/components/__tests__/cells.test.ts +193 -0
  48. package/src/components/cells.ts +108 -0
  49. package/src/components/shared/Bar.ts +22 -0
  50. package/src/components/shared/GridRow.ts +22 -0
  51. package/src/compute.ts +210 -0
  52. package/src/format.ts +219 -0
  53. package/src/index.ts +88 -0
  54. package/src/parser.ts +363 -0
  55. package/src/tabs/Languages.ts +102 -0
  56. package/src/tabs/Models.ts +108 -0
  57. package/src/tabs/Overview.ts +152 -0
  58. package/src/tabs/Projects.ts +92 -0
  59. package/src/tabs/Usage.ts +181 -0
  60. package/src/tabs/__tests__/Languages.test.ts +158 -0
  61. package/src/tabs/__tests__/Models.test.ts +143 -0
  62. package/src/tabs/__tests__/Overview.test.ts +92 -0
  63. package/src/tabs/__tests__/Projects.test.ts +142 -0
  64. package/src/tabs/__tests__/Usage.test.ts +174 -0
  65. package/src/types.ts +99 -0
  66. package/tsconfig.json +30 -0
@@ -0,0 +1,1396 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
5
+ import {
6
+ parseLanguageUsage,
7
+ emptyDay,
8
+ mergeDay,
9
+ parseAssistantMessage,
10
+ parseCompactionEntry,
11
+ parseFile,
12
+ parseModelChangeEntry,
13
+ parseSessionHeader,
14
+ parseSessionLogEntry,
15
+ parseThinkingLevelChangeEntry,
16
+ parseToolResultMessage,
17
+ parseUserMessage,
18
+ sessionProjectMap,
19
+ } from "../parser";
20
+ import type { DayAgg } from "../types";
21
+ import type {
22
+ AssistantMessage as PiAssistantMessage,
23
+ ToolResultMessage as PiToolResultMessage,
24
+ ToolCall,
25
+ } from "@earendil-works/pi-ai";
26
+ import type {
27
+ CompactionEntry,
28
+ ModelChangeEntry,
29
+ SessionHeader,
30
+ SessionMessageEntry,
31
+ ThinkingLevelChangeEntry,
32
+ } from "@earendil-works/pi-coding-agent";
33
+ import assert from "node:assert";
34
+
35
+ // Helper: minimal AssistantMessage with required fields
36
+ function mkAsst(msg: {
37
+ content?: PiAssistantMessage["content"];
38
+ model?: string;
39
+ provider?: string;
40
+ usage?: PiAssistantMessage["usage"];
41
+ }): PiAssistantMessage {
42
+ return {
43
+ role: "assistant",
44
+ content: msg.content ?? [],
45
+ api: "anthropic-messages",
46
+ provider: msg.provider ?? "deepseek",
47
+ model: msg.model ?? "deepseek-v4-pro",
48
+ usage: msg.usage ?? {
49
+ input: 0,
50
+ output: 0,
51
+ cacheRead: 0,
52
+ cacheWrite: 0,
53
+ totalTokens: 0,
54
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
55
+ },
56
+ stopReason: "stop",
57
+ timestamp: 1700000000000,
58
+ };
59
+ }
60
+
61
+ // Helper: minimal ToolResultMessage with required fields
62
+ function mkToolResult(msg: {
63
+ toolName?: string;
64
+ toolCallId?: string;
65
+ content?: PiToolResultMessage["content"];
66
+ }): PiToolResultMessage {
67
+ return {
68
+ role: "toolResult",
69
+ toolName: msg.toolName ?? "bash",
70
+ toolCallId: msg.toolCallId ?? "c1",
71
+ content: msg.content ?? [],
72
+ isError: false,
73
+ timestamp: 1700000000000,
74
+ };
75
+ }
76
+
77
+ // Helper: minimal ToolCall block
78
+ function tc(name: string, args?: Record<string, unknown>): ToolCall {
79
+ return { type: "toolCall", id: "c1", name, arguments: args ?? {} };
80
+ }
81
+
82
+ describe("emptyDay", () => {
83
+ it("creates a zeroed DayAgg with the given date", () => {
84
+ const day = emptyDay("2026-06-09");
85
+ expect(day.date).toBe("2026-06-09");
86
+ expect(day.cost).toBe(0);
87
+ expect(day.inTok).toBe(0);
88
+ expect(day.outTok).toBe(0);
89
+ expect(day.crTok).toBe(0);
90
+ expect(day.cwTok).toBe(0);
91
+ expect(day.userMsgs).toBe(0);
92
+ expect(day.asstMsgs).toBe(0);
93
+ expect(day.toolResults).toBe(0);
94
+ expect(day.sessionIds.size).toBe(0);
95
+ expect(day.langLines).toEqual({});
96
+ expect(day.langEdits).toEqual({});
97
+ expect(day.modelCost).toEqual({});
98
+ expect(day.modelCount).toEqual({});
99
+ expect(day.projectCost).toEqual({});
100
+ expect(day.projectSessions).toEqual({});
101
+ expect(day.toolCount).toEqual({});
102
+ expect(day.compactionCount).toBe(0);
103
+ expect(day.compactedTokens).toBe(0);
104
+ expect(day.modelChanges).toBe(0);
105
+ expect(day.thinkingLevelCount).toEqual({});
106
+ expect(day.hourCost).toEqual({});
107
+ });
108
+
109
+ it("returns a new empty object each call", () => {
110
+ const a = emptyDay("2026-06-09");
111
+ const b = emptyDay("2026-06-09");
112
+ expect(a).not.toBe(b);
113
+ expect(a.langLines).not.toBe(b.langLines);
114
+ expect(a.toolCount).not.toBe(b.toolCount);
115
+ });
116
+ });
117
+
118
+ describe("parseUserMessage", () => {
119
+ it("returns a DayAgg with userMsgs: 1", () => {
120
+ const day = parseUserMessage();
121
+ expect(day.userMsgs).toBe(1);
122
+ expect(day.asstMsgs).toBe(0);
123
+ expect(day.toolResults).toBe(0);
124
+ });
125
+ });
126
+
127
+ describe("parseToolResultMessage", () => {
128
+ it("counts one tool result and the tool name", () => {
129
+ const msg = mkToolResult({ toolName: "bash" });
130
+ const day = parseToolResultMessage(msg);
131
+ expect(day.toolResults).toBe(1);
132
+ expect(day.toolCount["bash"]).toBe(1);
133
+ });
134
+
135
+ it("handles missing toolName gracefully", () => {
136
+ const msg = mkToolResult({ toolName: "" });
137
+ const day = parseToolResultMessage(msg);
138
+ expect(day.toolResults).toBe(1);
139
+ expect(day.toolCount).toEqual({});
140
+ });
141
+
142
+ it("strips control characters from toolName", () => {
143
+ const msg = mkToolResult({ toolName: "ls -la agent/\n</parameter" });
144
+ const day = parseToolResultMessage(msg);
145
+ expect(day.toolResults).toBe(1);
146
+ expect(Object.keys(day.toolCount)[0]).toBe("ls -la agent/</parameter");
147
+ expect(day.toolCount["ls -la agent/</parameter"]).toBe(1);
148
+ });
149
+ });
150
+
151
+ describe("parseLanguageUsage", () => {
152
+ it("counts edits correctly", () => {
153
+ const day = parseLanguageUsage("edit", {
154
+ path: "/src/foo.ts",
155
+ edits: [
156
+ { oldText: "x", newText: "abc" },
157
+ { oldText: "y", newText: "defg" },
158
+ ],
159
+ });
160
+ expect(day.langEdits["TypeScript"]).toBe(2);
161
+ expect(day.langLines["TypeScript"]).toBe(2);
162
+ });
163
+
164
+ it("counts write call correctly", () => {
165
+ const day = parseLanguageUsage("write", {
166
+ path: "/src/lib.rs",
167
+ content: "fn main() {}",
168
+ });
169
+ expect(day.langLines["Rust"]).toBe(1);
170
+ expect(day.langEdits["Rust"]).toBeUndefined();
171
+ });
172
+
173
+ it("treats edits with no newText as a line", () => {
174
+ const day = parseLanguageUsage("edit", {
175
+ path: "/src/foo.ts",
176
+ edits: [{ oldText: "x" }],
177
+ });
178
+ expect(day.langLines["TypeScript"]).toBe(1);
179
+ expect(day.langEdits["TypeScript"]).toBe(1);
180
+ });
181
+
182
+ it("handles non-array edits gracefully", () => {
183
+ const day = parseLanguageUsage("edit", {
184
+ path: "/src/foo.ts",
185
+ edits: "not-an-array",
186
+ });
187
+ expect(day.langLines["TypeScript"]).toBe(1);
188
+ expect(day.langEdits["TypeScript"]).toBe(1);
189
+ });
190
+
191
+ it("handles missing content in write gracefully", () => {
192
+ const day = parseLanguageUsage("write", { path: "/src/foo.py" });
193
+ expect(day.langLines["Python"]).toBe(1);
194
+ });
195
+
196
+ it("returns empty day when path is missing", () => {
197
+ const day = parseLanguageUsage("write", {});
198
+ expect(day.langLines).toEqual({});
199
+ expect(day.langEdits).toEqual({});
200
+ });
201
+
202
+ it("returns empty day when args is undefined", () => {
203
+ const day = parseLanguageUsage("edit", undefined);
204
+ expect(day.langLines).toEqual({});
205
+ expect(day.langEdits).toEqual({});
206
+ });
207
+ });
208
+
209
+ describe("parseAssistantMessage", () => {
210
+ it("counts one assistant message and usage tokens", () => {
211
+ const msg = mkAsst({
212
+ content: [{ type: "text", text: "hello" }],
213
+ usage: {
214
+ input: 100,
215
+ output: 50,
216
+ cacheRead: 10,
217
+ cacheWrite: 5,
218
+ totalTokens: 165,
219
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
220
+ },
221
+ });
222
+ const day = parseAssistantMessage(msg);
223
+ expect(day.asstMsgs).toBe(1);
224
+ expect(day.inTok).toBe(100);
225
+ expect(day.outTok).toBe(50);
226
+ expect(day.crTok).toBe(10);
227
+ expect(day.cwTok).toBe(5);
228
+ });
229
+
230
+ it("records model cost and count when both model and cost present", () => {
231
+ const msg = mkAsst({
232
+ model: "deepseek-v4-pro",
233
+ usage: {
234
+ input: 10,
235
+ output: 5,
236
+ cacheRead: 0,
237
+ cacheWrite: 0,
238
+ totalTokens: 15,
239
+ cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
240
+ },
241
+ });
242
+ const day = parseAssistantMessage(msg);
243
+ expect(day.cost).toBe(0.003);
244
+ expect(day.modelCost["deepseek-v4-pro"]).toBe(0.003);
245
+ expect(day.modelCount["deepseek-v4-pro"]).toBe(1);
246
+ });
247
+
248
+ it("skips model cost when model is missing", () => {
249
+ const msg = mkAsst({
250
+ model: "",
251
+ usage: {
252
+ input: 10,
253
+ output: 5,
254
+ cacheRead: 0,
255
+ cacheWrite: 0,
256
+ totalTokens: 15,
257
+ cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
258
+ },
259
+ });
260
+ const day = parseAssistantMessage(msg);
261
+ expect(day.modelCost).toEqual({});
262
+ expect(day.modelCount).toEqual({});
263
+ });
264
+
265
+ it("counts tool calls from content blocks", () => {
266
+ const msg = mkAsst({
267
+ content: [
268
+ tc("read", { path: "/f" }),
269
+ { ...tc("bash", { command: "ls" }), id: "c2" },
270
+ { ...tc("read", { path: "/g" }), id: "c3" },
271
+ ],
272
+ });
273
+ const day = parseAssistantMessage(msg);
274
+ expect(day.toolCount["read"]).toBe(2);
275
+ expect(day.toolCount["bash"]).toBe(1);
276
+ });
277
+
278
+ it("strips control characters from tool call names", () => {
279
+ const msg = mkAsst({
280
+ content: [tc("ls -la agent/\n</parameter", { command: "ls -la agent/" })],
281
+ });
282
+ const day = parseAssistantMessage(msg);
283
+ expect(day.toolCount["ls -la agent/</parameter"]).toBe(1);
284
+ expect(day.toolCount["ls -la agent/\n</parameter"]).toBeUndefined();
285
+ });
286
+
287
+ it("detects language from edit/write tool calls", () => {
288
+ const msg = mkAsst({
289
+ content: [
290
+ tc("edit", { path: "/src/foo.ts", edits: [{ newText: "abc" }] }),
291
+ { ...tc("write", { path: "/src/bar.rs", content: "fn main() {}" }), id: "c2" },
292
+ ],
293
+ });
294
+ const day = parseAssistantMessage(msg);
295
+ expect(day.langLines["TypeScript"]).toBe(1);
296
+ expect(day.langEdits["TypeScript"]).toBe(1);
297
+ expect(day.langLines["Rust"]).toBe(1);
298
+ });
299
+
300
+ it("attributes cost to projects in sessionProject", () => {
301
+ sessionProjectMap.clear();
302
+ sessionProjectMap.set("s1", "alpha");
303
+ sessionProjectMap.set("s2", "beta");
304
+
305
+ const msg = mkAsst({
306
+ content: [{ type: "text", text: "ok" }],
307
+ usage: {
308
+ input: 0,
309
+ output: 0,
310
+ cacheRead: 0,
311
+ cacheWrite: 0,
312
+ totalTokens: 0,
313
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.05 },
314
+ },
315
+ });
316
+ const day = parseAssistantMessage(msg);
317
+ expect(day.projectCost["alpha"]).toBe(0.05);
318
+ expect(day.projectCost["beta"]).toBe(0.05);
319
+ });
320
+
321
+ it("handles missing usage gracefully", () => {
322
+ const msg = mkAsst({
323
+ content: [{ type: "text", text: "hi" }],
324
+ usage: {
325
+ input: 0,
326
+ output: 0,
327
+ cacheRead: 0,
328
+ cacheWrite: 0,
329
+ totalTokens: 0,
330
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
331
+ },
332
+ });
333
+ const day = parseAssistantMessage(msg);
334
+ expect(day.asstMsgs).toBe(1);
335
+ expect(day.inTok).toBe(0);
336
+ expect(day.cost).toBe(0);
337
+ });
338
+
339
+ it("handles missing content gracefully", () => {
340
+ const msg = mkAsst({
341
+ usage: {
342
+ input: 10,
343
+ output: 5,
344
+ cacheRead: 0,
345
+ cacheWrite: 0,
346
+ totalTokens: 15,
347
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
348
+ },
349
+ });
350
+ const day = parseAssistantMessage(msg);
351
+ expect(day.asstMsgs).toBe(1);
352
+ expect(day.toolCount).toEqual({});
353
+ });
354
+ });
355
+
356
+ describe("parseSessionHeader", () => {
357
+ it("creates a DayAgg with session id and date", () => {
358
+ sessionProjectMap.clear();
359
+ const entry: SessionHeader = {
360
+ type: "session",
361
+ version: 3,
362
+ id: "abc-123",
363
+ timestamp: "2026-06-09T10:00:00.000Z",
364
+ cwd: "/home/doe",
365
+ };
366
+ const day = parseSessionHeader(entry);
367
+ expect(day.date).toBe("2026-06-09");
368
+ expect(day.sessionIds.has("abc-123")).toBe(true);
369
+ });
370
+
371
+ it("registers project from cwd in sessionProject", () => {
372
+ sessionProjectMap.clear();
373
+ const entry: SessionHeader = {
374
+ type: "session",
375
+ version: 3,
376
+ id: "s1",
377
+ timestamp: "2026-06-09T10:00:00.000Z",
378
+ cwd: "/home/doe/dev/my-app",
379
+ };
380
+ const day = parseSessionHeader(entry);
381
+ expect(sessionProjectMap.get("s1")).toBe("my-app");
382
+ expect(day.projectCost["my-app"]).toBe(0);
383
+ expect(day.projectSessions["my-app"]?.has("s1")).toBe(true);
384
+ });
385
+
386
+ it("handles empty cwd gracefully", () => {
387
+ sessionProjectMap.clear();
388
+ const entry: SessionHeader = {
389
+ type: "session",
390
+ version: 3,
391
+ id: "s2",
392
+ timestamp: "2026-06-09T10:00:00.000Z",
393
+ cwd: "",
394
+ };
395
+ const day = parseSessionHeader(entry);
396
+ expect(sessionProjectMap.has("s2")).toBe(false);
397
+ expect(day.projectCost).toEqual({});
398
+ expect(day.projectSessions).toEqual({});
399
+ });
400
+ });
401
+
402
+ describe("parseModelChangeEntry", () => {
403
+ it("increments modelChanges", () => {
404
+ const entry: ModelChangeEntry = {
405
+ type: "model_change",
406
+ id: "m1",
407
+ parentId: "p",
408
+ timestamp: "2026-06-09T10:00:00.000Z",
409
+ provider: "deepseek",
410
+ modelId: "deepseek-v4-pro",
411
+ };
412
+ const day = parseModelChangeEntry(entry);
413
+ expect(day.date).toBe("2026-06-09");
414
+ expect(day.modelChanges).toBe(1);
415
+ expect(day.cost).toBe(0);
416
+ });
417
+ });
418
+
419
+ describe("parseThinkingLevelChangeEntry", () => {
420
+ it("counts one thinking level change", () => {
421
+ const entry: ThinkingLevelChangeEntry = {
422
+ type: "thinking_level_change",
423
+ id: "t1",
424
+ parentId: "p",
425
+ timestamp: "2026-06-09T10:00:00.000Z",
426
+ thinkingLevel: "high",
427
+ };
428
+ const day = parseThinkingLevelChangeEntry(entry);
429
+ expect(day.date).toBe("2026-06-09");
430
+ expect(day.thinkingLevelCount).toEqual({ high: 1 });
431
+ });
432
+
433
+ it("counts different thinking levels separately", () => {
434
+ const low: ThinkingLevelChangeEntry = {
435
+ type: "thinking_level_change",
436
+ id: "t1",
437
+ parentId: "p",
438
+ timestamp: "2026-06-09T10:00:00.000Z",
439
+ thinkingLevel: "low",
440
+ };
441
+ const high: ThinkingLevelChangeEntry = {
442
+ type: "thinking_level_change",
443
+ id: "t2",
444
+ parentId: "t1",
445
+ timestamp: "2026-06-09T10:01:00.000Z",
446
+ thinkingLevel: "high",
447
+ };
448
+
449
+ const base = emptyDay("2026-06-09");
450
+ mergeDay(base, parseThinkingLevelChangeEntry(low));
451
+ mergeDay(base, parseThinkingLevelChangeEntry(high));
452
+ expect(base.thinkingLevelCount).toEqual({ low: 1, high: 1 });
453
+ });
454
+ });
455
+
456
+ describe("parseCompactionEntry", () => {
457
+ it("increments compactionCount and sums tokensBefore", () => {
458
+ const entry: CompactionEntry = {
459
+ type: "compaction",
460
+ id: "c1",
461
+ parentId: "p",
462
+ timestamp: "2026-06-09T10:00:00.000Z",
463
+ summary: "Some summary",
464
+ firstKeptEntryId: "m1",
465
+ tokensBefore: 50000,
466
+ };
467
+ const day = parseCompactionEntry(entry);
468
+ expect(day.date).toBe("2026-06-09");
469
+ expect(day.compactionCount).toBe(1);
470
+ expect(day.compactedTokens).toBe(50000);
471
+ });
472
+ });
473
+
474
+ describe("parseSessionLogEntry", () => {
475
+ it("returns a DayAgg for a session entry", () => {
476
+ const entry: SessionHeader = {
477
+ type: "session",
478
+ version: 3,
479
+ id: "abc-123",
480
+ timestamp: "2026-06-08T17:37:04.122Z",
481
+ cwd: "/home/doe/dev/pi-atlas",
482
+ };
483
+
484
+ const dayAgg = parseSessionLogEntry(entry);
485
+
486
+ assert(dayAgg);
487
+ expect(dayAgg.date).toBe("2026-06-08");
488
+ expect(dayAgg.sessionIds.has("abc-123")).toBe(true);
489
+ });
490
+
491
+ it("returns null/undefined for corrupt entries", () => {
492
+ // @ts-expect-error: testing runtime resilience
493
+ const nullDay = parseSessionLogEntry(null);
494
+ expect(nullDay).toBeNull();
495
+ // @ts-expect-error: testing runtime resilience
496
+ const undefinedDay = parseSessionLogEntry(undefined);
497
+ expect(undefinedDay).toBeNull();
498
+ });
499
+
500
+ it("returns a DayAgg for an assistant message with usage", () => {
501
+ const msgEntry: SessionMessageEntry = {
502
+ type: "message",
503
+ id: "msg-1",
504
+ parentId: "prev",
505
+ timestamp: "2026-06-08T10:05:00.000Z",
506
+ message: mkAsst({
507
+ content: [{ type: "text", text: "hello" }],
508
+ provider: "deepseek",
509
+ model: "deepseek-v4-pro",
510
+ usage: {
511
+ input: 1000,
512
+ output: 200,
513
+ cacheRead: 100,
514
+ cacheWrite: 0,
515
+ totalTokens: 1300,
516
+ cost: { input: 0.001, output: 0.0004, cacheRead: 0.00001, cacheWrite: 0, total: 0.00141 },
517
+ },
518
+ }),
519
+ };
520
+
521
+ const dayAgg = parseSessionLogEntry(msgEntry);
522
+
523
+ assert(dayAgg);
524
+ expect(dayAgg.cost).toBe(0.00141);
525
+ expect(dayAgg.inTok).toBe(1000);
526
+ expect(dayAgg.outTok).toBe(200);
527
+ expect(dayAgg.crTok).toBe(100);
528
+ expect(dayAgg.cwTok).toBe(0);
529
+ expect(dayAgg.asstMsgs).toBe(1);
530
+ expect(dayAgg.modelCost["deepseek-v4-pro"]).toBe(0.00141);
531
+ expect(dayAgg.modelCount["deepseek-v4-pro"]).toBe(1);
532
+ expect(dayAgg.providerCost["deepseek"]).toBe(0.00141);
533
+ expect(dayAgg.providerCount["deepseek"]).toBe(1);
534
+ expect(dayAgg.modelToProvider.get("deepseek-v4-pro")).toBe("deepseek");
535
+ const localHour = new Date("2026-06-08T10:05:00.000Z").getHours();
536
+ expect(dayAgg.hourCost[localHour]).toBe(0.00141);
537
+ });
538
+
539
+ it("returns a DayAgg for a user message", () => {
540
+ const dayAgg = parseSessionLogEntry({
541
+ type: "message",
542
+ id: "m1",
543
+ parentId: "p",
544
+ timestamp: "2026-06-08T10:01:00.000Z",
545
+ message: { role: "user" as const, content: "hi", timestamp: 1700000000000 },
546
+ })!;
547
+
548
+ assert(dayAgg);
549
+ expect(dayAgg.userMsgs).toBe(1);
550
+ expect(dayAgg.date).toBe("2026-06-08");
551
+ // No cost => hourCost not incremented
552
+ expect(dayAgg.hourCost).toEqual({});
553
+ });
554
+
555
+ it("returns a DayAgg for a tool result message", () => {
556
+ const dayAgg = parseSessionLogEntry({
557
+ type: "message",
558
+ id: "m1",
559
+ parentId: "p",
560
+ timestamp: "2026-06-08T10:02:00.000Z",
561
+ message: mkToolResult({ toolName: "bash" }),
562
+ })!;
563
+
564
+ expect(dayAgg.toolResults).toBe(1);
565
+ expect(dayAgg.toolCount["bash"]).toBe(1);
566
+ expect(dayAgg.date).toBe("2026-06-08");
567
+ expect(dayAgg.hourCost).toEqual({});
568
+ });
569
+
570
+ it("detects languages from edit/write tool calls", () => {
571
+ const dayAgg = parseSessionLogEntry({
572
+ type: "message",
573
+ id: "m1",
574
+ parentId: "p",
575
+ timestamp: "2026-06-08T10:01:00.000Z",
576
+ message: mkAsst({
577
+ content: [
578
+ tc("edit", {
579
+ path: "/home/doe/proj/src/foo.ts",
580
+ edits: [{ oldText: "a", newText: "ab" }],
581
+ }),
582
+ {
583
+ ...tc("write", { path: "/home/doe/proj/src/bar.rs", content: "fn main() {}" }),
584
+ id: "c2",
585
+ },
586
+ { ...tc("read", { path: "/home/doe/proj/README.md" }), id: "c3" },
587
+ ],
588
+ model: "sonnet",
589
+ }),
590
+ })!;
591
+
592
+ expect(dayAgg.langLines["TypeScript"]).toBe(1);
593
+ expect(dayAgg.langEdits["TypeScript"]).toBe(1);
594
+ expect(dayAgg.langLines["Rust"]).toBe(1);
595
+ expect(dayAgg.langLines["Markdown"]).toBeUndefined();
596
+ });
597
+
598
+ it("extracts project name from session cwd", () => {
599
+ const dayAgg = parseSessionLogEntry({
600
+ type: "session",
601
+ version: 3,
602
+ id: "s1",
603
+ timestamp: "2026-06-08T10:00:00.000Z",
604
+ cwd: "/home/doe/Work/dev/my-cool-project",
605
+ })!;
606
+
607
+ expect(dayAgg.projectCost["my-cool-project"]).toBe(0);
608
+
609
+ assert(dayAgg.projectSessions["my-cool-project"]);
610
+ expect(dayAgg.projectSessions["my-cool-project"].has("s1")).toBe(true);
611
+ });
612
+
613
+ it("accumulates project costs", () => {
614
+ const session = parseSessionLogEntry({
615
+ type: "session",
616
+ version: 3,
617
+ id: "s1",
618
+ timestamp: "2026-06-08T10:00:00.000Z",
619
+ cwd: "/home/doe/proj",
620
+ })!;
621
+
622
+ expect(session.projectCost["proj"]).toBe(0);
623
+
624
+ const dayAgg = parseSessionLogEntry({
625
+ type: "message",
626
+ id: "m1",
627
+ parentId: "p",
628
+ timestamp: "2026-06-08T10:01:00.000Z",
629
+ message: mkAsst({
630
+ content: [{ type: "text", text: "ok" }],
631
+ model: "gpt",
632
+ usage: {
633
+ input: 0,
634
+ output: 0,
635
+ cacheRead: 0,
636
+ cacheWrite: 0,
637
+ totalTokens: 0,
638
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.05 },
639
+ },
640
+ }),
641
+ })!;
642
+
643
+ mergeDay(session, dayAgg);
644
+ expect(session.projectCost["proj"]).toBe(0.05);
645
+ });
646
+
647
+ it("counts tool calls from assistant content", () => {
648
+ const dayAgg = parseSessionLogEntry({
649
+ type: "message",
650
+ id: "m1",
651
+ parentId: "p",
652
+ timestamp: "2026-06-08T10:01:00.000Z",
653
+ message: mkAsst({
654
+ content: [
655
+ tc("bash", { command: "ls" }),
656
+ { ...tc("read", { path: "f" }), id: "c2" },
657
+ { ...tc("read", { path: "g" }), id: "c3" },
658
+ ],
659
+ }),
660
+ })!;
661
+
662
+ expect(dayAgg.toolCount["bash"]).toBe(1);
663
+ expect(dayAgg.toolCount["read"]).toBe(2);
664
+ });
665
+
666
+ it("handles missing usage gracefully", () => {
667
+ const session = parseSessionLogEntry({
668
+ type: "session",
669
+ version: 3,
670
+ id: "s1",
671
+ timestamp: "2026-06-08T10:00:00.000Z",
672
+ cwd: "/home/doe/proj",
673
+ })!;
674
+
675
+ const day = parseSessionLogEntry({
676
+ type: "message",
677
+ id: "m1",
678
+ parentId: "p",
679
+ timestamp: "2026-06-08T10:01:00.000Z",
680
+ message: mkAsst({
681
+ content: [{ type: "text", text: "hi" }],
682
+ model: "m",
683
+ usage: {
684
+ input: 0,
685
+ output: 0,
686
+ cacheRead: 0,
687
+ cacheWrite: 0,
688
+ totalTokens: 0,
689
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
690
+ },
691
+ }),
692
+ })!;
693
+
694
+ mergeDay(session, day);
695
+ expect(session.asstMsgs).toBe(1);
696
+ expect(session.cost).toBe(0);
697
+ expect(session.inTok).toBe(0);
698
+ });
699
+
700
+ it("handles session entry without cwd", () => {
701
+ const day = parseSessionLogEntry({
702
+ type: "session",
703
+ version: 3,
704
+ id: "s1",
705
+ timestamp: "2026-06-08T10:00:00.000Z",
706
+ cwd: "",
707
+ })!;
708
+
709
+ expect(day.date).toBe("2026-06-08");
710
+ expect(day.sessionIds.has("s1")).toBe(true);
711
+ expect(Object.keys(day.projectCost).length).toBe(0);
712
+ expect(Object.keys(day.projectSessions).length).toBe(0);
713
+ });
714
+
715
+ it("handles assistant message with model but no cost", () => {
716
+ const day = parseSessionLogEntry({
717
+ type: "message",
718
+ id: "m1",
719
+ parentId: "p",
720
+ timestamp: "2026-06-08T10:01:00.000Z",
721
+ message: mkAsst({
722
+ content: [{ type: "text", text: "hi" }],
723
+ model: "deepseek-v4",
724
+ usage: {
725
+ input: 100,
726
+ output: 50,
727
+ cacheRead: 0,
728
+ cacheWrite: 0,
729
+ totalTokens: 150,
730
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
731
+ },
732
+ }),
733
+ })!;
734
+
735
+ expect(day.asstMsgs).toBe(1);
736
+ expect(day.inTok).toBe(100);
737
+ expect(day.outTok).toBe(50);
738
+ expect(day.cost).toBe(0);
739
+ expect(day.modelCost).toEqual({ "deepseek-v4": 0 });
740
+ expect(day.modelCount).toEqual({ "deepseek-v4": 1 });
741
+ });
742
+
743
+ it("parses unknown file extensions as 'Other'", () => {
744
+ const session = parseSessionLogEntry({
745
+ type: "session",
746
+ version: 3,
747
+ id: "s1",
748
+ timestamp: "2026-06-08T10:00:00.000Z",
749
+ cwd: "/home/doe/proj",
750
+ })!;
751
+
752
+ const day = parseSessionLogEntry({
753
+ type: "message",
754
+ id: "m1",
755
+ parentId: "p",
756
+ timestamp: "2026-06-08T10:01:00.000Z",
757
+ message: mkAsst({
758
+ content: [tc("write", { path: "/x/config.xyz", content: "abc" })],
759
+ model: "m",
760
+ }),
761
+ })!;
762
+
763
+ mergeDay(session, day);
764
+ expect(session.langLines["Other"]).toBe(1);
765
+ });
766
+
767
+ it("returns null for unknown entry types", () => {
768
+ expect(
769
+ parseSessionLogEntry({
770
+ type: "branch_summary",
771
+ id: "b1",
772
+ parentId: "p",
773
+ timestamp: "2026-06-08T10:00:00.000Z",
774
+ fromId: "m1",
775
+ summary: "branch",
776
+ }),
777
+ ).toBeNull();
778
+
779
+ expect(
780
+ parseSessionLogEntry({
781
+ type: "custom",
782
+ id: "c1",
783
+ parentId: "p",
784
+ timestamp: "2026-06-08T10:00:00.000Z",
785
+ customType: "my-ext",
786
+ }),
787
+ ).toBeNull();
788
+
789
+ expect(
790
+ parseSessionLogEntry({
791
+ type: "label",
792
+ id: "l1",
793
+ parentId: "p",
794
+ timestamp: "2026-06-08T10:00:00.000Z",
795
+ targetId: "t1",
796
+ label: "checkpoint",
797
+ }),
798
+ ).toBeNull();
799
+ });
800
+
801
+ it("handles compaction entries", () => {
802
+ const day = parseSessionLogEntry({
803
+ type: "compaction",
804
+ id: "c1",
805
+ parentId: "p",
806
+ timestamp: "2026-06-08T10:00:00.000Z",
807
+ summary: "Summary",
808
+ firstKeptEntryId: "m1",
809
+ tokensBefore: 42000,
810
+ })!;
811
+
812
+ assert(day);
813
+ expect(day.compactionCount).toBe(1);
814
+ expect(day.compactedTokens).toBe(42000);
815
+ });
816
+
817
+ it("handles model_change entries", () => {
818
+ const day = parseSessionLogEntry({
819
+ type: "model_change",
820
+ id: "mc1",
821
+ parentId: "p",
822
+ timestamp: "2026-06-08T10:00:00.000Z",
823
+ provider: "openai",
824
+ modelId: "gpt-5",
825
+ })!;
826
+
827
+ assert(day);
828
+ expect(day.modelChanges).toBe(1);
829
+ });
830
+
831
+ it("handles thinking_level_change entries", () => {
832
+ const day = parseSessionLogEntry({
833
+ type: "thinking_level_change",
834
+ id: "t1",
835
+ parentId: "p",
836
+ timestamp: "2026-06-08T10:00:00.000Z",
837
+ thinkingLevel: "xhigh",
838
+ })!;
839
+
840
+ assert(day);
841
+ expect(day.thinkingLevelCount).toEqual({ xhigh: 1 });
842
+ });
843
+ });
844
+
845
+ describe("mergeDay", () => {
846
+ it("sums scalar fields across two DayAggs", () => {
847
+ const a = emptyDay("2026-06-08");
848
+ const b: DayAgg = {
849
+ ...emptyDay("2026-06-08"),
850
+ cost: 1,
851
+ inTok: 100,
852
+ outTok: 50,
853
+ userMsgs: 2,
854
+ asstMsgs: 3,
855
+ toolResults: 1,
856
+ };
857
+
858
+ mergeDay(a, b);
859
+ expect(a.cost).toBe(1);
860
+ expect(a.inTok).toBe(100);
861
+ expect(a.outTok).toBe(50);
862
+ expect(a.userMsgs).toBe(2);
863
+ expect(a.asstMsgs).toBe(3);
864
+ expect(a.toolResults).toBe(1);
865
+ });
866
+
867
+ it("merges session id sets", () => {
868
+ const a = emptyDay("2026-06-08");
869
+ const b = emptyDay("2026-06-08");
870
+ b.sessionIds.add("s1");
871
+ b.sessionIds.add("s2");
872
+
873
+ mergeDay(a, b);
874
+ expect(a.sessionIds.has("s1")).toBe(true);
875
+ expect(a.sessionIds.has("s2")).toBe(true);
876
+ });
877
+
878
+ it("merges record accumulators", () => {
879
+ const a = emptyDay("2026-06-08");
880
+ const b: DayAgg = {
881
+ ...emptyDay("2026-06-08"),
882
+ langLines: { TypeScript: 10, Rust: 5 },
883
+ toolCount: { bash: 2, edit: 1 },
884
+ };
885
+
886
+ mergeDay(a, b);
887
+ expect(a.langLines).toEqual({ TypeScript: 10, Rust: 5 });
888
+ expect(a.toolCount).toEqual({ bash: 2, edit: 1 });
889
+ });
890
+
891
+ it("merges hourCost records", () => {
892
+ const a = emptyDay("2026-06-08");
893
+ const b: DayAgg = {
894
+ ...emptyDay("2026-06-08"),
895
+ hourCost: { 10: 1.5, 14: 2.0 },
896
+ };
897
+
898
+ mergeDay(a, b);
899
+ expect(a.hourCost).toEqual({ 10: 1.5, 14: 2.0 });
900
+ });
901
+
902
+ it("sums hourCost from multiple merges", () => {
903
+ const a = emptyDay("2026-06-08");
904
+ const b: DayAgg = {
905
+ ...emptyDay("2026-06-08"),
906
+ hourCost: { 10: 1.5, 14: 2.0 },
907
+ };
908
+ const c: DayAgg = {
909
+ ...emptyDay("2026-06-08"),
910
+ hourCost: { 10: 0.5, 16: 3.0 },
911
+ };
912
+
913
+ mergeDay(a, b);
914
+ mergeDay(a, c);
915
+ expect(a.hourCost).toEqual({ 10: 2.0, 14: 2.0, 16: 3.0 });
916
+ });
917
+
918
+ it("sums record values from multiple merges", () => {
919
+ const a = emptyDay("2026-06-08");
920
+ const b: DayAgg = {
921
+ ...emptyDay("2026-06-08"),
922
+ langLines: { TypeScript: 10 },
923
+ toolCount: { bash: 2 },
924
+ };
925
+ const c: DayAgg = {
926
+ ...emptyDay("2026-06-08"),
927
+ langLines: { TypeScript: 20, Rust: 5 },
928
+ toolCount: { edit: 1 },
929
+ };
930
+
931
+ mergeDay(a, b);
932
+ mergeDay(a, c);
933
+ expect(a.langLines).toEqual({ TypeScript: 30, Rust: 5 });
934
+ expect(a.toolCount).toEqual({ bash: 2, edit: 1 });
935
+ });
936
+
937
+ it("merges project sessions sets", () => {
938
+ const a = emptyDay("2026-06-08");
939
+ const b: DayAgg = {
940
+ ...emptyDay("2026-06-08"),
941
+ projectSessions: { proj1: new Set(["s1", "s2"]) },
942
+ };
943
+
944
+ mergeDay(a, b);
945
+ expect(a.projectSessions["proj1"]?.has("s1")).toBe(true);
946
+ expect(a.projectSessions["proj1"]?.has("s2")).toBe(true);
947
+ });
948
+
949
+ it("sums crTok and cwTok", () => {
950
+ const a = emptyDay("2026-06-08");
951
+ const b: DayAgg = {
952
+ ...emptyDay("2026-06-08"),
953
+ crTok: 100,
954
+ cwTok: 200,
955
+ };
956
+
957
+ mergeDay(a, b);
958
+ expect(a.crTok).toBe(100);
959
+ expect(a.cwTok).toBe(200);
960
+ });
961
+
962
+ it("merges model cost and count records", () => {
963
+ const a = emptyDay("2026-06-08");
964
+ const b: DayAgg = {
965
+ ...emptyDay("2026-06-08"),
966
+ modelCost: { "deepseek-v4": 0.05, "gpt-5": 0.1 },
967
+ modelCount: { "deepseek-v4": 3, "gpt-5": 1 },
968
+ };
969
+ const c: DayAgg = {
970
+ ...emptyDay("2026-06-08"),
971
+ modelCost: { "deepseek-v4": 0.03 },
972
+ modelCount: { "deepseek-v4": 2 },
973
+ };
974
+
975
+ mergeDay(a, b);
976
+ mergeDay(a, c);
977
+ expect(a.modelCost).toEqual({ "deepseek-v4": 0.08, "gpt-5": 0.1 });
978
+ expect(a.modelCount).toEqual({ "deepseek-v4": 5, "gpt-5": 1 });
979
+ });
980
+
981
+ it("maps model to its provider", () => {
982
+ const a = parseSessionLogEntry({
983
+ type: "message",
984
+ id: "msg-1",
985
+ parentId: "prev",
986
+ timestamp: "2026-06-08T10:05:00.000Z",
987
+ message: mkAsst({
988
+ content: [{ type: "text", text: "hello" }],
989
+ provider: "deepseek",
990
+ model: "deepseek-v4-pro",
991
+ usage: {
992
+ input: 1000,
993
+ output: 200,
994
+ cacheRead: 100,
995
+ cacheWrite: 0,
996
+ totalTokens: 1300,
997
+ cost: { input: 0.001, output: 0.0004, cacheRead: 0.00001, cacheWrite: 0, total: 0.00141 },
998
+ },
999
+ }),
1000
+ })!;
1001
+ const b = parseSessionLogEntry({
1002
+ type: "message",
1003
+ id: "msg-2",
1004
+ parentId: "msg-1",
1005
+ timestamp: "2026-06-08T10:05:01.000Z",
1006
+ message: mkAsst({
1007
+ content: [{ type: "text", text: "??" }],
1008
+ provider: "deepseek",
1009
+ model: "deepseek-v4-pro",
1010
+ usage: {
1011
+ input: 1000,
1012
+ output: 200,
1013
+ cacheRead: 100,
1014
+ cacheWrite: 0,
1015
+ totalTokens: 1300,
1016
+ cost: { input: 0.001, output: 0.0004, cacheRead: 0.00001, cacheWrite: 0, total: 0.00141 },
1017
+ },
1018
+ }),
1019
+ })!;
1020
+
1021
+ const c = parseSessionLogEntry({
1022
+ type: "message",
1023
+ id: "msg-3",
1024
+ parentId: "msg-2",
1025
+ timestamp: "2026-06-08T10:05:02.000Z",
1026
+ message: mkAsst({
1027
+ content: [{ type: "text", text: "OK" }],
1028
+ provider: "openai",
1029
+ model: "gpt-4",
1030
+ usage: {
1031
+ input: 1000,
1032
+ output: 200,
1033
+ cacheRead: 100,
1034
+ cacheWrite: 0,
1035
+ totalTokens: 1300,
1036
+ cost: { input: 0.001, output: 0.0004, cacheRead: 0.00001, cacheWrite: 0, total: 0.00141 },
1037
+ },
1038
+ }),
1039
+ })!;
1040
+
1041
+ mergeDay(a, b);
1042
+ mergeDay(a, c);
1043
+ expect(a.modelToProvider.get("deepseek-v4-pro")).toBe("deepseek");
1044
+ expect(a.modelToProvider.get("gpt-4")).toBe("openai");
1045
+ });
1046
+
1047
+ it("merges provider cost and count records", () => {
1048
+ const a = emptyDay("2026-06-08");
1049
+ const b: DayAgg = {
1050
+ ...emptyDay("2026-06-08"),
1051
+ providerCost: { deepseek: 0.05, openai: 0.1 },
1052
+ providerCount: { deepseek: 3, openai: 1 },
1053
+ };
1054
+ const c: DayAgg = {
1055
+ ...emptyDay("2026-06-08"),
1056
+ providerCost: { deepseek: 0.03 },
1057
+ providerCount: { deepseek: 2 },
1058
+ };
1059
+
1060
+ mergeDay(a, b);
1061
+ mergeDay(a, c);
1062
+ expect(a.providerCost).toEqual({ deepseek: 0.08, openai: 0.1 });
1063
+ expect(a.providerCount).toEqual({ deepseek: 5, openai: 1 });
1064
+ });
1065
+
1066
+ it("merges projectCost records", () => {
1067
+ const a = emptyDay("2026-06-08");
1068
+ const b: DayAgg = {
1069
+ ...emptyDay("2026-06-08"),
1070
+ projectCost: { alpha: 1.5, beta: 2.0 },
1071
+ };
1072
+ const c: DayAgg = {
1073
+ ...emptyDay("2026-06-08"),
1074
+ projectCost: { alpha: 0.5, gamma: 3.0 },
1075
+ };
1076
+
1077
+ mergeDay(a, b);
1078
+ mergeDay(a, c);
1079
+ expect(a.projectCost).toEqual({ alpha: 2.0, beta: 2.0, gamma: 3.0 });
1080
+ });
1081
+
1082
+ it("merges new fields: compaction, modelChanges, thinkingLevelCount", () => {
1083
+ const a = emptyDay("2026-06-08");
1084
+ const b: DayAgg = {
1085
+ ...emptyDay("2026-06-08"),
1086
+ compactionCount: 2,
1087
+ compactedTokens: 80000,
1088
+ modelChanges: 1,
1089
+ thinkingLevelCount: { low: 2, high: 1 },
1090
+ };
1091
+ const c: DayAgg = {
1092
+ ...emptyDay("2026-06-08"),
1093
+ compactionCount: 1,
1094
+ compactedTokens: 20000,
1095
+ modelChanges: 2,
1096
+ thinkingLevelCount: { low: 1, xhigh: 1 },
1097
+ };
1098
+
1099
+ mergeDay(a, b);
1100
+ mergeDay(a, c);
1101
+ expect(a.compactionCount).toBe(3);
1102
+ expect(a.compactedTokens).toBe(100000);
1103
+ expect(a.modelChanges).toBe(3);
1104
+ expect(a.thinkingLevelCount).toEqual({ low: 3, high: 1, xhigh: 1 });
1105
+ });
1106
+ });
1107
+
1108
+ describe("parseFile", () => {
1109
+ let tmpDir: string;
1110
+
1111
+ beforeEach(async () => {
1112
+ tmpDir = join(tmpdir(), `pi-atlas-parser-test-${Date.now()}`);
1113
+ await mkdir(tmpDir, { recursive: true });
1114
+ });
1115
+
1116
+ afterEach(async () => {
1117
+ await rm(tmpDir, { recursive: true, force: true });
1118
+ });
1119
+
1120
+ it("parses a JSONL file into a day map", async () => {
1121
+ const filePath = join(tmpDir, "test.jsonl");
1122
+ const lines = [
1123
+ JSON.stringify({
1124
+ type: "session",
1125
+ version: 3,
1126
+ id: "s1",
1127
+ timestamp: "2026-06-08T10:00:00.000Z",
1128
+ cwd: "/home/doe/proj",
1129
+ }),
1130
+ JSON.stringify({
1131
+ type: "message",
1132
+ id: "m1",
1133
+ parentId: "p",
1134
+ timestamp: "2026-06-08T10:01:00.000Z",
1135
+ message: { role: "user", content: "hi", timestamp: 1700000000000 },
1136
+ }),
1137
+ "invalid json {broken",
1138
+ JSON.stringify({
1139
+ type: "message",
1140
+ id: "m2",
1141
+ parentId: "m1",
1142
+ timestamp: "2026-06-08T10:02:00.000Z",
1143
+ message: mkAsst({
1144
+ content: [{ type: "text", text: "hey" }],
1145
+ model: "m",
1146
+ usage: {
1147
+ input: 100,
1148
+ output: 50,
1149
+ cacheRead: 0,
1150
+ cacheWrite: 0,
1151
+ totalTokens: 150,
1152
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.01 },
1153
+ },
1154
+ }),
1155
+ }),
1156
+ ];
1157
+ await writeFile(filePath, lines.join("\n"));
1158
+
1159
+ let warnings = 0;
1160
+ const map = parseFile(filePath, (count) => {
1161
+ warnings = count;
1162
+ });
1163
+
1164
+ expect(map.size).toBe(1);
1165
+ const day = map.get("2026-06-08")!;
1166
+ expect(day.userMsgs).toBe(1);
1167
+ expect(day.asstMsgs).toBe(1);
1168
+ expect(day.cost).toBe(0.01);
1169
+ expect(warnings).toBe(1);
1170
+ });
1171
+
1172
+ it("returns empty map for empty file", async () => {
1173
+ const filePath = join(tmpDir, "empty.jsonl");
1174
+ await writeFile(filePath, "");
1175
+ const map = parseFile(filePath);
1176
+ expect(map.size).toBe(0);
1177
+ });
1178
+
1179
+ it("silently returns empty map for non-existent file", async () => {
1180
+ const map = parseFile("/nonexistent/path/never.jsonl");
1181
+ expect(map.size).toBe(0);
1182
+ });
1183
+
1184
+ it("splits entries across multiple dates into separate day buckets", async () => {
1185
+ const filePath = join(tmpDir, "multi-date.jsonl");
1186
+ const lines = [
1187
+ JSON.stringify({
1188
+ type: "message",
1189
+ id: "m1",
1190
+ parentId: "p",
1191
+ timestamp: "2026-06-08T10:00:00.000Z",
1192
+ message: { role: "user", content: "hi", timestamp: 1700000000000 },
1193
+ }),
1194
+ JSON.stringify({
1195
+ type: "message",
1196
+ id: "m2",
1197
+ parentId: "m1",
1198
+ timestamp: "2026-06-09T10:00:00.000Z",
1199
+ message: { role: "user", content: "bye", timestamp: 1700000000001 },
1200
+ }),
1201
+ ];
1202
+ await writeFile(filePath, lines.join("\n"));
1203
+
1204
+ const map = parseFile(filePath);
1205
+
1206
+ expect(map.size).toBe(2);
1207
+ expect(map.get("2026-06-08")?.userMsgs).toBe(1);
1208
+ expect(map.get("2026-06-09")?.userMsgs).toBe(1);
1209
+ });
1210
+
1211
+ it("handles missing onWarning callback gracefully", async () => {
1212
+ const filePath = join(tmpDir, "corrupt.jsonl");
1213
+ const lines = [
1214
+ "not valid json",
1215
+ "still not json",
1216
+ JSON.stringify({
1217
+ type: "message",
1218
+ id: "m1",
1219
+ parentId: "p",
1220
+ timestamp: "2026-06-08T10:00:00.000Z",
1221
+ message: { role: "user", content: "ok", timestamp: 1700000000000 },
1222
+ }),
1223
+ ];
1224
+ await writeFile(filePath, lines.join("\n"));
1225
+
1226
+ const map = parseFile(filePath);
1227
+
1228
+ expect(map.size).toBe(1);
1229
+ expect(map.get("2026-06-08")?.userMsgs).toBe(1);
1230
+ });
1231
+
1232
+ it("handles sessions with no messages", async () => {
1233
+ const filePath = join(tmpDir, "session-only.jsonl");
1234
+ const lines = [
1235
+ JSON.stringify({
1236
+ type: "session",
1237
+ version: 3,
1238
+ id: "s1",
1239
+ timestamp: "2026-06-08T10:00:00.000Z",
1240
+ cwd: "/home/doe/proj",
1241
+ }),
1242
+ ];
1243
+ await writeFile(filePath, lines.join("\n"));
1244
+
1245
+ const map = parseFile(filePath);
1246
+
1247
+ expect(map.size).toBe(1);
1248
+ const day = map.get("2026-06-08")!;
1249
+ expect(day.sessionIds.has("s1")).toBe(true);
1250
+ expect(day.userMsgs).toBe(0);
1251
+ expect(day.asstMsgs).toBe(0);
1252
+ expect(day.toolResults).toBe(0);
1253
+ expect(day.cost).toBe(0);
1254
+ });
1255
+
1256
+ it("returns empty map for file with only corrupt lines", async () => {
1257
+ const filePath = join(tmpDir, "all-corrupt.jsonl");
1258
+ const lines = ["not json at all", "{also broken", "still broken]"];
1259
+ await writeFile(filePath, lines.join("\n"));
1260
+
1261
+ let warnings = 0;
1262
+ const map = parseFile(filePath, (count) => {
1263
+ warnings = count;
1264
+ });
1265
+
1266
+ expect(map.size).toBe(0);
1267
+ expect(warnings).toBe(3);
1268
+ });
1269
+
1270
+ it("skips whitespace-only lines without counting them as corrupt", async () => {
1271
+ const filePath = join(tmpDir, "with-blanks.jsonl");
1272
+ const lines = [
1273
+ "",
1274
+ " ",
1275
+ JSON.stringify({
1276
+ type: "message",
1277
+ id: "m1",
1278
+ parentId: "p",
1279
+ timestamp: "2026-06-08T10:00:00.000Z",
1280
+ message: { role: "user", content: "hi", timestamp: 1700000000000 },
1281
+ }),
1282
+ "\t",
1283
+ ];
1284
+ await writeFile(filePath, lines.join("\n"));
1285
+
1286
+ let warnings = 0;
1287
+ const map = parseFile(filePath, (count) => {
1288
+ warnings = count;
1289
+ });
1290
+
1291
+ expect(map.size).toBe(1);
1292
+ expect(warnings).toBe(0);
1293
+ });
1294
+
1295
+ it("does not leak project costs across separate files", async () => {
1296
+ const costMsg = (cost: number) => ({
1297
+ type: "message",
1298
+ id: "m1",
1299
+ parentId: "p",
1300
+ timestamp: "2026-06-08T10:01:00.000Z",
1301
+ message: mkAsst({
1302
+ content: [{ type: "text", text: "ok" }],
1303
+ model: "m",
1304
+ usage: {
1305
+ input: 0,
1306
+ output: 0,
1307
+ cacheRead: 0,
1308
+ cacheWrite: 0,
1309
+ totalTokens: 0,
1310
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
1311
+ },
1312
+ }),
1313
+ });
1314
+
1315
+ const fileA = join(tmpDir, "project-a.jsonl");
1316
+ await writeFile(
1317
+ fileA,
1318
+ [
1319
+ JSON.stringify({
1320
+ type: "session",
1321
+ version: 3,
1322
+ id: "s-a",
1323
+ timestamp: "2026-06-08T10:00:00.000Z",
1324
+ cwd: "/home/doe/proj-alpha",
1325
+ }),
1326
+ JSON.stringify(costMsg(0.1)),
1327
+ ].join("\n"),
1328
+ );
1329
+
1330
+ const fileB = join(tmpDir, "project-b.jsonl");
1331
+ await writeFile(
1332
+ fileB,
1333
+ [
1334
+ JSON.stringify({
1335
+ type: "session",
1336
+ version: 3,
1337
+ id: "s-b",
1338
+ timestamp: "2026-06-08T10:00:00.000Z",
1339
+ cwd: "/home/doe/proj-beta",
1340
+ }),
1341
+ JSON.stringify(costMsg(0.25)),
1342
+ ].join("\n"),
1343
+ );
1344
+
1345
+ const mapA = parseFile(fileA);
1346
+ const mapB = parseFile(fileB);
1347
+
1348
+ const dayA = mapA.get("2026-06-08")!;
1349
+ expect(Object.keys(dayA.projectCost)).toEqual(["proj-alpha"]);
1350
+ expect(dayA.projectCost["proj-alpha"]).toBe(0.1);
1351
+
1352
+ const dayB = mapB.get("2026-06-08")!;
1353
+ expect(Object.keys(dayB.projectCost)).toEqual(["proj-beta"]);
1354
+ expect(dayB.projectCost["proj-beta"]).toBe(0.25);
1355
+ });
1356
+
1357
+ it("silently skips unknown entry types (branch_summary, custom, label, session_info)", async () => {
1358
+ const filePath = join(tmpDir, "unknown-types.jsonl");
1359
+ const lines = [
1360
+ JSON.stringify({
1361
+ type: "branch_summary",
1362
+ id: "b1",
1363
+ parentId: null,
1364
+ timestamp: "2026-06-08T10:00:00.000Z",
1365
+ fromId: "m1",
1366
+ summary: "branch",
1367
+ }),
1368
+ JSON.stringify({
1369
+ type: "custom",
1370
+ id: "c1",
1371
+ parentId: "b1",
1372
+ timestamp: "2026-06-08T10:01:00.000Z",
1373
+ customType: "my-ext",
1374
+ data: { x: 1 },
1375
+ }),
1376
+ JSON.stringify({
1377
+ type: "message",
1378
+ id: "m1",
1379
+ parentId: "c1",
1380
+ timestamp: "2026-06-08T10:02:00.000Z",
1381
+ message: { role: "user", content: "hi", timestamp: 1700000000000 },
1382
+ }),
1383
+ ];
1384
+ await writeFile(filePath, lines.join("\n"));
1385
+
1386
+ let warnings = 0;
1387
+ const map = parseFile(filePath, (c) => {
1388
+ warnings = c;
1389
+ });
1390
+
1391
+ // Only the user message should be counted; unknown types are silently skipped
1392
+ expect(map.size).toBe(1);
1393
+ expect(map.get("2026-06-08")?.userMsgs).toBe(1);
1394
+ expect(warnings).toBe(0);
1395
+ });
1396
+ });