@tmustier/pi-usage-extension 0.7.2 → 0.8.1

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.1] - 2026-07-17
4
+
5
+ ### Changed
6
+ - **Consistent units.** Every alarm in “Worth attention” now leads with dollars (the act-or-ignore number), with the period share in the dimmed parenthetical; “Where it went” keeps percentages (shares of the pie). Session concentration and upfront tax previously led with %.
7
+
8
+ ## [0.8.0] - 2026-07-17
9
+
10
+ ### Changed
11
+ - **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).
12
+ - **Insights sections reordered facts-first**: “Where it went” (structure) now comes before “Worth attention” (alarms).
13
+ - **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.
14
+
3
15
  ## [0.7.2] - 2026-07-17
4
16
 
5
17
  ### Changed
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. When nothing is flagged, the section shows an explicit all-clear.
73
-
74
- | Alarm | Fires when |
75
- |---|---|
76
- | Re-sends after a break (cache TTL) | ≥ 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 |
77
- | Mid-session re-sends (prefix change) | ≥ 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,12 +1058,23 @@ 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-sending conversations after a break (${fmtPercent(ttlPct)} of this period)`,
1061
+ headline: `spent resuming conversations after a break (${fmtPercent(ttlPct)} of this period)`,
1049
1062
  advice:
1050
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.",
1051
1064
  });
1052
1065
  }
1053
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.",
1075
+ });
1076
+ }
1077
+
1054
1078
  const prefixPct = (raw.prefixMissCost / total) * 100;
1055
1079
  if (prefixPct >= CACHE_MISS_ALARM_PERCENT && raw.prefixMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1056
1080
  insights.push({
@@ -1058,7 +1082,7 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1058
1082
  stat: fmtMoney(raw.prefixMissCost),
1059
1083
  headline: `spent re-sending conversations mid-session (${fmtPercent(prefixPct)} of this period)`,
1060
1084
  advice:
1061
- "These messages paid full price for context that had already been sent, without a pause to explain it. Usually a tool or workflow is restarting or rewriting conversations. Worth a look if it stays high.",
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
 
@@ -1069,8 +1093,8 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1069
1093
  if (topPct >= CONCENTRATION_ALARM_PERCENT) {
1070
1094
  insights.push({
1071
1095
  kind: "alarm",
1072
- stat: fmtPercent(topPct),
1073
- headline: `of this period's cost came from just ${TOP_SESSION_COUNT} sessions (of ${raw.sessionCosts.size})`,
1096
+ stat: fmtMoney(topWeight),
1097
+ headline: `came from just ${TOP_SESSION_COUNT} of your ${raw.sessionCosts.size} sessions (${fmtPercent(topPct)} of this period)`,
1074
1098
  advice: "A handful of sessions drove most of the spend. The graph view can show what they were doing.",
1075
1099
  });
1076
1100
  }
@@ -1080,8 +1104,8 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1080
1104
  if (upfrontPct >= UPFRONT_ALARM_PERCENT) {
1081
1105
  insights.push({
1082
1106
  kind: "alarm",
1083
- stat: fmtPercent(upfrontPct),
1084
- headline: "of cost went on the opening message of new sessions",
1107
+ stat: fmtMoney(raw.upfrontCost),
1108
+ headline: `spent on the opening message of new sessions (${fmtPercent(upfrontPct)} of this period)`,
1085
1109
  advice: "A session's first message sends everything from scratch. Fewer, longer sessions cut this overhead.",
1086
1110
  });
1087
1111
  }
package/index.ts CHANGED
@@ -553,6 +553,11 @@ class UsageComponent {
553
553
 
554
554
  const alarms = insights.filter((i) => i.kind === "alarm");
555
555
  const structure = insights.filter((i) => i.kind === "structure");
556
+ // Facts first, flagged waste second.
557
+ if (structure.length > 0) {
558
+ lines.push(sectionHeader("Where it went", "accent"));
559
+ for (const insight of structure) renderOne(insight);
560
+ }
556
561
  lines.push(sectionHeader("Worth attention", "warning"));
557
562
  if (alarms.length > 0) {
558
563
  for (const insight of alarms) renderOne(insight);
@@ -560,10 +565,6 @@ class UsageComponent {
560
565
  lines.push(` ${th.fg("success", padLeft("✓", 6))} ${th.fg("dim", "no waste patterns flagged for this period")}`);
561
566
  lines.push("");
562
567
  }
563
- if (structure.length > 0) {
564
- lines.push(sectionHeader("Where it went", "accent"));
565
- for (const insight of structure) renderOne(insight);
566
- }
567
568
 
568
569
  return lines;
569
570
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",