@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.
- package/.pi/extensions/guardrails.json +10 -0
- package/.pi/extensions/guardrails.v0.json +8 -0
- package/AGENTS.md +13 -0
- package/CONTEXT.md +119 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bun.lock +325 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/adr/0001-global-session-project-map.md +9 -0
- package/docs/adr/0002-precomputed-summaries.md +9 -0
- package/docs/agents/domain.md +42 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +14 -0
- package/package.json +49 -0
- package/src/__tests__/cache.test.ts +388 -0
- package/src/__tests__/components.fixtures.ts +54 -0
- package/src/__tests__/compute.fixtures.ts +49 -0
- package/src/__tests__/compute.test.ts +336 -0
- package/src/__tests__/e2e.test.ts +182 -0
- package/src/__tests__/format.test.ts +232 -0
- package/src/__tests__/parser.test.ts +1396 -0
- package/src/cache.ts +178 -0
- package/src/colorPalette.ts +119 -0
- package/src/components/BarChart.ts +288 -0
- package/src/components/Dashboard.ts +222 -0
- package/src/components/Header.ts +40 -0
- package/src/components/KpiCards.ts +104 -0
- package/src/components/LoadingView.ts +38 -0
- package/src/components/MarqueeText.ts +79 -0
- package/src/components/RangeSelector.ts +63 -0
- package/src/components/RankedBarList.ts +71 -0
- package/src/components/SortedTable.ts +221 -0
- package/src/components/StatCard.ts +64 -0
- package/src/components/TabBar.ts +59 -0
- package/src/components/UsageRow.ts +55 -0
- package/src/components/__tests__/Bar.test.ts +66 -0
- package/src/components/__tests__/BarChart.test.ts +224 -0
- package/src/components/__tests__/Dashboard.test.ts +452 -0
- package/src/components/__tests__/KpiCards.test.ts +83 -0
- package/src/components/__tests__/LoadingView.test.ts +26 -0
- package/src/components/__tests__/MarqueeText.test.ts +75 -0
- package/src/components/__tests__/RangeSelector.test.ts +34 -0
- package/src/components/__tests__/RankedBarList.test.ts +110 -0
- package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
- package/src/components/__tests__/SortedTable.test.ts +723 -0
- package/src/components/__tests__/TabBar.test.ts +62 -0
- package/src/components/__tests__/cells.test.ts +193 -0
- package/src/components/cells.ts +108 -0
- package/src/components/shared/Bar.ts +22 -0
- package/src/components/shared/GridRow.ts +22 -0
- package/src/compute.ts +210 -0
- package/src/format.ts +219 -0
- package/src/index.ts +88 -0
- package/src/parser.ts +363 -0
- package/src/tabs/Languages.ts +102 -0
- package/src/tabs/Models.ts +108 -0
- package/src/tabs/Overview.ts +152 -0
- package/src/tabs/Projects.ts +92 -0
- package/src/tabs/Usage.ts +181 -0
- package/src/tabs/__tests__/Languages.test.ts +158 -0
- package/src/tabs/__tests__/Models.test.ts +143 -0
- package/src/tabs/__tests__/Overview.test.ts +92 -0
- package/src/tabs/__tests__/Projects.test.ts +142 -0
- package/src/tabs/__tests__/Usage.test.ts +174 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# pi-atlas Architecture
|
|
2
|
+
|
|
3
|
+
## Module structure
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
src/
|
|
7
|
+
├── index.ts — Extension entry point. Registers /atlas.
|
|
8
|
+
├── types.ts — Shared types (DayAgg, StatsSummary, etc.)
|
|
9
|
+
├── parser.ts — .jsonl → DayAgg[]. Global sessionProjectMap for cost attribution.
|
|
10
|
+
├── compute.ts — summarize(): DayAgg[] × TimeRange → StatsSummary (pure).
|
|
11
|
+
├── cache.ts — loadAggregate(): try SHA-256-keyed cache, fall back to parse.
|
|
12
|
+
├── format.ts — Cost/number/date formatters + language detection (EXT_TO_LANG).
|
|
13
|
+
├── colorPalette.ts — chalk color lookups per language and per provider.
|
|
14
|
+
├── components/ — TUI components (Dashboard, SortedTable, cells, BarChart, etc.)
|
|
15
|
+
├── tabs/ — Five tab implementations (Overview, Languages, Models, Projects, Usage).
|
|
16
|
+
└── __tests__/ — Tests mirror src layout.
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Data pipeline
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
.jsonl files → parseFile() → DayAgg[] (per calendar day)
|
|
23
|
+
↓
|
|
24
|
+
summarize(days, range)
|
|
25
|
+
↓
|
|
26
|
+
StatsSummary × 4 (pre-computed for 1d/7d/30d/All)
|
|
27
|
+
↓
|
|
28
|
+
Tab receives relevant StatsSummary
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
All four ranges are computed up front (see [ADR-0002](../docs/adr/0002-precomputed-summaries.md)). `StatsSummary` is passed as a `Map<TimeRange, StatsSummary>`; tabs don't re-compute on range changes.
|
|
32
|
+
|
|
33
|
+
## Component hierarchy
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
Dashboard (extends BorderBox from @mohndoe/pi-tui-extras)
|
|
37
|
+
├── TabBar (←→ navigation)
|
|
38
|
+
├── separator
|
|
39
|
+
├── active tab content (one of: Overview, Languages, Models, Projects, Usage)
|
|
40
|
+
├── separator
|
|
41
|
+
└── controls hint
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Dashboard** always renders as a centered overlay popup (50% width, max 80% height).
|
|
45
|
+
- Title bar shows app name (left) + current range label (right). Footer shows update timestamp + controls hint.
|
|
46
|
+
- Tabs extend `Container` (from `@earendil-works/pi-tui`), which implements `Component`.
|
|
47
|
+
- Every tab wraps its content in one or more `BorderBox` instances (from `@mohndoe/pi-tui-extras`).
|
|
48
|
+
- The **SortedTable** (used by Languages, Models, Projects, Usage) delegates cell rendering to `cells.ts` — a factory of `CellComponent` types: `text`, `header` (with sort indicators), `marquee` (auto-scroll), `bar` (horizontal bar).
|
|
49
|
+
|
|
50
|
+
### Chrome
|
|
51
|
+
|
|
52
|
+
Dashboard calculates tab content height as: `floor(terminal.rows × 0.8) - chrome rows - 2`. Chrome rows: TabBar + separator + separator + footer = ~4. The exact number is a local constant.
|
|
53
|
+
|
|
54
|
+
## Key design decisions
|
|
55
|
+
|
|
56
|
+
**Cost attribution** — cost from an assistant message is attributed to all active projects seen in the current file (see [ADR-0001](../docs/adr/0001-global-session-project-map.md)).
|
|
57
|
+
|
|
58
|
+
**Pre-computed summaries** — four `StatsSummary` values are computed when the Dashboard opens, making tab switches instant (see [ADR-0002](../docs/adr/0002-precomputed-summaries.md)).
|
|
59
|
+
|
|
60
|
+
**Session log parsing** — eight JSONL entry types are handled (session, user/assistant/tool-result messages, model change, thinking level change, compaction). Branch summary, custom, custom message, label, and session info entries are skipped.
|
|
61
|
+
|
|
62
|
+
**Language counting** — lines measured by splitting on `\n`, not character length.
|
|
63
|
+
|
|
64
|
+
**Cache** — SHA-256 of file paths/sizes/mtimes. Deterministic (sorted paths). Rebuilds silently on corruption.
|
|
65
|
+
|
|
66
|
+
**RangeSelector** — accepts `RangeOption[]` from caller. Labels: "Today", "Last 7 days", "Last 30 days", "All time". The `r` key cycles through them.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Global mutable sessionProjectMap for cost attribution
|
|
2
|
+
|
|
3
|
+
Cost attribution to projects uses a module-level `sessionProjectMap: Map<string, string>` in `parser.ts`. When a `session` entry is parsed (containing `cwd`), the session ID is mapped to a project name. Subsequent `assistant` messages in the same file attribute their cost to all active projects in `sessionProjectMap`. The map is reset at the start of each `parseFile()` call.
|
|
4
|
+
|
|
5
|
+
This keeps cost attribution simple — parse functions don't need to carry a context object through the call chain — at the cost of global mutable state that's invisible to callers of individual `parse*()` functions.
|
|
6
|
+
|
|
7
|
+
**Trade-off**: Accepting mutable global state in the parser was cleaner than threading a `SessionProjectMap` through every `parse*()` function and returning it alongside every `DayAgg`. The alternative would bundle the map with DayAgg into a `ParseResult { days, sessionMap }` type and thread it through the entire call chain. The current approach keeps function signatures pure-looking `(entry) → DayAgg` while the map is implicitly available.
|
|
8
|
+
|
|
9
|
+
**Consequences**: Calling `parseSessionEntry()`, `parseUserMessage()`, etc. outside of a `parseFile()` lifecycle (e.g., in unit tests) will not have the expected project mapping. Tests that exercise cost attribution must set up `sessionProjectMap` first or go through `parseFile()`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Pre-computed StatsSummary for all time ranges
|
|
2
|
+
|
|
3
|
+
The Dashboard constructor computes `StatsSummary` for all four time ranges (1d, 7d, 30d, All) upfront, then passes the full array to every tab. Tabs read from the summary corresponding to the currently selected range.
|
|
4
|
+
|
|
5
|
+
This means every tab switch is instant — no computation needed — at the cost of holding four summaries in memory simultaneously (each ~tens of KB for a typical user). The alternative would compute summaries lazily on tab switch or range change, keeping only the current range's summary in memory.
|
|
6
|
+
|
|
7
|
+
**Trade-off**: For a typical user with hundreds of DayAgg entries, the memory overhead of four summaries is negligible (JSON-serialized cache is typically <500 KB). The latency benefit is real: tab switches happen in a single render cycle with no async work. The only scenario where lazy evaluation would help is an extremely large session history (>10k days), which is unlikely given pi's typical usage.
|
|
8
|
+
|
|
9
|
+
**Consequences**: The Dashboard constructor accepts `StatsSummary[]` (length 4) rather than `DayAgg[]`. Adding a new time range requires building a fifth summary. The summaries are recomputed only when the Dashboard is reconstructed (i.e., on range change via `buildTabs()`).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Domain Docs
|
|
2
|
+
|
|
3
|
+
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
|
4
|
+
|
|
5
|
+
## Before exploring, read these
|
|
6
|
+
|
|
7
|
+
- **`CONTEXT.md`** at the repo root
|
|
8
|
+
- **`docs/adr/`** — read ADRs that touch the area you're about to work in
|
|
9
|
+
- **`node_modules/@earendil-works/pi-tui/README.md`** — pi-tui framework docs (Component interface, built-in components, rendering pipeline). Always read before designing or modifying TUI components.
|
|
10
|
+
- **`node_modules/@earendil-works/pi-tui/dist/index.d.ts`** — pi-tui type declarations for exact API signatures.
|
|
11
|
+
- **`node_modules/@earendil-works/pi-coding-agent/docs/session-format.md`** — pi session JSONL format: all entry types (SessionHeader, MessageEntry, CompactionEntry, etc.), AgentMessage roles (user/assistant/toolResult), Usage/cost structure, content blocks. Critical reference for parser code — the project's own types.ts mirrors this.
|
|
12
|
+
- **`node_modules/@earendil-works/pi-coding-agent/docs/tui.md`** — pi-specific TUI docs: `ctx.ui.custom()`, component registration, focusable components.
|
|
13
|
+
- **`node_modules/@earendil-works/pi-coding-agent/docs/extensions.md`** — pi extension API: `registerCommand()`, `ExtensionAPI`, `ExtensionCommandContext`, theme type, extension lifecycle.
|
|
14
|
+
- **`node_modules/@earendil-works/pi-coding-agent/docs/sessions.md`** — pi session storage overview (file layout under `~/.pi/agent/sessions/`).
|
|
15
|
+
- **`node_modules/@earendil-works/pi-coding-agent/docs/index.md`** — full doc index; read any other pages as needed.
|
|
16
|
+
|
|
17
|
+
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
|
18
|
+
|
|
19
|
+
## File structure
|
|
20
|
+
|
|
21
|
+
Single-context repo:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
/
|
|
25
|
+
├── CONTEXT.md
|
|
26
|
+
├── docs/adr/
|
|
27
|
+
│ ├── 0001-global-session-project-map.md
|
|
28
|
+
│ └── 0002-precomputed-summaries.md
|
|
29
|
+
└── src/
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use the glossary's vocabulary
|
|
33
|
+
|
|
34
|
+
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
|
35
|
+
|
|
36
|
+
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
|
37
|
+
|
|
38
|
+
## Flag ADR conflicts
|
|
39
|
+
|
|
40
|
+
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
|
41
|
+
|
|
42
|
+
> _Contradicts ADR-0002 (pre-computed summaries) — but worth reopening because…_
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Issue tracker: GitHub
|
|
2
|
+
|
|
3
|
+
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
|
4
|
+
|
|
5
|
+
## Conventions
|
|
6
|
+
|
|
7
|
+
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
|
8
|
+
- **Read an issue**: `gh issue view <number> --comments`
|
|
9
|
+
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'`
|
|
10
|
+
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
|
11
|
+
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
|
12
|
+
- **Close**: `gh issue close <number> --comment "..."`
|
|
13
|
+
|
|
14
|
+
Infer the repo from `git remote -v`.
|
|
15
|
+
|
|
16
|
+
## When a skill says "publish to the issue tracker"
|
|
17
|
+
|
|
18
|
+
Create a GitHub issue.
|
|
19
|
+
|
|
20
|
+
## When a skill says "fetch the relevant ticket"
|
|
21
|
+
|
|
22
|
+
Run `gh issue view <number> --comments`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Triage Labels
|
|
2
|
+
|
|
3
|
+
| mattpocock/skills role | Our label | Meaning |
|
|
4
|
+
|------------------------|-----------|---------|
|
|
5
|
+
| *(pre-triage)* | `considering` | Initial state for new issues and PRDs |
|
|
6
|
+
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate |
|
|
7
|
+
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
|
8
|
+
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
|
9
|
+
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
|
10
|
+
| `wontfix` | `wontfix` | Will not be actioned |
|
|
11
|
+
|
|
12
|
+
New issues and PRDs are created with `considering`. A maintainer moves them to `needs-triage` after initial review.
|
|
13
|
+
|
|
14
|
+
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mohndoe/pi-atlas",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension providing an atlas of agent activity — costs, languages, models, projects, and tools from session logs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/MohnDoe/pi-atlas#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/MohnDoe/pi-atlas/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Kevin P. <@mohndoe>",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/MohnDoe/pi-atlas.git"
|
|
19
|
+
},
|
|
20
|
+
"directories": {
|
|
21
|
+
"doc": "docs"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"module": "src/index.ts",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "bun test"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@mohndoe/pi-tui-extras": "^0.1.4",
|
|
31
|
+
"chalk": "^5.6.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@earendil-works/pi-agent-core": ">=0.74.0 <0.77.0",
|
|
35
|
+
"@earendil-works/pi-ai": ">=0.74.0 <0.77.0",
|
|
36
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0 <0.77.0",
|
|
37
|
+
"@earendil-works/pi-tui": ">=0.74.0 <0.77.0",
|
|
38
|
+
"@types/bun": "latest"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-tui": ">=0.74.0 <0.77.0",
|
|
42
|
+
"typescript": "^5"
|
|
43
|
+
},
|
|
44
|
+
"pi": {
|
|
45
|
+
"extensions": [
|
|
46
|
+
"./src/index.ts"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
computeSignature,
|
|
7
|
+
getCacheTimestamp,
|
|
8
|
+
loadAggregate,
|
|
9
|
+
readCache,
|
|
10
|
+
writeCache,
|
|
11
|
+
} from "../cache";
|
|
12
|
+
import { emptyDay } from "../parser";
|
|
13
|
+
import { type DayAgg } from "../types";
|
|
14
|
+
|
|
15
|
+
describe("computeSignature", () => {
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tmpDir = join(tmpdir(), `pi-atlas-test-${Date.now()}`);
|
|
20
|
+
await mkdir(tmpDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns empty string for empty directory", async () => {
|
|
28
|
+
const sig = await computeSignature(tmpDir);
|
|
29
|
+
expect(sig).toBe("");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns a non-empty hash for a directory with files", async () => {
|
|
33
|
+
await writeFile(join(tmpDir, "a.jsonl"), "line1\n");
|
|
34
|
+
await writeFile(join(tmpDir, "b.jsonl"), "line2\n");
|
|
35
|
+
|
|
36
|
+
const sig1 = await computeSignature(tmpDir);
|
|
37
|
+
expect(sig1).toBeTruthy();
|
|
38
|
+
expect(sig1.length).toBeGreaterThan(0);
|
|
39
|
+
|
|
40
|
+
// Same directory, same signature
|
|
41
|
+
const sig2 = await computeSignature(tmpDir);
|
|
42
|
+
expect(sig2).toBe(sig1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("changes when a file is modified", async () => {
|
|
46
|
+
await writeFile(join(tmpDir, "a.jsonl"), "original\n");
|
|
47
|
+
const sig1 = await computeSignature(tmpDir);
|
|
48
|
+
|
|
49
|
+
await writeFile(join(tmpDir, "a.jsonl"), "modified\n");
|
|
50
|
+
const sig2 = await computeSignature(tmpDir);
|
|
51
|
+
|
|
52
|
+
expect(sig2).not.toBe(sig1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("changes when a file is added", async () => {
|
|
56
|
+
await writeFile(join(tmpDir, "a.jsonl"), "data\n");
|
|
57
|
+
const sig1 = await computeSignature(tmpDir);
|
|
58
|
+
|
|
59
|
+
await writeFile(join(tmpDir, "b.jsonl"), "more\n");
|
|
60
|
+
const sig2 = await computeSignature(tmpDir);
|
|
61
|
+
|
|
62
|
+
expect(sig2).not.toBe(sig1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("scans subdirectories", async () => {
|
|
66
|
+
const subDir = join(tmpDir, "project-a");
|
|
67
|
+
await mkdir(subDir);
|
|
68
|
+
await writeFile(join(subDir, "s1.jsonl"), "data\n");
|
|
69
|
+
|
|
70
|
+
const sig = await computeSignature(tmpDir);
|
|
71
|
+
expect(sig).toBeTruthy();
|
|
72
|
+
expect(sig.length).toBeGreaterThan(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("ignores non-.jsonl files", async () => {
|
|
76
|
+
await writeFile(join(tmpDir, "README.md"), "docs\n");
|
|
77
|
+
const sig = await computeSignature(tmpDir);
|
|
78
|
+
expect(sig).toBe("");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("cache read/write", () => {
|
|
83
|
+
let tmpDir: string;
|
|
84
|
+
let cachePath: string;
|
|
85
|
+
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
tmpDir = join(tmpdir(), `pi-atlas-cache-test-${Date.now()}`);
|
|
88
|
+
await mkdir(tmpDir, { recursive: true });
|
|
89
|
+
cachePath = join(tmpDir, "cache.json");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(async () => {
|
|
93
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("writes and reads cache", async () => {
|
|
97
|
+
const d = emptyDay("2026-06-08");
|
|
98
|
+
d.cost = 1.5;
|
|
99
|
+
d.sessionIds = new Set(["s1"]);
|
|
100
|
+
const days: DayAgg[] = [d];
|
|
101
|
+
await writeCache(cachePath, "sig-abc", days);
|
|
102
|
+
|
|
103
|
+
const payload = await readCache(cachePath);
|
|
104
|
+
expect(payload).toBeDefined();
|
|
105
|
+
expect(payload!.signature).toBe("sig-abc");
|
|
106
|
+
expect(payload!.days).toHaveLength(1);
|
|
107
|
+
expect(payload!.days[0]!.date).toBe("2026-06-08");
|
|
108
|
+
expect(payload!.days[0]!.cost).toBe(1.5);
|
|
109
|
+
// Sets are serialized as arrays
|
|
110
|
+
expect(payload!.days[0]!.sessionIds).toEqual(["s1"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns null for missing cache file", async () => {
|
|
114
|
+
const payload = await readCache("/nonexistent/path/cache.json");
|
|
115
|
+
expect(payload).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns null for corrupt cache", async () => {
|
|
119
|
+
await writeFile(cachePath, "not-json");
|
|
120
|
+
const payload = await readCache(cachePath);
|
|
121
|
+
expect(payload).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns generatedAt from valid cache", async () => {
|
|
125
|
+
const d = emptyDay("2026-06-08");
|
|
126
|
+
await writeCache(cachePath, "sig-abc", [d]);
|
|
127
|
+
const ts = await getCacheTimestamp(cachePath);
|
|
128
|
+
expect(ts).not.toBeNull();
|
|
129
|
+
// generatedAt is an ISO string
|
|
130
|
+
expect(new Date(ts!).toISOString()).toBe(ts as string);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns null for missing cache", async () => {
|
|
134
|
+
const ts = await getCacheTimestamp("/nonexistent/cache.json");
|
|
135
|
+
expect(ts).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns null for corrupt cache", async () => {
|
|
139
|
+
await writeFile(cachePath, "not-json");
|
|
140
|
+
const ts = await getCacheTimestamp(cachePath);
|
|
141
|
+
expect(ts).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("serializes and deserializes modelToProvider Map", async () => {
|
|
145
|
+
const d = emptyDay("2026-06-08");
|
|
146
|
+
d.modelToProvider.set("claude-sonnet-4", "anthropic");
|
|
147
|
+
d.modelToProvider.set("gpt-4o", "openai");
|
|
148
|
+
const days: DayAgg[] = [d];
|
|
149
|
+
await writeCache(cachePath, "sig-mtp", days);
|
|
150
|
+
|
|
151
|
+
// Read raw cache JSON — modelToProvider should not be empty {}
|
|
152
|
+
const payload = await readCache(cachePath);
|
|
153
|
+
expect(payload).toBeDefined();
|
|
154
|
+
expect(payload!.days).toHaveLength(1);
|
|
155
|
+
expect(payload!.days[0]!.modelToProvider).toEqual({
|
|
156
|
+
"claude-sonnet-4": "anthropic",
|
|
157
|
+
"gpt-4o": "openai",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Load via loadAggregate round-trip (needs a session dir with .jsonl for valid sig)
|
|
161
|
+
const sesDir = join(tmpDir, "sessions");
|
|
162
|
+
await mkdir(sesDir, { recursive: true });
|
|
163
|
+
await writeFile(
|
|
164
|
+
join(sesDir, "dummy.jsonl"),
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
type: "session",
|
|
167
|
+
version: 3,
|
|
168
|
+
id: "s1",
|
|
169
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
170
|
+
cwd: "/p",
|
|
171
|
+
}) + "\n",
|
|
172
|
+
);
|
|
173
|
+
const sig = await computeSignature(sesDir);
|
|
174
|
+
await writeFile(
|
|
175
|
+
cachePath,
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
signature: sig,
|
|
178
|
+
generatedAt: new Date().toISOString(),
|
|
179
|
+
days: [
|
|
180
|
+
{
|
|
181
|
+
date: "2026-06-08",
|
|
182
|
+
cost: 0,
|
|
183
|
+
inTok: 0,
|
|
184
|
+
outTok: 0,
|
|
185
|
+
crTok: 0,
|
|
186
|
+
cwTok: 0,
|
|
187
|
+
userMsgs: 0,
|
|
188
|
+
asstMsgs: 0,
|
|
189
|
+
toolResults: 0,
|
|
190
|
+
sessionIds: [],
|
|
191
|
+
langLines: {},
|
|
192
|
+
langEdits: {},
|
|
193
|
+
modelCost: {},
|
|
194
|
+
modelCount: {},
|
|
195
|
+
providerCost: {},
|
|
196
|
+
providerCount: {},
|
|
197
|
+
modelToProvider: { "claude-sonnet-4": "anthropic", "gpt-4o": "openai" },
|
|
198
|
+
projectCost: {},
|
|
199
|
+
projectSessions: {},
|
|
200
|
+
toolCount: {},
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
const loaded = await loadAggregate(cachePath, sesDir);
|
|
206
|
+
expect(loaded).toHaveLength(1);
|
|
207
|
+
expect(loaded[0]!.modelToProvider.get("claude-sonnet-4")).toBe("anthropic");
|
|
208
|
+
expect(loaded[0]!.modelToProvider.get("gpt-4o")).toBe("openai");
|
|
209
|
+
expect(loaded[0]!.modelToProvider.size).toBe(2);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("loadAggregate", () => {
|
|
214
|
+
let tmpDir: string;
|
|
215
|
+
let sessionsDir: string;
|
|
216
|
+
let cachePath: string;
|
|
217
|
+
|
|
218
|
+
beforeEach(async () => {
|
|
219
|
+
tmpDir = join(tmpdir(), `pi-atlas-load-${Date.now()}`);
|
|
220
|
+
await mkdir(tmpDir, { recursive: true });
|
|
221
|
+
sessionsDir = join(tmpDir, "sessions");
|
|
222
|
+
await mkdir(sessionsDir, { recursive: true });
|
|
223
|
+
cachePath = join(tmpDir, "cache.json");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
afterEach(async () => {
|
|
227
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("returns empty array for empty sessions dir", async () => {
|
|
231
|
+
const days = await loadAggregate(cachePath, sessionsDir);
|
|
232
|
+
expect(days).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("parses session files and returns DayAgg array", async () => {
|
|
236
|
+
const subDir = join(sessionsDir, "proj-a");
|
|
237
|
+
await mkdir(subDir);
|
|
238
|
+
await writeFile(
|
|
239
|
+
join(subDir, "s1.jsonl"),
|
|
240
|
+
[
|
|
241
|
+
JSON.stringify({
|
|
242
|
+
type: "session",
|
|
243
|
+
version: 3,
|
|
244
|
+
id: "s1",
|
|
245
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
246
|
+
cwd: "/home/doe/proj-a",
|
|
247
|
+
}),
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
type: "message",
|
|
250
|
+
id: "m1",
|
|
251
|
+
parentId: "p",
|
|
252
|
+
timestamp: "2026-06-08T10:01:00.000Z",
|
|
253
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
|
254
|
+
}),
|
|
255
|
+
].join("\n"),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const days = await loadAggregate(cachePath, sessionsDir);
|
|
259
|
+
expect(days).toHaveLength(1);
|
|
260
|
+
expect(days[0]!.date).toBe("2026-06-08");
|
|
261
|
+
expect(days[0]!.userMsgs).toBe(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("caches results and reuses them", async () => {
|
|
265
|
+
const subDir = join(sessionsDir, "proj-a");
|
|
266
|
+
await mkdir(subDir);
|
|
267
|
+
await writeFile(
|
|
268
|
+
join(subDir, "s1.jsonl"),
|
|
269
|
+
[
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
type: "session",
|
|
272
|
+
version: 3,
|
|
273
|
+
id: "s1",
|
|
274
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
275
|
+
cwd: "/home/doe/proj-a",
|
|
276
|
+
}),
|
|
277
|
+
].join("\n"),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const days1 = await loadAggregate(cachePath, sessionsDir);
|
|
281
|
+
expect(days1).toHaveLength(1);
|
|
282
|
+
|
|
283
|
+
// Second call should use cache
|
|
284
|
+
const days2 = await loadAggregate(cachePath, sessionsDir);
|
|
285
|
+
expect(days2).toHaveLength(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("invalidates cache when session files change", async () => {
|
|
289
|
+
const subDir = join(sessionsDir, "proj-a");
|
|
290
|
+
await mkdir(subDir);
|
|
291
|
+
await writeFile(
|
|
292
|
+
join(subDir, "s1.jsonl"),
|
|
293
|
+
[
|
|
294
|
+
JSON.stringify({
|
|
295
|
+
type: "session",
|
|
296
|
+
version: 3,
|
|
297
|
+
id: "s1",
|
|
298
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
299
|
+
cwd: "/home/doe/proj-a",
|
|
300
|
+
}),
|
|
301
|
+
].join("\n"),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
await loadAggregate(cachePath, sessionsDir);
|
|
305
|
+
|
|
306
|
+
// Add a new file
|
|
307
|
+
await writeFile(
|
|
308
|
+
join(subDir, "s2.jsonl"),
|
|
309
|
+
[
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
type: "session",
|
|
312
|
+
version: 3,
|
|
313
|
+
id: "s2",
|
|
314
|
+
timestamp: "2026-06-09T10:00:00.000Z",
|
|
315
|
+
cwd: "/home/doe/proj-b",
|
|
316
|
+
}),
|
|
317
|
+
].join("\n"),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const days = await loadAggregate(cachePath, sessionsDir);
|
|
321
|
+
expect(days).toHaveLength(2); // two days now
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("logs corrupt line count to stderr", async () => {
|
|
325
|
+
const subDir = join(sessionsDir, "proj-a");
|
|
326
|
+
await mkdir(subDir);
|
|
327
|
+
await writeFile(
|
|
328
|
+
join(subDir, "mixed.jsonl"),
|
|
329
|
+
[
|
|
330
|
+
JSON.stringify({
|
|
331
|
+
type: "session",
|
|
332
|
+
version: 3,
|
|
333
|
+
id: "s1",
|
|
334
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
335
|
+
cwd: "/home/doe/proj-a",
|
|
336
|
+
}),
|
|
337
|
+
"not valid json",
|
|
338
|
+
"also broken {",
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
type: "message",
|
|
341
|
+
id: "m1",
|
|
342
|
+
parentId: "p",
|
|
343
|
+
timestamp: "2026-06-08T10:01:00.000Z",
|
|
344
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
|
345
|
+
}),
|
|
346
|
+
].join("\n"),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const errors: string[] = [];
|
|
350
|
+
const spy = spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
351
|
+
errors.push(args.join(" "));
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
await loadAggregate(cachePath, sessionsDir, true);
|
|
356
|
+
|
|
357
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
358
|
+
const warning = errors.find((e) => e.includes("corrupt"));
|
|
359
|
+
expect(warning).toBeDefined();
|
|
360
|
+
expect(warning).toContain("2"); // 2 corrupt lines
|
|
361
|
+
} finally {
|
|
362
|
+
spy.mockRestore();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("calls onProgress during parsing", async () => {
|
|
367
|
+
const subDir = join(sessionsDir, "proj-a");
|
|
368
|
+
await mkdir(subDir);
|
|
369
|
+
await writeFile(
|
|
370
|
+
join(subDir, "s1.jsonl"),
|
|
371
|
+
[
|
|
372
|
+
JSON.stringify({
|
|
373
|
+
type: "session",
|
|
374
|
+
version: 3,
|
|
375
|
+
id: "s1",
|
|
376
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
377
|
+
cwd: "/home/doe/proj-a",
|
|
378
|
+
}),
|
|
379
|
+
].join("\n"),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const progress: number[] = [];
|
|
383
|
+
await loadAggregate(cachePath, sessionsDir, false, (p) => progress.push(p));
|
|
384
|
+
|
|
385
|
+
// Should have reported some progress
|
|
386
|
+
expect(progress.length).toBeGreaterThan(0);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ColorPalette } from "../colorPalette";
|
|
4
|
+
import { type RangeOption, RangeSelector } from "../components/RangeSelector";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pass-through mock theme for tests. All styling methods return text unchanged.
|
|
8
|
+
* Matches the pattern from rpiv-mono test-utils.
|
|
9
|
+
*/
|
|
10
|
+
export function makeTheme(overrides: Partial<Theme> = {}): Theme {
|
|
11
|
+
return {
|
|
12
|
+
fg: (_color, text) => text,
|
|
13
|
+
bg: (_color, text) => text,
|
|
14
|
+
bold: (text) => text,
|
|
15
|
+
italic: (text) => text,
|
|
16
|
+
underline: (text) => text,
|
|
17
|
+
inverse: (text) => text,
|
|
18
|
+
strikethrough: (text) => text,
|
|
19
|
+
...overrides,
|
|
20
|
+
} as Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function testPalette(): ColorPalette {
|
|
24
|
+
return new ColorPalette({});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mock TUI for tests. Only requestRender() is implemented as a no-op.
|
|
29
|
+
* All other TUI methods are left undefined - they're never called in tests
|
|
30
|
+
* that exercise MarqueeText or SortedTable marquee behavior.
|
|
31
|
+
*/
|
|
32
|
+
export function makeMockTUI(): TUI {
|
|
33
|
+
return {
|
|
34
|
+
requestRender() {},
|
|
35
|
+
terminal: {
|
|
36
|
+
get rows() {
|
|
37
|
+
return 24;
|
|
38
|
+
},
|
|
39
|
+
get columns() {
|
|
40
|
+
return 80;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
} as TUI;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function makeRangeSelector(theme: Theme): RangeSelector {
|
|
47
|
+
const rangeOptions: RangeOption[] = [
|
|
48
|
+
{ label: "Today", value: "1d" },
|
|
49
|
+
{ label: "Last 7 days", value: "7d" },
|
|
50
|
+
{ label: "Last 30 days", value: "30d" },
|
|
51
|
+
{ label: "All time", value: "All" },
|
|
52
|
+
];
|
|
53
|
+
return new RangeSelector(theme, rangeOptions, rangeOptions.length - 1);
|
|
54
|
+
}
|