@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.
- package/.github/workflows/release-please.yml +25 -0
- package/CHANGELOG.md +23 -0
- package/README.md +29 -26
- package/bunfig.toml +37 -0
- package/media/screenshot.png +0 -0
- package/package.json +4 -3
- package/src/__tests__/e2e.test.ts +3 -3
- package/src/{__tests__/cache.test.ts → cache.test.ts} +311 -10
- package/src/cache.ts +36 -3
- package/src/components/{__tests__/BarChart.test.ts → BarChart.test.ts} +9 -9
- package/src/components/{__tests__/Dashboard.test.ts → Dashboard.test.ts} +4 -4
- package/src/components/{__tests__/KpiCards.test.ts → KpiCards.test.ts} +5 -5
- package/src/components/KpiCards.ts +1 -1
- package/src/components/LoadingView.test.ts +116 -0
- package/src/components/LoadingView.ts +87 -25
- package/src/components/{__tests__/MarqueeText.test.ts → MarqueeText.test.ts} +2 -2
- package/src/components/{__tests__/RangeSelector.test.ts → RangeSelector.test.ts} +2 -2
- package/src/components/{__tests__/RankedBarList.test.ts → RankedBarList.test.ts} +2 -2
- package/src/components/{__tests__/SortedTable.test.ts → SortedTable.test.ts} +3 -4
- package/src/components/{__tests__/TabBar.test.ts → TabBar.test.ts} +2 -2
- package/src/components/__tests__/SortedTable.integration.test.ts +5 -8
- package/src/components/{__tests__/cells.test.ts → cells.test.ts} +2 -2
- package/src/{__tests__ → components}/components.fixtures.ts +1 -1
- package/src/components/shared/Bar.ts +10 -2
- package/src/{__tests__/compute.fixtures.ts → compute.fixtures.ts} +6 -1
- package/src/{__tests__/compute.test.ts → compute.test.ts} +135 -3
- package/src/compute.ts +24 -4
- package/src/{__tests__/format.test.ts → format.test.ts} +173 -31
- package/src/format.ts +20 -7
- package/src/index.ts +23 -20
- package/src/{__tests__/parser.test.ts → parser.test.ts} +339 -109
- package/src/parser.ts +1 -1
- package/src/tabs/{__tests__/Languages.test.ts → Languages.test.ts} +3 -7
- package/src/tabs/Languages.ts +11 -7
- package/src/tabs/{__tests__/Models.test.ts → Models.test.ts} +3 -6
- package/src/tabs/Models.ts +6 -8
- package/src/tabs/{__tests__/Overview.test.ts → Overview.test.ts} +18 -15
- package/src/tabs/Overview.ts +50 -39
- package/src/tabs/{__tests__/Projects.test.ts → Projects.test.ts} +5 -8
- package/src/tabs/Projects.ts +12 -7
- package/src/tabs/{__tests__/Usage.test.ts → Usage.test.ts} +8 -18
- package/src/tabs/Usage.ts +9 -5
- package/src/types.ts +11 -0
- package/src/components/__tests__/LoadingView.test.ts +0 -26
- /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
|
-
#
|
|
1
|
+
# @mohndoe/pi-atlas
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
-
- **
|
|
19
|
-
- **
|
|
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** —
|
|
35
|
-
- **Languages** —
|
|
36
|
-
- **Models** —
|
|
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
|
-
|
|
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
|
-
|
|
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/`
|
|
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.
|
|
4
|
-
"description": "
|
|
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 {
|
|
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 {
|
|
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 "
|
|
12
|
-
import { emptyDay } from "
|
|
13
|
-
import {
|
|
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("
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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:
|
|
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:
|
|
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 (!
|
|
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)
|
|
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) {
|