@tmustier/pi-usage-extension 0.6.1 → 0.7.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.
Files changed (5) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +28 -13
  3. package/data.ts +373 -161
  4. package/index.ts +47 -12
  5. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1] - 2026-07-17
4
+
5
+ ### Added
6
+ - 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.
7
+ - `collectUsageData` accepts an `onProgress` callback reporting mode (`first-run` / `rebuild` / `update`), files to parse, files parsed, and the newest already-ingested session activity timestamp.
8
+
9
+ ## [0.7.0] - 2026-07-17
10
+
11
+ ### Changed
12
+ - **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.
13
+ - 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).
14
+ - 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).
15
+ - 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).
16
+
17
+ ### Internal
18
+ - 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.
19
+ - 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.
20
+
3
21
  ## [0.6.1] - 2026-07-17
4
22
 
5
23
  ### 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 (with a progress message and live file counter), 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,32 @@ function getPeriodsForTimestamp(
525
618
  return periods;
526
619
  }
527
620
 
621
+ const DAY_MS = 24 * HOUR_MS;
622
+ const PROGRESS_REPORT_EVERY = 100;
623
+
528
624
  function addMessagesToUsageData(
529
625
  data: UsageData,
530
626
  sessionId: string,
627
+ project: string,
531
628
  messages: SessionMessage[],
629
+ meta: MessageMeta[],
532
630
  todayMs: number,
533
631
  weekStartMs: number,
534
632
  lastWeekStartMs: number,
535
633
  last30DaysStartMs: number,
536
634
  rawByPeriod: Record<TabName, PeriodRawData>,
537
- globalSessionSpans: Map<string, GlobalSessionSpan>
635
+ costByDayIdx: Map<number, number>
538
636
  ): void {
539
637
  const sessionContributed = { today: false, thisWeek: false, lastWeek: false, last30Days: false, allTime: false };
540
638
 
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.
639
+ for (let mi = 0; mi < messages.length; mi++) {
640
+ const msg = messages[mi]!;
641
+ const mm = meta[mi]!;
642
+
643
+ // Day-indexed cost totals power the burn-trend insight.
544
644
  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
- }
645
+ const dayIdx = Math.floor((msg.timestamp - todayMs) / DAY_MS);
646
+ costByDayIdx.set(dayIdx, (costByDayIdx.get(dayIdx) ?? 0) + msg.cost);
552
647
  }
553
648
 
554
649
  addToHourlyBuckets(data.hourly, msg);
@@ -590,15 +685,31 @@ function addMessagesToUsageData(
590
685
  sessionContributed[period] = true;
591
686
 
592
687
  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
- });
688
+ raw.totalCost += msg.cost;
689
+ raw.totalMessages++;
690
+ const ctx = msg.input + msg.cacheRead + msg.cacheWrite;
691
+ if (ctx >= CTX_TAX_THRESHOLD) {
692
+ raw.ctxHigh.cost += msg.cost;
693
+ raw.ctxHigh.messages++;
694
+ } else if (ctx < CTX_LOW_THRESHOLD) {
695
+ raw.ctxLow.cost += msg.cost;
696
+ raw.ctxLow.messages++;
697
+ }
698
+ raw.projectCosts.set(project, (raw.projectCosts.get(project) ?? 0) + msg.cost);
601
699
  raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
700
+ if (mm.isSessionStart) raw.upfrontCost += msg.cost;
701
+ if (
702
+ !msg.afterCompaction &&
703
+ mm.prevCtx >= MISS_MIN_PREV_CONTEXT &&
704
+ msg.cacheRead < Math.min(MISS_MAX_CACHE_READ, 0.3 * mm.prevCtx)
705
+ ) {
706
+ if (mm.gapMs > TTL_GAP_MS) raw.ttlMissCost += msg.cost;
707
+ else if (mm.gapMs >= 0) raw.prefixMissCost += msg.cost;
708
+ }
709
+ raw.reasoningTokens += msg.reasoning;
710
+ raw.outputTokens += msg.output;
711
+ raw.cacheReadTokens += msg.cacheRead;
712
+ raw.freshTokens += msg.input + msg.cacheWrite;
602
713
  }
603
714
  }
604
715
 
@@ -617,8 +728,21 @@ const STAT_CONCURRENCY = 16;
617
728
  const DEFAULT_PARSE_CONCURRENCY = 4;
618
729
  const AGGREGATE_YIELD_EVERY_FILES = 200;
619
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
+
620
742
  export interface CollectUsageOptions {
621
743
  signal?: AbortSignal;
744
+ /** Called once before parsing begins and periodically while files are parsed. */
745
+ onProgress?: (progress: CollectProgress) => void;
622
746
  /** Defaults to `<agentDir>/sessions`. */
623
747
  sessionsDir?: string;
624
748
  /** Defaults to `<agentDir>/usage-extension-cache.json`. Pass `null` to disable the on-disk cache. */
@@ -684,6 +808,15 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
684
808
  if (signal?.aborted) return null;
685
809
 
686
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
+ }
687
820
  const previous = cachePath ? await loadUsageCache(cachePath) : new Map<string, CachedFileState>();
688
821
  if (signal?.aborted) return null;
689
822
  const current = new Map<string, CachedFileState>();
@@ -708,6 +841,22 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
708
841
  }
709
842
  }
710
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
+
711
860
  // 4. Parse new/changed files with bounded concurrency.
712
861
  {
713
862
  let next = 0;
@@ -721,11 +870,16 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
721
870
  try {
722
871
  buffer = await readFile(filePath);
723
872
  } catch {
873
+ filesParsed++;
724
874
  continue; // File vanished — skip it.
725
875
  }
726
876
  const parsed = await parseSessionBuffer(buffer, signal);
727
877
  if (signal?.aborted) return; // Never cache a partial parse.
728
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
+ }
729
883
  }
730
884
  })
731
885
  );
@@ -759,7 +913,8 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
759
913
  last30Days: emptyPeriodRawData(),
760
914
  allTime: emptyPeriodRawData(),
761
915
  };
762
- const globalSessionSpans = new Map<string, GlobalSessionSpan>();
916
+ const costByDayIdx = new Map<number, number>();
917
+ const seenSessions = new Set<string>();
763
918
  const seenHashes = new Set<string>();
764
919
  let processedFiles = 0;
765
920
 
@@ -772,38 +927,57 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
772
927
  if (signal?.aborted) return null;
773
928
  }
774
929
 
775
- // Deduplicate copied history across branched session files.
776
- // Keep the existing ccusage-style hash so current totals remain comparable.
930
+ // Deduplicate copied history across branched session files, computing
931
+ // adjacency metadata (idle gaps, previous context) on the raw file order
932
+ // so branch copies do not distort miss classification.
933
+ const rawMsgs = state.parsed.messages;
777
934
  const deduped: SessionMessage[] = [];
778
- for (const m of state.parsed.messages) {
935
+ const meta: MessageMeta[] = [];
936
+ for (let i = 0; i < rawMsgs.length; i++) {
937
+ const m = rawMsgs[i]!;
779
938
  const hash = `${m.timestamp}:${m.input + m.output + m.cacheRead + m.cacheWrite}`;
780
939
  if (seenHashes.has(hash)) continue;
781
940
  seenHashes.add(hash);
941
+ const prev = i > 0 ? rawMsgs[i - 1]! : null;
782
942
  deduped.push(m);
943
+ meta.push({
944
+ gapMs: prev && prev.timestamp > 0 && m.timestamp > 0 ? m.timestamp - prev.timestamp : -1,
945
+ prevCtx: prev ? prev.input + prev.cacheRead + prev.cacheWrite : 0,
946
+ isSessionStart: false,
947
+ });
783
948
  }
784
949
  if (deduped.length === 0) continue;
950
+ if (!seenSessions.has(state.parsed.sessionId)) {
951
+ seenSessions.add(state.parsed.sessionId);
952
+ meta[0]!.isSessionStart = true;
953
+ }
785
954
 
786
955
  addMessagesToUsageData(
787
956
  data,
788
957
  state.parsed.sessionId,
958
+ projectLabelFromCwd(state.parsed.cwd),
789
959
  deduped,
960
+ meta,
790
961
  todayMs,
791
962
  weekStartMs,
792
963
  lastWeekStartMs,
793
964
  last30DaysStartMs,
794
965
  rawByPeriod,
795
- globalSessionSpans
966
+ costByDayIdx
796
967
  );
797
968
  }
798
969
 
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);
970
+ // Burn trend: last 7 calendar days vs the average weekly pace of the prior 28.
971
+ let last7 = 0;
972
+ let prior28 = 0;
973
+ for (const [idx, c] of costByDayIdx) {
974
+ if (idx >= -6) last7 += c;
975
+ else if (idx >= -34) prior28 += c;
803
976
  }
977
+ const trend: TrendInfo | null = prior28 > 0 ? { last7Cost: last7, priorWeeklyPace: prior28 / 4 } : null;
804
978
 
805
979
  for (const period of TAB_ORDER) {
806
- data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
980
+ data[period].insights = computeInsights(rawByPeriod[period], trend);
807
981
  }
808
982
 
809
983
  return data;
@@ -813,148 +987,186 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
813
987
  // Insights
814
988
  // =============================================================================
815
989
 
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;
990
+ // Context tax (structure)
991
+ const CTX_TAX_THRESHOLD = 150_000;
992
+ const CTX_LOW_THRESHOLD = 100_000;
993
+ // Project mix (structure)
994
+ const PROJECT_TOP_COUNT = 3;
995
+ const PROJECT_MAX_DOMINANCE_PERCENT = 90;
996
+ // Reasoning share (structure)
997
+ const REASONING_MIN_PERCENT = 5;
998
+ // Burn trend (structure)
999
+ const TREND_HIGH_RATIO = 1.5;
1000
+ const TREND_LOW_RATIO = 0.6;
1001
+ // Cache-miss alarms
1002
+ const TTL_GAP_MS = 5 * 60_000;
1003
+ const MISS_MIN_PREV_CONTEXT = 20_000;
1004
+ const MISS_MAX_CACHE_READ = 5_000;
1005
+ const CACHE_MISS_ALARM_PERCENT = 2;
1006
+ const CACHE_MISS_ALARM_MIN_COST = 1;
1007
+ // Concentration / upfront alarms
821
1008
  const TOP_SESSION_COUNT = 5;
822
- const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
823
- const MIN_PERCENT_TO_SHOW = 1;
1009
+ const CONCENTRATION_ALARM_PERCENT = 35;
1010
+ const UPFRONT_ALARM_PERCENT = 8;
1011
+ // Cache-leverage alarm
1012
+ const LEVERAGE_FLOOR = 5;
1013
+ const LEVERAGE_MIN_COST = 5;
1014
+ const LEVERAGE_MIN_FRESH_TOKENS = 1_000_000;
1015
+
1016
+ function fmtMoney(v: number): string {
1017
+ if (v >= 1000) return `$${(v / 1000).toFixed(1)}k`;
1018
+ if (v >= 100) return `$${Math.round(v)}`;
1019
+ return `$${v.toFixed(2)}`;
1020
+ }
1021
+
1022
+ function fmtPercent(p: number): string {
1023
+ return p >= 10 ? `${Math.round(p)}%` : `${p.toFixed(1)}%`;
1024
+ }
824
1025
 
825
1026
  /**
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.
1027
+ * Insights come in two kinds:
1028
+ * - structure: always-on decomposition of where the period's cost went.
1029
+ * - alarm: fires only when a wasteful pattern is material for the period, so
1030
+ * an all-clear period shows a calm panel instead of a wall of 2% factoids.
1031
+ * Periods with zero recorded cost produce an empty list — the UI renders a
1032
+ * distinct empty-state for that case.
830
1033
  */
831
- function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
832
- if (raw.messages.length === 0) {
1034
+ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodInsights {
1035
+ if (raw.totalMessages === 0 || raw.totalCost <= 0) {
833
1036
  return { insights: [] };
834
1037
  }
1038
+ const total = raw.totalCost;
1039
+ const insights: Insight[] = [];
835
1040
 
836
- const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
837
- if (total <= 0) {
838
- return { insights: [] };
839
- }
1041
+ // --- Alarms (listed first) ---
840
1042
 
841
- const candidates: Insight[] = [];
842
-
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`,
1043
+ const ttlPct = (raw.ttlMissCost / total) * 100;
1044
+ if (ttlPct >= CACHE_MISS_ALARM_PERCENT && raw.ttlMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1045
+ insights.push({
1046
+ kind: "alarm",
1047
+ stat: fmtMoney(raw.ttlMissCost),
1048
+ headline: `spent re-warming caches that expired during idle gaps (${fmtPercent(ttlPct)} of this period)`,
849
1049
  advice:
850
- "All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
1050
+ "Provider caches expire after a few minutes idle (Anthropic ~5min); the next message re-writes the whole context at premium rates. Batching replies instead of trickling them keeps caches warm.",
851
1051
  });
852
1052
  }
853
1053
 
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`,
1054
+ const prefixPct = (raw.prefixMissCost / total) * 100;
1055
+ if (prefixPct >= CACHE_MISS_ALARM_PERCENT && raw.prefixMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1056
+ insights.push({
1057
+ kind: "alarm",
1058
+ stat: fmtMoney(raw.prefixMissCost),
1059
+ headline: `spent on cache misses with no idle gap (${fmtPercent(prefixPct)} of this period)`,
885
1060
  advice:
886
- "These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
1061
+ "The request prefix changed mid-session (compaction excluded), so cached context was re-sent at full price. If this stays high, something in the workflow is defeating provider caching.",
887
1062
  });
888
1063
  }
889
1064
 
890
- // 5. Top-N session concentration.
891
1065
  if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
892
1066
  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"}`,
1067
+ const topWeight = sortedSessions.slice(0, TOP_SESSION_COUNT).reduce((sum, c) => sum + c, 0);
1068
+ const topPct = (topWeight / total) * 100;
1069
+ if (topPct >= CONCENTRATION_ALARM_PERCENT) {
1070
+ insights.push({
1071
+ kind: "alarm",
1072
+ stat: fmtPercent(topPct),
1073
+ headline: `of this period's cost came from just ${TOP_SESSION_COUNT} sessions (of ${raw.sessionCosts.size})`,
1074
+ advice: "Spend is unusually concentrated. The graph view's filters can pinpoint what those sessions were doing.",
1075
+ });
1076
+ }
1077
+ }
1078
+
1079
+ const upfrontPct = (raw.upfrontCost / total) * 100;
1080
+ if (upfrontPct >= UPFRONT_ALARM_PERCENT) {
1081
+ insights.push({
1082
+ kind: "alarm",
1083
+ stat: fmtPercent(upfrontPct),
1084
+ headline: "of cost was the first message of a session",
898
1085
  advice:
899
- "A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
1086
+ "Session starts pay for their whole prompt uncached. Fewer, longer-lived sessions amortize that setup cost.",
900
1087
  });
901
1088
  }
902
1089
 
903
- const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
904
- return { insights };
905
- }
1090
+ if (total >= LEVERAGE_MIN_COST && raw.freshTokens >= LEVERAGE_MIN_FRESH_TOKENS) {
1091
+ const leverage = raw.cacheReadTokens / raw.freshTokens;
1092
+ if (leverage < LEVERAGE_FLOOR) {
1093
+ insights.push({
1094
+ kind: "alarm",
1095
+ stat: `${leverage.toFixed(1)}×`,
1096
+ headline: "cache leverage — cached tokens served per fresh token paid",
1097
+ advice:
1098
+ "Healthy interactive use typically sees 10×+. Low leverage means context is being re-sent uncached — look for workflows that restart conversations.",
1099
+ });
1100
+ }
1101
+ }
906
1102
 
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++;
1103
+ // --- Structure (always-on) ---
1104
+
1105
+ if (raw.ctxHigh.messages > 0) {
1106
+ const pct = (raw.ctxHigh.cost / total) * 100;
1107
+ if (pct >= 1) {
1108
+ const avgHigh = raw.ctxHigh.cost / raw.ctxHigh.messages;
1109
+ const avgLow = raw.ctxLow.messages > 0 ? raw.ctxLow.cost / raw.ctxLow.messages : 0;
1110
+ const cmp =
1111
+ avgLow > 0
1112
+ ? ` — averaging ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
1113
+ : "";
1114
+ insights.push({
1115
+ kind: "structure",
1116
+ stat: fmtPercent(pct),
1117
+ headline: `of your cost was at ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} context${cmp}`,
1118
+ advice: "Context size is the main cost driver. /compact mid-task and /clear between tasks to reset it.",
1119
+ });
941
1120
  }
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++;
1121
+ }
1122
+
1123
+ if (raw.projectCosts.size >= 2) {
1124
+ const top = [...raw.projectCosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, PROJECT_TOP_COUNT);
1125
+ const topPct = (top[0]![1] / total) * 100;
1126
+ if (topPct < PROJECT_MAX_DOMINANCE_PERCENT) {
1127
+ const rest = top
1128
+ .slice(1)
1129
+ .map(([label, c]) => `${label} ${fmtPercent((c / total) * 100)}`)
1130
+ .join(", ");
1131
+ insights.push({
1132
+ kind: "structure",
1133
+ stat: fmtPercent(topPct),
1134
+ headline: `of your cost was ${top[0]![0]}${rest ? ` — then ${rest}` : ""}`,
1135
+ advice: "",
1136
+ });
952
1137
  }
1138
+ }
953
1139
 
954
- if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
1140
+ if (raw.outputTokens > 0) {
1141
+ const reasoningPct = (raw.reasoningTokens / raw.outputTokens) * 100;
1142
+ if (reasoningPct >= REASONING_MIN_PERCENT) {
1143
+ insights.push({
1144
+ kind: "structure",
1145
+ stat: fmtPercent(reasoningPct),
1146
+ headline: "of your output tokens were invisible reasoning",
1147
+ advice:
1148
+ "Reasoning is billed as output but never displayed. Recorded by pi 0.80.3+ (June 2026) only, so older periods understate it.",
1149
+ });
1150
+ }
955
1151
  }
956
1152
 
957
- return parallelCost;
1153
+ if (trend && trend.priorWeeklyPace > 0) {
1154
+ const ratio = trend.last7Cost / trend.priorWeeklyPace;
1155
+ const advice =
1156
+ ratio >= TREND_HIGH_RATIO
1157
+ ? "Spend is accelerating vs your own baseline — the graph view shows what changed."
1158
+ : ratio <= TREND_LOW_RATIO
1159
+ ? "Spend is well below your recent baseline."
1160
+ : "";
1161
+ insights.push({
1162
+ kind: "structure",
1163
+ stat: `${ratio.toFixed(1)}×`,
1164
+ headline: `your last 7 days (${fmtMoney(trend.last7Cost)}) vs your prior 4-week pace (${fmtMoney(trend.priorWeeklyPace)}/wk)`,
1165
+ advice,
1166
+ });
1167
+ }
1168
+
1169
+ return { insights };
958
1170
  }
959
1171
 
960
1172
  function formatThresholdTokens(n: number): string {
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,9 +176,16 @@ function seriesColor(index: number): string {
175
176
  return SERIES_COLORS[index % SERIES_COLORS.length]!;
176
177
  }
177
178
 
178
- function formatInsightPercent(p: number): string {
179
- if (p >= 10) return `${Math.round(p)}%`;
180
- return `${Math.round(p * 10) / 10}%`;
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);
181
189
  }
182
190
 
183
191
  function padLeft(s: string, len: number): string {
@@ -494,7 +502,7 @@ class UsageComponent {
494
502
  lines.push("What's contributing to your cost?");
495
503
  lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
496
504
  lines.push("");
497
- const note = `${TAB_LABELS[this.activeTab]} · weighted by cost (USD) · these overlap and can sum to >100%`;
505
+ const note = `${TAB_LABELS[this.activeTab]} · independent lenses — they overlap and don't sum to 100%`;
498
506
  lines.push(th.fg("dim", note));
499
507
  lines.push("");
500
508
 
@@ -509,21 +517,35 @@ class UsageComponent {
509
517
  return lines;
510
518
  }
511
519
  if (insights.length === 0) {
512
- lines.push(th.fg("dim", " No insights above 1% for this period."));
520
+ lines.push(th.fg("dim", " Nothing notable for this period."));
513
521
  lines.push("");
514
522
  return lines;
515
523
  }
516
524
 
517
- const indent = " ";
525
+ const indent = " ";
518
526
  const adviceWidth = Math.max(width - indent.length, 30);
519
527
 
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)}`);
528
+ const renderOne = (insight: (typeof insights)[number]): void => {
529
+ const marker = insight.kind === "alarm" ? "⚠ " : " ";
530
+ const stat = th.fg("accent", th.bold(padLeft(insight.stat, 5)));
531
+ lines.push(`${marker}${stat} ${insight.headline}`);
532
+ if (insight.advice) {
533
+ for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
534
+ lines.push(`${indent}${th.fg("dim", wrapped)}`);
535
+ }
525
536
  }
526
537
  lines.push("");
538
+ };
539
+
540
+ const alarms = insights.filter((i) => i.kind === "alarm");
541
+ const structure = insights.filter((i) => i.kind === "structure");
542
+ if (alarms.length > 0) {
543
+ lines.push(th.fg("accent", "Worth attention"));
544
+ for (const insight of alarms) renderOne(insight);
545
+ }
546
+ if (structure.length > 0) {
547
+ if (alarms.length > 0) lines.push(th.fg("dim", "Where it went"));
548
+ for (const insight of structure) renderOne(insight);
527
549
  }
528
550
 
529
551
  return lines;
@@ -713,7 +735,20 @@ export default function (pi: ExtensionAPI) {
713
735
 
714
736
  loader.onAbort = () => finish(null);
715
737
 
716
- collectUsageData({ signal: loader.signal })
738
+ const onProgress = (p: CollectProgress): void => {
739
+ if (finished || p.filesToParse === 0) return;
740
+ const files = `${p.filesParsed.toLocaleString()}/${p.filesToParse.toLocaleString()} files`;
741
+ if (p.mode === "update") {
742
+ const since = p.sinceMs !== null ? ` since ${formatSinceDate(p.sinceMs)}` : "";
743
+ loader.setMessage(`Updating your usage history${since}… (${files})`);
744
+ } else if (p.mode === "rebuild") {
745
+ loader.setMessage(`Rebuilding your usage history — the cache format changed… (${files})`);
746
+ } else {
747
+ loader.setMessage(`Building your usage history for the first time… (${files})`);
748
+ }
749
+ };
750
+
751
+ collectUsageData({ signal: loader.signal, onProgress })
717
752
  .then(finish)
718
753
  .catch(() => finish(null));
719
754
 
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.1",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",