pi-stats-ext 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Irfan Sofyana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # pi-stats-ext
2
+
3
+ [![npm](https://img.shields.io/npm/v/pi-stats-ext?color=blue)](https://www.npmjs.com/package/pi-stats-ext)
4
+
5
+ Local-first Pi coding-agent usage dashboard.
6
+
7
+ ![pi-stats overview](docs/img/pi-stats-overview.png)
8
+
9
+ ![pi-stats dashboard](docs/img/pi-stats-dashboard.png)
10
+
11
+ ## Dashboard
12
+
13
+ `/pi-stats` opens a dense Ops-console TUI over local session history:
14
+
15
+ - global date ranges: default `30d`, plus `today`, `7d`, `90d`, `all`, or `YYYY-MM-DD..YYYY-MM-DD`
16
+ - multi-view layout: Overview, Models, Projects, Sessions
17
+ - colored summary cards for fresh tokens, cost, cache, sessions/projects
18
+ - Claude Code-style insights: favorite model, total tokens, most active day, longest session, longest/current streak
19
+ - terminal heatmap with month labels, exact date range, and intensity legend for daily fresh tokens
20
+ - model token mix graphic with proportional bars, percentages, and input/output tokens
21
+ - bordered tables for top models, projects, and sessions by cost/tokens
22
+ - keyboard controls: `1` today, `2` 7d, `3` 30d, `4` 90d, `5` all, `d` custom date input, `o`/`m`/`p`/`s` views, tab cycles views, left/right cycles range, `q`/`esc` close
23
+ - incremental JSON cache at `~/.pi/agent/pi-stats/cache.json`
24
+
25
+ ## Installation
26
+
27
+ ### npm
28
+
29
+ ```bash
30
+ pi install npm:pi-stats-ext
31
+ ```
32
+
33
+ Then reload Pi and run:
34
+
35
+ ```text
36
+ /pi-stats
37
+ ```
38
+
39
+ ### Local development install
40
+
41
+ ```bash
42
+ npm install
43
+ pi install ./
44
+ # then reload pi and run:
45
+ /pi-stats
46
+ ```
47
+
48
+ ## Develop
49
+
50
+ ```bash
51
+ npm test
52
+ npm run build
53
+ ```
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-stats-ext",
3
+ "version": "0.1.0",
4
+ "description": "Dense local-first Pi coding-agent usage dashboard for tokens, costs, models, projects, and sessions.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "docs/img",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi",
17
+ "stats",
18
+ "usage",
19
+ "tokens"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "test": "tsx --test tests/*.test.ts",
29
+ "prepublishOnly": "npm test && npm run build",
30
+ "pack:check": "npm pack --dry-run"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@earendil-works/pi-coding-agent": "latest",
38
+ "@earendil-works/pi-tui": "latest",
39
+ "@types/node": "latest",
40
+ "tsx": "latest",
41
+ "typescript": "latest"
42
+ },
43
+ "license": "MIT",
44
+ "author": "Irfan Sofyana",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/irfansofyana/pi-stats-ext.git"
48
+ },
49
+ "homepage": "https://github.com/irfansofyana/pi-stats-ext#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/irfansofyana/pi-stats-ext/issues"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }
@@ -0,0 +1,615 @@
1
+ import { basename } from "node:path";
2
+ import {
3
+ aggregate,
4
+ formatCost,
5
+ formatTokens,
6
+ parseRange,
7
+ type AggregatedStats,
8
+ type DateRange,
9
+ type RefreshResult,
10
+ type StatsCache,
11
+ } from "./stats.js";
12
+
13
+ const PRESETS = ["today", "7d", "30d", "90d", "all"] as const;
14
+ const VIEWS = ["overview", "models", "projects", "sessions"] as const;
15
+ const RESET = "\x1b[0m";
16
+ const DIM = "\x1b[2m";
17
+ const BOLD = "\x1b[1m";
18
+ const FG = {
19
+ cyan: "\x1b[36m",
20
+ green: "\x1b[32m",
21
+ yellow: "\x1b[33m",
22
+ red: "\x1b[31m",
23
+ blue: "\x1b[34m",
24
+ magenta: "\x1b[35m",
25
+ gray: "\x1b[90m",
26
+ white: "\x1b[97m",
27
+ } as const;
28
+ const BG = { cyan: "\x1b[46m", blue: "\x1b[44m", gray: "\x1b[100m" } as const;
29
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
30
+ const DAY_MS = 24 * 60 * 60 * 1000;
31
+
32
+ type Done = (value: void) => void;
33
+ type View = (typeof VIEWS)[number];
34
+
35
+ type DashboardOptions = {
36
+ cache: StatsCache;
37
+ args: string;
38
+ done: Done;
39
+ refresh?: string;
40
+ };
41
+
42
+ type Column<T> = {
43
+ title: string;
44
+ width: number;
45
+ align?: "left" | "right";
46
+ render: (row: T, index: number) => string;
47
+ };
48
+
49
+ type InsightStats = {
50
+ favoriteModel: string;
51
+ totalDays: number;
52
+ mostActiveDay: string;
53
+ longestSessionMs: number;
54
+ longestStreak: number;
55
+ currentStreak: number;
56
+ };
57
+
58
+ export class PiStatsDashboard {
59
+ private cache: StatsCache;
60
+ private arg: string;
61
+ private stats: AggregatedStats;
62
+ private refresh: string;
63
+ private view: View = "overview";
64
+ private dateEditing = false;
65
+ private dateInput = "";
66
+ private dateError = "";
67
+ private cachedWidth = 0;
68
+ private cachedLines: string[] | undefined;
69
+
70
+ constructor(options: DashboardOptions) {
71
+ this.cache = options.cache;
72
+ this.arg = normalizeArg(options.args);
73
+ this.stats = aggregate(this.cache, parseRange(this.arg));
74
+ this.refresh = options.refresh || "loading cache";
75
+ this.done = options.done;
76
+ }
77
+
78
+ private done: Done;
79
+
80
+ setRefreshResult(result: RefreshResult): void {
81
+ this.cache = result.cache;
82
+ this.stats = aggregate(this.cache, parseRange(this.arg));
83
+ const errors = result.errors.length ? `, ${result.errors.length} errors` : "";
84
+ this.refresh = `indexed ${result.totalFiles} files (${result.parsedFiles} parsed, ${result.reusedFiles} cached${errors})`;
85
+ this.invalidate();
86
+ }
87
+
88
+ setRefreshError(error: unknown): void {
89
+ this.refresh = `index failed: ${error instanceof Error ? error.message : String(error)}`;
90
+ this.invalidate();
91
+ }
92
+
93
+ render(width: number): string[] {
94
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
95
+ const w = Math.max(56, width);
96
+ const stats = this.stats;
97
+ const lines: string[] = [];
98
+
99
+ lines.push(header(stats, this.refresh, w));
100
+ lines.push(tabs(this.arg, w));
101
+ lines.push(viewTabs(this.view, w));
102
+ if (this.dateEditing || this.dateError) lines.push(datePrompt(this.dateInput, this.dateError, w));
103
+ lines.push(legend(w));
104
+ lines.push("");
105
+ lines.push(...viewBody(this.view, this.cache, stats, w));
106
+
107
+ this.cachedWidth = width;
108
+ this.cachedLines = lines.map((line) => crop(line, width));
109
+ return this.cachedLines;
110
+ }
111
+
112
+ handleInput(data: string): void {
113
+ if (this.dateEditing) {
114
+ this.handleDateInput(data);
115
+ return;
116
+ }
117
+ if (data === "q" || data === "Q" || data === "\u001b" || data === "\x03") {
118
+ this.done();
119
+ return;
120
+ }
121
+ if (data === "1") this.setArg("today");
122
+ else if (data === "2") this.setArg("7d");
123
+ else if (data === "3") this.setArg("30d");
124
+ else if (data === "4") this.setArg("90d");
125
+ else if (data === "5") this.setArg("all");
126
+ else if (data === "d" || data === "D") this.startDateInput();
127
+ else if (data === "o" || data === "O") this.setView("overview");
128
+ else if (data === "m" || data === "M") this.setView("models");
129
+ else if (data === "p" || data === "P") this.setView("projects");
130
+ else if (data === "s" || data === "S") this.setView("sessions");
131
+ else if (data === "\t") this.cycleView(1);
132
+ else if (data.includes("[C")) this.cycle(1);
133
+ else if (data.includes("[D")) this.cycle(-1);
134
+ }
135
+
136
+ invalidate(): void {
137
+ this.cachedLines = undefined;
138
+ this.cachedWidth = 0;
139
+ }
140
+
141
+ private handleDateInput(data: string): void {
142
+ if (data === "\u001b" || data === "\x03") {
143
+ this.dateEditing = false;
144
+ this.dateError = "";
145
+ } else if (data === "\r" || data === "\n") {
146
+ const next = this.dateInput.trim().toLowerCase();
147
+ if (isValidRangeArg(next)) {
148
+ this.setArg(next);
149
+ this.dateEditing = false;
150
+ this.dateError = "";
151
+ } else {
152
+ this.dateError = "use today, 7d, 30d, 90d, all, or YYYY-MM-DD..YYYY-MM-DD";
153
+ }
154
+ } else if (data === "\x7f" || data === "\b") {
155
+ this.dateInput = this.dateInput.slice(0, -1);
156
+ this.dateError = "";
157
+ } else if (/^[\x20-\x7e]+$/.test(data)) {
158
+ this.dateInput = (this.dateInput + data).slice(0, 40);
159
+ this.dateError = "";
160
+ }
161
+ this.invalidate();
162
+ }
163
+
164
+ private startDateInput(): void {
165
+ this.dateEditing = true;
166
+ this.dateInput = "";
167
+ this.dateError = "";
168
+ this.invalidate();
169
+ }
170
+
171
+ private setView(view: View): void {
172
+ this.view = view;
173
+ this.invalidate();
174
+ }
175
+
176
+ private setArg(arg: string): void {
177
+ this.arg = normalizeArg(arg);
178
+ this.stats = aggregate(this.cache, parseRange(this.arg));
179
+ this.dateError = "";
180
+ this.invalidate();
181
+ }
182
+
183
+ private cycle(delta: number): void {
184
+ const current = PRESETS.indexOf(this.arg as (typeof PRESETS)[number]);
185
+ const next = current === -1 ? 1 : (current + delta + PRESETS.length) % PRESETS.length;
186
+ this.setArg(PRESETS[next]!);
187
+ }
188
+
189
+ private cycleView(delta: number): void {
190
+ const current = VIEWS.indexOf(this.view);
191
+ this.setView(VIEWS[(current + delta + VIEWS.length) % VIEWS.length]!);
192
+ }
193
+ }
194
+
195
+ export function normalizeArg(args: string): string {
196
+ const text = args.trim().toLowerCase();
197
+ if (!text) return "30d";
198
+ if (/^\d+$/.test(text)) return `${text}d`;
199
+ return text;
200
+ }
201
+
202
+ function isValidRangeArg(arg: string): boolean {
203
+ const text = normalizeArg(arg);
204
+ if (["today", "7d", "30d", "90d", "all"].includes(text)) return true;
205
+ const custom = text.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/);
206
+ if (!custom) return false;
207
+ const start = Date.parse(`${custom[1]}T00:00:00.000Z`);
208
+ const end = Date.parse(`${custom[2]}T00:00:00.000Z`);
209
+ return Number.isFinite(start) && Number.isFinite(end) && end >= start;
210
+ }
211
+
212
+ function header(stats: AggregatedStats, refresh: string, width: number): string {
213
+ const title = `${BOLD}${FG.white} pi-stats ${RESET}${DIM}│${RESET} ${FG.cyan}${stats.range.label}${RESET}`;
214
+ const statusColor = refresh.includes("failed") || refresh.includes("errors") ? FG.red : FG.green;
215
+ const right = `${statusColor}${refresh}${RESET}`;
216
+ const gap = Math.max(1, width - visible(title) - visible(right));
217
+ return crop(`${title}${" ".repeat(gap)}${right}`, width);
218
+ }
219
+
220
+ function tabs(active: string, width: number): string {
221
+ const labels = PRESETS.map((preset, index) => {
222
+ const label = `${index + 1}:${preset}`;
223
+ return preset === active ? `${BG.cyan}${FG.white}${BOLD} ${label} ${RESET}` : `${FG.gray} ${label} ${RESET}`;
224
+ });
225
+ return crop(`${labels.join(" ")} ${DIM}d custom · ←/→ range · q/esc close${RESET}`, width);
226
+ }
227
+
228
+ function viewTabs(active: View, width: number): string {
229
+ const labels: Array<[View, string]> = [
230
+ ["overview", "O:overview"],
231
+ ["models", "M:models"],
232
+ ["projects", "P:projects"],
233
+ ["sessions", "S:sessions"],
234
+ ];
235
+ return crop(labels.map(([view, label]) => (view === active ? `${BG.blue}${FG.white}${BOLD} ${label} ${RESET}` : `${FG.gray} ${label} ${RESET}`)).join(" ") + ` ${DIM}tab view${RESET}`, width);
236
+ }
237
+
238
+ function datePrompt(input: string, error: string, width: number): string {
239
+ const prompt = `${FG.yellow}date>${RESET} ${input}${BOLD}_${RESET}`;
240
+ const help = error ? `${FG.red}${error}${RESET}` : `${DIM}enter applies · esc cancels · e.g. today, 7d, all, 2026-06-28..2026-06-28${RESET}`;
241
+ return crop(`${prompt} ${help}`, width);
242
+ }
243
+
244
+ function legend(width: number): string {
245
+ return crop(`${DIM}Global date filter applies to every view · local cache only${RESET}`, width);
246
+ }
247
+
248
+ function viewBody(view: View, cache: StatsCache, stats: AggregatedStats, width: number): string[] {
249
+ if (view === "models") return [...box("MODEL TOKEN MIX / fresh tokens", modelTokenGraphic(stats, width - 4), width, FG.cyan), "", ...table("TOP MODELS", stats.models.slice(0, 12), modelColumns(), width, FG.cyan)];
250
+ if (view === "projects") return table("TOP PROJECTS", stats.projects.slice(0, 14), projectColumns(width), width, FG.yellow);
251
+ if (view === "sessions") return table("TOP SESSIONS", stats.sessions.slice(0, 14), sessionColumns(width), width, FG.magenta);
252
+ return [
253
+ ...summaryCards(stats, width),
254
+ "",
255
+ ...box("USAGE INSIGHTS", insightLines(dashboardInsights(cache, stats), stats, width - 4), width, FG.yellow),
256
+ "",
257
+ ...box(activityTitle(stats), heatmap(stats, width - 4), width, FG.green),
258
+ ];
259
+ }
260
+
261
+ function dashboardInsights(cache: StatsCache, stats: AggregatedStats): InsightStats {
262
+ const activeDays = [...stats.daily.entries()].filter(([, totals]) => totals.freshTokens > 0).map(([day]) => day).sort();
263
+ const activeSet = new Set(activeDays);
264
+ const favorite = [...stats.models].sort((a, b) => b.freshTokens - a.freshTokens)[0]?.label || "none";
265
+ const mostActive = [...stats.daily.entries()].sort((a, b) => b[1].freshTokens - a[1].freshTokens)[0]?.[0];
266
+ const sessions = new Map<string, { min: number; max: number }>();
267
+ const seenEvents = new Set<string>();
268
+ for (const file of Object.values(cache.files)) {
269
+ for (const event of file.session.events) {
270
+ if (!inDashboardRange(event.timestamp, stats.range)) continue;
271
+ const fingerprint = `${event.timestamp}|${event.provider}|${event.model}|${event.totalTokens}|${event.cost.toFixed(8)}`;
272
+ if (seenEvents.has(fingerprint)) continue;
273
+ seenEvents.add(fingerprint);
274
+ const current = sessions.get(event.sessionPath) || { min: event.timestamp, max: event.timestamp };
275
+ current.min = Math.min(current.min, event.timestamp);
276
+ current.max = Math.max(current.max, event.timestamp);
277
+ sessions.set(event.sessionPath, current);
278
+ }
279
+ }
280
+ const longestSessionMs = Math.max(0, ...[...sessions.values()].map((session) => session.max - session.min));
281
+ const range = heatRange(stats.range, stats.daily);
282
+ const totalDays = range ? Math.floor((range.end - range.start) / DAY_MS) + 1 : activeDays.length;
283
+ const currentEnd = shortDate(range?.end ?? Date.now());
284
+ return {
285
+ favoriteModel: favorite,
286
+ totalDays,
287
+ mostActiveDay: mostActive ? shortMonthDay(Date.parse(`${mostActive}T00:00:00.000Z`)) : "none",
288
+ longestSessionMs,
289
+ longestStreak: longestStreak(activeSet),
290
+ currentStreak: currentStreak(activeSet, currentEnd),
291
+ };
292
+ }
293
+
294
+ function insightLines(insights: InsightStats, stats: AggregatedStats, width: number): string[] {
295
+ const left = [
296
+ `Favorite model: ${FG.red}${insights.favoriteModel}${RESET}`,
297
+ `Sessions: ${FG.red}${compact(stats.sessionCount)}${RESET}`,
298
+ `Active days: ${FG.red}${stats.activeDays}${RESET}${DIM}/${insights.totalDays}${RESET}`,
299
+ `Most active day: ${FG.red}${insights.mostActiveDay}${RESET}`,
300
+ ];
301
+ const right = [
302
+ `Total tokens: ${FG.red}${formatTokens(stats.totals.totalTokens)}${RESET}`,
303
+ `Longest session: ${FG.red}${formatDuration(insights.longestSessionMs)}${RESET}`,
304
+ `Longest streak: ${FG.red}${insights.longestStreak}${RESET} ${plural("day", insights.longestStreak)}`,
305
+ `Current streak: ${FG.red}${insights.currentStreak}${RESET} ${plural("day", insights.currentStreak)}`,
306
+ ];
307
+ const half = Math.max(32, Math.floor((width - 3) / 2));
308
+ return left.map((line, index) => `${padAnsi(line, half)} ${DIM}│${RESET} ${right[index] ?? ""}`);
309
+ }
310
+
311
+ function modelTokenGraphic(stats: AggregatedStats, width: number): string[] {
312
+ const models = [...stats.models].sort((a, b) => b.freshTokens - a.freshTokens).slice(0, 5);
313
+ if (!models.length) return [`${DIM}no data${RESET}`];
314
+ const labelWidth = Math.min(24, Math.max(12, Math.floor(width * 0.22)));
315
+ const barWidth = Math.max(10, width - labelWidth - 28);
316
+ const max = Math.max(...models.map((model) => model.freshTokens), 1);
317
+ const colors = [FG.cyan, FG.green, FG.yellow, FG.magenta, FG.blue];
318
+ return models.map((model, index) => {
319
+ const color = colors[index % colors.length]!;
320
+ const cells = Math.max(1, Math.round((model.freshTokens / max) * barWidth));
321
+ const percent = stats.totals.freshTokens ? ((model.freshTokens / stats.totals.freshTokens) * 100).toFixed(1) : "0.0";
322
+ const bar = `${color}${"█".repeat(cells)}${DIM}${"░".repeat(Math.max(0, barWidth - cells))}${RESET}`;
323
+ const io = `${formatTokens(model.input)} in / ${formatTokens(model.output)} out`;
324
+ return `${color}●${RESET} ${padAnsi(model.label, labelWidth)} ${bar} ${padStartAnsi(`${percent}%`, 6)} ${DIM}${io}${RESET}`;
325
+ });
326
+ }
327
+
328
+ function summaryCards(stats: AggregatedStats, width: number): string[] {
329
+ const totals = stats.totals;
330
+ const cards = [
331
+ card("FRESH", `${formatTokens(totals.freshTokens)} tok`, `${formatTokens(totals.input)} in / ${formatTokens(totals.output)} out`, FG.green),
332
+ card("COST", formatCost(totals.cost), `${totals.messages} msgs`, totals.cost > 100 ? FG.red : FG.yellow),
333
+ card("CACHE", `${formatTokens(totals.cacheRead)} read`, `${formatTokens(totals.cacheWrite)} write`, FG.blue),
334
+ card("SCOPE", `${stats.sessionCount} sessions`, `${stats.projectCount} projects · ${stats.activeDays} days`, FG.magenta),
335
+ ];
336
+ return wrapColumns(cards, width);
337
+ }
338
+
339
+ function card(label: string, value: string, sub: string, color: string): string[] {
340
+ const inner = 24;
341
+ return [
342
+ `${color}╭${"─".repeat(inner)}╮${RESET}`,
343
+ `${color}│${RESET} ${DIM}${pad(label, inner - 1)}${RESET}${color}│${RESET}`,
344
+ `${color}│${RESET} ${BOLD}${pad(value, inner - 1)}${RESET}${color}│${RESET}`,
345
+ `${color}│${RESET} ${FG.gray}${pad(sub, inner - 1)}${RESET}${color}│${RESET}`,
346
+ `${color}╰${"─".repeat(inner)}╯${RESET}`,
347
+ ];
348
+ }
349
+
350
+ function wrapColumns(blocks: string[][], width: number): string[] {
351
+ const blockWidth = Math.max(...blocks.flat().map(visible));
352
+ const perRow = Math.max(1, Math.min(blocks.length, Math.floor((width + 1) / (blockWidth + 1))));
353
+ const rows: string[] = [];
354
+ for (let i = 0; i < blocks.length; i += perRow) {
355
+ const slice = blocks.slice(i, i + perRow);
356
+ for (let line = 0; line < slice[0]!.length; line++) rows.push(slice.map((b) => padAnsi(b[line]!, blockWidth)).join(" "));
357
+ }
358
+ return rows;
359
+ }
360
+
361
+ function modelColumns(): Column<AggregatedStats["models"][number]>[] {
362
+ return [
363
+ { title: "#", width: 3, align: "right", render: (_b, i) => `${FG.gray}${i + 1}${RESET}` },
364
+ { title: "model", width: 26, render: (b) => b.label },
365
+ { title: "cost", width: 9, align: "right", render: (b) => money(b.cost) },
366
+ { title: "fresh", width: 9, align: "right", render: (b) => `${formatTokens(b.freshTokens)} tok` },
367
+ { title: "msgs", width: 7, align: "right", render: (b) => String(b.messages) },
368
+ { title: "sess", width: 6, align: "right", render: (b) => String(b.sessions.size) },
369
+ ];
370
+ }
371
+
372
+ function projectColumns(width: number): Column<AggregatedStats["projects"][number]>[] {
373
+ return [
374
+ { title: "#", width: 3, align: "right", render: (_b, i) => `${FG.gray}${i + 1}${RESET}` },
375
+ { title: "project", width: 22, render: (b) => b.label },
376
+ { title: "cost", width: 9, align: "right", render: (b) => money(b.cost) },
377
+ { title: "fresh", width: 9, align: "right", render: (b) => `${formatTokens(b.freshTokens)} tok` },
378
+ { title: "sess", width: 6, align: "right", render: (b) => String(b.sessions.size) },
379
+ { title: "path", width: Math.max(18, width - 68), render: (b) => String(b.meta?.cwd || "") },
380
+ ];
381
+ }
382
+
383
+ function sessionColumns(width: number): Column<AggregatedStats["sessions"][number]>[] {
384
+ return [
385
+ { title: "#", width: 3, align: "right", render: (_b, i) => `${FG.gray}${i + 1}${RESET}` },
386
+ { title: "session", width: Math.max(24, width - 54), render: (b) => b.label },
387
+ { title: "cost", width: 9, align: "right", render: (b) => money(b.cost) },
388
+ { title: "fresh", width: 9, align: "right", render: (b) => `${formatTokens(b.freshTokens)} tok` },
389
+ { title: "project", width: 18, render: (b) => String(b.meta?.project || basename(String(b.meta?.cwd || ""))) },
390
+ ];
391
+ }
392
+
393
+ function table<T>(title: string, rows: T[], columns: Column<T>[], width: number, color: string): string[] {
394
+ const usable = Math.max(20, width - 4);
395
+ const fitted = fitColumns(columns, usable);
396
+ const border = "─".repeat(fitted.reduce((sum, col) => sum + col.width, 0) + fitted.length * 3 + 1);
397
+ const lines = [`${color}╭─ ${title} ${border.slice(title.length + 3)}╮${RESET}`];
398
+ lines.push(rowLine(fitted.map((col) => ({ text: col.title, width: col.width, align: col.align })), color, true));
399
+ lines.push(`${color}├${border}┤${RESET}`);
400
+ if (!rows.length) lines.push(`${color}│${RESET} ${DIM}${pad("no data", visible(border) - 1)}${RESET}${color}│${RESET}`);
401
+ rows.forEach((row, index) => {
402
+ lines.push(rowLine(fitted.map((col) => ({ text: col.render(row, index), width: col.width, align: col.align })), color));
403
+ });
404
+ lines.push(`${color}╰${border}╯${RESET}`);
405
+ return lines.map((line) => crop(line, width));
406
+ }
407
+
408
+ function fitColumns<T>(columns: Column<T>[], usable: number): Column<T>[] {
409
+ const total = columns.reduce((sum, col) => sum + col.width, 0) + columns.length * 3 + 1;
410
+ if (total <= usable) return columns;
411
+ const last = columns[columns.length - 1]!;
412
+ const excess = total - usable;
413
+ return [...columns.slice(0, -1), { ...last, width: Math.max(8, last.width - excess) }];
414
+ }
415
+
416
+ function rowLine(cells: { text: string; width: number; align?: "left" | "right" }[], color: string, heading = false): string {
417
+ const parts = cells.map((cell) => {
418
+ const text = crop(cell.text, cell.width);
419
+ const padded = cell.align === "right" ? padStartAnsi(text, cell.width) : padAnsi(text, cell.width);
420
+ return heading ? `${BOLD}${FG.white}${padded}${RESET}` : padded;
421
+ });
422
+ return `${color}│${RESET} ${parts.join(` ${color}│${RESET} `)} ${color}│${RESET}`;
423
+ }
424
+
425
+ function box(title: string, body: string[], width: number, color: string): string[] {
426
+ const inner = Math.max(20, width - 2);
427
+ const top = `${color}╭─ ${title} ${"─".repeat(Math.max(0, inner - title.length - 4))}╮${RESET}`;
428
+ const bottom = `${color}╰${"─".repeat(inner)}╯${RESET}`;
429
+ const rows = body.length ? body : [`${DIM}no data${RESET}`];
430
+ return [top, ...rows.map((line) => `${color}│${RESET}${padAnsi(` ${line}`, inner)}${color}│${RESET}`), bottom].map((line) => crop(line, width));
431
+ }
432
+
433
+ function activityTitle(stats: AggregatedStats): string {
434
+ const range = heatRange(stats.range, stats.daily);
435
+ return range ? `ACTIVITY / fresh tokens · ${shortDate(range.start)} → ${shortDate(range.end)}` : "ACTIVITY / fresh tokens";
436
+ }
437
+
438
+ function heatmap(stats: AggregatedStats, width: number): string[] {
439
+ const available = Math.max(12, width - 8);
440
+ const maxWeeks = Math.min(53, available);
441
+ const range = heatRange(stats.range, stats.daily);
442
+ if (!range) return [`${DIM}no data${RESET}`];
443
+
444
+ let start = startOfUtcWeek(range.start);
445
+ const end = range.end;
446
+ let weeks = Math.floor((end - start) / (7 * DAY_MS)) + 1;
447
+ if (weeks > maxWeeks) {
448
+ weeks = maxWeeks;
449
+ start = startOfUtcWeek(end - weeks * 7 * DAY_MS);
450
+ }
451
+
452
+ const max = Math.max(...[...stats.daily.values()].map((d) => d.freshTokens), 0);
453
+ const rows: string[] = [`${DIM} ${monthAxis(start, weeks, range.start, end)}${RESET}`];
454
+ const labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
455
+ for (let day = 0; day < 7; day++) {
456
+ let line = `${FG.gray}${labels[day]}${RESET} `;
457
+ for (let week = 0; week < weeks; week++) {
458
+ const ts = start + (week * 7 + day) * DAY_MS;
459
+ if (ts < range.start || ts > end) {
460
+ line += " ";
461
+ continue;
462
+ }
463
+ const key = new Date(ts).toISOString().slice(0, 10);
464
+ line += heatChar(stats.daily.get(key)?.freshTokens || 0, max);
465
+ }
466
+ rows.push(line);
467
+ }
468
+ rows.push(`${DIM} · none ${FG.green}░${RESET}${DIM} low ${FG.yellow}▒${RESET}${DIM} med ${FG.red}▓${RESET}${DIM} high ${FG.red}${BOLD}█${RESET}${DIM} peak${RESET}`);
469
+ return rows;
470
+ }
471
+
472
+ function inDashboardRange(timestamp: number, range: DateRange): boolean {
473
+ if (range.start !== undefined && timestamp < range.start) return false;
474
+ if (range.end !== undefined && timestamp >= range.end) return false;
475
+ return true;
476
+ }
477
+
478
+ function longestStreak(activeDays: Set<string>): number {
479
+ let best = 0;
480
+ let current = 0;
481
+ let previous = 0;
482
+ for (const day of [...activeDays].sort()) {
483
+ const ts = Date.parse(`${day}T00:00:00.000Z`);
484
+ current = previous && ts - previous === DAY_MS ? current + 1 : 1;
485
+ best = Math.max(best, current);
486
+ previous = ts;
487
+ }
488
+ return best;
489
+ }
490
+
491
+ function currentStreak(activeDays: Set<string>, endDay: string): number {
492
+ let streak = 0;
493
+ let ts = Date.parse(`${endDay}T00:00:00.000Z`);
494
+ while (activeDays.has(shortDate(ts))) {
495
+ streak++;
496
+ ts -= DAY_MS;
497
+ }
498
+ return streak;
499
+ }
500
+
501
+ function formatDuration(ms: number): string {
502
+ if (!ms) return "0m";
503
+ const minutes = Math.max(1, Math.round(ms / 60000));
504
+ const days = Math.floor(minutes / 1440);
505
+ const hours = Math.floor((minutes % 1440) / 60);
506
+ const mins = minutes % 60;
507
+ if (days) return `${days}d ${hours}h ${mins}m`;
508
+ if (hours) return `${hours}h ${mins}m`;
509
+ return `${mins}m`;
510
+ }
511
+
512
+ function compact(n: number): string {
513
+ if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
514
+ return String(n);
515
+ }
516
+
517
+ function plural(word: string, count: number): string {
518
+ return count === 1 ? word : `${word}s`;
519
+ }
520
+
521
+ function heatRange(range: DateRange, daily: AggregatedStats["daily"]): { start: number; end: number } | undefined {
522
+ const keys = [...daily.keys()].sort();
523
+ if (!keys.length && (range.start === undefined || range.end === undefined)) return undefined;
524
+ const start = range.start ?? Date.parse(`${keys[0]}T00:00:00.000Z`);
525
+ const end = (range.end ?? Date.parse(`${keys[keys.length - 1]}T00:00:00.000Z`) + DAY_MS) - DAY_MS;
526
+ return { start, end };
527
+ }
528
+
529
+ function monthAxis(start: number, weeks: number, rangeStart: number, end: number): string {
530
+ const chars = Array.from({ length: weeks }, () => " ");
531
+ let lastMonth = -1;
532
+ for (let week = 0; week < weeks; week++) {
533
+ const weekStart = start + week * 7 * DAY_MS;
534
+ if (weekStart > end) break;
535
+ const date = new Date(Math.max(weekStart, rangeStart));
536
+ const month = date.getUTCMonth();
537
+ if (week > 0 && month === lastMonth) continue;
538
+ const label = date.toLocaleString("en-US", { month: "short", timeZone: "UTC" });
539
+ if (week + label.length > chars.length) continue;
540
+ if (chars.slice(week, week + label.length).some((char) => char !== " ")) continue;
541
+ for (let i = 0; i < label.length; i++) chars[week + i] = label[i]!;
542
+ lastMonth = month;
543
+ }
544
+ return chars.join("");
545
+ }
546
+
547
+ function shortDate(timestamp: number): string {
548
+ return new Date(timestamp).toISOString().slice(0, 10);
549
+ }
550
+
551
+ function shortMonthDay(timestamp: number): string {
552
+ return new Date(timestamp).toLocaleString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
553
+ }
554
+
555
+ function startOfUtcWeek(timestamp: number): number {
556
+ const d = new Date(timestamp);
557
+ const day = d.getUTCDay();
558
+ const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
559
+ return start - day * DAY_MS;
560
+ }
561
+
562
+ function heatChar(value: number, max: number): string {
563
+ if (!value || !max) return `${FG.gray}·${RESET}`;
564
+ const ratio = value / max;
565
+ if (ratio < 0.25) return `${FG.green}░${RESET}`;
566
+ if (ratio < 0.5) return `${FG.yellow}▒${RESET}`;
567
+ if (ratio < 0.75) return `${FG.red}▓${RESET}`;
568
+ return `${FG.red}${BOLD}█${RESET}`;
569
+ }
570
+
571
+ function money(cost: number): string {
572
+ const color = cost > 100 ? FG.red : cost > 1 ? FG.yellow : FG.green;
573
+ return `${color}${formatCost(cost)}${RESET}`;
574
+ }
575
+
576
+ function stripAnsi(text: string): string {
577
+ return text.replace(ANSI_RE, "");
578
+ }
579
+
580
+ function visible(text: string): number {
581
+ return stripAnsi(text).length;
582
+ }
583
+
584
+ function pad(text: string, width: number): string {
585
+ return crop(stripAnsi(text), width).padEnd(width, " ");
586
+ }
587
+
588
+ function padAnsi(text: string, width: number): string {
589
+ return crop(text, width) + " ".repeat(Math.max(0, width - visible(crop(text, width))));
590
+ }
591
+
592
+ function padStartAnsi(text: string, width: number): string {
593
+ const cropped = crop(text, width);
594
+ return " ".repeat(Math.max(0, width - visible(cropped))) + cropped;
595
+ }
596
+
597
+ function crop(text: string, width: number): string {
598
+ if (width <= 0) return "";
599
+ if (visible(text) <= width) return text;
600
+ let out = "";
601
+ let seen = 0;
602
+ for (let i = 0; i < text.length && seen < width - 1; i++) {
603
+ if (text[i] === "\x1b") {
604
+ const match = text.slice(i).match(/^\x1b\[[0-9;]*m/);
605
+ if (match) {
606
+ out += match[0];
607
+ i += match[0].length - 1;
608
+ continue;
609
+ }
610
+ }
611
+ out += text[i];
612
+ seen++;
613
+ }
614
+ return `${out}…${RESET}`;
615
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { PiStatsDashboard } from "./dashboard.js";
3
+ import { getDefaultPaths, loadCache, refreshCache } from "./stats.js";
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ pi.registerCommand("pi-stats", {
7
+ description: "Show local Pi usage stats, heatmap, top models, projects, and sessions.",
8
+ handler: async (args, ctx) => {
9
+ const { sessionDir, cachePath } = getDefaultPaths();
10
+ const cached = await loadCache(cachePath);
11
+
12
+ if (ctx.mode !== "tui") {
13
+ const result = await refreshCache(sessionDir, cachePath);
14
+ const count = Object.values(result.cache.files).reduce((sum, file) => sum + file.session.events.length, 0);
15
+ console.log(`pi-stats indexed ${result.totalFiles} sessions, ${count} usage events`);
16
+ return;
17
+ }
18
+
19
+ await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
20
+ const dashboard = new PiStatsDashboard({ cache: cached, args, done });
21
+
22
+ void refreshCache(sessionDir, cachePath)
23
+ .then((result) => {
24
+ dashboard.setRefreshResult(result);
25
+ tui.requestRender();
26
+ })
27
+ .catch((error) => {
28
+ dashboard.setRefreshError(error);
29
+ tui.requestRender();
30
+ });
31
+
32
+ return dashboard;
33
+ });
34
+ },
35
+ });
36
+ }
package/src/stats.ts ADDED
@@ -0,0 +1,414 @@
1
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+
5
+ export type UsageTotals = {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ totalTokens: number;
11
+ freshTokens: number;
12
+ cost: number;
13
+ messages: number;
14
+ };
15
+
16
+ export type UsageEvent = UsageTotals & {
17
+ timestamp: number;
18
+ day: string;
19
+ provider: string;
20
+ model: string;
21
+ sessionPath: string;
22
+ sessionId: string;
23
+ sessionName: string;
24
+ cwd: string;
25
+ project: string;
26
+ };
27
+
28
+ export type SessionSummary = {
29
+ sessionPath: string;
30
+ sessionId: string;
31
+ sessionName: string;
32
+ cwd: string;
33
+ project: string;
34
+ startedAt: number;
35
+ events: UsageEvent[];
36
+ };
37
+
38
+ export type CachedFile = {
39
+ path: string;
40
+ mtimeMs: number;
41
+ size: number;
42
+ parsedAt: number;
43
+ session: SessionSummary;
44
+ };
45
+
46
+ export type StatsCache = {
47
+ version: 1;
48
+ files: Record<string, CachedFile>;
49
+ };
50
+
51
+ export type RefreshResult = {
52
+ cache: StatsCache;
53
+ totalFiles: number;
54
+ parsedFiles: number;
55
+ reusedFiles: number;
56
+ errors: Array<{ path: string; message: string }>;
57
+ };
58
+
59
+ export type DateRange = {
60
+ label: string;
61
+ start?: number;
62
+ end?: number;
63
+ };
64
+
65
+ export type Bucket = UsageTotals & {
66
+ key: string;
67
+ label: string;
68
+ sessions: Set<string>;
69
+ projects: Set<string>;
70
+ meta?: Record<string, string | number>;
71
+ };
72
+
73
+ export type AggregatedStats = {
74
+ range: DateRange;
75
+ totals: UsageTotals;
76
+ activeDays: number;
77
+ sessionCount: number;
78
+ projectCount: number;
79
+ daily: Map<string, UsageTotals>;
80
+ models: Bucket[];
81
+ projects: Bucket[];
82
+ sessions: Bucket[];
83
+ eventCount: number;
84
+ };
85
+
86
+ const DAY_MS = 24 * 60 * 60 * 1000;
87
+
88
+ export function getAgentDir(): string {
89
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
90
+ }
91
+
92
+ export function getDefaultPaths(agentDir = getAgentDir()) {
93
+ return {
94
+ sessionDir: join(agentDir, "sessions"),
95
+ cachePath: join(agentDir, "pi-stats", "cache.json"),
96
+ };
97
+ }
98
+
99
+ export function emptyTotals(): UsageTotals {
100
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, freshTokens: 0, cost: 0, messages: 0 };
101
+ }
102
+
103
+ export function addTotals(target: UsageTotals, source: UsageTotals): UsageTotals {
104
+ target.input += source.input;
105
+ target.output += source.output;
106
+ target.cacheRead += source.cacheRead;
107
+ target.cacheWrite += source.cacheWrite;
108
+ target.totalTokens += source.totalTokens;
109
+ target.freshTokens += source.freshTokens;
110
+ target.cost += source.cost;
111
+ target.messages += source.messages;
112
+ return target;
113
+ }
114
+
115
+ export function dayKey(timestamp: number): string {
116
+ return new Date(timestamp).toISOString().slice(0, 10);
117
+ }
118
+
119
+ export function parseRange(input: string, now = Date.now()): DateRange {
120
+ const text = input.trim();
121
+ if (!text || text === "30" || text === "30d") return lastDaysRange(30, now);
122
+ if (text === "today") return lastDaysRange(1, now, "today");
123
+ if (text === "7" || text === "7d") return lastDaysRange(7, now);
124
+ if (text === "90" || text === "90d") return lastDaysRange(90, now);
125
+ if (text === "all") return { label: "all time" };
126
+
127
+ const custom = text.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/);
128
+ if (custom) {
129
+ const start = Date.parse(`${custom[1]}T00:00:00.000Z`);
130
+ const end = Date.parse(`${custom[2]}T00:00:00.000Z`) + DAY_MS;
131
+ if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
132
+ return { label: `${custom[1]}..${custom[2]}`, start, end };
133
+ }
134
+ }
135
+
136
+ return lastDaysRange(30, now);
137
+ }
138
+
139
+ function lastDaysRange(days: number, now: number, label = `last ${days}d`): DateRange {
140
+ const endDay = Date.parse(dayKey(now) + "T00:00:00.000Z") + DAY_MS;
141
+ const start = endDay - days * DAY_MS;
142
+ return { label, start, end: endDay };
143
+ }
144
+
145
+ export async function loadCache(cachePath: string): Promise<StatsCache> {
146
+ try {
147
+ const raw = await readFile(cachePath, "utf8");
148
+ const parsed = JSON.parse(raw) as StatsCache;
149
+ if (parsed.version === 1 && parsed.files && typeof parsed.files === "object") return parsed;
150
+ } catch {
151
+ // no cache yet
152
+ }
153
+ return { version: 1, files: {} };
154
+ }
155
+
156
+ export async function saveCache(cachePath: string, cache: StatsCache): Promise<void> {
157
+ await mkdir(dirname(cachePath), { recursive: true });
158
+ await writeFile(cachePath, JSON.stringify(cache, null, 2), "utf8");
159
+ }
160
+
161
+ export async function refreshCache(sessionDir: string, cachePath: string): Promise<RefreshResult> {
162
+ const cache = await loadCache(cachePath);
163
+ const files = await listSessionFiles(sessionDir);
164
+ const nextFiles: Record<string, CachedFile> = {};
165
+ const errors: RefreshResult["errors"] = [];
166
+ let parsedFiles = 0;
167
+ let reusedFiles = 0;
168
+
169
+ for (const file of files) {
170
+ try {
171
+ const s = await stat(file);
172
+ const cached = cache.files[file];
173
+ if (cached && cached.mtimeMs === s.mtimeMs && cached.size === s.size) {
174
+ nextFiles[file] = cached;
175
+ reusedFiles++;
176
+ continue;
177
+ }
178
+ nextFiles[file] = { path: file, mtimeMs: s.mtimeMs, size: s.size, parsedAt: Date.now(), session: await parseSessionFile(file) };
179
+ parsedFiles++;
180
+ } catch (error) {
181
+ errors.push({ path: file, message: error instanceof Error ? error.message : String(error) });
182
+ }
183
+ }
184
+
185
+ const nextCache: StatsCache = { version: 1, files: nextFiles };
186
+ await saveCache(cachePath, nextCache);
187
+ return { cache: nextCache, totalFiles: files.length, parsedFiles, reusedFiles, errors };
188
+ }
189
+
190
+ export async function listSessionFiles(sessionDir: string): Promise<string[]> {
191
+ const root = resolve(sessionDir);
192
+ const out: string[] = [];
193
+
194
+ async function walk(dir: string): Promise<void> {
195
+ let entries;
196
+ try {
197
+ entries = await readdir(dir, { withFileTypes: true });
198
+ } catch {
199
+ return;
200
+ }
201
+ for (const entry of entries) {
202
+ const p = join(dir, entry.name);
203
+ if (entry.isDirectory()) await walk(p);
204
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(p);
205
+ }
206
+ }
207
+
208
+ await walk(root);
209
+ return out.sort();
210
+ }
211
+
212
+ export async function parseSessionFile(file: string): Promise<SessionSummary> {
213
+ const raw = await readFile(file, "utf8");
214
+ return parseSessionText(raw, file);
215
+ }
216
+
217
+ export function parseSessionText(raw: string, file = "session.jsonl"): SessionSummary {
218
+ let sessionId = basename(file, ".jsonl");
219
+ let cwd = "unknown";
220
+ let startedAt = 0;
221
+ let sessionName = "";
222
+ let firstUser = "";
223
+ const events: UsageEvent[] = [];
224
+
225
+ for (const line of raw.split(/\r?\n/)) {
226
+ if (!line.trim()) continue;
227
+ let entry: any;
228
+ try {
229
+ entry = JSON.parse(line);
230
+ } catch {
231
+ continue;
232
+ }
233
+
234
+ if (entry.type === "session") {
235
+ sessionId = String(entry.id || sessionId);
236
+ cwd = typeof entry.cwd === "string" && entry.cwd ? entry.cwd : cwd;
237
+ const headerTs = Date.parse(entry.timestamp || "");
238
+ if (Number.isFinite(headerTs)) startedAt = headerTs;
239
+ continue;
240
+ }
241
+
242
+ if (entry.type === "session_info" && typeof entry.name === "string") {
243
+ sessionName = entry.name;
244
+ continue;
245
+ }
246
+
247
+ if (entry.type !== "message" || !entry.message) continue;
248
+ const message = entry.message;
249
+
250
+ if (message.role === "user" && !firstUser) {
251
+ firstUser = contentPreview(message.content);
252
+ continue;
253
+ }
254
+
255
+ if (message.role !== "assistant" || !message.usage) continue;
256
+
257
+ const usage = message.usage;
258
+ const timestamp = (normalizeTimestamp(message.timestamp) ?? normalizeTimestamp(entry.timestamp) ?? startedAt) || 0;
259
+ const input = number(usage.input);
260
+ const output = number(usage.output);
261
+ const cacheRead = number(usage.cacheRead);
262
+ const cacheWrite = number(usage.cacheWrite);
263
+ const totalTokens = number(usage.totalTokens) || input + output + cacheRead + cacheWrite;
264
+ const cost = number(usage.cost?.total);
265
+ const project = projectLabel(cwd);
266
+
267
+ events.push({
268
+ timestamp,
269
+ day: dayKey(timestamp),
270
+ provider: String(message.provider || "unknown"),
271
+ model: String(message.model || "unknown"),
272
+ input,
273
+ output,
274
+ cacheRead,
275
+ cacheWrite,
276
+ totalTokens,
277
+ freshTokens: input + output + cacheWrite,
278
+ cost,
279
+ messages: 1,
280
+ sessionPath: file,
281
+ sessionId,
282
+ sessionName: "",
283
+ cwd,
284
+ project,
285
+ });
286
+ }
287
+
288
+ const name = sessionName || firstUser || basename(file, ".jsonl");
289
+ for (const event of events) event.sessionName = name;
290
+ if (!startedAt && events.length) startedAt = events[0]!.timestamp;
291
+ return { sessionPath: file, sessionId, sessionName: name, cwd, project: projectLabel(cwd), startedAt, events };
292
+ }
293
+
294
+ export function aggregate(cache: StatsCache, range: DateRange): AggregatedStats {
295
+ const totals = emptyTotals();
296
+ const daily = new Map<string, UsageTotals>();
297
+ const models = new Map<string, Bucket>();
298
+ const projects = new Map<string, Bucket>();
299
+ const sessions = new Map<string, Bucket>();
300
+ const seenEvents = new Set<string>();
301
+
302
+ for (const file of Object.values(cache.files)) {
303
+ for (const event of file.session.events) {
304
+ if (!inRange(event.timestamp, range)) continue;
305
+ const fingerprint = `${event.timestamp}|${event.provider}|${event.model}|${event.totalTokens}|${event.cost.toFixed(8)}`;
306
+ if (seenEvents.has(fingerprint)) continue;
307
+ seenEvents.add(fingerprint);
308
+
309
+ addTotals(totals, event);
310
+ addTotals(getTotals(daily, event.day), event);
311
+
312
+ addBucket(models, `${event.provider}/${event.model}`, event.model, event, { provider: event.provider });
313
+ addBucket(projects, event.cwd, event.project, event, { cwd: event.cwd });
314
+ addBucket(sessions, event.sessionPath, event.sessionName, event, {
315
+ project: event.project,
316
+ cwd: event.cwd,
317
+ startedAt: event.timestamp,
318
+ path: event.sessionPath,
319
+ });
320
+ }
321
+ }
322
+
323
+ const sortBuckets = (items: Bucket[]) => items.sort((a, b) => b.cost - a.cost || b.totalTokens - a.totalTokens || a.label.localeCompare(b.label));
324
+
325
+ return {
326
+ range,
327
+ totals,
328
+ activeDays: [...daily.values()].filter((d) => d.totalTokens > 0).length,
329
+ sessionCount: new Set([...sessions.values()].flatMap((b) => [...b.sessions])).size,
330
+ projectCount: projects.size,
331
+ daily,
332
+ models: sortBuckets([...models.values()]),
333
+ projects: sortBuckets([...projects.values()]),
334
+ sessions: sortBuckets([...sessions.values()]),
335
+ eventCount: seenEvents.size,
336
+ };
337
+ }
338
+
339
+ export function projectLabel(cwd: string): string {
340
+ if (!cwd || cwd === "unknown") return "unknown";
341
+ return basename(cwd) || cwd;
342
+ }
343
+
344
+ export function formatTokens(n: number): string {
345
+ if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`;
346
+ if (n >= 1_000) return `${trim(n / 1_000)}k`;
347
+ return String(Math.round(n));
348
+ }
349
+
350
+ export function formatCost(n: number): string {
351
+ if (!n) return "$0";
352
+ if (n < 0.01) return `$${n.toFixed(4)}`;
353
+ if (n < 10) return `$${n.toFixed(2)}`;
354
+ return `$${Math.round(n)}`;
355
+ }
356
+
357
+ function trim(n: number): string {
358
+ return n >= 10 ? n.toFixed(0) : n.toFixed(1).replace(/\.0$/, "");
359
+ }
360
+
361
+ function inRange(timestamp: number, range: DateRange): boolean {
362
+ if (range.start !== undefined && timestamp < range.start) return false;
363
+ if (range.end !== undefined && timestamp >= range.end) return false;
364
+ return true;
365
+ }
366
+
367
+ function getTotals(map: Map<string, UsageTotals>, key: string): UsageTotals {
368
+ let totals = map.get(key);
369
+ if (!totals) {
370
+ totals = emptyTotals();
371
+ map.set(key, totals);
372
+ }
373
+ return totals;
374
+ }
375
+
376
+ function addBucket(map: Map<string, Bucket>, key: string, label: string, event: UsageEvent, meta: Record<string, string | number>): void {
377
+ let bucket = map.get(key);
378
+ if (!bucket) {
379
+ bucket = { key, label, sessions: new Set(), projects: new Set(), meta, ...emptyTotals() };
380
+ map.set(key, bucket);
381
+ }
382
+ addTotals(bucket, event);
383
+ bucket.sessions.add(event.sessionId);
384
+ bucket.projects.add(event.cwd);
385
+ if (typeof meta.startedAt === "number") bucket.meta = { ...bucket.meta, startedAt: Math.min(number(bucket.meta?.startedAt) || meta.startedAt, meta.startedAt) };
386
+ }
387
+
388
+ function normalizeTimestamp(value: unknown): number | undefined {
389
+ if (typeof value === "number" && Number.isFinite(value)) return value;
390
+ if (typeof value === "string") {
391
+ const parsed = Date.parse(value);
392
+ if (Number.isFinite(parsed)) return parsed;
393
+ }
394
+ return undefined;
395
+ }
396
+
397
+ function number(value: unknown): number {
398
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
399
+ }
400
+
401
+ function contentPreview(content: unknown): string {
402
+ if (typeof content === "string") return clean(content);
403
+ if (Array.isArray(content)) {
404
+ const text = content
405
+ .map((block) => (block && typeof block === "object" && "text" in block ? String((block as { text: unknown }).text) : ""))
406
+ .join(" ");
407
+ return clean(text);
408
+ }
409
+ return "";
410
+ }
411
+
412
+ function clean(text: string): string {
413
+ return text.replace(/\s+/g, " ").trim().slice(0, 80);
414
+ }