@hank-warren/pi-stats 0.4.0 → 0.4.2
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/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/cache.ts +114 -36
- package/package.json +2 -2
- package/stats.ts +2 -2
- package/types.ts +1 -1
- package/widget.ts +43 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @hank-warren/pi-stats
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Label the month the heatmap grid ends in. The axis keyed each month change off its column's Monday and dropped a label that no longer fit at the right edge, so the current month was missing from the axis on 56% of days. Columns are now named for the month holding their Thursday, and the rightmost label is clamped left instead of dropped.
|
|
8
|
+
|
|
9
|
+
## 0.4.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- c079c51: Scan sessions through a bounded worker pool instead of one file at a time.
|
|
14
|
+
|
|
15
|
+
The stats scan now runs an ordered pool capped at eight concurrent readers, reduced by the discovery index, which shortens a cold scan over a large session history. Results stay in discovery order regardless of completion order, and progress is reported monotonically.
|
|
16
|
+
|
|
17
|
+
Cancellation is settled rather than abandoned: an abort stops further work being assigned and publishes nothing afterwards, but waits for reads already in flight, so the caller's `catch` never runs while file reads are still landing in the result array or progress is still being reported into a dashboard that is being torn down.
|
|
18
|
+
|
|
3
19
|
## 0.4.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -99,3 +99,7 @@ Sessions remain authoritative. For fast repeat opens, the extension stores one d
|
|
|
99
99
|
The location follows `PI_CODING_AGENT_DIR`. The cache contains file stat identity, session IDs/timestamps/working directories, model names, tool names with call and error counts, usage counters, and de-duplication fingerprints. It never stores prompts, responses, tool arguments, tool output, or other conversation content. It is written atomically with mode `0600` and can be deleted at any time; the next `/stats` invocation rebuilds it.
|
|
100
100
|
|
|
101
101
|
No network or billing API is used. Displayed costs are the estimates already recorded by Pi.
|
|
102
|
+
|
|
103
|
+
## Changelog
|
|
104
|
+
|
|
105
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
package/cache.ts
CHANGED
|
@@ -11,7 +11,7 @@ const SKIP_DIRECTORIES = new Set(["subagent-artifacts"]);
|
|
|
11
11
|
/** Extensions record out-of-transcript model usage in <agentDir>/<extension>/usage.jsonl. */
|
|
12
12
|
const USAGE_SIDECAR_NAMES = new Set(["usage.jsonl", "usage.jsonl.1"]);
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface ScanOptions {
|
|
15
15
|
agentDir: string;
|
|
16
16
|
activeSessionDir?: string;
|
|
17
17
|
env?: NodeJS.ProcessEnv;
|
|
@@ -240,6 +240,64 @@ export function statsCachePath(agentDir: string): string {
|
|
|
240
240
|
return join(agentDir, "pi-stats", "cache.json");
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
export async function mapConcurrentOrdered<T, R>(
|
|
244
|
+
items: readonly T[],
|
|
245
|
+
limit: number,
|
|
246
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
247
|
+
onProgress?: (completed: number, total: number) => void,
|
|
248
|
+
signal?: AbortSignal,
|
|
249
|
+
): Promise<R[]> {
|
|
250
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("concurrency limit must be positive");
|
|
251
|
+
const results = new Array<R>(items.length);
|
|
252
|
+
let nextIndex = 0;
|
|
253
|
+
let completed = 0;
|
|
254
|
+
// The first failure (an abort, a worker throw, or a progress callback that
|
|
255
|
+
// throws) stops work being assigned and stops anything else being published,
|
|
256
|
+
// but never abandons work already in flight: the caller's `catch` must not
|
|
257
|
+
// run while file reads are still landing in `results` and progress is still
|
|
258
|
+
// being reported into a widget that is about to be torn down.
|
|
259
|
+
let failure: unknown;
|
|
260
|
+
const failed = () => failure !== undefined;
|
|
261
|
+
const run = async () => {
|
|
262
|
+
while (!failed()) {
|
|
263
|
+
try {
|
|
264
|
+
signal?.throwIfAborted();
|
|
265
|
+
} catch (error) {
|
|
266
|
+
failure ??= error;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const index = nextIndex;
|
|
270
|
+
if (index >= items.length) return;
|
|
271
|
+
nextIndex += 1;
|
|
272
|
+
try {
|
|
273
|
+
const value = await worker(items[index]!, index);
|
|
274
|
+
// Re-check *after* the await: an abort during the read must not still
|
|
275
|
+
// publish a result or a progress tick into a dashboard that is being
|
|
276
|
+
// torn down. A sibling runner may not have noticed the abort yet.
|
|
277
|
+
signal?.throwIfAborted();
|
|
278
|
+
if (failed()) return;
|
|
279
|
+
results[index] = value;
|
|
280
|
+
completed += 1;
|
|
281
|
+
onProgress?.(completed, items.length);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
failure ??= error;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
|
|
289
|
+
if (failed()) throw failure;
|
|
290
|
+
return results;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type ScannedFile = {
|
|
294
|
+
file: string;
|
|
295
|
+
record?: CachedFileRecord;
|
|
296
|
+
reused: boolean;
|
|
297
|
+
unreadable: boolean;
|
|
298
|
+
changed: boolean;
|
|
299
|
+
};
|
|
300
|
+
|
|
243
301
|
export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
244
302
|
const roots = await resolveSessionRoots(options);
|
|
245
303
|
const sessionFiles = await discoverJsonlFiles(roots, options.signal);
|
|
@@ -260,45 +318,65 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
|
260
318
|
};
|
|
261
319
|
let changed = options.force === true || !loaded.valid;
|
|
262
320
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
321
|
+
const scanned = await mapConcurrentOrdered(
|
|
322
|
+
files,
|
|
323
|
+
8,
|
|
324
|
+
async (file): Promise<ScannedFile> => {
|
|
325
|
+
options.signal?.throwIfAborted();
|
|
326
|
+
try {
|
|
327
|
+
const metadata = await stat(file);
|
|
328
|
+
const previous = loaded.cache.files[file];
|
|
329
|
+
if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
|
|
330
|
+
return { file, record: previous!, reused: true, unreadable: false, changed: false };
|
|
331
|
+
}
|
|
332
|
+
if (sidecars.has(file)) {
|
|
333
|
+
const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
|
|
334
|
+
return {
|
|
335
|
+
file,
|
|
336
|
+
record: {
|
|
337
|
+
size: metadata.size,
|
|
338
|
+
mtimeMs: metadata.mtimeMs,
|
|
339
|
+
sidecar: parsed.records,
|
|
340
|
+
malformedLines: parsed.malformedLines,
|
|
341
|
+
},
|
|
342
|
+
reused: false,
|
|
343
|
+
unreadable: false,
|
|
344
|
+
changed: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
283
347
|
const parsed = parseSessionText(file, await readFile(file, "utf8"));
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
348
|
+
return {
|
|
349
|
+
file,
|
|
350
|
+
record: {
|
|
351
|
+
size: metadata.size,
|
|
352
|
+
mtimeMs: metadata.mtimeMs,
|
|
353
|
+
...(parsed.ignored ? { ignored: true as const } : {}),
|
|
354
|
+
...(parsed.session ? { session: parsed.session } : {}),
|
|
355
|
+
malformedLines: parsed.malformedLines,
|
|
356
|
+
},
|
|
357
|
+
reused: false,
|
|
358
|
+
unreadable: false,
|
|
359
|
+
changed: true,
|
|
290
360
|
};
|
|
291
|
-
|
|
292
|
-
|
|
361
|
+
} catch {
|
|
362
|
+
options.signal?.throwIfAborted();
|
|
363
|
+
return { file, reused: false, unreadable: true, changed: true };
|
|
293
364
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
365
|
+
},
|
|
366
|
+
options.onProgress,
|
|
367
|
+
options.signal,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
for (const result of scanned) {
|
|
371
|
+
if (result.record) {
|
|
372
|
+
next.files[result.file] = result.record;
|
|
373
|
+
if (result.reused) diagnostics.reusedFiles++;
|
|
374
|
+
else diagnostics.parsedFiles++;
|
|
375
|
+
if (result.record.ignored) diagnostics.ignoredFiles++;
|
|
376
|
+
diagnostics.malformedLines += result.record.malformedLines;
|
|
300
377
|
}
|
|
301
|
-
|
|
378
|
+
if (result.unreadable) diagnostics.unreadableFiles++;
|
|
379
|
+
if (result.changed) changed = true;
|
|
302
380
|
}
|
|
303
381
|
|
|
304
382
|
if (Object.keys(loaded.cache.files).some((file) => !(file in next.files))) changed = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-stats",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-stats#readme",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=22.19.0"
|
|
27
27
|
},
|
|
28
28
|
"pi": {
|
|
29
29
|
"extensions": [
|
package/stats.ts
CHANGED
|
@@ -43,7 +43,7 @@ function firstNumber(source: Record<string, unknown>, names: string[]): number {
|
|
|
43
43
|
return 0;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
function normalizeUsage(value: unknown): UsageTotals | undefined {
|
|
47
47
|
const usage = object(value);
|
|
48
48
|
if (!usage) return undefined;
|
|
49
49
|
const cost = object(usage.cost);
|
|
@@ -375,7 +375,7 @@ function hasPersistedSubagentChild(record: UsageRecord, sessionPaths: ReadonlySe
|
|
|
375
375
|
return record.toolName === "subagent" && Boolean(record.childSessionFiles?.some((file) => sessionPaths.has(resolve(file))));
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
-
|
|
378
|
+
function deduplicateToolCalls(sessions: readonly SessionRecord[]): ToolCallRecord[] {
|
|
379
379
|
const seen = new Set<string>();
|
|
380
380
|
const result: ToolCallRecord[] = [];
|
|
381
381
|
for (const session of sessions) {
|
package/types.ts
CHANGED
package/widget.ts
CHANGED
|
@@ -10,12 +10,12 @@ import {
|
|
|
10
10
|
type UsageTotals,
|
|
11
11
|
} from "./types.ts";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
type WidgetState =
|
|
14
14
|
| { kind: "loading"; completed: number; total: number }
|
|
15
15
|
| { kind: "error"; message: string }
|
|
16
16
|
| { kind: "ready"; snapshot: StatsSnapshot };
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
interface StatsWidgetOptions {
|
|
19
19
|
theme: Theme;
|
|
20
20
|
requestRender: () => void;
|
|
21
21
|
onClose: () => void;
|
|
@@ -37,7 +37,7 @@ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "
|
|
|
37
37
|
const RANGES: StatsRange[] = ["all", "7d", "30d"];
|
|
38
38
|
const RANGE_LABELS: Record<StatsRange, string> = { all: "All time", "7d": "Last 7 days", "30d": "Last 30 days" };
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
type StatsTab = "overview" | "models" | "tools" | "projects";
|
|
41
41
|
const TABS: StatsTab[] = ["overview", "models", "tools", "projects"];
|
|
42
42
|
const TAB_LABELS: Record<StatsTab, string> = { overview: "Overview", models: "Models", tools: "Tools", projects: "Projects" };
|
|
43
43
|
|
|
@@ -76,6 +76,45 @@ function localDay(time: number): string {
|
|
|
76
76
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The heatmap's month label row: one character per week column, `weeks` wide.
|
|
81
|
+
*
|
|
82
|
+
* A column belongs to the month holding its Thursday, so it is named for whichever month
|
|
83
|
+
* owns four or more of its seven days. Labels are placed right to left because only the
|
|
84
|
+
* right edge is constrained: each one sits at its month's first column unless its
|
|
85
|
+
* neighbour crowds it, and shifts left just enough to stay clear.
|
|
86
|
+
*/
|
|
87
|
+
export function monthAxis(start: Date, weeks: number): string {
|
|
88
|
+
const monthOf = (week: number): number => {
|
|
89
|
+
const thursday = new Date(start);
|
|
90
|
+
thursday.setDate(thursday.getDate() + week * 7 + 3);
|
|
91
|
+
return thursday.getMonth();
|
|
92
|
+
};
|
|
93
|
+
/** Three characters plus a space, so neighbouring labels never touch. */
|
|
94
|
+
const spacing = 4;
|
|
95
|
+
|
|
96
|
+
const boundaries: { column: number; month: number }[] = [];
|
|
97
|
+
for (let week = 0; week < weeks; week++) {
|
|
98
|
+
const month = monthOf(week);
|
|
99
|
+
if (month !== monthOf(week - 1)) boundaries.push({ column: week, month });
|
|
100
|
+
}
|
|
101
|
+
// A grid too short to leave its month has no boundary to label, so name it after its
|
|
102
|
+
// last column like every other grid.
|
|
103
|
+
if (boundaries.length === 0) boundaries.push({ column: 0, month: monthOf(weeks - 1) });
|
|
104
|
+
|
|
105
|
+
const placed: { column: number; month: number }[] = [];
|
|
106
|
+
let rightmost = weeks - 3;
|
|
107
|
+
for (let index = boundaries.length - 1; index >= 0 && rightmost >= 0; index--) {
|
|
108
|
+
const column = Math.min(boundaries[index]!.column, rightmost);
|
|
109
|
+
placed.unshift({ column, month: boundaries[index]!.month });
|
|
110
|
+
rightmost = column - spacing;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let row = "";
|
|
114
|
+
for (const { column, month } of placed) row = row.padEnd(column, " ") + MONTHS[month]!;
|
|
115
|
+
return row.padEnd(weeks, " ").slice(0, weeks);
|
|
116
|
+
}
|
|
117
|
+
|
|
79
118
|
function padRight(value: string, width: number): string {
|
|
80
119
|
const clipped = truncateToWidth(value, Math.max(0, width), "");
|
|
81
120
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
@@ -424,22 +463,7 @@ export class StatsWidget implements Component {
|
|
|
424
463
|
const gap = Math.max(1, width - visibleWidth(heading) - visibleWidth(legend) - 1);
|
|
425
464
|
const lines = [`${heading}${" ".repeat(gap)}${theme.fg("dim", legend)}`];
|
|
426
465
|
|
|
427
|
-
|
|
428
|
-
let lastLabel = -4;
|
|
429
|
-
for (let week = 0; week < weeks; week++) {
|
|
430
|
-
const date = new Date(start);
|
|
431
|
-
date.setDate(date.getDate() + week * 7);
|
|
432
|
-
const previous = new Date(date);
|
|
433
|
-
previous.setDate(previous.getDate() - 7);
|
|
434
|
-
if ((week === 0 || date.getMonth() !== previous.getMonth()) && week - lastLabel >= 4 && week + 3 <= weeks) {
|
|
435
|
-
months += MONTHS[date.getMonth()]!;
|
|
436
|
-
lastLabel = week;
|
|
437
|
-
week += 2;
|
|
438
|
-
} else {
|
|
439
|
-
months += " ";
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
lines.push(theme.fg("dim", months));
|
|
466
|
+
lines.push(theme.fg("dim", " ".repeat(HEATMAP_LABEL_WIDTH) + monthAxis(start, weeks)));
|
|
443
467
|
|
|
444
468
|
for (let weekday = 0; weekday < 7; weekday++) {
|
|
445
469
|
const label = weekday === 0 ? "Mon" : weekday === 2 ? "Wed" : weekday === 4 ? "Fri" : " ";
|