@mohndoe/pi-atlas 0.1.2 → 0.1.4

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 (45) hide show
  1. package/.github/workflows/release-please.yml +25 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +29 -26
  4. package/bunfig.toml +37 -0
  5. package/media/screenshot.png +0 -0
  6. package/package.json +4 -3
  7. package/src/__tests__/e2e.test.ts +3 -3
  8. package/src/{__tests__/cache.test.ts → cache.test.ts} +311 -10
  9. package/src/cache.ts +36 -3
  10. package/src/components/{__tests__/BarChart.test.ts → BarChart.test.ts} +9 -9
  11. package/src/components/{__tests__/Dashboard.test.ts → Dashboard.test.ts} +4 -4
  12. package/src/components/{__tests__/KpiCards.test.ts → KpiCards.test.ts} +5 -5
  13. package/src/components/KpiCards.ts +1 -1
  14. package/src/components/LoadingView.test.ts +116 -0
  15. package/src/components/LoadingView.ts +87 -25
  16. package/src/components/{__tests__/MarqueeText.test.ts → MarqueeText.test.ts} +2 -2
  17. package/src/components/{__tests__/RangeSelector.test.ts → RangeSelector.test.ts} +2 -2
  18. package/src/components/{__tests__/RankedBarList.test.ts → RankedBarList.test.ts} +2 -2
  19. package/src/components/{__tests__/SortedTable.test.ts → SortedTable.test.ts} +3 -4
  20. package/src/components/{__tests__/TabBar.test.ts → TabBar.test.ts} +2 -2
  21. package/src/components/__tests__/SortedTable.integration.test.ts +5 -8
  22. package/src/components/{__tests__/cells.test.ts → cells.test.ts} +2 -2
  23. package/src/{__tests__ → components}/components.fixtures.ts +1 -1
  24. package/src/components/shared/Bar.ts +10 -2
  25. package/src/{__tests__/compute.fixtures.ts → compute.fixtures.ts} +6 -1
  26. package/src/{__tests__/compute.test.ts → compute.test.ts} +135 -3
  27. package/src/compute.ts +24 -4
  28. package/src/{__tests__/format.test.ts → format.test.ts} +173 -31
  29. package/src/format.ts +20 -7
  30. package/src/index.ts +23 -20
  31. package/src/{__tests__/parser.test.ts → parser.test.ts} +339 -109
  32. package/src/parser.ts +1 -1
  33. package/src/tabs/{__tests__/Languages.test.ts → Languages.test.ts} +3 -7
  34. package/src/tabs/Languages.ts +11 -7
  35. package/src/tabs/{__tests__/Models.test.ts → Models.test.ts} +3 -6
  36. package/src/tabs/Models.ts +6 -8
  37. package/src/tabs/{__tests__/Overview.test.ts → Overview.test.ts} +18 -15
  38. package/src/tabs/Overview.ts +50 -39
  39. package/src/tabs/{__tests__/Projects.test.ts → Projects.test.ts} +5 -8
  40. package/src/tabs/Projects.ts +12 -7
  41. package/src/tabs/{__tests__/Usage.test.ts → Usage.test.ts} +8 -18
  42. package/src/tabs/Usage.ts +9 -5
  43. package/src/types.ts +11 -0
  44. package/src/components/__tests__/LoadingView.test.ts +0 -26
  45. /package/src/components/{__tests__ → shared}/Bar.test.ts +0 -0
@@ -0,0 +1,25 @@
1
+ on:
2
+ push:
3
+ branches:
4
+ - main
5
+
6
+ permissions:
7
+ contents: write
8
+ issues: write
9
+ pull-requests: write
10
+
11
+ name: release-please
12
+
13
+ jobs:
14
+ release-please:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: googleapis/release-please-action@v4
18
+ with:
19
+ # this assumes that you have created a personal access token
20
+ # (PAT) and configured it as a GitHub action secret named
21
+ # `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important).
22
+ token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
23
+ # this is a built-in strategy in release-please, see "Action Inputs"
24
+ # for more options
25
+ release-type: node
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## [0.1.4](https://github.com/MohnDoe/pi-atlas/compare/v0.1.3...v0.1.4) (2026-06-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * add Release Please GH Action ([5629007](https://github.com/MohnDoe/pi-atlas/commit/5629007ecacbeb013277bbccd4171b50ff3d3c36))
9
+ * add screenshot for pi.dev packages directory ([413890d](https://github.com/MohnDoe/pi-atlas/commit/413890d1c694b2c81b4dfcb7bb4219a36ad43937))
10
+ * add screenshot to README ([5e0895d](https://github.com/MohnDoe/pi-atlas/commit/5e0895d4620ac3220ddc0c93a0edcb5e659ee4c5))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **compute:** consolidate fragile double-sort into single sort ([781c5e4](https://github.com/MohnDoe/pi-atlas/commit/781c5e4950473b0c9d10371400147c24334f851b))
16
+ * Release Please workflow ([81b47ff](https://github.com/MohnDoe/pi-atlas/commit/81b47ff1797984d1bfc0e1b70f53e33bc17de615))
17
+ * replace invalid locale "en-EN" with "en-US" ([8e7f360](https://github.com/MohnDoe/pi-atlas/commit/8e7f3609761e8ce113e4d0601429521515f905e1))
18
+ * tables for languages, models, projects and usage have better proportions ([9866ef1](https://github.com/MohnDoe/pi-atlas/commit/9866ef173459be29158d3ef6d55676b93d48e9a9))
19
+
20
+
21
+ ### Miscellaneous Chores
22
+
23
+ * force version ([899f17d](https://github.com/MohnDoe/pi-atlas/commit/899f17d508689dd18c2d8adc5b247e51b412ef87))
package/README.md CHANGED
@@ -1,45 +1,48 @@
1
- # Pi Atlas
1
+ # @mohndoe/pi-atlas
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@mohndoe/pi-atlas)](https://www.npmjs.com/package/@mohndoe/pi-atlas)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
3
+ <p align="center">
4
+ <strong>See your AI usage and cost directly in Pi.</strong>
5
+ </p>
6
+ <p align="center">
7
+ <a href="https://www.npmjs.com/package/@mohndoe/pi-atlas"><img src="https://img.shields.io/npm/v/@mohndoe/pi-atlas" alt="Version"></a>
8
+ <img src="https://img.shields.io/badge/coverage-97%25-green" alt="Coverage">
9
+ <img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&colorA=222222&logo=typescript&logoColor=white" alt="TypeScript">
10
+ <img src="https://img.shields.io/badge/runtime-Bun-f472b6?style=flat&colorA=222222" alt="Bun">
11
+ <a href="https://github.com/mohndoe/pi-atlas/blob/main/LICENSE"><img src="https://img.shields.io/github/license/mohndoe/pi-atlas?style=flat&colorA=222222&colorB=58A6FF" alt="License"></a>
12
+ </p>
5
13
 
6
- A terminal UI extension for [pi](https://pi.dev) that turns your agent session logs into an interactive dashboard — costs, languages, models, projects, tools, and token usage at a glance.
14
+ A [pi](https://pi.dev) extension that turns your session logs into an interactive dashboard — costs, languages, models, projects, tools, and token usage at a glance.
15
+
16
+ <p align="center">
17
+ <img alt="pi-atlas screenshot" src="./media/screenshot.png" />
18
+ </p>
7
19
 
8
20
  ---
9
21
 
10
22
  ## Features
11
23
 
12
- - **Cost tracking** — per-model, per-project, and daily spend with ASCII bar charts
13
- - **Language breakdown** — lines written and edited, ranked with proportional bars
14
- - **Model analytics** — provider-aware model cost, call count, and sortable table
15
- - **Project attribution** — cost and session count per project directory
16
- - **Tool usage** — call frequency and token breakdown (input, output, cache read/write)
17
24
  - **Multiple time ranges** — Today, Last 7 days, Last 30 days, or All time
18
- - **Cache** — SHA-256-gated persists day aggregates; near-instant open on repeat visits
19
- - **Zero dependencies beyond pi** — uses only the pi TUI and the `pi-tui-extras` component library
25
+ - **Cost tracking** — per-model, per-project, based on real usage costs
26
+ - **Language breakdown** — lines written and edited
27
+ - **Model analytics** — provider-aware model cost, call count, works with local LLMs too
28
+ - **Project attribution** — cost and session count per project directory
29
+ - **Usage overview** — tool call frequency and token breakdown (input, output, cache read/write)
30
+ - **Cache** — SHA-256-gated persists day aggregates; near-instant open on next visits
31
+ - **Zero dependencies** — uses only the pi TUI and the [`@mohndoe/pi-tui-extras`](https://github.com/MohnDoe/pi-tui-extras) component library
20
32
 
21
33
  ## Dashboard
22
34
 
23
- The dashboard opens as a centered overlay popup (50% width, max 80% height). Navigate with the keyboard:
24
-
25
- | Key | Action |
26
- | -------------- | ------------------------------------------------ |
27
- | `←` `→` | Switch tabs |
28
- | `r` | Cycle time range (Today → 7d → 30d → All) |
29
- | `↑` `↓` | Scroll table rows (Models, Projects, Usage tabs) |
30
- | `q` / `Escape` | Close dashboard |
31
-
32
35
  ### Tabs
33
36
 
34
- - **Overview** — KPI cards (Total Cost, Sessions, Messages, Active Days, Avg/Day, Tokens) in a compact grid. Below: a daily spend bar chart auto-scaled to fill available height. Bottom row shows top language, top model, and top project side by side. On the 1d range, the bar chart switches to hourly spend.
35
- - **Languages** — Table of all programming languages detected in session logs. Color-coded per-language palette.
36
- - **Models** — Table of all models and providers used. Columns: Model, Provider, Calls, Cost. Color-coded per-provider palette.
37
+ - **Overview** — Cards displaying total cost, sessions count, messages count, active days, average cost per day, total tokens. A bar chart displaying cost overtime. And top language, top model, and top project side by side.
38
+ - **Languages** — Languages ranked by line written.
39
+ - **Models** — Models ranked by cost. Shows providers, calls and cost per model.
37
40
  - **Projects** — Projects ranked by cost. Shows session count and cost per project.
38
41
  - **Usage** — Token breakdown (Total, Input, Output, Cache Read, Cache Write) and table of tool usage.
39
42
 
40
- ## Install
43
+ All tabs displayed data correspond to the selected time range (press `r` to change it). Press left/right arrows to change tabs.
41
44
 
42
- ### Via pi
45
+ ## Install
43
46
 
44
47
  ```bash
45
48
  pi install npm:@mohndoe/pi-atlas
@@ -49,7 +52,7 @@ Then run `/reload` in pi (or restart pi). The `/atlas` command is now available.
49
52
 
50
53
  ## Usage
51
54
 
52
- In the pi terminal, type `/atlas` to open the atlas dashboard. Session data is loaded from `~/.pi/agent/sessions/` on first load this may take a moment while JSONL files are parsed. Subsequent opens use a cached snapshot and load instantly.
55
+ In the pi terminal, type `/atlas` to open the atlas dashboard. Session data is loaded from `~/.pi/agent/sessions/` -- on first load this may take a moment while JSONL files are parsed. Subsequent opens use a cached snapshot and load instantly.
53
56
 
54
57
  ## How it works
55
58
 
package/bunfig.toml ADDED
@@ -0,0 +1,37 @@
1
+ [test]
2
+ root = "src"
3
+ coverageThreshold = { lines = 0.85, functions = 0.90, statements = 0.80 }
4
+ coveragePathIgnorePatterns = [
5
+ # Test files
6
+ "**/*.test.ts",
7
+ "**/*.spec.ts",
8
+ "**/*.e2e.ts",
9
+ "**/*.fixtures.ts",
10
+
11
+ # Configuration files
12
+ "*.config.js",
13
+ "*.config.ts",
14
+ "webpack.config.*",
15
+ "vite.config.*",
16
+
17
+ # Build output
18
+ "dist/**",
19
+ "build/**",
20
+ ".next/**",
21
+
22
+ # Generated code
23
+ "generated/**",
24
+ "**/*.generated.ts",
25
+
26
+ # Vendor/third-party
27
+ "vendor/**",
28
+ "third-party/**",
29
+
30
+ # Utilities that don't need testing
31
+ "src/utils/constants.ts",
32
+ "src/types/**",
33
+
34
+ "node_modules/**",
35
+ # ignores linked local-dev package
36
+ "../pi-tui-extras/**",
37
+ ]
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mohndoe/pi-atlas",
3
- "version": "0.1.2",
4
- "description": "Pi extension providing an atlas of agent activity — costs, languages, models, projects, and tools from session logs.",
3
+ "version": "0.1.4",
4
+ "description": "See your agent usage and cost directly in Pi — costs, languages, models, projects, and tools from session logs.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-extension",
@@ -44,6 +44,7 @@
44
44
  "pi": {
45
45
  "extensions": [
46
46
  "./src/index.ts"
47
- ]
47
+ ],
48
+ "image": "https://github.com/mohndoe/pi-atlas/raw/main/media/screenshot.png"
48
49
  }
49
50
  }
@@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { allRanges } from "../components/__tests__/Dashboard.test";
5
+ import { makeMockTUI, makeRangeSelector, makeTheme } from "../components/components.fixtures";
6
6
  import { Dashboard } from "../components/Dashboard";
7
+ import { allRanges } from "../components/Dashboard.test";
7
8
  import { summarize } from "../compute";
8
9
  import { parseFile } from "../parser";
9
- import { type DayAgg } from "../types";
10
- import { makeMockTUI, makeRangeSelector, makeTheme } from "./components.fixtures";
10
+ import type { DayAgg } from "../types";
11
11
 
12
12
  const mockTui = makeMockTUI();
13
13
 
@@ -1,16 +1,18 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
- import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
+ type LoadingProgress,
6
7
  computeSignature,
7
8
  getCacheTimestamp,
9
+ isCacheValid,
8
10
  loadAggregate,
9
11
  readCache,
10
12
  writeCache,
11
- } from "../cache";
12
- import { emptyDay } from "../parser";
13
- import { type DayAgg } from "../types";
13
+ } from "./cache";
14
+ import { emptyDay } from "./parser";
15
+ import type { DayAgg } from "./types";
14
16
 
15
17
  describe("computeSignature", () => {
16
18
  let tmpDir: string;
@@ -72,6 +74,17 @@ describe("computeSignature", () => {
72
74
  expect(sig.length).toBeGreaterThan(0);
73
75
  });
74
76
 
77
+ it("scans nested subdirectories two levels deep", async () => {
78
+ const lvl1 = join(tmpDir, "project-a");
79
+ const lvl2 = join(lvl1, "nested");
80
+ await mkdir(lvl2, { recursive: true });
81
+ await writeFile(join(lvl2, "s1.jsonl"), "data\n");
82
+
83
+ const sig = await computeSignature(tmpDir);
84
+ expect(sig).toBeTruthy();
85
+ expect(sig.length).toBeGreaterThan(0);
86
+ });
87
+
75
88
  it("ignores non-.jsonl files", async () => {
76
89
  await writeFile(join(tmpDir, "README.md"), "docs\n");
77
90
  const sig = await computeSignature(tmpDir);
@@ -79,6 +92,57 @@ describe("computeSignature", () => {
79
92
  });
80
93
  });
81
94
 
95
+ describe("isCacheValid", () => {
96
+ let tmpDir: string;
97
+ let cachePath: string;
98
+ let sessionsDir: string;
99
+
100
+ beforeEach(async () => {
101
+ tmpDir = join(tmpdir(), `pi-atlas-isvalid-${Date.now()}`);
102
+ await mkdir(tmpDir, { recursive: true });
103
+ cachePath = join(tmpDir, "cache.json");
104
+ sessionsDir = join(tmpDir, "sessions");
105
+ await mkdir(sessionsDir, { recursive: true });
106
+ });
107
+
108
+ afterEach(async () => {
109
+ await rm(tmpDir, { recursive: true, force: true });
110
+ });
111
+
112
+ it("returns false when cache file does not exist", async () => {
113
+ const valid = await isCacheValid(cachePath, sessionsDir);
114
+ expect(valid).toBe(false);
115
+ });
116
+
117
+ it("returns false when cache signature differs from current", async () => {
118
+ // Write a session file, compute its signature, write cache with that sig
119
+ await writeFile(join(sessionsDir, "s1.jsonl"), "data\n");
120
+ const sig = await computeSignature(sessionsDir);
121
+ const d = emptyDay("2026-06-08");
122
+ await writeCache(cachePath, sig, [d]);
123
+
124
+ const validBefore = await isCacheValid(cachePath, sessionsDir);
125
+ expect(validBefore).toBe(true);
126
+
127
+ // Add a session file that changes the real signature
128
+ await writeFile(join(sessionsDir, "s2.jsonl"), "data\n");
129
+
130
+ const valid = await isCacheValid(cachePath, sessionsDir);
131
+ expect(valid).toBe(false);
132
+ });
133
+
134
+ it("returns true when cache signature matches current", async () => {
135
+ // Write a session file, compute its signature, write cache with that sig
136
+ await writeFile(join(sessionsDir, "s1.jsonl"), "data\n");
137
+ const sig = await computeSignature(sessionsDir);
138
+ const d = emptyDay("2026-06-08");
139
+ await writeCache(cachePath, sig, [d]);
140
+
141
+ const valid = await isCacheValid(cachePath, sessionsDir);
142
+ expect(valid).toBe(true);
143
+ });
144
+ });
145
+
82
146
  describe("cache read/write", () => {
83
147
  let tmpDir: string;
84
148
  let cachePath: string;
@@ -121,6 +185,18 @@ describe("cache read/write", () => {
121
185
  expect(payload).toBeNull();
122
186
  });
123
187
 
188
+ it("returns null when cached JSON lacks a signature", async () => {
189
+ await writeFile(cachePath, JSON.stringify({ days: [] }));
190
+ const payload = await readCache(cachePath);
191
+ expect(payload).toBeNull();
192
+ });
193
+
194
+ it("returns null when cached JSON has signature but days is not an array", async () => {
195
+ await writeFile(cachePath, JSON.stringify({ signature: "sig", days: {} }));
196
+ const payload = await readCache(cachePath);
197
+ expect(payload).toBeNull();
198
+ });
199
+
124
200
  it("returns generatedAt from valid cache", async () => {
125
201
  const d = emptyDay("2026-06-08");
126
202
  await writeCache(cachePath, "sig-abc", [d]);
@@ -135,10 +211,49 @@ describe("cache read/write", () => {
135
211
  expect(ts).toBeNull();
136
212
  });
137
213
 
138
- it("returns null for corrupt cache", async () => {
139
- await writeFile(cachePath, "not-json");
140
- const ts = await getCacheTimestamp(cachePath);
141
- expect(ts).toBeNull();
214
+ it("preserves hourCost values through round-trip (numeric keys become strings in JSON)", async () => {
215
+ const d = emptyDay("2026-06-08");
216
+ d.hourCost = { 10: 0.5, 14: 1.25 };
217
+ const days: DayAgg[] = [d];
218
+ await writeCache(cachePath, "sig-hc", days);
219
+
220
+ // Read raw JSON — numeric keys are stored as strings
221
+ const raw = await readFile(cachePath, "utf-8");
222
+ const parsed = JSON.parse(raw);
223
+ expect(parsed.days[0].hourCost).toEqual({ "10": 0.5, "14": 1.25 });
224
+
225
+ // Round-trip via loadAggregate
226
+ const sesDir = join(tmpDir, "sessions");
227
+ await mkdir(sesDir, { recursive: true });
228
+ await writeFile(
229
+ join(sesDir, "dummy.jsonl"),
230
+ JSON.stringify({
231
+ type: "session",
232
+ version: 3,
233
+ id: "s1",
234
+ timestamp: "2026-06-08T10:00:00.000Z",
235
+ cwd: "/p",
236
+ }) + "\n",
237
+ );
238
+ const sig = await computeSignature(sesDir);
239
+ await writeFile(
240
+ cachePath,
241
+ JSON.stringify({
242
+ signature: sig,
243
+ generatedAt: new Date().toISOString(),
244
+ days: [
245
+ {
246
+ ...parsed.days[0],
247
+ modelToProvider: {},
248
+ },
249
+ ],
250
+ }),
251
+ );
252
+ const loaded = await loadAggregate(cachePath, sesDir);
253
+ expect(loaded).toHaveLength(1);
254
+ // Numeric access still works via JS coercion
255
+ expect(loaded[0]!.hourCost[10]).toBe(0.5);
256
+ expect(loaded[0]!.hourCost[14]).toBe(1.25);
142
257
  });
143
258
 
144
259
  it("serializes and deserializes modelToProvider Map", async () => {
@@ -261,6 +376,90 @@ describe("loadAggregate", () => {
261
376
  expect(days[0]!.userMsgs).toBe(1);
262
377
  });
263
378
 
379
+ it("merges data from multiple files sharing the same date", async () => {
380
+ const projA = join(sessionsDir, "proj-a");
381
+ const projB = join(sessionsDir, "proj-b");
382
+ await mkdir(projA, { recursive: true });
383
+ await mkdir(projB, { recursive: true });
384
+
385
+ // Two files, same date, different sessions
386
+ await writeFile(
387
+ join(projA, "s1.jsonl"),
388
+ [
389
+ JSON.stringify({
390
+ type: "session",
391
+ version: 3,
392
+ id: "s1",
393
+ timestamp: "2026-06-08T10:00:00.000Z",
394
+ cwd: "/home/doe/proj-a",
395
+ }),
396
+ JSON.stringify({
397
+ type: "message",
398
+ id: "m1",
399
+ parentId: "p",
400
+ timestamp: "2026-06-08T10:01:00.000Z",
401
+ message: { role: "user", content: [{ type: "text", text: "hi from a" }] },
402
+ }),
403
+ ].join("\n"),
404
+ );
405
+
406
+ await writeFile(
407
+ join(projB, "s2.jsonl"),
408
+ [
409
+ JSON.stringify({
410
+ type: "session",
411
+ version: 3,
412
+ id: "s2",
413
+ timestamp: "2026-06-08T14:00:00.000Z",
414
+ cwd: "/home/doe/proj-b",
415
+ }),
416
+ JSON.stringify({
417
+ type: "message",
418
+ id: "m1",
419
+ parentId: "p",
420
+ timestamp: "2026-06-08T14:01:00.000Z",
421
+ message: { role: "user", content: [{ type: "text", text: "hi from b" }] },
422
+ }),
423
+ ].join("\n"),
424
+ );
425
+
426
+ const days = await loadAggregate(cachePath, sessionsDir, true);
427
+ expect(days).toHaveLength(1);
428
+ expect(days[0]!.date).toBe("2026-06-08");
429
+ expect(days[0]!.userMsgs).toBe(2); // one from each file
430
+ expect(days[0]!.sessionIds.size).toBe(2); // s1 and s2 merged
431
+ });
432
+
433
+ it("writes a cache file on disk after parsing", async () => {
434
+ const subDir = join(sessionsDir, "proj-a");
435
+ await mkdir(subDir);
436
+ await writeFile(
437
+ join(subDir, "s1.jsonl"),
438
+ [
439
+ JSON.stringify({
440
+ type: "session",
441
+ version: 3,
442
+ id: "s1",
443
+ timestamp: "2026-06-08T10:00:00.000Z",
444
+ cwd: "/home/doe/proj-a",
445
+ }),
446
+ ].join("\n"),
447
+ );
448
+
449
+ await loadAggregate(cachePath, sessionsDir);
450
+
451
+ // Verify the cache file was written
452
+ const payload = await readCache(cachePath);
453
+ expect(payload).not.toBeNull();
454
+ expect(payload!.days).toHaveLength(1);
455
+ expect(payload!.days[0]!.date).toBe("2026-06-08");
456
+ expect(payload!.generatedAt).toBeTruthy();
457
+
458
+ // Verify the signature corresponds to the actual session files
459
+ const realSig = await computeSignature(sessionsDir);
460
+ expect(payload!.signature).toBe(realSig);
461
+ });
462
+
264
463
  it("caches results and reuses them", async () => {
265
464
  const subDir = join(sessionsDir, "proj-a");
266
465
  await mkdir(subDir);
@@ -363,6 +562,73 @@ describe("loadAggregate", () => {
363
562
  }
364
563
  });
365
564
 
565
+ it("force=true re-parses even when valid cache exists", async () => {
566
+ // Create a session file
567
+ const subDir = join(sessionsDir, "proj-a");
568
+ await mkdir(subDir);
569
+ await writeFile(
570
+ join(subDir, "s1.jsonl"),
571
+ [
572
+ JSON.stringify({
573
+ type: "session",
574
+ version: 3,
575
+ id: "s1",
576
+ timestamp: "2026-06-08T10:00:00.000Z",
577
+ cwd: "/home/doe/proj-a",
578
+ }),
579
+ JSON.stringify({
580
+ type: "message",
581
+ id: "m1",
582
+ parentId: "p",
583
+ timestamp: "2026-06-08T10:01:00.000Z",
584
+ message: { role: "user", content: [{ type: "text", text: "hi" }] },
585
+ }),
586
+ ].join("\n"),
587
+ );
588
+
589
+ // Compute the real signature for these files
590
+ const realSig = await computeSignature(sessionsDir);
591
+
592
+ // Write a cache file directly with the correct signature but STALE data
593
+ await writeFile(
594
+ cachePath,
595
+ JSON.stringify({
596
+ signature: realSig,
597
+ generatedAt: new Date().toISOString(),
598
+ days: [
599
+ {
600
+ date: "2026-06-08",
601
+ cost: 0,
602
+ inTok: 0,
603
+ outTok: 0,
604
+ crTok: 0,
605
+ cwTok: 0,
606
+ userMsgs: 9999, // stale: real data has 1
607
+ asstMsgs: 0,
608
+ toolResults: 0,
609
+ sessionIds: [],
610
+ langLines: {},
611
+ langEdits: {},
612
+ modelCost: {},
613
+ modelCount: {},
614
+ providerCost: {},
615
+ providerCount: {},
616
+ modelToProvider: {},
617
+ projectCost: {},
618
+ projectSessions: {},
619
+ toolCount: {},
620
+ },
621
+ ],
622
+ }),
623
+ );
624
+
625
+ // force=true should ignore the stale cache and re-parse
626
+ const days = await loadAggregate(cachePath, sessionsDir, true);
627
+ expect(days).toHaveLength(1);
628
+ expect(days[0]!.userMsgs).toBe(1);
629
+ expect(days[0]!.asstMsgs).toBe(0);
630
+ });
631
+
366
632
  it("calls onProgress during parsing", async () => {
367
633
  const subDir = join(sessionsDir, "proj-a");
368
634
  await mkdir(subDir);
@@ -379,10 +645,45 @@ describe("loadAggregate", () => {
379
645
  ].join("\n"),
380
646
  );
381
647
 
382
- const progress: number[] = [];
648
+ const progress: LoadingProgress[] = [];
383
649
  await loadAggregate(cachePath, sessionsDir, false, (p) => progress.push(p));
384
650
 
385
- // Should have reported some progress
651
+ // Should have reported some progress and reached 100%
386
652
  expect(progress.length).toBeGreaterThan(0);
653
+ expect(progress[progress.length - 1]!.pct).toBe(100);
654
+ });
655
+
656
+ it("reports intermediate progress with multiple files", async () => {
657
+ const projA = join(sessionsDir, "a");
658
+ const projB = join(sessionsDir, "b");
659
+ await mkdir(projA, { recursive: true });
660
+ await mkdir(projB, { recursive: true });
661
+
662
+ await writeFile(
663
+ join(projA, "s1.jsonl"),
664
+ JSON.stringify({
665
+ type: "session",
666
+ version: 3,
667
+ id: "s1",
668
+ timestamp: "2026-06-08T10:00:00.000Z",
669
+ cwd: "/p",
670
+ }) + "\n",
671
+ );
672
+ await writeFile(
673
+ join(projB, "s2.jsonl"),
674
+ JSON.stringify({
675
+ type: "session",
676
+ version: 3,
677
+ id: "s2",
678
+ timestamp: "2026-06-09T10:00:00.000Z",
679
+ cwd: "/p",
680
+ }) + "\n",
681
+ );
682
+
683
+ const progress: LoadingProgress[] = [];
684
+ await loadAggregate(cachePath, sessionsDir, true, (p) => progress.push(p));
685
+
686
+ // Two files: should get 50% and 100%
687
+ expect(progress.map((p) => p.pct)).toEqual([50, 100]);
387
688
  });
388
689
  });
package/src/cache.ts CHANGED
@@ -105,6 +105,9 @@ export async function isCacheValid(cachePath: string, sessionsDir: string): Prom
105
105
  return cached.signature === currentSig;
106
106
  }
107
107
 
108
+ /** Sleep helper for debug delay */
109
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
110
+
108
111
  // ---- Aggregate loading ----
109
112
 
110
113
  async function findAllJsonlFiles(dir: string): Promise<string[]> {
@@ -125,15 +128,24 @@ async function findAllJsonlFiles(dir: string): Promise<string[]> {
125
128
  await walk(dir);
126
129
  return result;
127
130
  }
131
+ export interface LoadingProgress {
132
+ total: number;
133
+ done: number;
134
+ pct: number;
135
+ remainingTimeMs?: number;
136
+ }
128
137
 
129
138
  export async function loadAggregate(
130
139
  cachePath: string,
131
140
  sessionsDir: string,
132
141
  force = false,
133
- onProgress?: (p: number) => void,
142
+ onProgress?: (p: LoadingProgress) => void,
134
143
  ): Promise<DayAgg[]> {
144
+ // Debug flags: PI_ATLAS_FORCE_CACHE=1 skips cache, PI_ATLAS_SLOW_DELAY_MS=<ms> adds per-file delay
145
+ const effectiveForce = force || Boolean(Number(process.env["PI_ATLAS_FORCE_CACHE"] ?? 0));
146
+
135
147
  // Try cache first
136
- if (!force) {
148
+ if (!effectiveForce) {
137
149
  const valid = await isCacheValid(cachePath, sessionsDir);
138
150
  if (valid) {
139
151
  const cached = await readCache(cachePath);
@@ -146,7 +158,16 @@ export async function loadAggregate(
146
158
  const map = new Map<string, DayAgg>();
147
159
  let totalCorrupt = 0;
148
160
 
161
+ const slowDelayMs = Number(process.env["PI_ATLAS_SLOW_DELAY_MS"] ?? 0);
162
+ const parseStart = performance.now();
163
+
149
164
  for (let i = 0; i < files.length; i++) {
165
+ if (slowDelayMs > 0) {
166
+ // Progress callback to show we're alive before first file
167
+ if (i === 0 && onProgress) onProgress({ total: 0, done: 0, pct: 0 });
168
+ await sleep(slowDelayMs);
169
+ }
170
+
150
171
  let lastCount = 0;
151
172
  const fileMap = parseFile(files[i]!, (count) => {
152
173
  lastCount = count;
@@ -161,7 +182,19 @@ export async function loadAggregate(
161
182
  map.set(date, day);
162
183
  }
163
184
  }
164
- if (onProgress) onProgress(Math.round(((i + 1) / files.length) * 100));
185
+ if (onProgress) {
186
+ const done = i + 1;
187
+ const elapsedMs = performance.now() - parseStart;
188
+ // Minimum 3 samples before showing estimate (too noisy before that)
189
+ const remainingTimeMs =
190
+ done >= 3 ? Math.round((elapsedMs / done) * (files.length - done)) : undefined;
191
+ onProgress({
192
+ done,
193
+ total: files.length,
194
+ pct: Math.round((done / files.length) * 100),
195
+ remainingTimeMs,
196
+ });
197
+ }
165
198
  }
166
199
 
167
200
  if (totalCorrupt > 0) {