@hank-warren/pi-stats 0.3.1 → 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
@@ -27,7 +27,7 @@ Run `/stats` in interactive mode. The dashboard temporarily replaces the editor
27
27
  - `u`: force a full rescan and rebuild the session index
28
28
  - `Escape`: close
29
29
 
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. 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.
31
31
 
32
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`.
33
33
 
@@ -70,7 +70,7 @@ Each variable is matched exactly: `PI_STATS_DISABLE_USAGE_SIDECARS=true` does no
70
70
 
71
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
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 is counted in the totals either way.
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
74
 
75
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
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-stats",
3
- "version": "0.3.1",
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