@tmustier/pi-usage-extension 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +31 -15
- package/data.ts +329 -161
- package/graph.ts +29 -4
- package/index.ts +22 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-07-17
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- **Insights view redesigned around materiality.** Insights are now split into two sections: **Worth attention** (alarms that only fire when a wasteful pattern is material for the period) and **Where it went** (always-on structural lenses). Each insight leads with a pre-formatted stat ($, %, or ×) instead of a raw percentage.
|
|
7
|
+
- Alarms: TTL re-warm tax (large context re-written after a > 5 min idle gap; provider caches expire after a few minutes), prefix-change cache misses (no idle gap; messages right after a pi compaction are excluded), top-5 session concentration (≥ 35%), upfront session-start tax (≥ 8%), and a cache-leverage floor (< 5× cached-per-fresh tokens).
|
|
8
|
+
- Structure: context tax (share of cost at ≥ 150k context, with $/msg vs small-context messages), project mix (top 3 project directories from each session's `cwd`; home prefixes from other machines collapse to `~`, worktrees collapse into their repository), reasoning share of output tokens, and a burn trend (last 7 days vs prior 4-week weekly pace).
|
|
9
|
+
- Removed the old parallel-sessions, large-uncached-prompt, long-running-sessions, and unconditional top-5-concentration insights — measured against real usage they were either immaterial or misattributed (e.g. "parallel cost" was mostly just cost that happened while other sessions existed).
|
|
10
|
+
|
|
11
|
+
### Internal
|
|
12
|
+
- Cache format bumped to **v3**: per-message `afterCompaction` flag and per-file session `cwd`. First open after upgrading does a one-off full rebuild (`compaction` entries and session headers are now parsed), then warm opens are fast again.
|
|
13
|
+
- Insight aggregation is accumulator-based — raw per-period message arrays are no longer kept, so the insights redesign adds no steady-state memory or time cost.
|
|
14
|
+
|
|
15
|
+
## [0.6.1] - 2026-07-17
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Graph lines are clipped to each series' active range (first to last bucket with usage). Applies to every grouping — provider, model, thinking level, `other`, and Total — so a model that starts late (e.g. reasoning tokens, only recorded since pi 0.80.3) or gets retired no longer draws a flat zero/flat cumulative tail across the whole period.
|
|
19
|
+
|
|
3
20
|
## [0.6.0] - 2026-07-17
|
|
4
21
|
|
|
5
22
|
### Added
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A Pi extension that displays aggregated usage statistics across all sessions.
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-07-17 (0.6.
|
|
10
|
+
- **Last updated:** 2026-07-17 (0.6.1)
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -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
|
|
|
@@ -76,18 +101,9 @@ The **Graphs** view plots usage over time for the active period as a braille lin
|
|
|
76
101
|
- **Cumulative vs per-bucket** (`c` to toggle): running total across the period (default), or the raw per-bucket rate.
|
|
77
102
|
- **Filtering**: move the legend cursor with `↑`/`↓` and toggle series visibility with `Enter`/`Space` (`a` shows all again). The y-axis rescales to the visible series — hide the big lines to zoom into the small ones.
|
|
78
103
|
- **Buckets**: hourly for Today / This Week / Last Week, daily for Last 30 Days / All Time.
|
|
104
|
+
- **Line clipping**: every series (provider, model, thinking level, `other`, Total) is drawn only between its first and last bucket with usage, so late-starting or retired series don't drag a flat zero/flat tail across the whole period.
|
|
79
105
|
|
|
80
|
-
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.
|
|
81
|
-
|
|
82
|
-
The insights currently shown:
|
|
83
|
-
|
|
84
|
-
| Insight | Threshold |
|
|
85
|
-
|---|---|
|
|
86
|
-
| Parallel sessions | ≥ 4 sessions active within an exact ±2 min window |
|
|
87
|
-
| Large context | `input + cacheRead + cacheWrite > 150k` |
|
|
88
|
-
| Large uncached prompt | `input + cacheWrite > 100k` |
|
|
89
|
-
| Long-running sessions | session lifetime ≥ 8 hours (global, not per-period slice) |
|
|
90
|
-
| Top-session concentration | top 5 sessions by cost |
|
|
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.
|
|
91
107
|
|
|
92
108
|
### Time Periods
|
|
93
109
|
|
|
@@ -143,7 +159,7 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
|
|
|
143
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.
|
|
144
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.
|
|
145
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.
|
|
146
|
-
- **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, then warm opens are fast again.
|
|
147
163
|
|
|
148
164
|
## Provider Notes
|
|
149
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,31 @@ function getPeriodsForTimestamp(
|
|
|
525
618
|
return periods;
|
|
526
619
|
}
|
|
527
620
|
|
|
621
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
622
|
+
|
|
528
623
|
function addMessagesToUsageData(
|
|
529
624
|
data: UsageData,
|
|
530
625
|
sessionId: string,
|
|
626
|
+
project: string,
|
|
531
627
|
messages: SessionMessage[],
|
|
628
|
+
meta: MessageMeta[],
|
|
532
629
|
todayMs: number,
|
|
533
630
|
weekStartMs: number,
|
|
534
631
|
lastWeekStartMs: number,
|
|
535
632
|
last30DaysStartMs: number,
|
|
536
633
|
rawByPeriod: Record<TabName, PeriodRawData>,
|
|
537
|
-
|
|
634
|
+
costByDayIdx: Map<number, number>
|
|
538
635
|
): void {
|
|
539
636
|
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, last30Days: false, allTime: false };
|
|
540
637
|
|
|
541
|
-
for (
|
|
542
|
-
|
|
543
|
-
|
|
638
|
+
for (let mi = 0; mi < messages.length; mi++) {
|
|
639
|
+
const msg = messages[mi]!;
|
|
640
|
+
const mm = meta[mi]!;
|
|
641
|
+
|
|
642
|
+
// Day-indexed cost totals power the burn-trend insight.
|
|
544
643
|
if (msg.timestamp > 0) {
|
|
545
|
-
const
|
|
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
|
-
}
|
|
644
|
+
const dayIdx = Math.floor((msg.timestamp - todayMs) / DAY_MS);
|
|
645
|
+
costByDayIdx.set(dayIdx, (costByDayIdx.get(dayIdx) ?? 0) + msg.cost);
|
|
552
646
|
}
|
|
553
647
|
|
|
554
648
|
addToHourlyBuckets(data.hourly, msg);
|
|
@@ -590,15 +684,31 @@ function addMessagesToUsageData(
|
|
|
590
684
|
sessionContributed[period] = true;
|
|
591
685
|
|
|
592
686
|
const raw = rawByPeriod[period];
|
|
593
|
-
raw.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
687
|
+
raw.totalCost += msg.cost;
|
|
688
|
+
raw.totalMessages++;
|
|
689
|
+
const ctx = msg.input + msg.cacheRead + msg.cacheWrite;
|
|
690
|
+
if (ctx >= CTX_TAX_THRESHOLD) {
|
|
691
|
+
raw.ctxHigh.cost += msg.cost;
|
|
692
|
+
raw.ctxHigh.messages++;
|
|
693
|
+
} else if (ctx < CTX_LOW_THRESHOLD) {
|
|
694
|
+
raw.ctxLow.cost += msg.cost;
|
|
695
|
+
raw.ctxLow.messages++;
|
|
696
|
+
}
|
|
697
|
+
raw.projectCosts.set(project, (raw.projectCosts.get(project) ?? 0) + msg.cost);
|
|
601
698
|
raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
|
|
699
|
+
if (mm.isSessionStart) raw.upfrontCost += msg.cost;
|
|
700
|
+
if (
|
|
701
|
+
!msg.afterCompaction &&
|
|
702
|
+
mm.prevCtx >= MISS_MIN_PREV_CONTEXT &&
|
|
703
|
+
msg.cacheRead < Math.min(MISS_MAX_CACHE_READ, 0.3 * mm.prevCtx)
|
|
704
|
+
) {
|
|
705
|
+
if (mm.gapMs > TTL_GAP_MS) raw.ttlMissCost += msg.cost;
|
|
706
|
+
else if (mm.gapMs >= 0) raw.prefixMissCost += msg.cost;
|
|
707
|
+
}
|
|
708
|
+
raw.reasoningTokens += msg.reasoning;
|
|
709
|
+
raw.outputTokens += msg.output;
|
|
710
|
+
raw.cacheReadTokens += msg.cacheRead;
|
|
711
|
+
raw.freshTokens += msg.input + msg.cacheWrite;
|
|
602
712
|
}
|
|
603
713
|
}
|
|
604
714
|
|
|
@@ -759,7 +869,8 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
759
869
|
last30Days: emptyPeriodRawData(),
|
|
760
870
|
allTime: emptyPeriodRawData(),
|
|
761
871
|
};
|
|
762
|
-
const
|
|
872
|
+
const costByDayIdx = new Map<number, number>();
|
|
873
|
+
const seenSessions = new Set<string>();
|
|
763
874
|
const seenHashes = new Set<string>();
|
|
764
875
|
let processedFiles = 0;
|
|
765
876
|
|
|
@@ -772,38 +883,57 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
772
883
|
if (signal?.aborted) return null;
|
|
773
884
|
}
|
|
774
885
|
|
|
775
|
-
// Deduplicate copied history across branched session files
|
|
776
|
-
//
|
|
886
|
+
// Deduplicate copied history across branched session files, computing
|
|
887
|
+
// adjacency metadata (idle gaps, previous context) on the raw file order
|
|
888
|
+
// so branch copies do not distort miss classification.
|
|
889
|
+
const rawMsgs = state.parsed.messages;
|
|
777
890
|
const deduped: SessionMessage[] = [];
|
|
778
|
-
|
|
891
|
+
const meta: MessageMeta[] = [];
|
|
892
|
+
for (let i = 0; i < rawMsgs.length; i++) {
|
|
893
|
+
const m = rawMsgs[i]!;
|
|
779
894
|
const hash = `${m.timestamp}:${m.input + m.output + m.cacheRead + m.cacheWrite}`;
|
|
780
895
|
if (seenHashes.has(hash)) continue;
|
|
781
896
|
seenHashes.add(hash);
|
|
897
|
+
const prev = i > 0 ? rawMsgs[i - 1]! : null;
|
|
782
898
|
deduped.push(m);
|
|
899
|
+
meta.push({
|
|
900
|
+
gapMs: prev && prev.timestamp > 0 && m.timestamp > 0 ? m.timestamp - prev.timestamp : -1,
|
|
901
|
+
prevCtx: prev ? prev.input + prev.cacheRead + prev.cacheWrite : 0,
|
|
902
|
+
isSessionStart: false,
|
|
903
|
+
});
|
|
783
904
|
}
|
|
784
905
|
if (deduped.length === 0) continue;
|
|
906
|
+
if (!seenSessions.has(state.parsed.sessionId)) {
|
|
907
|
+
seenSessions.add(state.parsed.sessionId);
|
|
908
|
+
meta[0]!.isSessionStart = true;
|
|
909
|
+
}
|
|
785
910
|
|
|
786
911
|
addMessagesToUsageData(
|
|
787
912
|
data,
|
|
788
913
|
state.parsed.sessionId,
|
|
914
|
+
projectLabelFromCwd(state.parsed.cwd),
|
|
789
915
|
deduped,
|
|
916
|
+
meta,
|
|
790
917
|
todayMs,
|
|
791
918
|
weekStartMs,
|
|
792
919
|
lastWeekStartMs,
|
|
793
920
|
last30DaysStartMs,
|
|
794
921
|
rawByPeriod,
|
|
795
|
-
|
|
922
|
+
costByDayIdx
|
|
796
923
|
);
|
|
797
924
|
}
|
|
798
925
|
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
926
|
+
// Burn trend: last 7 calendar days vs the average weekly pace of the prior 28.
|
|
927
|
+
let last7 = 0;
|
|
928
|
+
let prior28 = 0;
|
|
929
|
+
for (const [idx, c] of costByDayIdx) {
|
|
930
|
+
if (idx >= -6) last7 += c;
|
|
931
|
+
else if (idx >= -34) prior28 += c;
|
|
803
932
|
}
|
|
933
|
+
const trend: TrendInfo | null = prior28 > 0 ? { last7Cost: last7, priorWeeklyPace: prior28 / 4 } : null;
|
|
804
934
|
|
|
805
935
|
for (const period of TAB_ORDER) {
|
|
806
|
-
data[period].insights = computeInsights(rawByPeriod[period],
|
|
936
|
+
data[period].insights = computeInsights(rawByPeriod[period], trend);
|
|
807
937
|
}
|
|
808
938
|
|
|
809
939
|
return data;
|
|
@@ -813,148 +943,186 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
|
|
|
813
943
|
// Insights
|
|
814
944
|
// =============================================================================
|
|
815
945
|
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
const
|
|
946
|
+
// Context tax (structure)
|
|
947
|
+
const CTX_TAX_THRESHOLD = 150_000;
|
|
948
|
+
const CTX_LOW_THRESHOLD = 100_000;
|
|
949
|
+
// Project mix (structure)
|
|
950
|
+
const PROJECT_TOP_COUNT = 3;
|
|
951
|
+
const PROJECT_MAX_DOMINANCE_PERCENT = 90;
|
|
952
|
+
// Reasoning share (structure)
|
|
953
|
+
const REASONING_MIN_PERCENT = 5;
|
|
954
|
+
// Burn trend (structure)
|
|
955
|
+
const TREND_HIGH_RATIO = 1.5;
|
|
956
|
+
const TREND_LOW_RATIO = 0.6;
|
|
957
|
+
// Cache-miss alarms
|
|
958
|
+
const TTL_GAP_MS = 5 * 60_000;
|
|
959
|
+
const MISS_MIN_PREV_CONTEXT = 20_000;
|
|
960
|
+
const MISS_MAX_CACHE_READ = 5_000;
|
|
961
|
+
const CACHE_MISS_ALARM_PERCENT = 2;
|
|
962
|
+
const CACHE_MISS_ALARM_MIN_COST = 1;
|
|
963
|
+
// Concentration / upfront alarms
|
|
821
964
|
const TOP_SESSION_COUNT = 5;
|
|
822
|
-
const
|
|
823
|
-
const
|
|
965
|
+
const CONCENTRATION_ALARM_PERCENT = 35;
|
|
966
|
+
const UPFRONT_ALARM_PERCENT = 8;
|
|
967
|
+
// Cache-leverage alarm
|
|
968
|
+
const LEVERAGE_FLOOR = 5;
|
|
969
|
+
const LEVERAGE_MIN_COST = 5;
|
|
970
|
+
const LEVERAGE_MIN_FRESH_TOKENS = 1_000_000;
|
|
971
|
+
|
|
972
|
+
function fmtMoney(v: number): string {
|
|
973
|
+
if (v >= 1000) return `$${(v / 1000).toFixed(1)}k`;
|
|
974
|
+
if (v >= 100) return `$${Math.round(v)}`;
|
|
975
|
+
return `$${v.toFixed(2)}`;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function fmtPercent(p: number): string {
|
|
979
|
+
return p >= 10 ? `${Math.round(p)}%` : `${p.toFixed(1)}%`;
|
|
980
|
+
}
|
|
824
981
|
|
|
825
982
|
/**
|
|
826
|
-
* Insights
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
983
|
+
* Insights come in two kinds:
|
|
984
|
+
* - structure: always-on decomposition of where the period's cost went.
|
|
985
|
+
* - alarm: fires only when a wasteful pattern is material for the period, so
|
|
986
|
+
* an all-clear period shows a calm panel instead of a wall of 2% factoids.
|
|
987
|
+
* Periods with zero recorded cost produce an empty list — the UI renders a
|
|
988
|
+
* distinct empty-state for that case.
|
|
830
989
|
*/
|
|
831
|
-
function computeInsights(raw: PeriodRawData,
|
|
832
|
-
if (raw.
|
|
990
|
+
function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodInsights {
|
|
991
|
+
if (raw.totalMessages === 0 || raw.totalCost <= 0) {
|
|
833
992
|
return { insights: [] };
|
|
834
993
|
}
|
|
994
|
+
const total = raw.totalCost;
|
|
995
|
+
const insights: Insight[] = [];
|
|
835
996
|
|
|
836
|
-
|
|
837
|
-
if (total <= 0) {
|
|
838
|
-
return { insights: [] };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const candidates: Insight[] = [];
|
|
997
|
+
// --- Alarms (listed first) ---
|
|
842
998
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
headline: `
|
|
999
|
+
const ttlPct = (raw.ttlMissCost / total) * 100;
|
|
1000
|
+
if (ttlPct >= CACHE_MISS_ALARM_PERCENT && raw.ttlMissCost >= CACHE_MISS_ALARM_MIN_COST) {
|
|
1001
|
+
insights.push({
|
|
1002
|
+
kind: "alarm",
|
|
1003
|
+
stat: fmtMoney(raw.ttlMissCost),
|
|
1004
|
+
headline: `spent re-warming caches that expired during idle gaps (${fmtPercent(ttlPct)} of this period)`,
|
|
849
1005
|
advice:
|
|
850
|
-
"
|
|
1006
|
+
"Provider caches expire after a few minutes idle (Anthropic ~5min); the next message re-writes the whole context at premium rates. Batching replies instead of trickling them keeps caches warm.",
|
|
851
1007
|
});
|
|
852
1008
|
}
|
|
853
1009
|
|
|
854
|
-
|
|
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`,
|
|
1010
|
+
const prefixPct = (raw.prefixMissCost / total) * 100;
|
|
1011
|
+
if (prefixPct >= CACHE_MISS_ALARM_PERCENT && raw.prefixMissCost >= CACHE_MISS_ALARM_MIN_COST) {
|
|
1012
|
+
insights.push({
|
|
1013
|
+
kind: "alarm",
|
|
1014
|
+
stat: fmtMoney(raw.prefixMissCost),
|
|
1015
|
+
headline: `spent on cache misses with no idle gap (${fmtPercent(prefixPct)} of this period)`,
|
|
885
1016
|
advice:
|
|
886
|
-
"
|
|
1017
|
+
"The request prefix changed mid-session (compaction excluded), so cached context was re-sent at full price. If this stays high, something in the workflow is defeating provider caching.",
|
|
887
1018
|
});
|
|
888
1019
|
}
|
|
889
1020
|
|
|
890
|
-
// 5. Top-N session concentration.
|
|
891
1021
|
if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
|
|
892
1022
|
const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
|
|
893
|
-
const
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1023
|
+
const topWeight = sortedSessions.slice(0, TOP_SESSION_COUNT).reduce((sum, c) => sum + c, 0);
|
|
1024
|
+
const topPct = (topWeight / total) * 100;
|
|
1025
|
+
if (topPct >= CONCENTRATION_ALARM_PERCENT) {
|
|
1026
|
+
insights.push({
|
|
1027
|
+
kind: "alarm",
|
|
1028
|
+
stat: fmtPercent(topPct),
|
|
1029
|
+
headline: `of this period's cost came from just ${TOP_SESSION_COUNT} sessions (of ${raw.sessionCosts.size})`,
|
|
1030
|
+
advice: "Spend is unusually concentrated. The graph view's filters can pinpoint what those sessions were doing.",
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const upfrontPct = (raw.upfrontCost / total) * 100;
|
|
1036
|
+
if (upfrontPct >= UPFRONT_ALARM_PERCENT) {
|
|
1037
|
+
insights.push({
|
|
1038
|
+
kind: "alarm",
|
|
1039
|
+
stat: fmtPercent(upfrontPct),
|
|
1040
|
+
headline: "of cost was the first message of a session",
|
|
898
1041
|
advice:
|
|
899
|
-
"
|
|
1042
|
+
"Session starts pay for their whole prompt uncached. Fewer, longer-lived sessions amortize that setup cost.",
|
|
900
1043
|
});
|
|
901
1044
|
}
|
|
902
1045
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1046
|
+
if (total >= LEVERAGE_MIN_COST && raw.freshTokens >= LEVERAGE_MIN_FRESH_TOKENS) {
|
|
1047
|
+
const leverage = raw.cacheReadTokens / raw.freshTokens;
|
|
1048
|
+
if (leverage < LEVERAGE_FLOOR) {
|
|
1049
|
+
insights.push({
|
|
1050
|
+
kind: "alarm",
|
|
1051
|
+
stat: `${leverage.toFixed(1)}×`,
|
|
1052
|
+
headline: "cache leverage — cached tokens served per fresh token paid",
|
|
1053
|
+
advice:
|
|
1054
|
+
"Healthy interactive use typically sees 10×+. Low leverage means context is being re-sent uncached — look for workflows that restart conversations.",
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
906
1058
|
|
|
907
|
-
|
|
908
|
-
|
|
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++;
|
|
1059
|
+
// --- Structure (always-on) ---
|
|
1060
|
+
|
|
1061
|
+
if (raw.ctxHigh.messages > 0) {
|
|
1062
|
+
const pct = (raw.ctxHigh.cost / total) * 100;
|
|
1063
|
+
if (pct >= 1) {
|
|
1064
|
+
const avgHigh = raw.ctxHigh.cost / raw.ctxHigh.messages;
|
|
1065
|
+
const avgLow = raw.ctxLow.messages > 0 ? raw.ctxLow.cost / raw.ctxLow.messages : 0;
|
|
1066
|
+
const cmp =
|
|
1067
|
+
avgLow > 0
|
|
1068
|
+
? ` — averaging ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
|
|
1069
|
+
: "";
|
|
1070
|
+
insights.push({
|
|
1071
|
+
kind: "structure",
|
|
1072
|
+
stat: fmtPercent(pct),
|
|
1073
|
+
headline: `of your cost was at ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} context${cmp}`,
|
|
1074
|
+
advice: "Context size is the main cost driver. /compact mid-task and /clear between tasks to reset it.",
|
|
1075
|
+
});
|
|
941
1076
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (raw.projectCosts.size >= 2) {
|
|
1080
|
+
const top = [...raw.projectCosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, PROJECT_TOP_COUNT);
|
|
1081
|
+
const topPct = (top[0]![1] / total) * 100;
|
|
1082
|
+
if (topPct < PROJECT_MAX_DOMINANCE_PERCENT) {
|
|
1083
|
+
const rest = top
|
|
1084
|
+
.slice(1)
|
|
1085
|
+
.map(([label, c]) => `${label} ${fmtPercent((c / total) * 100)}`)
|
|
1086
|
+
.join(", ");
|
|
1087
|
+
insights.push({
|
|
1088
|
+
kind: "structure",
|
|
1089
|
+
stat: fmtPercent(topPct),
|
|
1090
|
+
headline: `of your cost was ${top[0]![0]}${rest ? ` — then ${rest}` : ""}`,
|
|
1091
|
+
advice: "",
|
|
1092
|
+
});
|
|
952
1093
|
}
|
|
1094
|
+
}
|
|
953
1095
|
|
|
954
|
-
|
|
1096
|
+
if (raw.outputTokens > 0) {
|
|
1097
|
+
const reasoningPct = (raw.reasoningTokens / raw.outputTokens) * 100;
|
|
1098
|
+
if (reasoningPct >= REASONING_MIN_PERCENT) {
|
|
1099
|
+
insights.push({
|
|
1100
|
+
kind: "structure",
|
|
1101
|
+
stat: fmtPercent(reasoningPct),
|
|
1102
|
+
headline: "of your output tokens were invisible reasoning",
|
|
1103
|
+
advice:
|
|
1104
|
+
"Reasoning is billed as output but never displayed. Recorded by pi 0.80.3+ (June 2026) only, so older periods understate it.",
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (trend && trend.priorWeeklyPace > 0) {
|
|
1110
|
+
const ratio = trend.last7Cost / trend.priorWeeklyPace;
|
|
1111
|
+
const advice =
|
|
1112
|
+
ratio >= TREND_HIGH_RATIO
|
|
1113
|
+
? "Spend is accelerating vs your own baseline — the graph view shows what changed."
|
|
1114
|
+
: ratio <= TREND_LOW_RATIO
|
|
1115
|
+
? "Spend is well below your recent baseline."
|
|
1116
|
+
: "";
|
|
1117
|
+
insights.push({
|
|
1118
|
+
kind: "structure",
|
|
1119
|
+
stat: `${ratio.toFixed(1)}×`,
|
|
1120
|
+
headline: `your last 7 days (${fmtMoney(trend.last7Cost)}) vs your prior 4-week pace (${fmtMoney(trend.priorWeeklyPace)}/wk)`,
|
|
1121
|
+
advice,
|
|
1122
|
+
});
|
|
955
1123
|
}
|
|
956
1124
|
|
|
957
|
-
return
|
|
1125
|
+
return { insights };
|
|
958
1126
|
}
|
|
959
1127
|
|
|
960
1128
|
function formatThresholdTokens(n: number): string {
|
package/graph.ts
CHANGED
|
@@ -60,6 +60,10 @@ export interface GraphSeries {
|
|
|
60
60
|
/** Period total for this series (not affected by cumulative). */
|
|
61
61
|
total: number;
|
|
62
62
|
hidden: boolean;
|
|
63
|
+
/** First bucket index with activity, or -1 when the series is empty. */
|
|
64
|
+
firstIdx: number;
|
|
65
|
+
/** Last bucket index with activity, or -1 when the series is empty. */
|
|
66
|
+
lastIdx: number;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
export interface GraphModel {
|
|
@@ -183,21 +187,40 @@ export function buildGraphModel(
|
|
|
183
187
|
const hidden = options.hidden ?? new Set<string>();
|
|
184
188
|
const series: GraphSeries[] = [];
|
|
185
189
|
|
|
190
|
+
// Active range per series (computed on raw per-bucket values, before any
|
|
191
|
+
// cumulative transform): lines are later drawn only between the first and
|
|
192
|
+
// last bucket with usage, so late-starting or retired series do not drag a
|
|
193
|
+
// flat zero/flat tail across the whole period.
|
|
194
|
+
const activeRange = (points: number[]): { firstIdx: number; lastIdx: number } => {
|
|
195
|
+
let firstIdx = -1;
|
|
196
|
+
let lastIdx = -1;
|
|
197
|
+
for (let i = 0; i < points.length; i++) {
|
|
198
|
+
if (points[i] !== 0) {
|
|
199
|
+
if (firstIdx === -1) firstIdx = i;
|
|
200
|
+
lastIdx = i;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { firstIdx, lastIdx };
|
|
204
|
+
};
|
|
205
|
+
|
|
186
206
|
series.push({
|
|
187
207
|
key: TOTAL_SERIES_KEY,
|
|
188
208
|
label: "Total",
|
|
189
209
|
points: totalPoints.slice(),
|
|
190
210
|
total: totalSum,
|
|
191
211
|
hidden: hidden.has(TOTAL_SERIES_KEY),
|
|
212
|
+
...activeRange(totalPoints),
|
|
192
213
|
});
|
|
193
214
|
|
|
194
215
|
for (const [groupKey, total] of kept) {
|
|
216
|
+
const points = groupValues.get(groupKey)!;
|
|
195
217
|
series.push({
|
|
196
218
|
key: groupKey,
|
|
197
219
|
label: groupKey,
|
|
198
|
-
points
|
|
220
|
+
points,
|
|
199
221
|
total,
|
|
200
222
|
hidden: hidden.has(groupKey),
|
|
223
|
+
...activeRange(points),
|
|
201
224
|
});
|
|
202
225
|
}
|
|
203
226
|
|
|
@@ -215,6 +238,7 @@ export function buildGraphModel(
|
|
|
215
238
|
points,
|
|
216
239
|
total,
|
|
217
240
|
hidden: hidden.has(OTHER_SERIES_KEY),
|
|
241
|
+
...activeRange(points),
|
|
218
242
|
});
|
|
219
243
|
}
|
|
220
244
|
|
|
@@ -295,10 +319,11 @@ export function renderChart(model: GraphModel, options: ChartRenderOptions): str
|
|
|
295
319
|
const yMax = model.yMax > 0 ? model.yMax : 1;
|
|
296
320
|
const bucketCount = model.bucketStarts.length;
|
|
297
321
|
|
|
298
|
-
const plot = (seriesIndex: number, points: number[]) => {
|
|
322
|
+
const plot = (seriesIndex: number, points: number[], firstIdx: number, lastIdx: number) => {
|
|
323
|
+
if (firstIdx < 0) return;
|
|
299
324
|
let prevX = -1;
|
|
300
325
|
let prevY = -1;
|
|
301
|
-
for (let i =
|
|
326
|
+
for (let i = firstIdx; i <= lastIdx; i++) {
|
|
302
327
|
const x = bucketCount === 1 ? dotW - 1 : Math.round((i / (bucketCount - 1)) * (dotW - 1));
|
|
303
328
|
const y = Math.round((1 - (points[i]! / yMax)) * (dotH - 1));
|
|
304
329
|
if (prevX >= 0) {
|
|
@@ -335,7 +360,7 @@ export function renderChart(model: GraphModel, options: ChartRenderOptions): str
|
|
|
335
360
|
entry.s.key === TOTAL_SERIES_KEY ? Number.POSITIVE_INFINITY : entry.s.key === OTHER_SERIES_KEY ? -1 : entry.s.total;
|
|
336
361
|
return rank(a) - rank(b);
|
|
337
362
|
});
|
|
338
|
-
for (const { s, i } of drawOrder) plot(i, s.points);
|
|
363
|
+
for (const { s, i } of drawOrder) plot(i, s.points, s.firstIdx, s.lastIdx);
|
|
339
364
|
|
|
340
365
|
// Compose text rows.
|
|
341
366
|
const lines: string[] = [];
|
package/index.ts
CHANGED
|
@@ -175,11 +175,6 @@ function seriesColor(index: number): string {
|
|
|
175
175
|
return SERIES_COLORS[index % SERIES_COLORS.length]!;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
function formatInsightPercent(p: number): string {
|
|
179
|
-
if (p >= 10) return `${Math.round(p)}%`;
|
|
180
|
-
return `${Math.round(p * 10) / 10}%`;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
178
|
function padLeft(s: string, len: number): string {
|
|
184
179
|
const vis = visibleWidth(s);
|
|
185
180
|
if (vis >= len) return s;
|
|
@@ -494,7 +489,7 @@ class UsageComponent {
|
|
|
494
489
|
lines.push("What's contributing to your cost?");
|
|
495
490
|
lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
|
|
496
491
|
lines.push("");
|
|
497
|
-
const note = `${TAB_LABELS[this.activeTab]} ·
|
|
492
|
+
const note = `${TAB_LABELS[this.activeTab]} · independent lenses — they overlap and don't sum to 100%`;
|
|
498
493
|
lines.push(th.fg("dim", note));
|
|
499
494
|
lines.push("");
|
|
500
495
|
|
|
@@ -509,21 +504,35 @@ class UsageComponent {
|
|
|
509
504
|
return lines;
|
|
510
505
|
}
|
|
511
506
|
if (insights.length === 0) {
|
|
512
|
-
lines.push(th.fg("dim", "
|
|
507
|
+
lines.push(th.fg("dim", " Nothing notable for this period."));
|
|
513
508
|
lines.push("");
|
|
514
509
|
return lines;
|
|
515
510
|
}
|
|
516
511
|
|
|
517
|
-
const indent = "
|
|
512
|
+
const indent = " ";
|
|
518
513
|
const adviceWidth = Math.max(width - indent.length, 30);
|
|
519
514
|
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
515
|
+
const renderOne = (insight: (typeof insights)[number]): void => {
|
|
516
|
+
const marker = insight.kind === "alarm" ? "⚠ " : " ";
|
|
517
|
+
const stat = th.fg("accent", th.bold(padLeft(insight.stat, 5)));
|
|
518
|
+
lines.push(`${marker}${stat} ${insight.headline}`);
|
|
519
|
+
if (insight.advice) {
|
|
520
|
+
for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
|
|
521
|
+
lines.push(`${indent}${th.fg("dim", wrapped)}`);
|
|
522
|
+
}
|
|
525
523
|
}
|
|
526
524
|
lines.push("");
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const alarms = insights.filter((i) => i.kind === "alarm");
|
|
528
|
+
const structure = insights.filter((i) => i.kind === "structure");
|
|
529
|
+
if (alarms.length > 0) {
|
|
530
|
+
lines.push(th.fg("accent", "Worth attention"));
|
|
531
|
+
for (const insight of alarms) renderOne(insight);
|
|
532
|
+
}
|
|
533
|
+
if (structure.length > 0) {
|
|
534
|
+
if (alarms.length > 0) lines.push(th.fg("dim", "Where it went"));
|
|
535
|
+
for (const insight of structure) renderOne(insight);
|
|
527
536
|
}
|
|
528
537
|
|
|
529
538
|
return lines;
|