@tmustier/pi-usage-extension 0.7.1 → 0.8.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0] - 2026-07-17
4
+
5
+ ### Changed
6
+ - **Cache-miss taxonomy split into three behaviours**, each with its own alarm: resuming a conversation after a break (cache expired), switching models mid-conversation (the previous model's cache doesn't transfer), and true mid-session prefix changes (no break, compaction, or model switch to explain them). Validated against a second public corpus (badlogicgames/pi-mono, 628 sessions).
7
+ - **Insights sections reordered facts-first**: “Where it went” (structure) now comes before “Worth attention” (alarms).
8
+ - **pi's built-in test providers excluded**: `faux-provider` and `fake-provider` never call a real API and no longer appear in tables, graphs, totals, or insights.
9
+
10
+ ## [0.7.2] - 2026-07-17
11
+
12
+ ### Changed
13
+ - **Insights view polish.** Section headers with rules (“Worth attention” in warning colour, “Where it went” in accent), alarm markers and stats in warning colour, the trailing “(x% of this period)” de-emphasised, content capped at 100 columns for readability on wide terminals, and an explicit all-clear line (✓) when no alarms fire. The stat column fits 6-character values (e.g. `$58.79`).
14
+ - **Plain-language copy.** Insight headlines now state what happened rather than the cache mechanism (“spent re-sending conversations after a break” instead of “re-warming caches that expired during idle gaps”), advice avoids jargon, and vendor-specific cache details are gone.
15
+ - The period · “independent lenses” note is folded into the subtitle: “Approximate, based on local sessions on this machine (these are independent and don't sum to 100%).”
16
+
3
17
  ## [0.7.1] - 2026-07-17
4
18
 
5
19
  ### Added
package/README.md CHANGED
@@ -67,17 +67,7 @@ In Pi, run:
67
67
 
68
68
  ### Insights
69
69
 
70
- The **Insights** view has two sections:
71
-
72
- **Worth attention** — alarms that only appear when a wasteful pattern is material for the period. A quiet panel means nothing notable was detected.
73
-
74
- | Alarm | Fires when |
75
- |---|---|
76
- | TTL re-warm tax | ≥ 2% of the period's cost (and ≥ $1) went to messages that re-wrote a large context from scratch after a > 5 min idle gap — provider caches expire after a few minutes idle (Anthropic ~5 min) |
77
- | Prefix-change misses | ≥ 2% of cost (and ≥ $1) went to large-context cache misses with **no** idle gap — the request prefix changed mid-session. Messages right after a pi compaction are excluded, since compaction legitimately rewrites the prefix |
78
- | Session concentration | the top 5 sessions account for ≥ 35% of the period's cost |
79
- | Upfront tax | ≥ 8% of cost was the first message of a session (session starts pay for their whole prompt uncached) |
80
- | Cache leverage floor | fewer than 5 cached tokens served per fresh token paid (shown only above $5 / 1M fresh tokens, to avoid noise) |
70
+ The **Insights** view has two sections, facts first:
81
71
 
82
72
  **Where it went** — always-on structural lenses:
83
73
 
@@ -85,9 +75,22 @@ The **Insights** view has two sections:
85
75
  |---|---|
86
76
  | Context tax | share of cost spent at ≥ 150k context, with the average per-message cost vs messages under 100k |
87
77
  | Project mix | top 3 project directories by cost, derived from each session's working directory (worktrees collapse into their repository; hidden when one project is ≥ 90% — you already know) |
88
- | Reasoning share | share of output tokens that were invisible reasoning (recorded by pi 0.80.3+ only; hidden below 5%) |
78
+ | Reasoning share | share of output tokens that were hidden reasoning (recorded by pi 0.80.3+ only; hidden below 5%) |
89
79
  | Burn trend | your last 7 days of spend vs your prior 4-week weekly pace (same on every tab) |
90
80
 
81
+ **Worth attention** — alarms that only appear when a wasteful pattern is material for the period. When nothing is flagged, the section shows an explicit all-clear. Large-context cache misses are split into three distinct behaviours:
82
+
83
+ | Alarm | Fires when |
84
+ |---|---|
85
+ | Resuming after a break | ≥ 2% of the period's cost (and ≥ $1) went to messages that re-sent a large conversation from scratch after a > 5 min idle gap — provider caches expire after a few minutes idle |
86
+ | Switching models mid-conversation | ≥ 2% of cost (and ≥ $1) went to large-context misses right after the provider/model changed mid-session — the previous model's cache doesn't transfer |
87
+ | Mid-session re-sends (prefix change) | ≥ 2% of cost (and ≥ $1) went to large-context misses with **no** idle gap, compaction, or model switch to explain them — something rewrote the request prefix |
88
+ | Session concentration | the top 5 sessions account for ≥ 35% of the period's cost |
89
+ | Upfront tax | ≥ 8% of cost was the first message of a session (session starts pay for their whole prompt uncached) |
90
+ | Cache leverage floor | fewer than 5 cached tokens served per fresh token paid (shown only above $5 / 1M fresh tokens, to avoid noise) |
91
+
92
+ pi's built-in test providers (`faux-provider`, `fake-provider`) never call a real API and are excluded from all statistics, graphs, and insights.
93
+
91
94
  **Unit:** insights are weighted by recorded API cost (USD). Periods with no recorded cost show an explicit empty state rather than silently switching to a different unit.
92
95
 
93
96
  A note on reading them: cache-miss and context numbers are **observed cost, not promised savings** — big-context messages often do bigger work. Treat the alarms as "look here", not as an invoice for waste.
package/data.ts CHANGED
@@ -76,9 +76,11 @@ interface PeriodRawData {
76
76
  sessionCosts: Map<string, number>;
77
77
  /** Cost of each session's first-ever message falling in this period. */
78
78
  upfrontCost: number;
79
- /** Cache misses after >TTL_GAP_MS idle — expired-cache re-warms. */
79
+ /** Cache misses after >TTL_GAP_MS idle — resuming after the cache expired. */
80
80
  ttlMissCost: number;
81
- /** Cache misses without an idle gap (compaction excluded) — prefix changes. */
81
+ /** Cache misses right after a mid-session model switch (no idle gap). */
82
+ modelSwitchMissCost: number;
83
+ /** Cache misses with no idle gap, compaction, or model switch — true prefix changes. */
82
84
  prefixMissCost: number;
83
85
  reasoningTokens: number;
84
86
  outputTokens: number;
@@ -92,6 +94,8 @@ interface MessageMeta {
92
94
  gapMs: number;
93
95
  /** Context size of the previous assistant message in the same file; 0 when first. */
94
96
  prevCtx: number;
97
+ /** True when the provider/model differs from the previous assistant message. */
98
+ modelSwitched: boolean;
95
99
  /** True for the first deduped message of a session across all its files. */
96
100
  isSessionStart: boolean;
97
101
  }
@@ -514,6 +518,7 @@ function emptyPeriodRawData(): PeriodRawData {
514
518
  sessionCosts: new Map(),
515
519
  upfrontCost: 0,
516
520
  ttlMissCost: 0,
521
+ modelSwitchMissCost: 0,
517
522
  prefixMissCost: 0,
518
523
  reasoningTokens: 0,
519
524
  outputTokens: 0,
@@ -640,6 +645,9 @@ function addMessagesToUsageData(
640
645
  const msg = messages[mi]!;
641
646
  const mm = meta[mi]!;
642
647
 
648
+ // pi's built-in test providers never call a real API — keep them out of stats.
649
+ if (EXCLUDED_PROVIDERS.has(msg.provider)) continue;
650
+
643
651
  // Day-indexed cost totals power the burn-trend insight.
644
652
  if (msg.timestamp > 0) {
645
653
  const dayIdx = Math.floor((msg.timestamp - todayMs) / DAY_MS);
@@ -704,6 +712,7 @@ function addMessagesToUsageData(
704
712
  msg.cacheRead < Math.min(MISS_MAX_CACHE_READ, 0.3 * mm.prevCtx)
705
713
  ) {
706
714
  if (mm.gapMs > TTL_GAP_MS) raw.ttlMissCost += msg.cost;
715
+ else if (mm.gapMs >= 0 && mm.modelSwitched) raw.modelSwitchMissCost += msg.cost;
707
716
  else if (mm.gapMs >= 0) raw.prefixMissCost += msg.cost;
708
717
  }
709
718
  raw.reasoningTokens += msg.reasoning;
@@ -943,6 +952,7 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
943
952
  meta.push({
944
953
  gapMs: prev && prev.timestamp > 0 && m.timestamp > 0 ? m.timestamp - prev.timestamp : -1,
945
954
  prevCtx: prev ? prev.input + prev.cacheRead + prev.cacheWrite : 0,
955
+ modelSwitched: prev !== null && (prev.provider !== m.provider || prev.model !== m.model),
946
956
  isSessionStart: false,
947
957
  });
948
958
  }
@@ -1002,6 +1012,9 @@ const TREND_LOW_RATIO = 0.6;
1002
1012
  const TTL_GAP_MS = 5 * 60_000;
1003
1013
  const MISS_MIN_PREV_CONTEXT = 20_000;
1004
1014
  const MISS_MAX_CACHE_READ = 5_000;
1015
+ /** pi's built-in test providers never send anything to a real API. */
1016
+ const EXCLUDED_PROVIDERS = new Set(["faux-provider", "fake-provider"]);
1017
+
1005
1018
  const CACHE_MISS_ALARM_PERCENT = 2;
1006
1019
  const CACHE_MISS_ALARM_MIN_COST = 1;
1007
1020
  // Concentration / upfront alarms
@@ -1045,9 +1058,20 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1045
1058
  insights.push({
1046
1059
  kind: "alarm",
1047
1060
  stat: fmtMoney(raw.ttlMissCost),
1048
- headline: `spent re-warming caches that expired during idle gaps (${fmtPercent(ttlPct)} of this period)`,
1061
+ headline: `spent resuming conversations after a break (${fmtPercent(ttlPct)} of this period)`,
1049
1062
  advice:
1050
- "Provider caches expire after a few minutes idle (Anthropic ~5min); the next message re-writes the whole context at premium rates. Batching replies instead of trickling them keeps caches warm.",
1063
+ "Sent context is only reusable for a few minutes. After a longer pause, the next message pays to send the whole conversation again. Replying while a session is fresh avoids this.",
1064
+ });
1065
+ }
1066
+
1067
+ const switchPct = (raw.modelSwitchMissCost / total) * 100;
1068
+ if (switchPct >= CACHE_MISS_ALARM_PERCENT && raw.modelSwitchMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1069
+ insights.push({
1070
+ kind: "alarm",
1071
+ stat: fmtMoney(raw.modelSwitchMissCost),
1072
+ headline: `spent switching models mid-conversation (${fmtPercent(switchPct)} of this period)`,
1073
+ advice:
1074
+ "Changing model re-sends the whole conversation at full price — the previous model's saved context doesn't transfer. Switching between tasks instead of mid-conversation avoids this.",
1051
1075
  });
1052
1076
  }
1053
1077
 
@@ -1056,9 +1080,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1056
1080
  insights.push({
1057
1081
  kind: "alarm",
1058
1082
  stat: fmtMoney(raw.prefixMissCost),
1059
- headline: `spent on cache misses with no idle gap (${fmtPercent(prefixPct)} of this period)`,
1083
+ headline: `spent re-sending conversations mid-session (${fmtPercent(prefixPct)} of this period)`,
1060
1084
  advice:
1061
- "The request prefix changed mid-session (compaction excluded), so cached context was re-sent at full price. If this stays high, something in the workflow is defeating provider caching.",
1085
+ "These messages paid full price for context that had already been sent — with no break, compaction, or model switch to explain it. Usually a tool or workflow is restarting or rewriting conversations. Worth a look if it stays high.",
1062
1086
  });
1063
1087
  }
1064
1088
 
@@ -1071,7 +1095,7 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1071
1095
  kind: "alarm",
1072
1096
  stat: fmtPercent(topPct),
1073
1097
  headline: `of this period's cost came from just ${TOP_SESSION_COUNT} sessions (of ${raw.sessionCosts.size})`,
1074
- advice: "Spend is unusually concentrated. The graph view's filters can pinpoint what those sessions were doing.",
1098
+ advice: "A handful of sessions drove most of the spend. The graph view can show what they were doing.",
1075
1099
  });
1076
1100
  }
1077
1101
  }
@@ -1081,9 +1105,8 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1081
1105
  insights.push({
1082
1106
  kind: "alarm",
1083
1107
  stat: fmtPercent(upfrontPct),
1084
- headline: "of cost was the first message of a session",
1085
- advice:
1086
- "Session starts pay for their whole prompt uncached. Fewer, longer-lived sessions amortize that setup cost.",
1108
+ headline: "of cost went on the opening message of new sessions",
1109
+ advice: "A session's first message sends everything from scratch. Fewer, longer sessions cut this overhead.",
1087
1110
  });
1088
1111
  }
1089
1112
 
@@ -1093,9 +1116,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1093
1116
  insights.push({
1094
1117
  kind: "alarm",
1095
1118
  stat: `${leverage.toFixed(1)}×`,
1096
- headline: "cache leverage — cached tokens served per fresh token paid",
1119
+ headline: "tokens reused from history for every token paid at full price",
1097
1120
  advice:
1098
- "Healthy interactive use typically sees 10×+. Low leverage means context is being re-sent uncached — look for workflows that restart conversations.",
1121
+ "Typical interactive use reuses 10× or more. A low number means conversations keep being sent from scratch — look for workflows that restart sessions.",
1099
1122
  });
1100
1123
  }
1101
1124
  }
@@ -1109,13 +1132,13 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1109
1132
  const avgLow = raw.ctxLow.messages > 0 ? raw.ctxLow.cost / raw.ctxLow.messages : 0;
1110
1133
  const cmp =
1111
1134
  avgLow > 0
1112
- ? ` — averaging ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
1135
+ ? ` — ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
1113
1136
  : "";
1114
1137
  insights.push({
1115
1138
  kind: "structure",
1116
1139
  stat: fmtPercent(pct),
1117
- headline: `of your cost was at ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} context${cmp}`,
1118
- advice: "Context size is the main cost driver. /compact mid-task and /clear between tasks to reset it.",
1140
+ headline: `of your cost came from messages with ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} tokens loaded${cmp}`,
1141
+ advice: "Long conversations cost more per message. /compact mid-task and /clear between tasks keep them lean.",
1119
1142
  });
1120
1143
  }
1121
1144
  }
@@ -1143,9 +1166,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1143
1166
  insights.push({
1144
1167
  kind: "structure",
1145
1168
  stat: fmtPercent(reasoningPct),
1146
- headline: "of your output tokens were invisible reasoning",
1169
+ headline: "of your output tokens were hidden reasoning",
1147
1170
  advice:
1148
- "Reasoning is billed as output but never displayed. Recorded by pi 0.80.3+ (June 2026) only, so older periods understate it.",
1171
+ "Models charge for their behind-the-scenes thinking as output tokens. pi records this only from 0.80.3 (June 2026), so older periods understate it.",
1149
1172
  });
1150
1173
  }
1151
1174
  }
@@ -1154,9 +1177,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1154
1177
  const ratio = trend.last7Cost / trend.priorWeeklyPace;
1155
1178
  const advice =
1156
1179
  ratio >= TREND_HIGH_RATIO
1157
- ? "Spend is accelerating vs your own baseline — the graph view shows what changed."
1180
+ ? "Spending is up against your own baseline — the graph view shows what changed."
1158
1181
  : ratio <= TREND_LOW_RATIO
1159
- ? "Spend is well below your recent baseline."
1182
+ ? "Spending is well below your recent baseline."
1160
1183
  : "";
1161
1184
  insights.push({
1162
1185
  kind: "structure",
package/index.ts CHANGED
@@ -499,11 +499,14 @@ class UsageComponent {
499
499
  const hasCost = stats.totals.cost > 0;
500
500
  const lines: string[] = [];
501
501
 
502
- lines.push("What's contributing to your cost?");
503
- lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
504
- lines.push("");
505
- const note = `${TAB_LABELS[this.activeTab]} · independent lenses — they overlap and don't sum to 100%`;
506
- lines.push(th.fg("dim", note));
502
+ // Cap the content column so advice stays readable on very wide terminals.
503
+ const contentWidth = Math.max(Math.min(width, 100), 40);
504
+
505
+ lines.push(th.bold("What's contributing to your cost?"));
506
+ const subtitle = "Approximate, based on local sessions on this machine (these are independent and don't sum to 100%).";
507
+ for (const wrapped of wrapTextWithAnsi(subtitle, contentWidth)) {
508
+ lines.push(th.fg("dim", wrapped));
509
+ }
507
510
  lines.push("");
508
511
 
509
512
  if (!hasMessages) {
@@ -522,13 +525,24 @@ class UsageComponent {
522
525
  return lines;
523
526
  }
524
527
 
525
- const indent = " ";
526
- const adviceWidth = Math.max(width - indent.length, 30);
528
+ // Columns: marker(2) + stat(6) + gap(1); advice aligns under the headline.
529
+ const indent = " ";
530
+ const adviceWidth = Math.max(contentWidth - indent.length, 30);
531
+
532
+ const sectionHeader = (label: string, color: "warning" | "accent"): string => {
533
+ const rule = "─".repeat(Math.max(contentWidth - label.length - 1, 4));
534
+ return `${th.fg(color, th.bold(label))} ${th.fg("border", rule)}`;
535
+ };
527
536
 
528
537
  const renderOne = (insight: (typeof insights)[number]): void => {
529
- const marker = insight.kind === "alarm" ? "⚠ " : " ";
530
- const stat = th.fg("accent", th.bold(padLeft(insight.stat, 5)));
531
- lines.push(`${marker}${stat} ${insight.headline}`);
538
+ const isAlarm = insight.kind === "alarm";
539
+ const marker = isAlarm ? th.fg("warning", "⚠ ") : " ";
540
+ const statText = padLeft(insight.stat, 6);
541
+ const stat = isAlarm ? th.fg("warning", th.bold(statText)) : th.fg("accent", th.bold(statText));
542
+ // De-emphasise the trailing period-share parenthetical on alarm headlines.
543
+ const match = insight.headline.match(/^(.*?)\s*(\(\d[\d.,]*% of this period\))$/);
544
+ const headline = match ? `${match[1]} ${th.fg("dim", match[2]!)}` : insight.headline;
545
+ lines.push(`${marker}${stat} ${headline}`);
532
546
  if (insight.advice) {
533
547
  for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
534
548
  lines.push(`${indent}${th.fg("dim", wrapped)}`);
@@ -539,14 +553,18 @@ class UsageComponent {
539
553
 
540
554
  const alarms = insights.filter((i) => i.kind === "alarm");
541
555
  const structure = insights.filter((i) => i.kind === "structure");
542
- if (alarms.length > 0) {
543
- lines.push(th.fg("accent", "Worth attention"));
544
- for (const insight of alarms) renderOne(insight);
545
- }
556
+ // Facts first, flagged waste second.
546
557
  if (structure.length > 0) {
547
- if (alarms.length > 0) lines.push(th.fg("dim", "Where it went"));
558
+ lines.push(sectionHeader("Where it went", "accent"));
548
559
  for (const insight of structure) renderOne(insight);
549
560
  }
561
+ lines.push(sectionHeader("Worth attention", "warning"));
562
+ if (alarms.length > 0) {
563
+ for (const insight of alarms) renderOne(insight);
564
+ } else {
565
+ lines.push(` ${th.fg("success", padLeft("✓", 6))} ${th.fg("dim", "no waste patterns flagged for this period")}`);
566
+ lines.push("");
567
+ }
550
568
 
551
569
  return lines;
552
570
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",