@hank-warren/pi-stats 0.4.1 → 0.5.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/CHANGELOG.md +16 -0
- package/README.md +8 -2
- package/cache.ts +8 -3
- package/package.json +2 -2
- package/stats.ts +23 -2
- package/types.ts +5 -3
- package/widget.ts +43 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @hank-warren/pi-stats
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Count Pi `usage` session entries and de-duplicate them against mirrored extension sidecars (#42).
|
|
8
|
+
|
|
9
|
+
Pi 0.86 added `SessionManager.appendUsage()`, which records model calls made outside the conversation, such as cache warming, as `type: "usage"` session entries. `/stats` now counts them in every total and shows them as `provider/model (<kind>)` rows. Unknown kinds are counted like any other usage.
|
|
10
|
+
|
|
11
|
+
An extension that also mirrors such a call into a usage sidecar for older pi-stats versions gives the sidecar the id `<sessionId>:<usageEntryId>`. That call is counted once, from the session entry. Sidecars without a matching entry, and entries without a matching sidecar, are still counted. The stats cache version is bumped, so existing caches are rescanned once.
|
|
12
|
+
|
|
13
|
+
## 0.4.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## 0.4.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Every tab is responsive: as the terminal narrows, lower-priority columns move to
|
|
|
39
39
|
|
|
40
40
|
## What is counted
|
|
41
41
|
|
|
42
|
-
Pi writes usage to assistant messages, usage-bearing tool results, compactions, and
|
|
42
|
+
Pi writes usage to assistant messages, usage-bearing tool results, compactions, branch summaries, and `usage` entries (Pi 0.86+, for model calls outside the conversation such as cache warming). `pi-stats` recursively reads valid Pi session files and counts every recorded model call, including compacted and abandoned branches. Copied fork history is fingerprinted and counted once.
|
|
43
43
|
|
|
44
44
|
Tool calls are counted separately from tokens, because most of them record no model usage. Every tool result in a session is tallied by name, along with whether it reported an error. Provider tool-call ids are reused verbatim when a session is forked, so they double as the de-duplication key and a forked branch never inflates the counts. Namespaced names such as `functions.bash` are ranked under their base name.
|
|
45
45
|
|
|
@@ -53,7 +53,9 @@ Some extensions call a model outside the agent loop, so their usage never reache
|
|
|
53
53
|
{"v":1,"id":"3f2b…","ts":"2026-08-11T22:41:03.118Z","source":"auto-permissions","label":"guardian","provider":"anthropic","model":"claude-fable-5","usage":{"input":812,"output":96,"cacheRead":18442,"cacheWrite":0,"reasoning":48,"cost":0.0121}}
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
Records are de-duplicated by `id`, counted in every total, and shown as their own `provider/model (label)` row so the overhead stays visible.
|
|
56
|
+
Records are de-duplicated by `id`, counted in every total, and shown as their own `provider/model (label)` row so the overhead stays visible. Session `usage` entries appear the same way as `provider/model (kind)`.
|
|
57
|
+
|
|
58
|
+
An extension that records a call both ways — a session `usage` entry for Pi 0.86+ and a sidecar for older `pi-stats` versions — gives the sidecar the id `<sessionId>:<usageEntryId>` (the session header id and the usage entry id). That call is counted once, from the session entry; sidecars without a matching entry, and entries without a matching sidecar, are each counted normally. They never create sessions, so session counts, streaks, and session spans are unaffected. `@hank-warren/pi-auto-permissions` writes one for guardian reviews; set `PI_STATS_DISABLE_USAGE_SIDECARS=1` to ignore all of them.
|
|
57
59
|
|
|
58
60
|
The standard Pi session root, the current external session directory, `PI_CODING_AGENT_SESSION_DIR`, and a configured `subagents.defaultSessionDir` are discovered automatically. Add unusual one-off roots with the platform-delimited `PI_STATS_SESSION_DIRS` environment variable.
|
|
59
61
|
|
|
@@ -99,3 +101,7 @@ Sessions remain authoritative. For fast repeat opens, the extension stores one d
|
|
|
99
101
|
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
102
|
|
|
101
103
|
No network or billing API is used. Displayed costs are the estimates already recorded by Pi.
|
|
104
|
+
|
|
105
|
+
## Changelog
|
|
106
|
+
|
|
107
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
package/cache.ts
CHANGED
|
@@ -6,12 +6,12 @@ import { makeIndex, parseSessionText, parseUsageSidecar } from "./stats.ts";
|
|
|
6
6
|
import type { CachedFileRecord, ScanDiagnostics, SessionRecord, StatsCacheFile, StatsIndex, UsageRecord } from "./types.ts";
|
|
7
7
|
|
|
8
8
|
/** Bumped to 3 when tool-call records were added; older caches lack them and must be reparsed. */
|
|
9
|
-
const CACHE_VERSION =
|
|
9
|
+
const CACHE_VERSION = 4 as const;
|
|
10
10
|
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;
|
|
@@ -143,7 +143,12 @@ function validUsageRecord(value: unknown): value is UsageRecord {
|
|
|
143
143
|
typeof record.fingerprint === "string" &&
|
|
144
144
|
validNumber(record.timestamp) &&
|
|
145
145
|
typeof record.model === "string" &&
|
|
146
|
-
(record.kind === "assistant" ||
|
|
146
|
+
(record.kind === "assistant" ||
|
|
147
|
+
record.kind === "tool" ||
|
|
148
|
+
record.kind === "summary" ||
|
|
149
|
+
record.kind === "usage" ||
|
|
150
|
+
record.kind === "sidecar") &&
|
|
151
|
+
(record.sharedId === undefined || typeof record.sharedId === "string") &&
|
|
147
152
|
Boolean(usage) &&
|
|
148
153
|
validNumber(usage?.input) &&
|
|
149
154
|
validNumber(usage?.output) &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-stats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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);
|
|
@@ -203,6 +203,22 @@ function normalizeEntry(
|
|
|
203
203
|
kind: "summary",
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
// Pi >=0.86 appendUsage(): model usage outside the conversation, such as cache warming.
|
|
208
|
+
if (entry.type === "usage") {
|
|
209
|
+
const usage = normalizeUsage(entry.usage);
|
|
210
|
+
if (!usage) return undefined;
|
|
211
|
+
const usageKind = typeof entry.kind === "string" && entry.kind.length > 0 ? entry.kind : "usage";
|
|
212
|
+
const model = `${modelName(entry.provider, entry.model)} (${usageKind})`;
|
|
213
|
+
return {
|
|
214
|
+
fingerprint: fingerprint([id, entry.timestamp, "usage", model, usage]),
|
|
215
|
+
timestamp: entryTimestamp,
|
|
216
|
+
model,
|
|
217
|
+
usage,
|
|
218
|
+
kind: "usage",
|
|
219
|
+
...(typeof entry.id === "string" && entry.id.length > 0 ? { sharedId: `${sessionId}:${entry.id}` } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
206
222
|
return undefined;
|
|
207
223
|
}
|
|
208
224
|
|
|
@@ -342,6 +358,7 @@ export function parseUsageSidecar(filePath: string, text: string): { records: Us
|
|
|
342
358
|
usage,
|
|
343
359
|
kind: "sidecar",
|
|
344
360
|
toolName: source,
|
|
361
|
+
...(typeof entry.id === "string" && entry.id.length > 0 ? { sharedId: entry.id } : {}),
|
|
345
362
|
});
|
|
346
363
|
}
|
|
347
364
|
return { records, malformedLines };
|
|
@@ -375,7 +392,7 @@ function hasPersistedSubagentChild(record: UsageRecord, sessionPaths: ReadonlySe
|
|
|
375
392
|
return record.toolName === "subagent" && Boolean(record.childSessionFiles?.some((file) => sessionPaths.has(resolve(file))));
|
|
376
393
|
}
|
|
377
394
|
|
|
378
|
-
|
|
395
|
+
function deduplicateToolCalls(sessions: readonly SessionRecord[]): ToolCallRecord[] {
|
|
379
396
|
const seen = new Set<string>();
|
|
380
397
|
const result: ToolCallRecord[] = [];
|
|
381
398
|
for (const session of sessions) {
|
|
@@ -391,9 +408,12 @@ export function deduplicateToolCalls(sessions: readonly SessionRecord[]): ToolCa
|
|
|
391
408
|
export function deduplicateUsage(sessions: readonly SessionRecord[], sidecar: readonly UsageRecord[] = []): UsageRecord[] {
|
|
392
409
|
const sessionPaths = new Set(sessions.map((session) => resolve(session.path)));
|
|
393
410
|
const seen = new Set<string>();
|
|
411
|
+
const sharedIds = new Set<string>();
|
|
394
412
|
const result: UsageRecord[] = [];
|
|
395
413
|
for (const session of sessions) {
|
|
396
414
|
for (const record of session.usage) {
|
|
415
|
+
// Registered before the fork check so a copied entry still claims the original's sidecar.
|
|
416
|
+
if (record.sharedId) sharedIds.add(record.sharedId);
|
|
397
417
|
if (hasPersistedSubagentChild(record, sessionPaths)) continue;
|
|
398
418
|
if (seen.has(record.fingerprint)) continue;
|
|
399
419
|
seen.add(record.fingerprint);
|
|
@@ -401,6 +421,7 @@ export function deduplicateUsage(sessions: readonly SessionRecord[], sidecar: re
|
|
|
401
421
|
}
|
|
402
422
|
}
|
|
403
423
|
for (const record of sidecar) {
|
|
424
|
+
if (record.sharedId && sharedIds.has(record.sharedId)) continue;
|
|
404
425
|
if (seen.has(record.fingerprint)) continue;
|
|
405
426
|
seen.add(record.fingerprint);
|
|
406
427
|
result.push(record);
|
package/types.ts
CHANGED
|
@@ -8,15 +8,17 @@ export interface UsageTotals {
|
|
|
8
8
|
calls: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
type SessionSource = "main" | "subagent";
|
|
12
12
|
|
|
13
13
|
export interface UsageRecord {
|
|
14
14
|
fingerprint: string;
|
|
15
15
|
timestamp: number;
|
|
16
16
|
model: string;
|
|
17
17
|
usage: UsageTotals;
|
|
18
|
-
kind: "assistant" | "tool" | "summary" | "sidecar";
|
|
18
|
+
kind: "assistant" | "tool" | "summary" | "usage" | "sidecar";
|
|
19
19
|
toolName?: string;
|
|
20
|
+
/** `<sessionId>:<usageEntryId>` on usage entries, the record id on sidecars; a match counts the call once. */
|
|
21
|
+
sharedId?: string;
|
|
20
22
|
childSessionFiles?: string[];
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -55,7 +57,7 @@ export interface CachedFileRecord {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export interface StatsCacheFile {
|
|
58
|
-
version:
|
|
60
|
+
version: 4;
|
|
59
61
|
files: Record<string, CachedFileRecord>;
|
|
60
62
|
}
|
|
61
63
|
|
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" : " ";
|