@tmustier/pi-usage-extension 0.7.0 → 0.7.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/CHANGELOG.md +13 -0
- package/README.md +4 -4
- package/data.ts +60 -17
- package/index.ts +56 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.2] - 2026-07-17
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- **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`).
|
|
7
|
+
- **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.
|
|
8
|
+
- 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%).”
|
|
9
|
+
|
|
10
|
+
## [0.7.1] - 2026-07-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Descriptive loading messages with a live file counter while `/usage` opens: “Building your usage history for the first time…” on a fresh install, “Rebuilding your usage history — the cache format changed…” after an upgrade that bumps the cache version, and “Updating your usage history since \<date>…” for routine incremental refreshes. Warm opens with nothing to parse keep the plain spinner.
|
|
14
|
+
- `collectUsageData` accepts an `onProgress` callback reporting mode (`first-run` / `rebuild` / `update`), files to parse, files parsed, and the newest already-ingested session activity timestamp.
|
|
15
|
+
|
|
3
16
|
## [0.7.0] - 2026-07-17
|
|
4
17
|
|
|
5
18
|
### Changed
|
package/README.md
CHANGED
|
@@ -69,12 +69,12 @@ In Pi, run:
|
|
|
69
69
|
|
|
70
70
|
The **Insights** view has two sections:
|
|
71
71
|
|
|
72
|
-
**Worth attention** — alarms that only appear when a wasteful pattern is material for the period.
|
|
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
73
|
|
|
74
74
|
| Alarm | Fires when |
|
|
75
75
|
|---|---|
|
|
76
|
-
|
|
|
77
|
-
|
|
|
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
78
|
| Session concentration | the top 5 sessions account for ≥ 35% of the period's cost |
|
|
79
79
|
| Upfront tax | ≥ 8% of cost was the first message of a session (session starts pay for their whole prompt uncached) |
|
|
80
80
|
| Cache leverage floor | fewer than 5 cached tokens served per fresh token paid (shown only above $5 / 1M fresh tokens, to avoid noise) |
|
|
@@ -159,7 +159,7 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
|
|
|
159
159
|
- **On-disk cache.** Per-file extraction results are cached in `<agentDir>/usage-extension-cache.json` (respects `PI_CODING_AGENT_DIR`), keyed by file size + mtime. Warm opens only re-parse session files that changed since the last run — on a 5.2 GB / 3,310-file corpus that takes the open from ~17 s to ~0.3 s.
|
|
160
160
|
- **First open** after install (or after deleting the cache) does a one-off full build, showing the usual cancellable loader. Cancelling saves partial progress, so the next open resumes where it left off.
|
|
161
161
|
- The cache is safe to delete at any time; it is rebuilt automatically. Corrupt or version-mismatched caches are ignored and rebuilt rather than trusted.
|
|
162
|
-
- **0.7.0 bumps the cache format to v3** (adds session working directory and compaction markers; 0.6.0's v2 added thinking level and reasoning tokens). The first open after upgrading does a one-off full rebuild, then warm opens are fast again.
|
|
162
|
+
- **0.7.0 bumps the cache format to v3** (adds session working directory and compaction markers; 0.6.0's v2 added thinking level and reasoning tokens). The first open after upgrading does a one-off full rebuild (with a progress message and live file counter), then warm opens are fast again.
|
|
163
163
|
|
|
164
164
|
## Provider Notes
|
|
165
165
|
|
package/data.ts
CHANGED
|
@@ -619,6 +619,7 @@ function getPeriodsForTimestamp(
|
|
|
619
619
|
}
|
|
620
620
|
|
|
621
621
|
const DAY_MS = 24 * HOUR_MS;
|
|
622
|
+
const PROGRESS_REPORT_EVERY = 100;
|
|
622
623
|
|
|
623
624
|
function addMessagesToUsageData(
|
|
624
625
|
data: UsageData,
|
|
@@ -727,8 +728,21 @@ const STAT_CONCURRENCY = 16;
|
|
|
727
728
|
const DEFAULT_PARSE_CONCURRENCY = 4;
|
|
728
729
|
const AGGREGATE_YIELD_EVERY_FILES = 200;
|
|
729
730
|
|
|
731
|
+
export interface CollectProgress {
|
|
732
|
+
/** Why this pass needs to parse files. */
|
|
733
|
+
mode: "first-run" | "rebuild" | "update";
|
|
734
|
+
/** Session files that need parsing this pass (0 = fully warm). */
|
|
735
|
+
filesToParse: number;
|
|
736
|
+
/** Files parsed so far; reported in coarse increments. */
|
|
737
|
+
filesParsed: number;
|
|
738
|
+
/** Newest session activity already ingested (ms since epoch); null when starting fresh. */
|
|
739
|
+
sinceMs: number | null;
|
|
740
|
+
}
|
|
741
|
+
|
|
730
742
|
export interface CollectUsageOptions {
|
|
731
743
|
signal?: AbortSignal;
|
|
744
|
+
/** Called once before parsing begins and periodically while files are parsed. */
|
|
745
|
+
onProgress?: (progress: CollectProgress) => void;
|
|
732
746
|
/** Defaults to `<agentDir>/sessions`. */
|
|
733
747
|
sessionsDir?: string;
|
|
734
748
|
/** Defaults to `<agentDir>/usage-extension-cache.json`. Pass `null` to disable the on-disk cache. */
|
|
@@ -794,6 +808,15 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
794
808
|
if (signal?.aborted) return null;
|
|
795
809
|
|
|
796
810
|
// 3. Load the cache and decide which files actually need parsing.
|
|
811
|
+
let cacheFileExists = false;
|
|
812
|
+
if (cachePath) {
|
|
813
|
+
try {
|
|
814
|
+
await stat(cachePath);
|
|
815
|
+
cacheFileExists = true;
|
|
816
|
+
} catch {
|
|
817
|
+
// No cache file yet — first run.
|
|
818
|
+
}
|
|
819
|
+
}
|
|
797
820
|
const previous = cachePath ? await loadUsageCache(cachePath) : new Map<string, CachedFileState>();
|
|
798
821
|
if (signal?.aborted) return null;
|
|
799
822
|
const current = new Map<string, CachedFileState>();
|
|
@@ -818,6 +841,22 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
818
841
|
}
|
|
819
842
|
}
|
|
820
843
|
|
|
844
|
+
// Progress reporting: distinguish a true first run from a format-change
|
|
845
|
+
// rebuild (cache file present but unusable) and a routine incremental update.
|
|
846
|
+
const progressMode: CollectProgress["mode"] =
|
|
847
|
+
previous.size > 0 ? "update" : cacheFileExists ? "rebuild" : "first-run";
|
|
848
|
+
let sinceMs: number | null = null;
|
|
849
|
+
if (progressMode === "update") {
|
|
850
|
+
for (const state of previous.values()) {
|
|
851
|
+
if (state.mtimeMs > (sinceMs ?? 0)) sinceMs = state.mtimeMs;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
let filesParsed = 0;
|
|
855
|
+
const reportProgress = (): void => {
|
|
856
|
+
options.onProgress?.({ mode: progressMode, filesToParse: toParse.length, filesParsed, sinceMs });
|
|
857
|
+
};
|
|
858
|
+
reportProgress();
|
|
859
|
+
|
|
821
860
|
// 4. Parse new/changed files with bounded concurrency.
|
|
822
861
|
{
|
|
823
862
|
let next = 0;
|
|
@@ -831,11 +870,16 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
831
870
|
try {
|
|
832
871
|
buffer = await readFile(filePath);
|
|
833
872
|
} catch {
|
|
873
|
+
filesParsed++;
|
|
834
874
|
continue; // File vanished — skip it.
|
|
835
875
|
}
|
|
836
876
|
const parsed = await parseSessionBuffer(buffer, signal);
|
|
837
877
|
if (signal?.aborted) return; // Never cache a partial parse.
|
|
838
878
|
current.set(filePath, { size: st.size, mtimeMs: st.mtimeMs, parsed });
|
|
879
|
+
filesParsed++;
|
|
880
|
+
if (filesParsed % PROGRESS_REPORT_EVERY === 0 || filesParsed === toParse.length) {
|
|
881
|
+
reportProgress();
|
|
882
|
+
}
|
|
839
883
|
}
|
|
840
884
|
})
|
|
841
885
|
);
|
|
@@ -1001,9 +1045,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1001
1045
|
insights.push({
|
|
1002
1046
|
kind: "alarm",
|
|
1003
1047
|
stat: fmtMoney(raw.ttlMissCost),
|
|
1004
|
-
headline: `spent re-
|
|
1048
|
+
headline: `spent re-sending conversations after a break (${fmtPercent(ttlPct)} of this period)`,
|
|
1005
1049
|
advice:
|
|
1006
|
-
"
|
|
1050
|
+
"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.",
|
|
1007
1051
|
});
|
|
1008
1052
|
}
|
|
1009
1053
|
|
|
@@ -1012,9 +1056,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1012
1056
|
insights.push({
|
|
1013
1057
|
kind: "alarm",
|
|
1014
1058
|
stat: fmtMoney(raw.prefixMissCost),
|
|
1015
|
-
headline: `spent
|
|
1059
|
+
headline: `spent re-sending conversations mid-session (${fmtPercent(prefixPct)} of this period)`,
|
|
1016
1060
|
advice:
|
|
1017
|
-
"
|
|
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.",
|
|
1018
1062
|
});
|
|
1019
1063
|
}
|
|
1020
1064
|
|
|
@@ -1027,7 +1071,7 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1027
1071
|
kind: "alarm",
|
|
1028
1072
|
stat: fmtPercent(topPct),
|
|
1029
1073
|
headline: `of this period's cost came from just ${TOP_SESSION_COUNT} sessions (of ${raw.sessionCosts.size})`,
|
|
1030
|
-
advice: "
|
|
1074
|
+
advice: "A handful of sessions drove most of the spend. The graph view can show what they were doing.",
|
|
1031
1075
|
});
|
|
1032
1076
|
}
|
|
1033
1077
|
}
|
|
@@ -1037,9 +1081,8 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1037
1081
|
insights.push({
|
|
1038
1082
|
kind: "alarm",
|
|
1039
1083
|
stat: fmtPercent(upfrontPct),
|
|
1040
|
-
headline: "of cost
|
|
1041
|
-
advice:
|
|
1042
|
-
"Session starts pay for their whole prompt uncached. Fewer, longer-lived sessions amortize that setup cost.",
|
|
1084
|
+
headline: "of cost went on the opening message of new sessions",
|
|
1085
|
+
advice: "A session's first message sends everything from scratch. Fewer, longer sessions cut this overhead.",
|
|
1043
1086
|
});
|
|
1044
1087
|
}
|
|
1045
1088
|
|
|
@@ -1049,9 +1092,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1049
1092
|
insights.push({
|
|
1050
1093
|
kind: "alarm",
|
|
1051
1094
|
stat: `${leverage.toFixed(1)}×`,
|
|
1052
|
-
headline: "
|
|
1095
|
+
headline: "tokens reused from history for every token paid at full price",
|
|
1053
1096
|
advice:
|
|
1054
|
-
"
|
|
1097
|
+
"Typical interactive use reuses 10× or more. A low number means conversations keep being sent from scratch — look for workflows that restart sessions.",
|
|
1055
1098
|
});
|
|
1056
1099
|
}
|
|
1057
1100
|
}
|
|
@@ -1065,13 +1108,13 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1065
1108
|
const avgLow = raw.ctxLow.messages > 0 ? raw.ctxLow.cost / raw.ctxLow.messages : 0;
|
|
1066
1109
|
const cmp =
|
|
1067
1110
|
avgLow > 0
|
|
1068
|
-
? ` —
|
|
1111
|
+
? ` — ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
|
|
1069
1112
|
: "";
|
|
1070
1113
|
insights.push({
|
|
1071
1114
|
kind: "structure",
|
|
1072
1115
|
stat: fmtPercent(pct),
|
|
1073
|
-
headline: `of your cost
|
|
1074
|
-
advice: "
|
|
1116
|
+
headline: `of your cost came from messages with ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} tokens loaded${cmp}`,
|
|
1117
|
+
advice: "Long conversations cost more per message. /compact mid-task and /clear between tasks keep them lean.",
|
|
1075
1118
|
});
|
|
1076
1119
|
}
|
|
1077
1120
|
}
|
|
@@ -1099,9 +1142,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1099
1142
|
insights.push({
|
|
1100
1143
|
kind: "structure",
|
|
1101
1144
|
stat: fmtPercent(reasoningPct),
|
|
1102
|
-
headline: "of your output tokens were
|
|
1145
|
+
headline: "of your output tokens were hidden reasoning",
|
|
1103
1146
|
advice:
|
|
1104
|
-
"
|
|
1147
|
+
"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.",
|
|
1105
1148
|
});
|
|
1106
1149
|
}
|
|
1107
1150
|
}
|
|
@@ -1110,9 +1153,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
|
|
|
1110
1153
|
const ratio = trend.last7Cost / trend.priorWeeklyPace;
|
|
1111
1154
|
const advice =
|
|
1112
1155
|
ratio >= TREND_HIGH_RATIO
|
|
1113
|
-
? "
|
|
1156
|
+
? "Spending is up against your own baseline — the graph view shows what changed."
|
|
1114
1157
|
: ratio <= TREND_LOW_RATIO
|
|
1115
|
-
? "
|
|
1158
|
+
? "Spending is well below your recent baseline."
|
|
1116
1159
|
: "";
|
|
1117
1160
|
insights.push({
|
|
1118
1161
|
kind: "structure",
|
package/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
|
14
14
|
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
15
15
|
|
|
16
16
|
import { collectUsageData, TAB_ORDER } from "./data";
|
|
17
|
+
import type { CollectProgress } from "./data";
|
|
17
18
|
import type { BaseStats, TabName, UsageData } from "./data";
|
|
18
19
|
import {
|
|
19
20
|
buildGraphModel,
|
|
@@ -175,6 +176,18 @@ function seriesColor(index: number): string {
|
|
|
175
176
|
return SERIES_COLORS[index % SERIES_COLORS.length]!;
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
/** "14:32" if the timestamp is today, otherwise "16 Jul" (with year if not this year). */
|
|
180
|
+
function formatSinceDate(ms: number): string {
|
|
181
|
+
const d = new Date(ms);
|
|
182
|
+
const now = new Date();
|
|
183
|
+
if (d.toDateString() === now.toDateString()) {
|
|
184
|
+
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
185
|
+
}
|
|
186
|
+
const opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" };
|
|
187
|
+
if (d.getFullYear() !== now.getFullYear()) opts.year = "numeric";
|
|
188
|
+
return d.toLocaleDateString(undefined, opts);
|
|
189
|
+
}
|
|
190
|
+
|
|
178
191
|
function padLeft(s: string, len: number): string {
|
|
179
192
|
const vis = visibleWidth(s);
|
|
180
193
|
if (vis >= len) return s;
|
|
@@ -486,11 +499,14 @@ class UsageComponent {
|
|
|
486
499
|
const hasCost = stats.totals.cost > 0;
|
|
487
500
|
const lines: string[] = [];
|
|
488
501
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
+
}
|
|
494
510
|
lines.push("");
|
|
495
511
|
|
|
496
512
|
if (!hasMessages) {
|
|
@@ -509,13 +525,24 @@ class UsageComponent {
|
|
|
509
525
|
return lines;
|
|
510
526
|
}
|
|
511
527
|
|
|
512
|
-
|
|
513
|
-
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
|
+
};
|
|
514
536
|
|
|
515
537
|
const renderOne = (insight: (typeof insights)[number]): void => {
|
|
516
|
-
const
|
|
517
|
-
const
|
|
518
|
-
|
|
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}`);
|
|
519
546
|
if (insight.advice) {
|
|
520
547
|
for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
|
|
521
548
|
lines.push(`${indent}${th.fg("dim", wrapped)}`);
|
|
@@ -526,12 +553,15 @@ class UsageComponent {
|
|
|
526
553
|
|
|
527
554
|
const alarms = insights.filter((i) => i.kind === "alarm");
|
|
528
555
|
const structure = insights.filter((i) => i.kind === "structure");
|
|
556
|
+
lines.push(sectionHeader("Worth attention", "warning"));
|
|
529
557
|
if (alarms.length > 0) {
|
|
530
|
-
lines.push(th.fg("accent", "Worth attention"));
|
|
531
558
|
for (const insight of alarms) renderOne(insight);
|
|
559
|
+
} else {
|
|
560
|
+
lines.push(` ${th.fg("success", padLeft("✓", 6))} ${th.fg("dim", "no waste patterns flagged for this period")}`);
|
|
561
|
+
lines.push("");
|
|
532
562
|
}
|
|
533
563
|
if (structure.length > 0) {
|
|
534
|
-
|
|
564
|
+
lines.push(sectionHeader("Where it went", "accent"));
|
|
535
565
|
for (const insight of structure) renderOne(insight);
|
|
536
566
|
}
|
|
537
567
|
|
|
@@ -722,7 +752,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
722
752
|
|
|
723
753
|
loader.onAbort = () => finish(null);
|
|
724
754
|
|
|
725
|
-
|
|
755
|
+
const onProgress = (p: CollectProgress): void => {
|
|
756
|
+
if (finished || p.filesToParse === 0) return;
|
|
757
|
+
const files = `${p.filesParsed.toLocaleString()}/${p.filesToParse.toLocaleString()} files`;
|
|
758
|
+
if (p.mode === "update") {
|
|
759
|
+
const since = p.sinceMs !== null ? ` since ${formatSinceDate(p.sinceMs)}` : "";
|
|
760
|
+
loader.setMessage(`Updating your usage history${since}… (${files})`);
|
|
761
|
+
} else if (p.mode === "rebuild") {
|
|
762
|
+
loader.setMessage(`Rebuilding your usage history — the cache format changed… (${files})`);
|
|
763
|
+
} else {
|
|
764
|
+
loader.setMessage(`Building your usage history for the first time… (${files})`);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
collectUsageData({ signal: loader.signal, onProgress })
|
|
726
769
|
.then(finish)
|
|
727
770
|
.catch(() => finish(null));
|
|
728
771
|
|