@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.
- package/CHANGELOG.md +18 -0
- package/README.md +28 -13
- package/data.ts +373 -161
- package/index.ts +47 -12
- 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** —
|
|
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
|

|
|
67
67
|
|
|
68
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
60
|
-
sessionId: string;
|
|
61
|
-
timestamp: number;
|
|
63
|
+
interface CostCount {
|
|
62
64
|
cost: number;
|
|
63
|
-
|
|
64
|
-
cacheRead: number;
|
|
65
|
-
cacheWrite: number;
|
|
65
|
+
messages: number;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
interface PeriodRawData {
|
|
69
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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 =
|
|
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 !==
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
542
|
-
|
|
543
|
-
|
|
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
|
|
546
|
-
|
|
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.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
966
|
+
costByDayIdx
|
|
796
967
|
);
|
|
797
968
|
}
|
|
798
969
|
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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],
|
|
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
|
-
|
|
817
|
-
const
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
const
|
|
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
|
|
823
|
-
const
|
|
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
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
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,
|
|
832
|
-
if (raw.
|
|
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
|
-
|
|
837
|
-
if (total <= 0) {
|
|
838
|
-
return { insights: [] };
|
|
839
|
-
}
|
|
1041
|
+
// --- Alarms (listed first) ---
|
|
840
1042
|
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
.
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
"
|
|
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
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
"
|
|
1086
|
+
"Session starts pay for their whole prompt uncached. Fewer, longer-lived sessions amortize that setup cost.",
|
|
900
1087
|
});
|
|
901
1088
|
}
|
|
902
1089
|
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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]} ·
|
|
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", "
|
|
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
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
|