@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 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 branch summaries. `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.
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. 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.
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 = 3 as const;
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
- export interface ScanOptions {
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" || record.kind === "tool" || record.kind === "summary" || record.kind === "sidecar") &&
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.4.1",
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": ">=18.0.0"
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
- export function normalizeUsage(value: unknown): UsageTotals | undefined {
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
- export function deduplicateToolCalls(sessions: readonly SessionRecord[]): ToolCallRecord[] {
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
- export type SessionSource = "main" | "subagent";
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: 3;
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
- export type WidgetState =
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
- export interface StatsWidgetOptions {
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
- export type StatsTab = "overview" | "models" | "tools" | "projects";
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
- let months = " ".repeat(HEATMAP_LABEL_WIDTH);
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" : " ";