@tmustier/pi-usage-extension 0.6.1 → 0.7.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.
Files changed (5) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +28 -13
  3. package/data.ts +329 -161
  4. package/index.ts +22 -13
  5. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-07-17
4
+
5
+ ### Changed
6
+ - **Insights view redesigned around materiality.** Insights are now split into two sections: **Worth attention** (alarms that only fire when a wasteful pattern is material for the period) and **Where it went** (always-on structural lenses). Each insight leads with a pre-formatted stat ($, %, or ×) instead of a raw percentage.
7
+ - Alarms: TTL re-warm tax (large context re-written after a > 5 min idle gap; provider caches expire after a few minutes), prefix-change cache misses (no idle gap; messages right after a pi compaction are excluded), top-5 session concentration (≥ 35%), upfront session-start tax (≥ 8%), and a cache-leverage floor (< 5× cached-per-fresh tokens).
8
+ - Structure: context tax (share of cost at ≥ 150k context, with $/msg vs small-context messages), project mix (top 3 project directories from each session's `cwd`; home prefixes from other machines collapse to `~`, worktrees collapse into their repository), reasoning share of output tokens, and a burn trend (last 7 days vs prior 4-week weekly pace).
9
+ - Removed the old parallel-sessions, large-uncached-prompt, long-running-sessions, and unconditional top-5-concentration insights — measured against real usage they were either immaterial or misattributed (e.g. "parallel cost" was mostly just cost that happened while other sessions existed).
10
+
11
+ ### Internal
12
+ - Cache format bumped to **v3**: per-message `afterCompaction` flag and per-file session `cwd`. First open after upgrading does a one-off full rebuild (`compaction` entries and session headers are now parsed), then warm opens are fast again.
13
+ - Insight aggregation is accumulator-based — raw per-period message arrays are no longer kept, so the insights redesign adds no steady-state memory or time cost.
14
+
3
15
  ## [0.6.1] - 2026-07-17
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -60,12 +60,37 @@ In Pi, run:
60
60
  `/usage` has three view modes, cycled with `v`:
61
61
 
62
62
  - **Table** (default) — per-provider / per-model stats with cost and token breakdown (screenshot at the top of this page).
63
- - **Insights** — narrative characteristics of your cost for the active time period, e.g. *"X% of your cost was at >150k context"*. Insights are **independent characteristics**, not a breakdown, so they overlap and can sum to more than 100%.
63
+ - **Insights** — data-driven characteristics of your cost for the active time period (details below). Insights are **independent lenses**, not a breakdown, so they overlap and don't sum to 100%.
64
64
  - **Graphs** — an interactive braille line-chart explorer for usage over time (details below).
65
65
 
66
66
  ![Insights view of /usage](insights-screenshot.png)
67
67
 
68
- **Unit:** insights are always weighted by recorded API cost (USD). Periods with no recorded cost show an explicit empty state rather than silently switching to a different unit.
68
+ ### Insights
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) |
81
+
82
+ **Where it went** — always-on structural lenses:
83
+
84
+ | Insight | Detail |
85
+ |---|---|
86
+ | Context tax | share of cost spent at ≥ 150k context, with the average per-message cost vs messages under 100k |
87
+ | 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%) |
89
+ | Burn trend | your last 7 days of spend vs your prior 4-week weekly pace (same on every tab) |
90
+
91
+ **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
+
93
+ 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.
69
94
 
70
95
  ### Graph explorer
71
96
 
@@ -80,16 +105,6 @@ The **Graphs** view plots usage over time for the active period as a braille lin
80
105
 
81
106
  Thinking levels are replayed from `thinking_level_change` entries in each session file; messages before the first recorded change appear as `unknown`. Reasoning token counts come from `usage.reasoning` where providers report them; pi only records this field since **pi 0.80.3 (30 June 2026)**, so earlier sessions show zero reasoning tokens even though thinking models were in use.
82
107
 
83
- The insights currently shown:
84
-
85
- | Insight | Threshold |
86
- |---|---|
87
- | Parallel sessions | ≥ 4 sessions active within an exact ±2 min window |
88
- | Large context | `input + cacheRead + cacheWrite > 150k` |
89
- | Large uncached prompt | `input + cacheWrite > 100k` |
90
- | Long-running sessions | session lifetime ≥ 8 hours (global, not per-period slice) |
91
- | Top-session concentration | top 5 sessions by cost |
92
-
93
108
  ### Time Periods
94
109
 
95
110
  | Period | Definition |
@@ -144,7 +159,7 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
144
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.
145
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.
146
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.
147
- - **0.6.0 bumps the cache format to v2** (adds thinking level and reasoning tokens per message). 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, then warm opens are fast again.
148
163
 
149
164
  ## Provider Notes
150
165
 
package/data.ts CHANGED
@@ -47,8 +47,12 @@ export interface TotalStats extends BaseStats {
47
47
  }
48
48
 
49
49
  export interface Insight {
50
- percent: number; // 0-100
50
+ /** Structure insights always show; alarms fire only when material. */
51
+ kind: "structure" | "alarm";
52
+ /** Leading stat, already formatted (e.g. "34%", "$446", "1.4×"). */
53
+ stat: string;
51
54
  headline: string;
55
+ /** Dimmed follow-up line; empty string renders nothing. */
52
56
  advice: string;
53
57
  }
54
58
 
@@ -56,23 +60,47 @@ export interface PeriodInsights {
56
60
  insights: Insight[];
57
61
  }
58
62
 
59
- interface RawMessage {
60
- sessionId: string;
61
- timestamp: number;
63
+ interface CostCount {
62
64
  cost: number;
63
- input: number;
64
- cacheRead: number;
65
- cacheWrite: number;
65
+ messages: number;
66
66
  }
67
67
 
68
68
  interface PeriodRawData {
69
- messages: RawMessage[];
69
+ totalCost: number;
70
+ totalMessages: number;
71
+ /** Messages at ≥ CTX_TAX_THRESHOLD context. */
72
+ ctxHigh: CostCount;
73
+ /** Messages below CTX_LOW_THRESHOLD context (comparison group). */
74
+ ctxLow: CostCount;
75
+ projectCosts: Map<string, number>;
70
76
  sessionCosts: Map<string, number>;
77
+ /** Cost of each session's first-ever message falling in this period. */
78
+ upfrontCost: number;
79
+ /** Cache misses after >TTL_GAP_MS idle — expired-cache re-warms. */
80
+ ttlMissCost: number;
81
+ /** Cache misses without an idle gap (compaction excluded) — prefix changes. */
82
+ prefixMissCost: number;
83
+ reasoningTokens: number;
84
+ outputTokens: number;
85
+ cacheReadTokens: number;
86
+ freshTokens: number;
87
+ }
88
+
89
+ /** Per-message adjacency info, computed on raw file order before dedupe. */
90
+ interface MessageMeta {
91
+ /** Gap to the previous assistant message in the same file; -1 when unknown. */
92
+ gapMs: number;
93
+ /** Context size of the previous assistant message in the same file; 0 when first. */
94
+ prevCtx: number;
95
+ /** True for the first deduped message of a session across all its files. */
96
+ isSessionStart: boolean;
71
97
  }
72
98
 
73
- interface GlobalSessionSpan {
74
- startMs: number;
75
- endMs: number;
99
+ export interface TrendInfo {
100
+ /** Cost over the last 7 calendar days including today. */
101
+ last7Cost: number;
102
+ /** Average weekly cost over the prior 28 days. */
103
+ priorWeeklyPace: number;
76
104
  }
77
105
 
78
106
  export interface TimeFilteredStats {
@@ -145,11 +173,19 @@ export interface SessionMessage {
145
173
  /** Reasoning output tokens reported by the provider (0 when absent). */
146
174
  reasoning: number;
147
175
  timestamp: number;
176
+ /**
177
+ * True when a compaction entry occurred between the previous assistant
178
+ * message and this one. Compaction legitimately changes the request prefix,
179
+ * so such messages are excluded from prefix-change cache-miss accounting.
180
+ */
181
+ afterCompaction: boolean;
148
182
  }
149
183
 
150
184
  export interface ParsedSessionFile {
151
185
  /** Empty string when the file has no session header — such files are ignored. */
152
186
  sessionId: string;
187
+ /** Working directory from the session header; "" when absent. */
188
+ cwd: string;
153
189
  /** Extracted assistant messages, pre-dedupe. Dedupe happens at aggregation. */
154
190
  messages: SessionMessage[];
155
191
  }
@@ -216,6 +252,8 @@ const PATTERN_SESSION_COMPACT = Buffer.from('"type":"session"');
216
252
  const PATTERN_SESSION_SPACED = Buffer.from('"type": "session"');
217
253
  const PATTERN_THINKING_COMPACT = Buffer.from('"type":"thinking_level_change"');
218
254
  const PATTERN_THINKING_SPACED = Buffer.from('"type": "thinking_level_change"');
255
+ const PATTERN_COMPACTION_COMPACT = Buffer.from('"type":"compaction"');
256
+ const PATTERN_COMPACTION_SPACED = Buffer.from('"type": "compaction"');
219
257
 
220
258
  const PARSE_YIELD_EVERY_LINES = 2000;
221
259
 
@@ -224,9 +262,11 @@ function lineMightBeRelevant(line: Buffer): boolean {
224
262
  line.includes(PATTERN_ASSISTANT_COMPACT) ||
225
263
  line.includes(PATTERN_SESSION_COMPACT) ||
226
264
  line.includes(PATTERN_THINKING_COMPACT) ||
265
+ line.includes(PATTERN_COMPACTION_COMPACT) ||
227
266
  line.includes(PATTERN_ASSISTANT_SPACED) ||
228
267
  line.includes(PATTERN_SESSION_SPACED) ||
229
- line.includes(PATTERN_THINKING_SPACED)
268
+ line.includes(PATTERN_THINKING_SPACED) ||
269
+ line.includes(PATTERN_COMPACTION_SPACED)
230
270
  );
231
271
  }
232
272
 
@@ -238,11 +278,13 @@ function lineMightBeRelevant(line: Buffer): boolean {
238
278
  export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal): Promise<ParsedSessionFile> {
239
279
  const messages: SessionMessage[] = [];
240
280
  let sessionId = "";
281
+ let cwd = "";
241
282
  // Assistant messages don't carry the thinking level; pi records it as separate
242
283
  // thinking_level_change entries, always written before the first assistant
243
284
  // message of a session. Replaying them in append order attributes each message
244
285
  // to the level active when it was produced.
245
286
  let thinkingLevel = "";
287
+ let compactionPending = false;
246
288
  let start = 0;
247
289
  let lineNumber = 0;
248
290
 
@@ -253,7 +295,7 @@ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal):
253
295
  lineNumber++;
254
296
  if (lineNumber % PARSE_YIELD_EVERY_LINES === 0) {
255
297
  await new Promise<void>((resolve) => setImmediate(resolve));
256
- if (signal?.aborted) return { sessionId, messages };
298
+ if (signal?.aborted) return { sessionId, cwd, messages };
257
299
  }
258
300
 
259
301
  if (end > start && lineMightBeRelevant(buffer.subarray(start, end))) {
@@ -262,8 +304,11 @@ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal):
262
304
 
263
305
  if (entry.type === "session") {
264
306
  sessionId = entry.id;
307
+ if (typeof entry.cwd === "string") cwd = entry.cwd;
265
308
  } else if (entry.type === "thinking_level_change") {
266
309
  if (typeof entry.thinkingLevel === "string") thinkingLevel = entry.thinkingLevel;
310
+ } else if (entry.type === "compaction") {
311
+ compactionPending = true;
267
312
  } else if (entry.type === "message" && entry.message?.role === "assistant") {
268
313
  const msg = entry.message;
269
314
  if (msg.usage && msg.provider && msg.model) {
@@ -279,7 +324,9 @@ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal):
279
324
  cacheWrite: msg.usage.cacheWrite || 0,
280
325
  reasoning: msg.usage.reasoning || 0,
281
326
  timestamp: msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs),
327
+ afterCompaction: compactionPending,
282
328
  });
329
+ compactionPending = false;
283
330
  }
284
331
  }
285
332
  } catch {
@@ -290,14 +337,14 @@ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal):
290
337
  start = end + 1;
291
338
  }
292
339
 
293
- return { sessionId, messages };
340
+ return { sessionId, cwd, messages };
294
341
  }
295
342
 
296
343
  // =============================================================================
297
344
  // On-disk cache
298
345
  // =============================================================================
299
346
 
300
- const CACHE_VERSION = 2;
347
+ const CACHE_VERSION = 3;
301
348
 
302
349
  type CachedMessageTuple = [
303
350
  providerIdx: number,
@@ -310,12 +357,14 @@ type CachedMessageTuple = [
310
357
  timestamp: number,
311
358
  thinkingLevelIdx: number,
312
359
  reasoning: number,
360
+ afterCompaction: 0 | 1,
313
361
  ];
314
362
 
315
363
  interface CacheFileEntry {
316
364
  size: number;
317
365
  mtimeMs: number;
318
366
  sessionId: string;
367
+ cwd: string;
319
368
  messages: CachedMessageTuple[];
320
369
  }
321
370
 
@@ -344,6 +393,7 @@ export async function loadUsageCache(cachePath: string): Promise<Map<string, Cac
344
393
  typeof entry.size !== "number" ||
345
394
  typeof entry.mtimeMs !== "number" ||
346
395
  typeof entry.sessionId !== "string" ||
396
+ typeof entry.cwd !== "string" ||
347
397
  !Array.isArray(entry.messages)
348
398
  ) {
349
399
  continue;
@@ -351,7 +401,7 @@ export async function loadUsageCache(cachePath: string): Promise<Map<string, Cac
351
401
  const messages: SessionMessage[] = [];
352
402
  let valid = true;
353
403
  for (const tuple of entry.messages) {
354
- if (!Array.isArray(tuple) || tuple.length !== 10) {
404
+ if (!Array.isArray(tuple) || tuple.length !== 11) {
355
405
  valid = false;
356
406
  break;
357
407
  }
@@ -373,13 +423,14 @@ export async function loadUsageCache(cachePath: string): Promise<Map<string, Cac
373
423
  cacheWrite: Number(tuple[6]) || 0,
374
424
  timestamp: Number(tuple[7]) || 0,
375
425
  reasoning: Number(tuple[9]) || 0,
426
+ afterCompaction: tuple[10] === 1,
376
427
  });
377
428
  }
378
429
  if (!valid) continue;
379
430
  result.set(filePath, {
380
431
  size: entry.size,
381
432
  mtimeMs: entry.mtimeMs,
382
- parsed: { sessionId: entry.sessionId, messages },
433
+ parsed: { sessionId: entry.sessionId, cwd: entry.cwd, messages },
383
434
  });
384
435
  }
385
436
  return result;
@@ -404,6 +455,7 @@ export async function saveUsageCache(cachePath: string, states: Map<string, Cach
404
455
  size: state.size,
405
456
  mtimeMs: state.mtimeMs,
406
457
  sessionId: state.parsed.sessionId,
458
+ cwd: state.parsed.cwd,
407
459
  messages: state.parsed.messages.map((m): CachedMessageTuple => [
408
460
  intern(m.provider),
409
461
  intern(m.model),
@@ -415,6 +467,7 @@ export async function saveUsageCache(cachePath: string, states: Map<string, Cach
415
467
  m.timestamp,
416
468
  intern(m.thinkingLevel),
417
469
  m.reasoning,
470
+ m.afterCompaction ? 1 : 0,
418
471
  ]),
419
472
  };
420
473
  }
@@ -452,7 +505,47 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
452
505
  }
453
506
 
454
507
  function emptyPeriodRawData(): PeriodRawData {
455
- return { messages: [], sessionCosts: new Map() };
508
+ return {
509
+ totalCost: 0,
510
+ totalMessages: 0,
511
+ ctxHigh: { cost: 0, messages: 0 },
512
+ ctxLow: { cost: 0, messages: 0 },
513
+ projectCosts: new Map(),
514
+ sessionCosts: new Map(),
515
+ upfrontCost: 0,
516
+ ttlMissCost: 0,
517
+ prefixMissCost: 0,
518
+ reasoningTokens: 0,
519
+ outputTokens: 0,
520
+ cacheReadTokens: 0,
521
+ freshTokens: 0,
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Collapse a session cwd to a short, stable project label: `~` for the home
527
+ * directory, up to two path segments below home (worktrees collapse to their
528
+ * repository), absolute paths elsewhere.
529
+ */
530
+ export function projectLabelFromCwd(cwd: string): string {
531
+ if (!cwd) return "(unknown)";
532
+ // Collapse any home-directory prefix to "~", not just the current user's —
533
+ // session stores merged from other machines can carry a different username.
534
+ const home = homedir();
535
+ let homePrefix: string | null = null;
536
+ if (cwd === home || cwd.startsWith(home + "/")) {
537
+ homePrefix = home;
538
+ } else {
539
+ const m = /^(\/Users\/[^/]+|\/home\/[^/]+)(?=\/|$)/.exec(cwd);
540
+ if (m) homePrefix = m[1]!;
541
+ }
542
+ if (homePrefix !== null && cwd.length <= homePrefix.length) return "~";
543
+ let rel = homePrefix !== null ? cwd.slice(homePrefix.length + 1) : cwd;
544
+ const wt = rel.indexOf("/.worktrees/");
545
+ if (wt !== -1) rel = rel.slice(0, wt);
546
+ const parts = rel.split("/").filter(Boolean);
547
+ const label = parts.slice(0, 2).join("/");
548
+ return homePrefix !== null ? `~/${label}` : `/${label}`;
456
549
  }
457
550
 
458
551
  function emptyUsageData(bounds: PeriodBounds): UsageData {
@@ -525,30 +618,31 @@ function getPeriodsForTimestamp(
525
618
  return periods;
526
619
  }
527
620
 
621
+ const DAY_MS = 24 * HOUR_MS;
622
+
528
623
  function addMessagesToUsageData(
529
624
  data: UsageData,
530
625
  sessionId: string,
626
+ project: string,
531
627
  messages: SessionMessage[],
628
+ meta: MessageMeta[],
532
629
  todayMs: number,
533
630
  weekStartMs: number,
534
631
  lastWeekStartMs: number,
535
632
  last30DaysStartMs: number,
536
633
  rawByPeriod: Record<TabName, PeriodRawData>,
537
- globalSessionSpans: Map<string, GlobalSessionSpan>
634
+ costByDayIdx: Map<number, number>
538
635
  ): void {
539
636
  const sessionContributed = { today: false, thisWeek: false, lastWeek: false, last30Days: false, allTime: false };
540
637
 
541
- for (const msg of messages) {
542
- // Track real per-session lifetime across every message we see, regardless of
543
- // which period the message falls into. Used later for the "8h+ session" insight.
638
+ for (let mi = 0; mi < messages.length; mi++) {
639
+ const msg = messages[mi]!;
640
+ const mm = meta[mi]!;
641
+
642
+ // Day-indexed cost totals power the burn-trend insight.
544
643
  if (msg.timestamp > 0) {
545
- const span = globalSessionSpans.get(sessionId);
546
- if (!span) {
547
- globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
548
- } else {
549
- if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
550
- if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
551
- }
644
+ const dayIdx = Math.floor((msg.timestamp - todayMs) / DAY_MS);
645
+ costByDayIdx.set(dayIdx, (costByDayIdx.get(dayIdx) ?? 0) + msg.cost);
552
646
  }
553
647
 
554
648
  addToHourlyBuckets(data.hourly, msg);
@@ -590,15 +684,31 @@ function addMessagesToUsageData(
590
684
  sessionContributed[period] = true;
591
685
 
592
686
  const raw = rawByPeriod[period];
593
- raw.messages.push({
594
- sessionId,
595
- timestamp: msg.timestamp,
596
- cost: msg.cost,
597
- input: msg.input,
598
- cacheRead: msg.cacheRead,
599
- cacheWrite: msg.cacheWrite,
600
- });
687
+ raw.totalCost += msg.cost;
688
+ raw.totalMessages++;
689
+ const ctx = msg.input + msg.cacheRead + msg.cacheWrite;
690
+ if (ctx >= CTX_TAX_THRESHOLD) {
691
+ raw.ctxHigh.cost += msg.cost;
692
+ raw.ctxHigh.messages++;
693
+ } else if (ctx < CTX_LOW_THRESHOLD) {
694
+ raw.ctxLow.cost += msg.cost;
695
+ raw.ctxLow.messages++;
696
+ }
697
+ raw.projectCosts.set(project, (raw.projectCosts.get(project) ?? 0) + msg.cost);
601
698
  raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
699
+ if (mm.isSessionStart) raw.upfrontCost += msg.cost;
700
+ if (
701
+ !msg.afterCompaction &&
702
+ mm.prevCtx >= MISS_MIN_PREV_CONTEXT &&
703
+ msg.cacheRead < Math.min(MISS_MAX_CACHE_READ, 0.3 * mm.prevCtx)
704
+ ) {
705
+ if (mm.gapMs > TTL_GAP_MS) raw.ttlMissCost += msg.cost;
706
+ else if (mm.gapMs >= 0) raw.prefixMissCost += msg.cost;
707
+ }
708
+ raw.reasoningTokens += msg.reasoning;
709
+ raw.outputTokens += msg.output;
710
+ raw.cacheReadTokens += msg.cacheRead;
711
+ raw.freshTokens += msg.input + msg.cacheWrite;
602
712
  }
603
713
  }
604
714
 
@@ -759,7 +869,8 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
759
869
  last30Days: emptyPeriodRawData(),
760
870
  allTime: emptyPeriodRawData(),
761
871
  };
762
- const globalSessionSpans = new Map<string, GlobalSessionSpan>();
872
+ const costByDayIdx = new Map<number, number>();
873
+ const seenSessions = new Set<string>();
763
874
  const seenHashes = new Set<string>();
764
875
  let processedFiles = 0;
765
876
 
@@ -772,38 +883,57 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
772
883
  if (signal?.aborted) return null;
773
884
  }
774
885
 
775
- // Deduplicate copied history across branched session files.
776
- // Keep the existing ccusage-style hash so current totals remain comparable.
886
+ // Deduplicate copied history across branched session files, computing
887
+ // adjacency metadata (idle gaps, previous context) on the raw file order
888
+ // so branch copies do not distort miss classification.
889
+ const rawMsgs = state.parsed.messages;
777
890
  const deduped: SessionMessage[] = [];
778
- for (const m of state.parsed.messages) {
891
+ const meta: MessageMeta[] = [];
892
+ for (let i = 0; i < rawMsgs.length; i++) {
893
+ const m = rawMsgs[i]!;
779
894
  const hash = `${m.timestamp}:${m.input + m.output + m.cacheRead + m.cacheWrite}`;
780
895
  if (seenHashes.has(hash)) continue;
781
896
  seenHashes.add(hash);
897
+ const prev = i > 0 ? rawMsgs[i - 1]! : null;
782
898
  deduped.push(m);
899
+ meta.push({
900
+ gapMs: prev && prev.timestamp > 0 && m.timestamp > 0 ? m.timestamp - prev.timestamp : -1,
901
+ prevCtx: prev ? prev.input + prev.cacheRead + prev.cacheWrite : 0,
902
+ isSessionStart: false,
903
+ });
783
904
  }
784
905
  if (deduped.length === 0) continue;
906
+ if (!seenSessions.has(state.parsed.sessionId)) {
907
+ seenSessions.add(state.parsed.sessionId);
908
+ meta[0]!.isSessionStart = true;
909
+ }
785
910
 
786
911
  addMessagesToUsageData(
787
912
  data,
788
913
  state.parsed.sessionId,
914
+ projectLabelFromCwd(state.parsed.cwd),
789
915
  deduped,
916
+ meta,
790
917
  todayMs,
791
918
  weekStartMs,
792
919
  lastWeekStartMs,
793
920
  last30DaysStartMs,
794
921
  rawByPeriod,
795
- globalSessionSpans
922
+ costByDayIdx
796
923
  );
797
924
  }
798
925
 
799
- // Classify sessions that are globally long-running once, then reuse across periods.
800
- const longSessionIds = new Set<string>();
801
- for (const [id, span] of globalSessionSpans) {
802
- if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
926
+ // Burn trend: last 7 calendar days vs the average weekly pace of the prior 28.
927
+ let last7 = 0;
928
+ let prior28 = 0;
929
+ for (const [idx, c] of costByDayIdx) {
930
+ if (idx >= -6) last7 += c;
931
+ else if (idx >= -34) prior28 += c;
803
932
  }
933
+ const trend: TrendInfo | null = prior28 > 0 ? { last7Cost: last7, priorWeeklyPace: prior28 / 4 } : null;
804
934
 
805
935
  for (const period of TAB_ORDER) {
806
- data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
936
+ data[period].insights = computeInsights(rawByPeriod[period], trend);
807
937
  }
808
938
 
809
939
  return data;
@@ -813,148 +943,186 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
813
943
  // Insights
814
944
  // =============================================================================
815
945
 
816
- const PARALLEL_WINDOW_MS = 2 * 60_000; // exact ±N milliseconds around each message
817
- const PARALLEL_SESSION_THRESHOLD = 4;
818
- const LARGE_CONTEXT_THRESHOLD = 150_000;
819
- const LARGE_CACHE_MISS_THRESHOLD = 100_000;
820
- const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
946
+ // Context tax (structure)
947
+ const CTX_TAX_THRESHOLD = 150_000;
948
+ const CTX_LOW_THRESHOLD = 100_000;
949
+ // Project mix (structure)
950
+ const PROJECT_TOP_COUNT = 3;
951
+ const PROJECT_MAX_DOMINANCE_PERCENT = 90;
952
+ // Reasoning share (structure)
953
+ const REASONING_MIN_PERCENT = 5;
954
+ // Burn trend (structure)
955
+ const TREND_HIGH_RATIO = 1.5;
956
+ const TREND_LOW_RATIO = 0.6;
957
+ // Cache-miss alarms
958
+ const TTL_GAP_MS = 5 * 60_000;
959
+ const MISS_MIN_PREV_CONTEXT = 20_000;
960
+ const MISS_MAX_CACHE_READ = 5_000;
961
+ const CACHE_MISS_ALARM_PERCENT = 2;
962
+ const CACHE_MISS_ALARM_MIN_COST = 1;
963
+ // Concentration / upfront alarms
821
964
  const TOP_SESSION_COUNT = 5;
822
- const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
823
- const MIN_PERCENT_TO_SHOW = 1;
965
+ const CONCENTRATION_ALARM_PERCENT = 35;
966
+ const UPFRONT_ALARM_PERCENT = 8;
967
+ // Cache-leverage alarm
968
+ const LEVERAGE_FLOOR = 5;
969
+ const LEVERAGE_MIN_COST = 5;
970
+ const LEVERAGE_MIN_FRESH_TOKENS = 1_000_000;
971
+
972
+ function fmtMoney(v: number): string {
973
+ if (v >= 1000) return `$${(v / 1000).toFixed(1)}k`;
974
+ if (v >= 100) return `$${Math.round(v)}`;
975
+ return `$${v.toFixed(2)}`;
976
+ }
977
+
978
+ function fmtPercent(p: number): string {
979
+ return p >= 10 ? `${Math.round(p)}%` : `${p.toFixed(1)}%`;
980
+ }
824
981
 
825
982
  /**
826
- * Insights are weighted by recorded API cost. Periods with zero total cost produce
827
- * an empty `insights` list — the UI renders a distinct empty-state for that case.
828
- * Long-running-session classification is passed in from a global pass so that a
829
- * session's real lifetime is used rather than the slice visible inside this period.
983
+ * Insights come in two kinds:
984
+ * - structure: always-on decomposition of where the period's cost went.
985
+ * - alarm: fires only when a wasteful pattern is material for the period, so
986
+ * an all-clear period shows a calm panel instead of a wall of 2% factoids.
987
+ * Periods with zero recorded cost produce an empty list — the UI renders a
988
+ * distinct empty-state for that case.
830
989
  */
831
- function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
832
- if (raw.messages.length === 0) {
990
+ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodInsights {
991
+ if (raw.totalMessages === 0 || raw.totalCost <= 0) {
833
992
  return { insights: [] };
834
993
  }
994
+ const total = raw.totalCost;
995
+ const insights: Insight[] = [];
835
996
 
836
- const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
837
- if (total <= 0) {
838
- return { insights: [] };
839
- }
840
-
841
- const candidates: Insight[] = [];
997
+ // --- Alarms (listed first) ---
842
998
 
843
- // 1. Parallel sessions — ≥ N unique sessions active within an exact ±W ms window.
844
- const parallelWeight = computeParallelCostWeight(raw.messages);
845
- if (parallelWeight !== null) {
846
- candidates.push({
847
- percent: (parallelWeight / total) * 100,
848
- headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
999
+ const ttlPct = (raw.ttlMissCost / total) * 100;
1000
+ if (ttlPct >= CACHE_MISS_ALARM_PERCENT && raw.ttlMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1001
+ insights.push({
1002
+ kind: "alarm",
1003
+ stat: fmtMoney(raw.ttlMissCost),
1004
+ headline: `spent re-warming caches that expired during idle gaps (${fmtPercent(ttlPct)} of this period)`,
849
1005
  advice:
850
- "All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
1006
+ "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.",
851
1007
  });
852
1008
  }
853
1009
 
854
- // 2. Large context — input + cacheRead + cacheWrite > threshold.
855
- const largeContextWeight = raw.messages
856
- .filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD)
857
- .reduce((sum, m) => sum + m.cost, 0);
858
- candidates.push({
859
- percent: (largeContextWeight / total) * 100,
860
- headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
861
- advice:
862
- "Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks.",
863
- });
864
-
865
- // 3. Large uncached prompt — fresh (non-cached) input > threshold, per the v0.2.0 formula.
866
- const uncachedWeight = raw.messages
867
- .filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD)
868
- .reduce((sum, m) => sum + m.cost, 0);
869
- candidates.push({
870
- percent: (uncachedWeight / total) * 100,
871
- headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
872
- advice:
873
- "Uncached input is expensive, and often happens when sending a message to a session that has gone idle. /compact before stepping away keeps the cold-start small.",
874
- });
875
-
876
- // 4. Long-running sessions — classification comes from the global pass so we use
877
- // true session lifetime, not just the span visible inside this period slice.
878
- const longWeight = raw.messages
879
- .filter((m) => longSessionIds.has(m.sessionId))
880
- .reduce((sum, m) => sum + m.cost, 0);
881
- if (longWeight > 0) {
882
- candidates.push({
883
- percent: (longWeight / total) * 100,
884
- headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3_600_000}+ hours`,
1010
+ const prefixPct = (raw.prefixMissCost / total) * 100;
1011
+ if (prefixPct >= CACHE_MISS_ALARM_PERCENT && raw.prefixMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1012
+ insights.push({
1013
+ kind: "alarm",
1014
+ stat: fmtMoney(raw.prefixMissCost),
1015
+ headline: `spent on cache misses with no idle gap (${fmtPercent(prefixPct)} of this period)`,
885
1016
  advice:
886
- "These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
1017
+ "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.",
887
1018
  });
888
1019
  }
889
1020
 
890
- // 5. Top-N session concentration.
891
1021
  if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
892
1022
  const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
893
- const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
894
- const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
895
- candidates.push({
896
- percent: (topWeight / total) * 100,
897
- headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
1023
+ const topWeight = sortedSessions.slice(0, TOP_SESSION_COUNT).reduce((sum, c) => sum + c, 0);
1024
+ const topPct = (topWeight / total) * 100;
1025
+ if (topPct >= CONCENTRATION_ALARM_PERCENT) {
1026
+ insights.push({
1027
+ kind: "alarm",
1028
+ stat: fmtPercent(topPct),
1029
+ headline: `of this period's cost came from just ${TOP_SESSION_COUNT} sessions (of ${raw.sessionCosts.size})`,
1030
+ advice: "Spend is unusually concentrated. The graph view's filters can pinpoint what those sessions were doing.",
1031
+ });
1032
+ }
1033
+ }
1034
+
1035
+ const upfrontPct = (raw.upfrontCost / total) * 100;
1036
+ if (upfrontPct >= UPFRONT_ALARM_PERCENT) {
1037
+ insights.push({
1038
+ kind: "alarm",
1039
+ stat: fmtPercent(upfrontPct),
1040
+ headline: "of cost was the first message of a session",
898
1041
  advice:
899
- "A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
1042
+ "Session starts pay for their whole prompt uncached. Fewer, longer-lived sessions amortize that setup cost.",
900
1043
  });
901
1044
  }
902
1045
 
903
- const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
904
- return { insights };
905
- }
1046
+ if (total >= LEVERAGE_MIN_COST && raw.freshTokens >= LEVERAGE_MIN_FRESH_TOKENS) {
1047
+ const leverage = raw.cacheReadTokens / raw.freshTokens;
1048
+ if (leverage < LEVERAGE_FLOOR) {
1049
+ insights.push({
1050
+ kind: "alarm",
1051
+ stat: `${leverage.toFixed(1)}×`,
1052
+ headline: "cache leverage — cached tokens served per fresh token paid",
1053
+ advice:
1054
+ "Healthy interactive use typically sees 10×+. Low leverage means context is being re-sent uncached — look for workflows that restart conversations.",
1055
+ });
1056
+ }
1057
+ }
906
1058
 
907
- /**
908
- * Two-pointer sweep of messages sorted by timestamp. For each message, count the
909
- * number of distinct session IDs whose messages fall within an exact ± window.
910
- * Returns the total cost attributable to moments when ≥ threshold sessions were
911
- * active, or null if the period has too few sessions/messages to call it.
912
- *
913
- * Messages with missing/invalid timestamps (parsed as 0) are filtered out first —
914
- * otherwise they would collapse into a single synthetic instant and inflate the
915
- * parallel count on older or incomplete logs.
916
- */
917
- function computeParallelCostWeight(messages: RawMessage[]): number | null {
918
- const timed = messages.filter((m) => m.timestamp > 0);
919
- if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT) return null;
920
- const distinctSessions = new Set(timed.map((m) => m.sessionId));
921
- if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD) return null;
922
-
923
- const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
924
- const sidCount = new Map<string, number>();
925
- let uniqueCount = 0;
926
- let left = 0;
927
- let right = 0;
928
- let parallelCost = 0;
929
-
930
- for (let i = 0; i < sorted.length; i++) {
931
- const current = sorted[i]!;
932
- const high = current.timestamp + PARALLEL_WINDOW_MS;
933
- const low = current.timestamp - PARALLEL_WINDOW_MS;
934
-
935
- while (right < sorted.length && sorted[right]!.timestamp <= high) {
936
- const sid = sorted[right]!.sessionId;
937
- const next = (sidCount.get(sid) ?? 0) + 1;
938
- sidCount.set(sid, next);
939
- if (next === 1) uniqueCount++;
940
- right++;
1059
+ // --- Structure (always-on) ---
1060
+
1061
+ if (raw.ctxHigh.messages > 0) {
1062
+ const pct = (raw.ctxHigh.cost / total) * 100;
1063
+ if (pct >= 1) {
1064
+ const avgHigh = raw.ctxHigh.cost / raw.ctxHigh.messages;
1065
+ const avgLow = raw.ctxLow.messages > 0 ? raw.ctxLow.cost / raw.ctxLow.messages : 0;
1066
+ const cmp =
1067
+ avgLow > 0
1068
+ ? ` — averaging ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
1069
+ : "";
1070
+ insights.push({
1071
+ kind: "structure",
1072
+ stat: fmtPercent(pct),
1073
+ headline: `of your cost was at ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} context${cmp}`,
1074
+ advice: "Context size is the main cost driver. /compact mid-task and /clear between tasks to reset it.",
1075
+ });
941
1076
  }
942
- while (left < right && sorted[left]!.timestamp < low) {
943
- const sid = sorted[left]!.sessionId;
944
- const next = (sidCount.get(sid) ?? 0) - 1;
945
- if (next === 0) {
946
- sidCount.delete(sid);
947
- uniqueCount--;
948
- } else {
949
- sidCount.set(sid, next);
950
- }
951
- left++;
1077
+ }
1078
+
1079
+ if (raw.projectCosts.size >= 2) {
1080
+ const top = [...raw.projectCosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, PROJECT_TOP_COUNT);
1081
+ const topPct = (top[0]![1] / total) * 100;
1082
+ if (topPct < PROJECT_MAX_DOMINANCE_PERCENT) {
1083
+ const rest = top
1084
+ .slice(1)
1085
+ .map(([label, c]) => `${label} ${fmtPercent((c / total) * 100)}`)
1086
+ .join(", ");
1087
+ insights.push({
1088
+ kind: "structure",
1089
+ stat: fmtPercent(topPct),
1090
+ headline: `of your cost was ${top[0]![0]}${rest ? ` — then ${rest}` : ""}`,
1091
+ advice: "",
1092
+ });
952
1093
  }
1094
+ }
953
1095
 
954
- if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
1096
+ if (raw.outputTokens > 0) {
1097
+ const reasoningPct = (raw.reasoningTokens / raw.outputTokens) * 100;
1098
+ if (reasoningPct >= REASONING_MIN_PERCENT) {
1099
+ insights.push({
1100
+ kind: "structure",
1101
+ stat: fmtPercent(reasoningPct),
1102
+ headline: "of your output tokens were invisible reasoning",
1103
+ advice:
1104
+ "Reasoning is billed as output but never displayed. Recorded by pi 0.80.3+ (June 2026) only, so older periods understate it.",
1105
+ });
1106
+ }
1107
+ }
1108
+
1109
+ if (trend && trend.priorWeeklyPace > 0) {
1110
+ const ratio = trend.last7Cost / trend.priorWeeklyPace;
1111
+ const advice =
1112
+ ratio >= TREND_HIGH_RATIO
1113
+ ? "Spend is accelerating vs your own baseline — the graph view shows what changed."
1114
+ : ratio <= TREND_LOW_RATIO
1115
+ ? "Spend is well below your recent baseline."
1116
+ : "";
1117
+ insights.push({
1118
+ kind: "structure",
1119
+ stat: `${ratio.toFixed(1)}×`,
1120
+ headline: `your last 7 days (${fmtMoney(trend.last7Cost)}) vs your prior 4-week pace (${fmtMoney(trend.priorWeeklyPace)}/wk)`,
1121
+ advice,
1122
+ });
955
1123
  }
956
1124
 
957
- return parallelCost;
1125
+ return { insights };
958
1126
  }
959
1127
 
960
1128
  function formatThresholdTokens(n: number): string {
package/index.ts CHANGED
@@ -175,11 +175,6 @@ function seriesColor(index: number): string {
175
175
  return SERIES_COLORS[index % SERIES_COLORS.length]!;
176
176
  }
177
177
 
178
- function formatInsightPercent(p: number): string {
179
- if (p >= 10) return `${Math.round(p)}%`;
180
- return `${Math.round(p * 10) / 10}%`;
181
- }
182
-
183
178
  function padLeft(s: string, len: number): string {
184
179
  const vis = visibleWidth(s);
185
180
  if (vis >= len) return s;
@@ -494,7 +489,7 @@ class UsageComponent {
494
489
  lines.push("What's contributing to your cost?");
495
490
  lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
496
491
  lines.push("");
497
- const note = `${TAB_LABELS[this.activeTab]} · weighted by cost (USD) · these overlap and can sum to >100%`;
492
+ const note = `${TAB_LABELS[this.activeTab]} · independent lenses — they overlap and don't sum to 100%`;
498
493
  lines.push(th.fg("dim", note));
499
494
  lines.push("");
500
495
 
@@ -509,21 +504,35 @@ class UsageComponent {
509
504
  return lines;
510
505
  }
511
506
  if (insights.length === 0) {
512
- lines.push(th.fg("dim", " No insights above 1% for this period."));
507
+ lines.push(th.fg("dim", " Nothing notable for this period."));
513
508
  lines.push("");
514
509
  return lines;
515
510
  }
516
511
 
517
- const indent = " ";
512
+ const indent = " ";
518
513
  const adviceWidth = Math.max(width - indent.length, 30);
519
514
 
520
- for (const insight of insights) {
521
- const pct = th.fg("accent", th.bold(formatInsightPercent(insight.percent)));
522
- lines.push(`${pct} ${insight.headline}`);
523
- for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
524
- lines.push(`${indent}${th.fg("dim", wrapped)}`);
515
+ const renderOne = (insight: (typeof insights)[number]): void => {
516
+ const marker = insight.kind === "alarm" ? "⚠ " : " ";
517
+ const stat = th.fg("accent", th.bold(padLeft(insight.stat, 5)));
518
+ lines.push(`${marker}${stat} ${insight.headline}`);
519
+ if (insight.advice) {
520
+ for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
521
+ lines.push(`${indent}${th.fg("dim", wrapped)}`);
522
+ }
525
523
  }
526
524
  lines.push("");
525
+ };
526
+
527
+ const alarms = insights.filter((i) => i.kind === "alarm");
528
+ const structure = insights.filter((i) => i.kind === "structure");
529
+ if (alarms.length > 0) {
530
+ lines.push(th.fg("accent", "Worth attention"));
531
+ for (const insight of alarms) renderOne(insight);
532
+ }
533
+ if (structure.length > 0) {
534
+ if (alarms.length > 0) lines.push(th.fg("dim", "Where it went"));
535
+ for (const insight of structure) renderOne(insight);
527
536
  }
528
537
 
529
538
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",