@hank-warren/pi-stats 0.3.0 → 0.3.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/README.md CHANGED
@@ -8,6 +8,12 @@ A compact, theme-aware `/stats` dashboard for [Pi](https://pi.dev). It totals th
8
8
  pi install npm:@hank-warren/pi-stats
9
9
  ```
10
10
 
11
+ Try it for a single run without installing:
12
+
13
+ ```bash
14
+ pi -e npm:@hank-warren/pi-stats
15
+ ```
16
+
11
17
  Do not install this standalone package on a host that also installs the aggregate `hank-warren/pi-extensions` Git package; that would load the extension twice.
12
18
 
13
19
  ## Use
@@ -21,7 +27,7 @@ Run `/stats` in interactive mode. The dashboard temporarily replaces the editor
21
27
  - `u`: force a full rescan and rebuild the session index
22
28
  - `Escape`: close
23
29
 
24
- The Overview compares current-session, selected-range, and all-time usage in one aligned table covering input, output, cache read, cache write, reasoning, cache-hit rate, and recorded cost, followed by a month-labelled activity heatmap and all-time session, streak, and favourite-model highlights. Reasoning is shown as a subset of output and is never added to the total twice.
30
+ The Overview compares current-session, selected-range, and all-time usage in one aligned table covering input, output, cache read, cache write, reasoning, cache-hit rate, and recorded cost, followed by a month-labelled activity heatmap and all-time session, streak, and favourite-model highlights. The heatmap always spans the width the terminal offers, up to a full year; weeks before your first recorded session render as empty cells and fill in as history accumulates. Reasoning is shown as a subset of output and is never added to the total twice.
25
31
 
26
32
  The Models tab ranks models for the selected range with share bars and a totals row that reconciles with the range total. It attributes assistant usage to the provider response model and keeps otherwise unattributed nested usage in `Tools/summaries`.
27
33
 
@@ -45,6 +51,33 @@ Records are de-duplicated by `id`, counted in every total, and shown as their ow
45
51
 
46
52
  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.
47
53
 
54
+ ## Configuration
55
+
56
+ There is no config file; behavior is controlled entirely by environment variables.
57
+
58
+ | Env var | Default | Purpose |
59
+ | --- | --- | --- |
60
+ | `PI_STATS_DISABLE_USAGE_SIDECARS` | unset | Set to `1` to ignore all extension usage sidecars and count only session transcripts. |
61
+ | `PI_STATS_SESSION_DIRS` | unset | Extra session roots to scan, joined by the platform path delimiter (`:` on Unix, `;` on Windows). Added to the automatically discovered roots. |
62
+ | `PI_CODING_AGENT_SESSION_DIR` | unset | Standard Pi variable; the directory it names is picked up as a session root. |
63
+ | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Standard Pi variable; determines where the cache and sidecars are read from. |
64
+
65
+ Each variable is matched exactly: `PI_STATS_DISABLE_USAGE_SIDECARS=true` does nothing, only `=1` disables sidecars.
66
+
67
+ ## Troubleshooting
68
+
69
+ **Totals look too low, or a session is missing.** Press `u` in the dashboard to force a full rescan and rebuild the index; the cache keys off file stat identity, so a session restored from backup with older timestamps can otherwise be skipped. If the sessions live outside the standard root, add their directory to `PI_STATS_SESSION_DIRS`.
70
+
71
+ **Guardian or other extension usage is missing.** Sidecar reading is skipped entirely when `PI_STATS_DISABLE_USAGE_SIDECARS=1`. Otherwise confirm the writing extension is enabled and that `<agent dir>/<extension>/usage.jsonl` exists; sidecars are only discovered one level below the agent directory.
72
+
73
+ **A model shows under `Tools/summaries` instead of its own row.** That bucket holds nested usage that carries no attributable response model, such as some compaction and branch-summary records. It also absorbs Pi's own placeholder turns — assistant messages whose model is `<synthetic>` or missing — but only when they recorded no tokens and no cost; a placeholder that was actually billed keeps its own row so the spend stays visible. Everything in the bucket is counted in the totals either way.
74
+
75
+ **Costs read `$0.00` or look wrong.** Displayed cost is whatever Pi already recorded in the session; `pi-stats` performs no pricing lookup and contacts no billing API. Providers used through a subscription or proxy commonly record zero cost.
76
+
77
+ **The dashboard looks cramped.** Columns collapse onto a labelled continuation line as the terminal narrows. Widen the terminal to restore the single-line layout, or press `e` to switch to compact totals.
78
+
79
+ **Reset everything.** Delete `~/.pi/agent/pi-stats/cache.json` (path follows `PI_CODING_AGENT_DIR`). It is a disposable index and the next `/stats` run rebuilds it from the sessions.
80
+
48
81
  ## Cache and privacy
49
82
 
50
83
  Sessions remain authoritative. For fast repeat opens, the extension stores one disposable index:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-stats",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
5
5
  "type": "module",
6
6
  "keywords": [
package/stats.ts CHANGED
@@ -14,6 +14,9 @@ import {
14
14
  type UsageTotals,
15
15
  } from "./types.ts";
16
16
 
17
+ /** Bucket for usage that carries no attributable response model. */
18
+ const SUMMARY_MODEL = "Tools/summaries";
19
+
17
20
  interface ParseResult {
18
21
  ignored: boolean;
19
22
  malformedLines: number;
@@ -73,6 +76,29 @@ function detailsModel(details: unknown): string | undefined {
73
76
  return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined;
74
77
  }
75
78
 
79
+ /**
80
+ * Pi records placeholder assistant messages for turns it generates itself; today they
81
+ * carry the literal model `<synthetic>`, and a missing model normalizes to `unknown`.
82
+ * Angle brackets are never valid in a provider model id, so they are a safe marker.
83
+ */
84
+ function placeholderModel(model: string): boolean {
85
+ const bare = model.slice(model.lastIndexOf("/") + 1);
86
+ return bare === "unknown" || (bare.startsWith("<") && bare.endsWith(">"));
87
+ }
88
+
89
+ function unmetered(usage: UsageTotals): boolean {
90
+ return totalTokens(usage) === 0 && usage.reasoning === 0 && usage.cost === 0;
91
+ }
92
+
93
+ /**
94
+ * Placeholder models that recorded no tokens and no cost would otherwise get their own
95
+ * zero-value model row. Roll them into the unattributed bucket so the call is still
96
+ * counted without inventing a model that was never billed.
97
+ */
98
+ function attributedModel(model: string, usage: UsageTotals): string {
99
+ return placeholderModel(model) && unmetered(usage) ? SUMMARY_MODEL : model;
100
+ }
101
+
76
102
  function nestedSubagentUsage(details: unknown): UsageTotals | undefined {
77
103
  const record = object(details);
78
104
  if (!record) return undefined;
@@ -135,7 +161,7 @@ function normalizeEntry(
135
161
  if (role === "assistant") {
136
162
  const usage = normalizeUsage(message.usage);
137
163
  if (!usage) return undefined;
138
- const model = modelName(message.provider, message.responseModel ?? message.model);
164
+ const model = attributedModel(modelName(message.provider, message.responseModel ?? message.model), usage);
139
165
  return {
140
166
  fingerprint: fingerprint([id, entry.timestamp, "assistant", model, usage]),
141
167
  timestamp: timestamp(message.timestamp, entryTimestamp),
@@ -148,7 +174,7 @@ function normalizeEntry(
148
174
  const toolName = typeof message.toolName === "string" ? message.toolName : "tool";
149
175
  const usage = normalizeUsage(message.usage) ?? (toolName === "subagent" ? nestedSubagentUsage(message.details) : undefined);
150
176
  if (!usage) return undefined;
151
- const model = detailsModel(message.details) ?? "Tools/summaries";
177
+ const model = attributedModel(detailsModel(message.details) ?? SUMMARY_MODEL, usage);
152
178
  const children = toolName === "subagent" ? childSessionFiles(message.details, sessionFile) : [];
153
179
  return {
154
180
  fingerprint: fingerprint([id, entry.timestamp, "tool", toolName, model, usage]),
@@ -167,9 +193,9 @@ function normalizeEntry(
167
193
  const usage = normalizeUsage(entry.usage);
168
194
  if (!usage) return undefined;
169
195
  return {
170
- fingerprint: fingerprint([id, entry.timestamp, entry.type, "Tools/summaries", usage]),
196
+ fingerprint: fingerprint([id, entry.timestamp, entry.type, SUMMARY_MODEL, usage]),
171
197
  timestamp: entryTimestamp,
172
- model: "Tools/summaries",
198
+ model: SUMMARY_MODEL,
173
199
  usage,
174
200
  kind: "summary",
175
201
  };
package/widget.ts CHANGED
@@ -374,10 +374,10 @@ export class StatsWidget implements Component {
374
374
  today.setHours(12, 0, 0, 0);
375
375
  const end = new Date(today);
376
376
  end.setDate(end.getDate() + (6 - ((end.getDay() + 6) % 7)));
377
- const active = snapshot.days.filter((day) => day.total > 0);
378
- const first = active[0] ? new Date(`${active[0].day}T12:00:00`) : today;
379
- const span = Math.ceil((end.getTime() - first.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1;
380
- const weeks = Math.max(HEATMAP_MIN_WEEKS, Math.min(cells, HEATMAP_MAX_WEEKS, Math.max(span, HEATMAP_MIN_WEEKS)));
377
+ // Always claim the width the terminal offers, up to a year. Weeks before the first
378
+ // recorded day render as empty cells so a short history fills the grid in over time
379
+ // instead of leaving most of a wide row blank.
380
+ const weeks = Math.max(HEATMAP_MIN_WEEKS, Math.min(cells, HEATMAP_MAX_WEEKS));
381
381
  const start = new Date(end);
382
382
  start.setDate(start.getDate() - weeks * 7 + 1);
383
383