@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 +14 -0
- package/README.md +15 -12
- package/data.ts +42 -19
- package/index.ts +33 -15
- package/package.json +1 -1
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
|
|
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 —
|
|
79
|
+
/** Cache misses after >TTL_GAP_MS idle — resuming after the cache expired. */
|
|
80
80
|
ttlMissCost: number;
|
|
81
|
-
/** Cache misses
|
|
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
|
|
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.",
|
|
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
|
|
1083
|
+
headline: `spent re-sending conversations mid-session (${fmtPercent(prefixPct)} of this period)`,
|
|
1060
1084
|
advice:
|
|
1061
|
-
"
|
|
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: "
|
|
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
|
|
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: "
|
|
1119
|
+
headline: "tokens reused from history for every token paid at full price",
|
|
1097
1120
|
advice:
|
|
1098
|
-
"
|
|
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
|
-
? ` —
|
|
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
|
|
1118
|
-
advice: "
|
|
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
|
|
1169
|
+
headline: "of your output tokens were hidden reasoning",
|
|
1147
1170
|
advice:
|
|
1148
|
-
"
|
|
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
|
-
? "
|
|
1180
|
+
? "Spending is up against your own baseline — the graph view shows what changed."
|
|
1158
1181
|
: ratio <= TREND_LOW_RATIO
|
|
1159
|
-
? "
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
526
|
-
const
|
|
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
|
|
530
|
-
const
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|