@po.dev/pi-usage-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +11 -0
  2. package/data.ts +1859 -0
  3. package/export.ts +146 -0
  4. package/graph.ts +399 -0
  5. package/index.ts +1231 -0
  6. package/package.json +13 -0
package/data.ts ADDED
@@ -0,0 +1,1859 @@
1
+ /**
2
+ * Data collection, caching, and insights for the /usage dashboard.
3
+ *
4
+ * Performance model (see CHANGELOG 0.4.0):
5
+ * - Session JSONL files are scanned at the buffer level. Only lines relevant
6
+ * to assistant or auxiliary accounting are decoded and JSON.parsed. Ordinary
7
+ * multi-megabyte tool results are skipped; accounting-bearing large results
8
+ * use an allocation-safe byte parser for their small metadata fields.
9
+ * - Per-file extraction results are persisted to an on-disk cache keyed by
10
+ * (size, mtimeMs). Session files are append-only, so a warm load only
11
+ * re-parses files that changed since the last run.
12
+ */
13
+
14
+ import { readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
17
+
18
+ // =============================================================================
19
+ // Types
20
+ // =============================================================================
21
+
22
+ export interface TokenStats {
23
+ total: number;
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ cacheWrite: number;
28
+ }
29
+
30
+ export interface BaseStats {
31
+ messages: number;
32
+ cost: number;
33
+ tokens: TokenStats;
34
+ }
35
+
36
+ export interface ModelStats extends BaseStats {
37
+ sessions: Set<string>;
38
+ }
39
+
40
+ export interface ProviderStats extends BaseStats {
41
+ sessions: Set<string>;
42
+ models: Map<string, ModelStats>;
43
+ }
44
+
45
+ export interface TotalStats extends BaseStats {
46
+ sessions: number;
47
+ }
48
+
49
+ export interface Insight {
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;
54
+ headline: string;
55
+ /** Dimmed follow-up line; empty string renders nothing. */
56
+ advice: string;
57
+ }
58
+
59
+ export interface PeriodInsights {
60
+ insights: Insight[];
61
+ }
62
+
63
+ interface CostCount {
64
+ cost: number;
65
+ messages: number;
66
+ }
67
+
68
+ interface PeriodRawData {
69
+ /** All recorded cost, including usage reported by tools and summaries. */
70
+ totalCost: number;
71
+ /** Cost attached to assistant messages, used as the turn-insight denominator. */
72
+ assistantCost: number;
73
+ /** Usage reported by tool results, compactions, and branch summaries. */
74
+ auxiliaryCost: number;
75
+ /** Messages at ≥ CTX_TAX_THRESHOLD context. */
76
+ ctxHigh: CostCount;
77
+ /** Messages below CTX_LOW_THRESHOLD context (comparison group). */
78
+ ctxLow: CostCount;
79
+ projectCosts: Map<string, number>;
80
+ sessionCosts: Map<string, number>;
81
+ /** Cost of each session's first-ever message falling in this period. */
82
+ upfrontCost: number;
83
+ /** Cache misses after >TTL_GAP_MS idle — resuming after the cache expired. */
84
+ ttlMissCost: number;
85
+ /** Cache misses right after a mid-session model switch (no idle gap). */
86
+ modelSwitchMissCost: number;
87
+ /** Cache misses with no idle gap, compaction, or model switch — true prefix changes. */
88
+ prefixMissCost: number;
89
+ reasoningTokens: number;
90
+ outputTokens: number;
91
+ cacheReadTokens: number;
92
+ freshTokens: number;
93
+ }
94
+
95
+ /** Per-message adjacency info, computed on raw file order before dedupe. */
96
+ interface MessageMeta {
97
+ /** Gap to the previous assistant message in the same file; -1 when unknown. */
98
+ gapMs: number;
99
+ /** Context size of the previous assistant message in the same file; 0 when first. */
100
+ prevCtx: number;
101
+ /** True when the provider/model differs from the previous assistant message. */
102
+ modelSwitched: boolean;
103
+ /** True for the first deduped message of a session across all its files. */
104
+ isSessionStart: boolean;
105
+ }
106
+
107
+ export interface TrendInfo {
108
+ /** Cost over the last 7 calendar days including today. */
109
+ last7Cost: number;
110
+ /** Average weekly cost over the prior 28 days. */
111
+ priorWeeklyPace: number;
112
+ }
113
+
114
+ export interface TimeFilteredStats {
115
+ providers: Map<string, ProviderStats>;
116
+ totals: TotalStats;
117
+ insights: PeriodInsights;
118
+ }
119
+
120
+ /**
121
+ * One (provider, model, thinkingLevel) cell inside an hourly bucket.
122
+ * Powers the graph explorer; built post-dedupe so it matches table totals.
123
+ */
124
+ export interface HourlyCell {
125
+ messages: number;
126
+ cost: number;
127
+ input: number;
128
+ output: number;
129
+ cacheRead: number;
130
+ cacheWrite: number;
131
+ reasoning: number;
132
+ }
133
+
134
+ /** Composite key: `${provider}\u0000${model}\u0000${thinkingLevel}` */
135
+ export type HourlyKey = string;
136
+
137
+ export const HOURLY_KEY_SEP = "\u0000";
138
+
139
+ export function makeHourlyKey(provider: string, model: string, thinkingLevel: string): HourlyKey {
140
+ return provider + HOURLY_KEY_SEP + model + HOURLY_KEY_SEP + thinkingLevel;
141
+ }
142
+
143
+ export function splitHourlyKey(key: HourlyKey): { provider: string; model: string; thinkingLevel: string } {
144
+ const [provider = "", model = "", thinkingLevel = ""] = key.split(HOURLY_KEY_SEP);
145
+ return { provider, model, thinkingLevel };
146
+ }
147
+
148
+ export interface PeriodBounds {
149
+ todayMs: number;
150
+ weekStartMs: number;
151
+ lastWeekStartMs: number;
152
+ last30DaysStartMs: number;
153
+ nowMs: number;
154
+ }
155
+
156
+ export interface SessionSummary {
157
+ id: string;
158
+ cwd: string;
159
+ timestamp: number;
160
+ messages: number;
161
+ tokens: TokenStats;
162
+ cost: number;
163
+ }
164
+
165
+ export interface UsageData {
166
+ today: TimeFilteredStats;
167
+ thisWeek: TimeFilteredStats;
168
+ lastWeek: TimeFilteredStats;
169
+ last30Days: TimeFilteredStats;
170
+ allTime: TimeFilteredStats;
171
+ /** Deduped assistant-only totals, used by the history picker. */
172
+ sessions: Map<string, SessionSummary>;
173
+ /** Deduped usage bucketed by hour start (ms) → series key → metrics. */
174
+ hourly: Map<number, Map<HourlyKey, HourlyCell>>;
175
+ bounds: PeriodBounds;
176
+ }
177
+
178
+ export type TabName = "today" | "thisWeek" | "lastWeek" | "last30Days" | "allTime";
179
+
180
+ export const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
181
+
182
+ export type UsageSource = "assistant" | "auxiliary";
183
+
184
+ /** Pi's own label for usage that cannot be attributed to a provider/model. */
185
+ export const AUXILIARY_PROVIDER = "Tools";
186
+ export const AUXILIARY_MODEL = "summaries";
187
+ export const AUXILIARY_THINKING_LEVEL = "Tools/summaries";
188
+
189
+ export interface UsageAmount {
190
+ cost: number;
191
+ input: number;
192
+ output: number;
193
+ cacheRead: number;
194
+ cacheWrite: number;
195
+ reasoning: number;
196
+ }
197
+
198
+ export interface SessionMessage extends UsageAmount {
199
+ provider: string;
200
+ model: string;
201
+ /** Thinking level active when the message was produced; "" when unknown. */
202
+ thinkingLevel: string;
203
+ /** Assistant response, or usage reported by a tool/summary entry. */
204
+ source: UsageSource;
205
+ /** Session entry id used to dedupe copied auxiliary entries; empty for assistant messages. */
206
+ sourceId: string;
207
+ timestamp: number;
208
+ /**
209
+ * True when a compaction entry occurred between the previous assistant
210
+ * message and this one. Compaction legitimately changes the request prefix,
211
+ * so such messages are excluded from prefix-change cache-miss accounting.
212
+ */
213
+ afterCompaction: boolean;
214
+ }
215
+
216
+ export interface ChildToolUsage {
217
+ resultIndex: number;
218
+ /** Persisted child session path when the tool supplied one; empty otherwise. */
219
+ sessionFile: string;
220
+ usage: UsageAmount;
221
+ }
222
+
223
+ export interface ToolUsageRecord {
224
+ /** Parent tool-result entry id, stable across copied branch history. */
225
+ sourceId: string;
226
+ timestamp: number;
227
+ /** Canonical Pi 0.81+ tool usage; null for legacy nested-agent results. */
228
+ reportedUsage: UsageAmount | null;
229
+ /** Run id used to derive the standard nested-session path when needed. */
230
+ runId: string;
231
+ /** Recognised per-child usage from nested-agent tool details. */
232
+ children: ChildToolUsage[];
233
+ }
234
+
235
+ export interface ParsedSessionFile {
236
+ /** Empty string when the file has no session header — such files are ignored. */
237
+ sessionId: string;
238
+ /** Working directory from the session header; "" when absent. */
239
+ cwd: string;
240
+ /** Extracted assistant and summary usage records, pre-dedupe. */
241
+ messages: SessionMessage[];
242
+ /** Tool usage is reconciled against recursively scanned child sessions later. */
243
+ toolUsages: ToolUsageRecord[];
244
+ }
245
+
246
+ // =============================================================================
247
+ // Paths
248
+ // =============================================================================
249
+
250
+ export function getAgentDir(): string {
251
+ // Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
252
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
253
+ }
254
+
255
+ export function getSessionsDir(): string {
256
+ return join(getAgentDir(), "sessions");
257
+ }
258
+
259
+ export function getDefaultCachePath(): string {
260
+ return join(getAgentDir(), "usage-extension-cache.json");
261
+ }
262
+
263
+ // =============================================================================
264
+ // Session file discovery
265
+ // =============================================================================
266
+
267
+ async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
268
+ try {
269
+ const entries = await readdir(dir, { withFileTypes: true });
270
+ for (const entry of entries) {
271
+ if (signal?.aborted) return;
272
+ const entryPath = join(dir, entry.name);
273
+ if (entry.isDirectory()) {
274
+ await collectSessionFilesRecursively(entryPath, files, signal);
275
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
276
+ files.push(entryPath);
277
+ }
278
+ }
279
+ } catch {
280
+ // Skip directories we can't read
281
+ }
282
+ }
283
+
284
+ async function getAllSessionFiles(sessionsDir: string, signal?: AbortSignal): Promise<string[]> {
285
+ const files: string[] = [];
286
+ await collectSessionFilesRecursively(sessionsDir, files, signal);
287
+ files.sort();
288
+ return files;
289
+ }
290
+
291
+ // =============================================================================
292
+ // Session file parsing
293
+ // =============================================================================
294
+
295
+ const NEWLINE = 0x0a;
296
+
297
+ // Relevance patterns for the buffer-level pre-filter. Pi writes compact JSON
298
+ // (`"role":"assistant"`), but imported/third-party session files have been seen
299
+ // with Python-style spaced JSON (`"role": "assistant"`), so both are matched.
300
+ // False positives (e.g. a tool result quoting one of these strings verbatim)
301
+ // only cost a wasted JSON.parse — the parsed entry is still shape-checked.
302
+ const PATTERN_ASSISTANT_COMPACT = Buffer.from('"role":"assistant"');
303
+ const PATTERN_ASSISTANT_SPACED = Buffer.from('"role": "assistant"');
304
+ const PATTERN_TOOL_RESULT_COMPACT = Buffer.from('"role":"toolResult"');
305
+ const PATTERN_TOOL_RESULT_SPACED = Buffer.from('"role": "toolResult"');
306
+ const PATTERN_USAGE_COMPACT = Buffer.from('"usage":{');
307
+ const PATTERN_USAGE_SPACED = Buffer.from('"usage": {');
308
+ const PATTERN_SESSION_COMPACT = Buffer.from('"type":"session"');
309
+ const PATTERN_SESSION_SPACED = Buffer.from('"type": "session"');
310
+ const PATTERN_THINKING_COMPACT = Buffer.from('"type":"thinking_level_change"');
311
+ const PATTERN_THINKING_SPACED = Buffer.from('"type": "thinking_level_change"');
312
+ const PATTERN_COMPACTION_COMPACT = Buffer.from('"type":"compaction"');
313
+ const PATTERN_COMPACTION_SPACED = Buffer.from('"type": "compaction"');
314
+ const PATTERN_BRANCH_SUMMARY_COMPACT = Buffer.from('"type":"branch_summary"');
315
+ const PATTERN_BRANCH_SUMMARY_SPACED = Buffer.from('"type": "branch_summary"');
316
+ // pi-subagents versions predating Pi 0.81 persisted child usage in details but
317
+ // could not put it on the canonical tool-result usage field. Their tool names
318
+ // are near the start of the line, so we can recover those records without
319
+ // scanning every multi-megabyte tool result.
320
+ const PATTERN_SUBAGENT_TOOL_COMPACT = Buffer.from('"toolName":"subagent"');
321
+ const PATTERN_SUBAGENT_TOOL_SPACED = Buffer.from('"toolName": "subagent"');
322
+ const PATTERN_SUBAGENT_WAIT_TOOL_COMPACT = Buffer.from('"toolName":"subagent_wait"');
323
+ const PATTERN_SUBAGENT_WAIT_TOOL_SPACED = Buffer.from('"toolName": "subagent_wait"');
324
+
325
+ const PARSE_YIELD_EVERY_LINES = 2000;
326
+
327
+ function finiteNumber(value: unknown): number {
328
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
329
+ }
330
+
331
+ function parseUsageAmount(value: unknown): UsageAmount | null {
332
+ if (!value || typeof value !== "object") return null;
333
+ const persisted = value as Record<string, unknown>;
334
+ const costValue = persisted.cost;
335
+ const cost =
336
+ typeof costValue === "number"
337
+ ? finiteNumber(costValue)
338
+ : costValue && typeof costValue === "object"
339
+ ? finiteNumber((costValue as Record<string, unknown>).total)
340
+ : 0;
341
+ const usage = {
342
+ cost,
343
+ input: finiteNumber(persisted.input),
344
+ output: finiteNumber(persisted.output),
345
+ cacheRead: finiteNumber(persisted.cacheRead),
346
+ cacheWrite: finiteNumber(persisted.cacheWrite),
347
+ reasoning: finiteNumber(persisted.reasoning),
348
+ };
349
+ return usage.cost === 0 && usage.input === 0 && usage.output === 0 && usage.cacheRead === 0 && usage.cacheWrite === 0
350
+ ? null
351
+ : usage;
352
+ }
353
+
354
+ function parsedTimestamp(messageTimestamp: unknown, entryTimestamp: unknown): number {
355
+ const parsed =
356
+ typeof messageTimestamp === "number"
357
+ ? messageTimestamp
358
+ : new Date(String(messageTimestamp ?? entryTimestamp ?? "")).getTime();
359
+ return Number.isFinite(parsed) ? parsed : 0;
360
+ }
361
+
362
+ function auxiliaryMessage(usage: UsageAmount, timestamp: number, sourceId: string): SessionMessage {
363
+ return {
364
+ provider: AUXILIARY_PROVIDER,
365
+ model: AUXILIARY_MODEL,
366
+ thinkingLevel: AUXILIARY_THINKING_LEVEL,
367
+ source: "auxiliary",
368
+ sourceId,
369
+ ...usage,
370
+ timestamp,
371
+ afterCompaction: false,
372
+ };
373
+ }
374
+
375
+ function buildToolUsageRecord(
376
+ toolName: unknown,
377
+ detailsValue: unknown,
378
+ reportedValue: unknown,
379
+ sourceIdValue: unknown,
380
+ messageTimestamp: unknown,
381
+ entryTimestamp: unknown
382
+ ): ToolUsageRecord | null {
383
+ const reportedUsage = parseUsageAmount(reportedValue);
384
+ const details = detailsValue && typeof detailsValue === "object" ? (detailsValue as Record<string, unknown>) : null;
385
+ const totalChildUsage = parseUsageAmount(details?.totalChildUsage);
386
+ const knownNestedTool = toolName === "subagent" || toolName === "subagent_wait";
387
+ const children: ChildToolUsage[] = [];
388
+ if (details && Array.isArray(details.results)) {
389
+ for (let resultIndex = 0; resultIndex < details.results.length; resultIndex++) {
390
+ const result = details.results[resultIndex];
391
+ if (!result || typeof result !== "object") continue;
392
+ const child = result as Record<string, unknown>;
393
+ const usage = parseUsageAmount(child.usage);
394
+ const sessionFile = typeof child.sessionFile === "string" ? child.sessionFile : "";
395
+ // Legacy fallback is deliberately restricted to recognised nested-agent
396
+ // records. Generic Pi 0.81 tools remain canonical through reportedUsage.
397
+ if (usage && (knownNestedTool || totalChildUsage || (reportedUsage && sessionFile))) {
398
+ children.push({ resultIndex, sessionFile, usage });
399
+ }
400
+ }
401
+ }
402
+ if (!reportedUsage && children.length === 0) return null;
403
+ return {
404
+ sourceId: typeof sourceIdValue === "string" ? sourceIdValue : "",
405
+ timestamp: parsedTimestamp(messageTimestamp, entryTimestamp),
406
+ reportedUsage,
407
+ runId: details && typeof details.runId === "string" ? details.runId : "",
408
+ children,
409
+ };
410
+ }
411
+
412
+ const LARGE_TOOL_RESULT_BYTES = 64 * 1024;
413
+ const PROPERTY_ID = Buffer.from('"id":');
414
+ const PROPERTY_TIMESTAMP = Buffer.from('"timestamp":');
415
+ const PROPERTY_MESSAGE = Buffer.from('"message":');
416
+ const PROPERTY_TOOL_NAME = Buffer.from('"toolName":');
417
+ const PROPERTY_DETAILS = Buffer.from('"details":');
418
+ const PROPERTY_USAGE = Buffer.from('"usage":');
419
+ const DIRECT_CHILD_PROPERTIES = new Set(["usage", "sessionFile"]);
420
+
421
+ function skipJsonWhitespace(buffer: Buffer, offset: number, limit: number): number {
422
+ while (offset < limit && (buffer[offset] === 0x20 || buffer[offset] === 0x09 || buffer[offset] === 0x0a || buffer[offset] === 0x0d)) offset++;
423
+ return offset;
424
+ }
425
+
426
+ /** Find one JSON value's end without decoding large strings or container bodies. */
427
+ function jsonValueEnd(buffer: Buffer, offset: number, limit: number): number {
428
+ offset = skipJsonWhitespace(buffer, offset, limit);
429
+ if (offset >= limit) return offset;
430
+ const first = buffer[offset];
431
+ if (first === 0x22) {
432
+ let escaped = false;
433
+ for (let i = offset + 1; i < limit; i++) {
434
+ const byte = buffer[i];
435
+ if (escaped) escaped = false;
436
+ else if (byte === 0x5c) escaped = true;
437
+ else if (byte === 0x22) return i + 1;
438
+ }
439
+ return limit;
440
+ }
441
+ if (first === 0x7b || first === 0x5b) {
442
+ let depth = 0;
443
+ let inString = false;
444
+ let escaped = false;
445
+ for (let i = offset; i < limit; i++) {
446
+ const byte = buffer[i];
447
+ if (inString) {
448
+ if (escaped) escaped = false;
449
+ else if (byte === 0x5c) escaped = true;
450
+ else if (byte === 0x22) inString = false;
451
+ continue;
452
+ }
453
+ if (byte === 0x22) inString = true;
454
+ else if (byte === 0x7b || byte === 0x5b) depth++;
455
+ else if (byte === 0x7d || byte === 0x5d) {
456
+ depth--;
457
+ if (depth === 0) return i + 1;
458
+ }
459
+ }
460
+ return limit;
461
+ }
462
+ let end = offset;
463
+ while (end < limit && buffer[end] !== 0x2c && buffer[end] !== 0x5d && buffer[end] !== 0x7d) end++;
464
+ return end;
465
+ }
466
+
467
+ function parseJsonValueAt(buffer: Buffer, offset: number, limit: number): unknown {
468
+ const start = skipJsonWhitespace(buffer, offset, limit);
469
+ const end = jsonValueEnd(buffer, start, limit);
470
+ if (end <= start) return undefined;
471
+ try {
472
+ return JSON.parse(buffer.toString("utf8", start, end));
473
+ } catch {
474
+ return undefined;
475
+ }
476
+ }
477
+
478
+ function parsePropertyValue(buffer: Buffer, property: Buffer, from: number, to: number): unknown {
479
+ const propertyOffset = buffer.indexOf(property, from);
480
+ if (propertyOffset < 0 || propertyOffset >= to) return undefined;
481
+ return parseJsonValueAt(buffer, propertyOffset + property.length, to);
482
+ }
483
+
484
+ function parseLastPropertyValue(buffer: Buffer, property: Buffer, from: number, to: number): unknown {
485
+ const propertyOffset = buffer.lastIndexOf(property, to - 1);
486
+ if (propertyOffset < from) return undefined;
487
+ return parseJsonValueAt(buffer, propertyOffset + property.length, to);
488
+ }
489
+
490
+ interface DirectObjectScan {
491
+ end: number;
492
+ values: Map<string, [start: number, end: number]>;
493
+ }
494
+
495
+ /** Scan direct object properties while skipping nested values allocation-free. */
496
+ function scanDirectObjectProperties(buffer: Buffer, objectStart: number, limit: number, wanted: Set<string>): DirectObjectScan {
497
+ const values = new Map<string, [number, number]>();
498
+ let cursor = objectStart + 1;
499
+ while (cursor < limit) {
500
+ cursor = skipJsonWhitespace(buffer, cursor, limit);
501
+ if (buffer[cursor] === 0x7d) return { end: cursor + 1, values };
502
+ if (buffer[cursor] === 0x2c) {
503
+ cursor++;
504
+ continue;
505
+ }
506
+ if (buffer[cursor] !== 0x22) return { end: jsonValueEnd(buffer, objectStart, limit), values };
507
+ const keyEnd = jsonValueEnd(buffer, cursor, limit);
508
+ let colon = skipJsonWhitespace(buffer, keyEnd, limit);
509
+ if (buffer[colon] !== 0x3a) return { end: jsonValueEnd(buffer, objectStart, limit), values };
510
+ const valueStart = skipJsonWhitespace(buffer, colon + 1, limit);
511
+ const valueEnd = jsonValueEnd(buffer, valueStart, limit);
512
+ const key = buffer.toString("utf8", cursor + 1, keyEnd - 1);
513
+ if (wanted.has(key)) values.set(key, [valueStart, valueEnd]);
514
+ if (valueEnd <= valueStart) return { end: limit, values };
515
+ cursor = valueEnd;
516
+ }
517
+ return { end: limit, values };
518
+ }
519
+
520
+ function parseJsonRange(buffer: Buffer, range: [number, number] | undefined): unknown {
521
+ if (!range) return undefined;
522
+ try {
523
+ return JSON.parse(buffer.toString("utf8", range[0], range[1]));
524
+ } catch {
525
+ return undefined;
526
+ }
527
+ }
528
+
529
+ interface ChildResultsScan {
530
+ end: number;
531
+ results: Array<Record<string, unknown>>;
532
+ }
533
+
534
+ function scanChildResults(buffer: Buffer, arrayStart: number, limit: number): ChildResultsScan {
535
+ const results: Array<Record<string, unknown>> = [];
536
+ let cursor = arrayStart + 1;
537
+ while (cursor < limit) {
538
+ cursor = skipJsonWhitespace(buffer, cursor, limit);
539
+ if (buffer[cursor] === 0x5d) return { end: cursor + 1, results };
540
+ if (buffer[cursor] === 0x2c) {
541
+ cursor++;
542
+ continue;
543
+ }
544
+ if (buffer[cursor] === 0x7b) {
545
+ const scanned = scanDirectObjectProperties(buffer, cursor, limit, DIRECT_CHILD_PROPERTIES);
546
+ results.push({
547
+ usage: parseJsonRange(buffer, scanned.values.get("usage")),
548
+ sessionFile: parseJsonRange(buffer, scanned.values.get("sessionFile")),
549
+ });
550
+ if (scanned.end <= cursor) return { end: limit, results };
551
+ cursor = scanned.end;
552
+ continue;
553
+ }
554
+ const valueEnd = jsonValueEnd(buffer, cursor, limit);
555
+ if (valueEnd <= cursor) return { end: limit, results };
556
+ cursor = valueEnd;
557
+ }
558
+ return { end: limit, results };
559
+ }
560
+
561
+ interface LargeDetailsScan {
562
+ end: number;
563
+ details: Record<string, unknown>;
564
+ }
565
+
566
+ /** Scan nested-agent details and its result metadata in a single byte pass. */
567
+ function scanLargeDetails(buffer: Buffer, objectStart: number, limit: number): LargeDetailsScan {
568
+ const details: Record<string, unknown> = { results: [] };
569
+ let cursor = objectStart + 1;
570
+ while (cursor < limit) {
571
+ cursor = skipJsonWhitespace(buffer, cursor, limit);
572
+ if (buffer[cursor] === 0x7d) return { end: cursor + 1, details };
573
+ if (buffer[cursor] === 0x2c) {
574
+ cursor++;
575
+ continue;
576
+ }
577
+ if (buffer[cursor] !== 0x22) return { end: jsonValueEnd(buffer, objectStart, limit), details };
578
+ const keyEnd = jsonValueEnd(buffer, cursor, limit);
579
+ let colon = skipJsonWhitespace(buffer, keyEnd, limit);
580
+ if (buffer[colon] !== 0x3a) return { end: jsonValueEnd(buffer, objectStart, limit), details };
581
+ const valueStart = skipJsonWhitespace(buffer, colon + 1, limit);
582
+ const key = buffer.toString("utf8", cursor + 1, keyEnd - 1);
583
+ let valueEnd: number;
584
+ if (key === "results" && buffer[valueStart] === 0x5b) {
585
+ const scanned = scanChildResults(buffer, valueStart, limit);
586
+ details.results = scanned.results;
587
+ valueEnd = scanned.end;
588
+ } else {
589
+ valueEnd = jsonValueEnd(buffer, valueStart, limit);
590
+ if (key === "runId" || key === "totalChildUsage") {
591
+ details[key] = parseJsonRange(buffer, [valueStart, valueEnd]);
592
+ }
593
+ }
594
+ if (valueEnd <= valueStart) return { end: limit, details };
595
+ cursor = valueEnd;
596
+ }
597
+ return { end: limit, details };
598
+ }
599
+
600
+ /**
601
+ * Parse only the small accounting fields from a large tool-result line. This
602
+ * avoids UTF-8 decoding and JSON.parse allocation for multi-megabyte content.
603
+ */
604
+ function parseLargeToolResultLine(line: Buffer): ToolUsageRecord | null {
605
+ const messageOffset = line.indexOf(PROPERTY_MESSAGE);
606
+ if (messageOffset < 0) return null;
607
+ const detailsOffset = line.indexOf(PROPERTY_DETAILS, messageOffset);
608
+ let detailsEnd = -1;
609
+ let details: Record<string, unknown> | null = null;
610
+ if (detailsOffset >= 0) {
611
+ const detailsStart = skipJsonWhitespace(line, detailsOffset + PROPERTY_DETAILS.length, line.length);
612
+ if (line[detailsStart] === 0x7b) {
613
+ const scanned = scanLargeDetails(line, detailsStart, line.length);
614
+ detailsEnd = scanned.end;
615
+ details = scanned.details;
616
+ }
617
+ }
618
+
619
+ const reportedUsage = detailsEnd >= 0
620
+ ? parsePropertyValue(line, PROPERTY_USAGE, detailsEnd, line.length)
621
+ : parseLastPropertyValue(line, PROPERTY_USAGE, messageOffset, line.length);
622
+ const sourceId = parsePropertyValue(line, PROPERTY_ID, 0, messageOffset);
623
+ const entryTimestamp = parsePropertyValue(line, PROPERTY_TIMESTAMP, 0, messageOffset);
624
+ const messageTimestamp = parseLastPropertyValue(line, PROPERTY_TIMESTAMP, messageOffset, line.length);
625
+ const toolName = parsePropertyValue(line, PROPERTY_TOOL_NAME, messageOffset, detailsOffset >= 0 ? detailsOffset : line.length);
626
+ return buildToolUsageRecord(toolName, details, reportedUsage, sourceId, messageTimestamp, entryTimestamp);
627
+ }
628
+
629
+ function lineMightBeRelevant(line: Buffer): boolean {
630
+ // Entry type/role fields are at the front of Pi's JSONL objects. Restrict
631
+ // those checks to a small prefix so multi-megabyte tool output is not scanned
632
+ // repeatedly for every possible entry shape.
633
+ const head = line.length > 1024 ? line.subarray(0, 1024) : line;
634
+ if (head.includes(PATTERN_ASSISTANT_COMPACT) || head.includes(PATTERN_ASSISTANT_SPACED)) return true;
635
+
636
+ if (head.includes(PATTERN_TOOL_RESULT_COMPACT) || head.includes(PATTERN_TOOL_RESULT_SPACED)) {
637
+ if (
638
+ head.includes(PATTERN_SUBAGENT_TOOL_COMPACT) ||
639
+ head.includes(PATTERN_SUBAGENT_TOOL_SPACED) ||
640
+ head.includes(PATTERN_SUBAGENT_WAIT_TOOL_COMPACT) ||
641
+ head.includes(PATTERN_SUBAGENT_WAIT_TOOL_SPACED)
642
+ ) {
643
+ return true;
644
+ }
645
+ // Pi serializes optional tool usage after content/details and immediately
646
+ // before the small isError/timestamp suffix. Checking the tail preserves
647
+ // the fast path for ordinary tool results, which dominate session bytes.
648
+ const tail = line.length > 4096 ? line.subarray(line.length - 4096) : line;
649
+ return tail.includes(PATTERN_USAGE_COMPACT) || tail.includes(PATTERN_USAGE_SPACED);
650
+ }
651
+
652
+ return (
653
+ head.includes(PATTERN_SESSION_COMPACT) ||
654
+ head.includes(PATTERN_THINKING_COMPACT) ||
655
+ head.includes(PATTERN_COMPACTION_COMPACT) ||
656
+ head.includes(PATTERN_BRANCH_SUMMARY_COMPACT) ||
657
+ head.includes(PATTERN_SESSION_SPACED) ||
658
+ head.includes(PATTERN_THINKING_SPACED) ||
659
+ head.includes(PATTERN_COMPACTION_SPACED) ||
660
+ head.includes(PATTERN_BRANCH_SUMMARY_SPACED)
661
+ );
662
+ }
663
+
664
+ /**
665
+ * Extract the session id plus assistant/tool/summary usage from a JSONL buffer.
666
+ * Returns partial results when aborted — callers must check `signal.aborted`
667
+ * before caching or using the result.
668
+ */
669
+ export async function parseSessionBuffer(buffer: Buffer, signal?: AbortSignal): Promise<ParsedSessionFile> {
670
+ const messages: SessionMessage[] = [];
671
+ const toolUsages: ToolUsageRecord[] = [];
672
+ let sessionId = "";
673
+ let cwd = "";
674
+ // Assistant messages don't carry the thinking level; pi records it as separate
675
+ // thinking_level_change entries, always written before the first assistant
676
+ // message of a session. Replaying them in append order attributes each message
677
+ // to the level active when it was produced.
678
+ let thinkingLevel = "";
679
+ let compactionPending = false;
680
+
681
+ let start = 0;
682
+ let lineNumber = 0;
683
+
684
+ while (start < buffer.length) {
685
+ let end = buffer.indexOf(NEWLINE, start);
686
+ if (end === -1) end = buffer.length;
687
+
688
+ lineNumber++;
689
+ if (lineNumber % PARSE_YIELD_EVERY_LINES === 0) {
690
+ await new Promise<void>((resolve) => setImmediate(resolve));
691
+ if (signal?.aborted) return { sessionId, cwd, messages, toolUsages };
692
+ }
693
+
694
+ const lineBuffer = buffer.subarray(start, end);
695
+ if (end > start && lineMightBeRelevant(lineBuffer)) {
696
+ const head = lineBuffer.subarray(0, Math.min(1024, lineBuffer.length));
697
+ if (lineBuffer.length > LARGE_TOOL_RESULT_BYTES && head.includes(PATTERN_TOOL_RESULT_COMPACT)) {
698
+ const toolUsage = parseLargeToolResultLine(lineBuffer);
699
+ if (toolUsage) toolUsages.push(toolUsage);
700
+ start = end + 1;
701
+ continue;
702
+ }
703
+ try {
704
+ const entry = JSON.parse(buffer.toString("utf8", start, end));
705
+
706
+ if (entry.type === "session") {
707
+ sessionId = entry.id;
708
+ if (typeof entry.cwd === "string") cwd = entry.cwd;
709
+ } else if (entry.type === "thinking_level_change") {
710
+ if (typeof entry.thinkingLevel === "string") thinkingLevel = entry.thinkingLevel;
711
+ } else if (entry.type === "compaction") {
712
+ const usage = parseUsageAmount(entry.usage);
713
+ if (usage) messages.push(auxiliaryMessage(usage, parsedTimestamp(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
714
+ compactionPending = true;
715
+ } else if (entry.type === "branch_summary") {
716
+ const usage = parseUsageAmount(entry.usage);
717
+ if (usage) messages.push(auxiliaryMessage(usage, parsedTimestamp(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
718
+ } else if (entry.type === "message" && entry.message?.role === "assistant") {
719
+ const msg = entry.message;
720
+ if (msg.usage && msg.provider && msg.model) {
721
+ const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
722
+ messages.push({
723
+ provider: msg.provider,
724
+ model: msg.model,
725
+ thinkingLevel,
726
+ source: "assistant",
727
+ sourceId: "",
728
+ cost: msg.usage.cost?.total || 0,
729
+ input: msg.usage.input || 0,
730
+ output: msg.usage.output || 0,
731
+ cacheRead: msg.usage.cacheRead || 0,
732
+ cacheWrite: msg.usage.cacheWrite || 0,
733
+ reasoning: msg.usage.reasoning || 0,
734
+ timestamp: msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs),
735
+ afterCompaction: compactionPending,
736
+ });
737
+ compactionPending = false;
738
+ }
739
+ } else if (entry.type === "message" && entry.message?.role === "toolResult") {
740
+ const msg = entry.message;
741
+ const toolUsage = buildToolUsageRecord(msg.toolName, msg.details, msg.usage, entry.id, msg.timestamp, entry.timestamp);
742
+ if (toolUsage) toolUsages.push(toolUsage);
743
+ }
744
+ } catch {
745
+ // Skip malformed lines
746
+ }
747
+ }
748
+
749
+ start = end + 1;
750
+ }
751
+
752
+ return { sessionId, cwd, messages, toolUsages };
753
+ }
754
+
755
+ // =============================================================================
756
+ // On-disk cache
757
+ // =============================================================================
758
+
759
+ const CACHE_VERSION = 5;
760
+
761
+ type CachedMessageTuple = [
762
+ providerIdx: number,
763
+ modelIdx: number,
764
+ cost: number,
765
+ input: number,
766
+ output: number,
767
+ cacheRead: number,
768
+ cacheWrite: number,
769
+ timestamp: number,
770
+ thinkingLevelIdx: number,
771
+ reasoning: number,
772
+ afterCompaction: 0 | 1,
773
+ auxiliary: 0 | 1,
774
+ sourceIdIdx: number,
775
+ ];
776
+
777
+ type CachedUsageTuple = [
778
+ cost: number,
779
+ input: number,
780
+ output: number,
781
+ cacheRead: number,
782
+ cacheWrite: number,
783
+ reasoning: number,
784
+ ];
785
+
786
+ type CachedChildToolUsageTuple = [
787
+ resultIndex: number,
788
+ sessionFileIdx: number,
789
+ usage: CachedUsageTuple,
790
+ ];
791
+
792
+ type CachedToolUsageTuple = [
793
+ sourceIdIdx: number,
794
+ timestamp: number,
795
+ reportedUsage: CachedUsageTuple | null,
796
+ runIdIdx: number,
797
+ children: CachedChildToolUsageTuple[],
798
+ ];
799
+
800
+ interface CacheFileEntry {
801
+ size: number;
802
+ mtimeMs: number;
803
+ sessionId: string;
804
+ cwd: string;
805
+ messages: CachedMessageTuple[];
806
+ toolUsages: CachedToolUsageTuple[];
807
+ }
808
+
809
+ export interface CachedFileState {
810
+ size: number;
811
+ mtimeMs: number;
812
+ parsed: ParsedSessionFile;
813
+ }
814
+
815
+ export async function loadUsageCache(cachePath: string): Promise<Map<string, CachedFileState>> {
816
+ const result = new Map<string, CachedFileState>();
817
+ let raw: { version?: unknown; names?: unknown; files?: unknown };
818
+ try {
819
+ raw = JSON.parse(await readFile(cachePath, "utf8"));
820
+ } catch {
821
+ return result; // Missing or corrupt cache — rebuild from scratch.
822
+ }
823
+ if (!raw || raw.version !== CACHE_VERSION || !Array.isArray(raw.names) || typeof raw.files !== "object" || raw.files === null) {
824
+ return result;
825
+ }
826
+
827
+ const names = raw.names as unknown[];
828
+ for (const [filePath, entry] of Object.entries(raw.files as Record<string, CacheFileEntry>)) {
829
+ if (
830
+ !entry ||
831
+ typeof entry.size !== "number" ||
832
+ typeof entry.mtimeMs !== "number" ||
833
+ typeof entry.sessionId !== "string" ||
834
+ typeof entry.cwd !== "string" ||
835
+ !Array.isArray(entry.messages) ||
836
+ !Array.isArray(entry.toolUsages)
837
+ ) {
838
+ continue;
839
+ }
840
+ const messages: SessionMessage[] = [];
841
+ let valid = true;
842
+ for (const tuple of entry.messages) {
843
+ if (!Array.isArray(tuple) || tuple.length !== 13) {
844
+ valid = false;
845
+ break;
846
+ }
847
+ const provider = names[tuple[0]];
848
+ const model = names[tuple[1]];
849
+ const thinkingLevel = names[tuple[8]];
850
+ const sourceId = names[tuple[12]];
851
+ if (
852
+ typeof provider !== "string" ||
853
+ typeof model !== "string" ||
854
+ typeof thinkingLevel !== "string" ||
855
+ typeof sourceId !== "string" ||
856
+ (tuple[11] !== 0 && tuple[11] !== 1)
857
+ ) {
858
+ valid = false;
859
+ break;
860
+ }
861
+ messages.push({
862
+ provider,
863
+ model,
864
+ thinkingLevel,
865
+ source: tuple[11] === 1 ? "auxiliary" : "assistant",
866
+ sourceId,
867
+ cost: Number(tuple[2]) || 0,
868
+ input: Number(tuple[3]) || 0,
869
+ output: Number(tuple[4]) || 0,
870
+ cacheRead: Number(tuple[5]) || 0,
871
+ cacheWrite: Number(tuple[6]) || 0,
872
+ timestamp: Number(tuple[7]) || 0,
873
+ reasoning: Number(tuple[9]) || 0,
874
+ afterCompaction: tuple[10] === 1,
875
+ });
876
+ }
877
+ if (!valid) continue;
878
+ const toolUsages: ToolUsageRecord[] = [];
879
+ for (const tuple of entry.toolUsages) {
880
+ if (!Array.isArray(tuple) || tuple.length !== 5 || !Array.isArray(tuple[4])) {
881
+ valid = false;
882
+ break;
883
+ }
884
+ const sourceId = names[tuple[0]];
885
+ const runId = names[tuple[3]];
886
+ const reportedUsage = cachedUsageAmount(tuple[2]);
887
+ if (typeof sourceId !== "string" || typeof runId !== "string" || (tuple[2] !== null && !reportedUsage)) {
888
+ valid = false;
889
+ break;
890
+ }
891
+ const children: ChildToolUsage[] = [];
892
+ for (const childTuple of tuple[4]) {
893
+ if (!Array.isArray(childTuple) || childTuple.length !== 3) {
894
+ valid = false;
895
+ break;
896
+ }
897
+ const sessionFile = names[childTuple[1]];
898
+ const usage = cachedUsageAmount(childTuple[2]);
899
+ if (typeof childTuple[0] !== "number" || typeof sessionFile !== "string" || !usage) {
900
+ valid = false;
901
+ break;
902
+ }
903
+ children.push({ resultIndex: childTuple[0], sessionFile, usage });
904
+ }
905
+ if (!valid) break;
906
+ toolUsages.push({
907
+ sourceId,
908
+ timestamp: Number(tuple[1]) || 0,
909
+ reportedUsage,
910
+ runId,
911
+ children,
912
+ });
913
+ }
914
+ if (!valid) continue;
915
+ result.set(filePath, {
916
+ size: entry.size,
917
+ mtimeMs: entry.mtimeMs,
918
+ parsed: { sessionId: entry.sessionId, cwd: entry.cwd, messages, toolUsages },
919
+ });
920
+ }
921
+ return result;
922
+ }
923
+
924
+ function cachedUsageAmount(value: unknown): UsageAmount | null {
925
+ if (!Array.isArray(value) || value.length !== 6 || value.some((part) => typeof part !== "number" || !Number.isFinite(part))) {
926
+ return null;
927
+ }
928
+ return {
929
+ cost: value[0],
930
+ input: value[1],
931
+ output: value[2],
932
+ cacheRead: value[3],
933
+ cacheWrite: value[4],
934
+ reasoning: value[5],
935
+ };
936
+ }
937
+
938
+ function cacheUsageAmount(usage: UsageAmount): CachedUsageTuple {
939
+ return [usage.cost, usage.input, usage.output, usage.cacheRead, usage.cacheWrite, usage.reasoning];
940
+ }
941
+
942
+ export async function saveUsageCache(cachePath: string, states: Map<string, CachedFileState>): Promise<void> {
943
+ const names: string[] = [];
944
+ const nameIndex = new Map<string, number>();
945
+ const intern = (name: string): number => {
946
+ let idx = nameIndex.get(name);
947
+ if (idx === undefined) {
948
+ idx = names.length;
949
+ names.push(name);
950
+ nameIndex.set(name, idx);
951
+ }
952
+ return idx;
953
+ };
954
+
955
+ const files: Record<string, CacheFileEntry> = {};
956
+ for (const [filePath, state] of states) {
957
+ files[filePath] = {
958
+ size: state.size,
959
+ mtimeMs: state.mtimeMs,
960
+ sessionId: state.parsed.sessionId,
961
+ cwd: state.parsed.cwd,
962
+ messages: state.parsed.messages.map((m): CachedMessageTuple => [
963
+ intern(m.provider),
964
+ intern(m.model),
965
+ m.cost,
966
+ m.input,
967
+ m.output,
968
+ m.cacheRead,
969
+ m.cacheWrite,
970
+ m.timestamp,
971
+ intern(m.thinkingLevel),
972
+ m.reasoning,
973
+ m.afterCompaction ? 1 : 0,
974
+ m.source === "auxiliary" ? 1 : 0,
975
+ intern(m.source === "auxiliary" ? m.sourceId : ""),
976
+ ]),
977
+ toolUsages: state.parsed.toolUsages.map((tool): CachedToolUsageTuple => [
978
+ intern(tool.sourceId),
979
+ tool.timestamp,
980
+ tool.reportedUsage ? cacheUsageAmount(tool.reportedUsage) : null,
981
+ intern(tool.runId),
982
+ tool.children.map((child): CachedChildToolUsageTuple => [
983
+ child.resultIndex,
984
+ intern(child.sessionFile),
985
+ cacheUsageAmount(child.usage),
986
+ ]),
987
+ ]),
988
+ };
989
+ }
990
+
991
+ const payload = JSON.stringify({ version: CACHE_VERSION, names, files });
992
+ // Atomic-ish write: concurrent /usage runs race to a last-writer-wins rename
993
+ // instead of interleaving partial writes.
994
+ const tmpPath = join(dirname(cachePath), `.usage-cache-${process.pid}-${Date.now()}.tmp`);
995
+ await writeFile(tmpPath, payload, "utf8");
996
+ await rename(tmpPath, cachePath);
997
+ }
998
+
999
+ // =============================================================================
1000
+ // Aggregation
1001
+ // =============================================================================
1002
+
1003
+ function emptyTokens(): TokenStats {
1004
+ return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1005
+ }
1006
+
1007
+ function emptyModelStats(): ModelStats {
1008
+ return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens() };
1009
+ }
1010
+
1011
+ function emptyProviderStats(): ProviderStats {
1012
+ return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens(), models: new Map() };
1013
+ }
1014
+
1015
+ function emptyTimeFilteredStats(): TimeFilteredStats {
1016
+ return {
1017
+ providers: new Map(),
1018
+ totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
1019
+ insights: { insights: [] },
1020
+ };
1021
+ }
1022
+
1023
+ function emptyPeriodRawData(): PeriodRawData {
1024
+ return {
1025
+ totalCost: 0,
1026
+ assistantCost: 0,
1027
+ auxiliaryCost: 0,
1028
+ ctxHigh: { cost: 0, messages: 0 },
1029
+ ctxLow: { cost: 0, messages: 0 },
1030
+ projectCosts: new Map(),
1031
+ sessionCosts: new Map(),
1032
+ upfrontCost: 0,
1033
+ ttlMissCost: 0,
1034
+ modelSwitchMissCost: 0,
1035
+ prefixMissCost: 0,
1036
+ reasoningTokens: 0,
1037
+ outputTokens: 0,
1038
+ cacheReadTokens: 0,
1039
+ freshTokens: 0,
1040
+ };
1041
+ }
1042
+
1043
+ /**
1044
+ * Collapse a session cwd to a short, stable project label: `~` for the home
1045
+ * directory, up to two path segments below home (worktrees collapse to their
1046
+ * repository), absolute paths elsewhere.
1047
+ */
1048
+ export function projectLabelFromCwd(cwd: string): string {
1049
+ if (!cwd) return "(unknown)";
1050
+ // Collapse any home-directory prefix to "~", not just the current user's —
1051
+ // session stores merged from other machines can carry a different username.
1052
+ const home = homedir();
1053
+ let homePrefix: string | null = null;
1054
+ if (cwd === home || cwd.startsWith(home + "/")) {
1055
+ homePrefix = home;
1056
+ } else {
1057
+ const m = /^(\/Users\/[^/]+|\/home\/[^/]+)(?=\/|$)/.exec(cwd);
1058
+ if (m) homePrefix = m[1]!;
1059
+ }
1060
+ if (homePrefix !== null && cwd.length <= homePrefix.length) return "~";
1061
+ let rel = homePrefix !== null ? cwd.slice(homePrefix.length + 1) : cwd;
1062
+ const wt = rel.indexOf("/.worktrees/");
1063
+ if (wt !== -1) rel = rel.slice(0, wt);
1064
+ const parts = rel.split("/").filter(Boolean);
1065
+ const label = parts.slice(0, 2).join("/");
1066
+ return homePrefix !== null ? `~/${label}` : `/${label}`;
1067
+ }
1068
+
1069
+ function emptyUsageData(bounds: PeriodBounds): UsageData {
1070
+ return {
1071
+ today: emptyTimeFilteredStats(),
1072
+ thisWeek: emptyTimeFilteredStats(),
1073
+ lastWeek: emptyTimeFilteredStats(),
1074
+ last30Days: emptyTimeFilteredStats(),
1075
+ allTime: emptyTimeFilteredStats(),
1076
+ sessions: new Map(),
1077
+ hourly: new Map(),
1078
+ bounds,
1079
+ };
1080
+ }
1081
+
1082
+ const HOUR_MS = 3_600_000;
1083
+
1084
+ function addToHourlyBuckets(hourly: Map<number, Map<HourlyKey, HourlyCell>>, msg: SessionMessage): void {
1085
+ if (msg.timestamp <= 0) return; // Unknown time can't be placed on a time axis.
1086
+ const hour = Math.floor(msg.timestamp / HOUR_MS) * HOUR_MS;
1087
+ let bucket = hourly.get(hour);
1088
+ if (!bucket) {
1089
+ bucket = new Map();
1090
+ hourly.set(hour, bucket);
1091
+ }
1092
+ const key = makeHourlyKey(msg.provider, msg.model, msg.thinkingLevel);
1093
+ let cell = bucket.get(key);
1094
+ if (!cell) {
1095
+ cell = { messages: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
1096
+ bucket.set(key, cell);
1097
+ }
1098
+ if (msg.source === "assistant") cell.messages++;
1099
+ cell.cost += msg.cost;
1100
+ cell.input += msg.input;
1101
+ cell.output += msg.output;
1102
+ cell.cacheRead += msg.cacheRead;
1103
+ cell.cacheWrite += msg.cacheWrite;
1104
+ cell.reasoning += msg.reasoning;
1105
+ }
1106
+
1107
+ // Helper to accumulate stats into a target
1108
+ function accumulateStats(
1109
+ target: BaseStats,
1110
+ cost: number,
1111
+ tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number },
1112
+ countMessage: boolean
1113
+ ): void {
1114
+ if (countMessage) target.messages++;
1115
+ target.cost += cost;
1116
+ target.tokens.total += tokens.total;
1117
+ target.tokens.input += tokens.input;
1118
+ target.tokens.output += tokens.output;
1119
+ target.tokens.cacheRead += tokens.cacheRead;
1120
+ target.tokens.cacheWrite += tokens.cacheWrite;
1121
+ }
1122
+
1123
+ function getPeriodsForTimestamp(
1124
+ timestamp: number,
1125
+ todayMs: number,
1126
+ weekStartMs: number,
1127
+ lastWeekStartMs: number,
1128
+ last30DaysStartMs: number
1129
+ ): TabName[] {
1130
+ const periods: TabName[] = ["allTime"];
1131
+ if (timestamp >= todayMs) periods.push("today");
1132
+ if (timestamp >= weekStartMs) {
1133
+ periods.push("thisWeek");
1134
+ } else if (timestamp >= lastWeekStartMs) {
1135
+ periods.push("lastWeek");
1136
+ }
1137
+ if (timestamp >= last30DaysStartMs) periods.push("last30Days");
1138
+ return periods;
1139
+ }
1140
+
1141
+ const DAY_MS = 24 * HOUR_MS;
1142
+ const PROGRESS_REPORT_EVERY = 100;
1143
+
1144
+ function addMessagesToUsageData(
1145
+ data: UsageData,
1146
+ sessionId: string,
1147
+ project: string,
1148
+ messages: SessionMessage[],
1149
+ meta: MessageMeta[],
1150
+ todayMs: number,
1151
+ weekStartMs: number,
1152
+ lastWeekStartMs: number,
1153
+ last30DaysStartMs: number,
1154
+ rawByPeriod: Record<TabName, PeriodRawData>,
1155
+ costByDayIdx: Map<number, number>
1156
+ ): void {
1157
+ const sessionContributed = { today: false, thisWeek: false, lastWeek: false, last30Days: false, allTime: false };
1158
+
1159
+ for (let mi = 0; mi < messages.length; mi++) {
1160
+ const msg = messages[mi]!;
1161
+ const mm = meta[mi]!;
1162
+
1163
+ // pi's built-in test providers never call a real API — keep them out of stats.
1164
+ if (EXCLUDED_PROVIDERS.has(msg.provider)) continue;
1165
+
1166
+ // Day-indexed cost totals power the burn-trend insight.
1167
+ if (msg.timestamp > 0) {
1168
+ const dayIdx = Math.floor((msg.timestamp - todayMs) / DAY_MS);
1169
+ costByDayIdx.set(dayIdx, (costByDayIdx.get(dayIdx) ?? 0) + msg.cost);
1170
+ }
1171
+
1172
+ addToHourlyBuckets(data.hourly, msg);
1173
+
1174
+ const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs);
1175
+ const tokens = {
1176
+ // Count fresh tokens processed this turn.
1177
+ // Include cacheWrite because those prompt tokens were newly written and billed.
1178
+ // Exclude cacheRead because repeated cache hits would otherwise dominate totals.
1179
+ total: msg.input + msg.output + msg.cacheWrite,
1180
+ input: msg.input,
1181
+ output: msg.output,
1182
+ cacheRead: msg.cacheRead,
1183
+ cacheWrite: msg.cacheWrite,
1184
+ };
1185
+
1186
+ for (const period of periods) {
1187
+ const stats = data[period];
1188
+
1189
+ let providerStats = stats.providers.get(msg.provider);
1190
+ if (!providerStats) {
1191
+ providerStats = emptyProviderStats();
1192
+ stats.providers.set(msg.provider, providerStats);
1193
+ }
1194
+
1195
+ let modelStats = providerStats.models.get(msg.model);
1196
+ if (!modelStats) {
1197
+ modelStats = emptyModelStats();
1198
+ providerStats.models.set(msg.model, modelStats);
1199
+ }
1200
+
1201
+ const isAssistant = msg.source === "assistant";
1202
+ modelStats.sessions.add(sessionId);
1203
+ accumulateStats(modelStats, msg.cost, tokens, isAssistant);
1204
+
1205
+ providerStats.sessions.add(sessionId);
1206
+ accumulateStats(providerStats, msg.cost, tokens, isAssistant);
1207
+
1208
+ accumulateStats(stats.totals, msg.cost, tokens, isAssistant);
1209
+ sessionContributed[period] = true;
1210
+
1211
+ const raw = rawByPeriod[period];
1212
+ raw.totalCost += msg.cost;
1213
+ raw.projectCosts.set(project, (raw.projectCosts.get(project) ?? 0) + msg.cost);
1214
+ raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
1215
+
1216
+ // Auxiliary calls belong in accounting totals, project/session mix, and
1217
+ // burn trend. They are not assistant turns, so do not let their synthetic
1218
+ // model identity or nested context distort turn/cache insights.
1219
+ if (!isAssistant) {
1220
+ raw.auxiliaryCost += msg.cost;
1221
+ continue;
1222
+ }
1223
+ raw.assistantCost += msg.cost;
1224
+
1225
+ const ctx = msg.input + msg.cacheRead + msg.cacheWrite;
1226
+ if (ctx >= CTX_TAX_THRESHOLD) {
1227
+ raw.ctxHigh.cost += msg.cost;
1228
+ raw.ctxHigh.messages++;
1229
+ } else if (ctx < CTX_LOW_THRESHOLD) {
1230
+ raw.ctxLow.cost += msg.cost;
1231
+ raw.ctxLow.messages++;
1232
+ }
1233
+ if (mm.isSessionStart) raw.upfrontCost += msg.cost;
1234
+ if (
1235
+ !msg.afterCompaction &&
1236
+ mm.prevCtx >= MISS_MIN_PREV_CONTEXT &&
1237
+ msg.cacheRead < Math.min(MISS_MAX_CACHE_READ, 0.3 * mm.prevCtx)
1238
+ ) {
1239
+ if (mm.gapMs > TTL_GAP_MS) raw.ttlMissCost += msg.cost;
1240
+ else if (mm.gapMs >= 0 && mm.modelSwitched) raw.modelSwitchMissCost += msg.cost;
1241
+ else if (mm.gapMs >= 0) raw.prefixMissCost += msg.cost;
1242
+ }
1243
+ raw.reasoningTokens += msg.reasoning;
1244
+ raw.outputTokens += msg.output;
1245
+ raw.cacheReadTokens += msg.cacheRead;
1246
+ raw.freshTokens += msg.input + msg.cacheWrite;
1247
+ }
1248
+ }
1249
+
1250
+ if (sessionContributed.today) data.today.totals.sessions++;
1251
+ if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
1252
+ if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
1253
+ if (sessionContributed.last30Days) data.last30Days.totals.sessions++;
1254
+ if (sessionContributed.allTime) data.allTime.totals.sessions++;
1255
+ }
1256
+
1257
+ // =============================================================================
1258
+ // Nested tool-usage accounting
1259
+ // =============================================================================
1260
+
1261
+ // Recognised nested-agent tool results report usage for child runs that
1262
+ // pi-subagents also persists as ordinary session files, which this scan
1263
+ // already counts with full model attribution. When every child session file
1264
+ // behind a report is part of the scan, the children speak for themselves and
1265
+ // the parent's aggregate is skipped. Otherwise the aggregate (or, for pre-0.81
1266
+ // legacy entries, each unresolved child's reported usage) is counted under
1267
+ // Tools / summaries.
1268
+ //
1269
+ // Copied branch history gets a new parent filename, so a copy's runId-derived
1270
+ // child paths can dangle even though the original's resolve. Resolved child
1271
+ // identities are therefore unioned across all copies of an entry before any
1272
+ // emission, and identical emissions collapse in the sourceId dedupe.
1273
+
1274
+ interface ScannedSessionIndex {
1275
+ /** Resolved paths of scanned files that have a session header. */
1276
+ paths: Set<string>;
1277
+ /** Directory of each scanned file → number of scanned files inside it. */
1278
+ fileCountByDir: Map<string, number>;
1279
+ }
1280
+
1281
+ function buildScannedSessionIndex(states: Map<string, CachedFileState>): ScannedSessionIndex {
1282
+ const paths = new Set<string>();
1283
+ const fileCountByDir = new Map<string, number>();
1284
+ for (const [filePath, state] of states) {
1285
+ if (!state.parsed.sessionId) continue;
1286
+ const resolved = resolve(filePath);
1287
+ paths.add(resolved);
1288
+ const dir = dirname(resolved);
1289
+ fileCountByDir.set(dir, (fileCountByDir.get(dir) ?? 0) + 1);
1290
+ }
1291
+ return { paths, fileCountByDir };
1292
+ }
1293
+
1294
+ function childSessionScanned(
1295
+ parentFilePath: string,
1296
+ tool: ToolUsageRecord,
1297
+ child: ChildToolUsage,
1298
+ index: ScannedSessionIndex
1299
+ ): boolean {
1300
+ if (child.sessionFile) {
1301
+ const explicit = isAbsolute(child.sessionFile)
1302
+ ? resolve(child.sessionFile)
1303
+ : resolve(dirname(parentFilePath), child.sessionFile);
1304
+ if (index.paths.has(explicit)) return true;
1305
+ }
1306
+ if (tool.runId) {
1307
+ const runDir = resolve(dirname(parentFilePath), basename(parentFilePath, ".jsonl"), tool.runId, `run-${child.resultIndex}`);
1308
+ // The run directory holds exactly one session per child run, so either the
1309
+ // conventional name or a lone scanned file inside it identifies the child.
1310
+ if (index.paths.has(join(runDir, "session.jsonl"))) return true;
1311
+ if (index.fileCountByDir.get(runDir) === 1) return true;
1312
+ }
1313
+ return false;
1314
+ }
1315
+
1316
+ /** Identity of one child slot of one tool entry, stable across copied history. */
1317
+ function toolChildIdentity(tool: ToolUsageRecord, child: ChildToolUsage): string {
1318
+ const fingerprint = child.usage.input + child.usage.output + child.usage.cacheRead + child.usage.cacheWrite;
1319
+ return `${tool.sourceId}:${tool.timestamp}:${child.resultIndex}:${fingerprint}`;
1320
+ }
1321
+
1322
+ function resolvedToolChildIdentities(states: Map<string, CachedFileState>, index: ScannedSessionIndex): Set<string> {
1323
+ const resolved = new Set<string>();
1324
+ for (const [filePath, state] of states) {
1325
+ for (const tool of state.parsed.toolUsages) {
1326
+ if (!tool.sourceId) continue;
1327
+ for (const child of tool.children) {
1328
+ if (childSessionScanned(filePath, tool, child, index)) resolved.add(toolChildIdentity(tool, child));
1329
+ }
1330
+ }
1331
+ }
1332
+ return resolved;
1333
+ }
1334
+
1335
+ function toolUsageMessages(
1336
+ parentFilePath: string,
1337
+ tool: ToolUsageRecord,
1338
+ index: ScannedSessionIndex,
1339
+ resolvedChildren: Set<string>
1340
+ ): SessionMessage[] {
1341
+ const scanned = (child: ChildToolUsage) =>
1342
+ childSessionScanned(parentFilePath, tool, child, index) ||
1343
+ (tool.sourceId !== "" && resolvedChildren.has(toolChildIdentity(tool, child)));
1344
+ if (tool.reportedUsage) {
1345
+ if (tool.children.length > 0 && tool.children.every(scanned)) return [];
1346
+ return [auxiliaryMessage(tool.reportedUsage, tool.timestamp, tool.sourceId)];
1347
+ }
1348
+ // Before Pi 0.81, recognised nested-agent tools persisted per-child usage in
1349
+ // details only. Count just the children whose sessions this scan cannot see.
1350
+ return tool.children
1351
+ .filter((child) => !scanned(child))
1352
+ .map((child) => auxiliaryMessage(child.usage, tool.timestamp, tool.sourceId ? `${tool.sourceId}:child:${child.resultIndex}` : ""));
1353
+ }
1354
+ // =============================================================================
1355
+ // Collection orchestration
1356
+ // =============================================================================
1357
+
1358
+ const STAT_CONCURRENCY = 16;
1359
+ const DEFAULT_PARSE_CONCURRENCY = 4;
1360
+ const AGGREGATE_YIELD_EVERY_FILES = 200;
1361
+
1362
+ export interface CollectProgress {
1363
+ /** Why this pass needs to parse files. */
1364
+ mode: "first-run" | "rebuild" | "update";
1365
+ /** Session files that need parsing this pass (0 = fully warm). */
1366
+ filesToParse: number;
1367
+ /** Files parsed so far; reported in coarse increments. */
1368
+ filesParsed: number;
1369
+ /** Newest session activity already ingested (ms since epoch); null when starting fresh. */
1370
+ sinceMs: number | null;
1371
+ }
1372
+
1373
+ export interface CollectUsageOptions {
1374
+ signal?: AbortSignal;
1375
+ /** Called once before parsing begins and periodically while files are parsed. */
1376
+ onProgress?: (progress: CollectProgress) => void;
1377
+ /** Defaults to `<agentDir>/sessions`. */
1378
+ sessionsDir?: string;
1379
+ /** Defaults to `<agentDir>/usage-extension-cache.json`. Pass `null` to disable the on-disk cache. */
1380
+ cachePath?: string | null;
1381
+ /** Reference time for period bucketing. Defaults to `new Date()`. */
1382
+ now?: Date;
1383
+ parseConcurrency?: number;
1384
+ }
1385
+
1386
+ export async function collectUsageData(options: CollectUsageOptions = {}): Promise<UsageData | null> {
1387
+ const signal = options.signal;
1388
+ const now = options.now ?? new Date();
1389
+ const sessionsDir = options.sessionsDir ?? getSessionsDir();
1390
+ const cachePath = options.cachePath === undefined ? getDefaultCachePath() : options.cachePath;
1391
+ const parseConcurrency = Math.max(1, options.parseConcurrency ?? DEFAULT_PARSE_CONCURRENCY);
1392
+
1393
+ const startOfToday = new Date(now);
1394
+ startOfToday.setHours(0, 0, 0, 0);
1395
+ const todayMs = startOfToday.getTime();
1396
+
1397
+ // Start of current week (Monday 00:00)
1398
+ const startOfWeek = new Date(now);
1399
+ const dayOfWeek = startOfWeek.getDay(); // 0 = Sunday, 1 = Monday, ...
1400
+ const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
1401
+ startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
1402
+ startOfWeek.setHours(0, 0, 0, 0);
1403
+ const weekStartMs = startOfWeek.getTime();
1404
+
1405
+ // Start of last week (previous Monday 00:00)
1406
+ const startOfLastWeek = new Date(startOfWeek);
1407
+ startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
1408
+ const lastWeekStartMs = startOfLastWeek.getTime();
1409
+
1410
+ // Rolling 30-day window: the last 30 calendar days including today,
1411
+ // i.e. from midnight 29 days before today. setDate handles DST correctly.
1412
+ const startOfLast30Days = new Date(startOfToday);
1413
+ startOfLast30Days.setDate(startOfLast30Days.getDate() - 29);
1414
+ const last30DaysStartMs = startOfLast30Days.getTime();
1415
+
1416
+ // 1. Discover session files.
1417
+ const filePaths = await getAllSessionFiles(sessionsDir, signal);
1418
+ if (signal?.aborted) return null;
1419
+
1420
+ // 2. Stat them (batched) so cache freshness can be checked without reading contents.
1421
+ const fileStats = new Map<string, { size: number; mtimeMs: number }>();
1422
+ {
1423
+ let next = 0;
1424
+ await Promise.all(
1425
+ Array.from({ length: STAT_CONCURRENCY }, async () => {
1426
+ while (next < filePaths.length) {
1427
+ if (signal?.aborted) return;
1428
+ const filePath = filePaths[next++]!;
1429
+ try {
1430
+ const st = await stat(filePath);
1431
+ fileStats.set(filePath, { size: st.size, mtimeMs: st.mtimeMs });
1432
+ } catch {
1433
+ // File vanished between listing and stat — skip it.
1434
+ }
1435
+ }
1436
+ })
1437
+ );
1438
+ }
1439
+ if (signal?.aborted) return null;
1440
+
1441
+ // 3. Load the cache and decide which files actually need parsing.
1442
+ let cacheFileExists = false;
1443
+ if (cachePath) {
1444
+ try {
1445
+ await stat(cachePath);
1446
+ cacheFileExists = true;
1447
+ } catch {
1448
+ // No cache file yet — first run.
1449
+ }
1450
+ }
1451
+ const previous = cachePath ? await loadUsageCache(cachePath) : new Map<string, CachedFileState>();
1452
+ if (signal?.aborted) return null;
1453
+ const current = new Map<string, CachedFileState>();
1454
+ const toParse: string[] = [];
1455
+ for (const filePath of filePaths) {
1456
+ const st = fileStats.get(filePath);
1457
+ if (!st) continue;
1458
+ const cached = previous.get(filePath);
1459
+ if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) {
1460
+ current.set(filePath, cached);
1461
+ } else {
1462
+ toParse.push(filePath);
1463
+ }
1464
+ }
1465
+ let dirty = toParse.length > 0;
1466
+ if (!dirty) {
1467
+ for (const filePath of previous.keys()) {
1468
+ if (!fileStats.has(filePath)) {
1469
+ dirty = true; // A cached file was deleted — evict it by rewriting.
1470
+ break;
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ // Progress reporting: distinguish a true first run from a format-change
1476
+ // rebuild (cache file present but unusable) and a routine incremental update.
1477
+ const progressMode: CollectProgress["mode"] =
1478
+ previous.size > 0 ? "update" : cacheFileExists ? "rebuild" : "first-run";
1479
+ let sinceMs: number | null = null;
1480
+ if (progressMode === "update") {
1481
+ for (const state of previous.values()) {
1482
+ if (state.mtimeMs > (sinceMs ?? 0)) sinceMs = state.mtimeMs;
1483
+ }
1484
+ }
1485
+ let filesParsed = 0;
1486
+ const reportProgress = (): void => {
1487
+ options.onProgress?.({ mode: progressMode, filesToParse: toParse.length, filesParsed, sinceMs });
1488
+ };
1489
+ reportProgress();
1490
+
1491
+ // 4. Parse new/changed files with bounded concurrency.
1492
+ {
1493
+ let next = 0;
1494
+ await Promise.all(
1495
+ Array.from({ length: parseConcurrency }, async () => {
1496
+ while (next < toParse.length) {
1497
+ if (signal?.aborted) return;
1498
+ const filePath = toParse[next++]!;
1499
+ const st = fileStats.get(filePath)!;
1500
+ let buffer: Buffer;
1501
+ try {
1502
+ buffer = await readFile(filePath);
1503
+ } catch {
1504
+ filesParsed++;
1505
+ continue; // File vanished — skip it.
1506
+ }
1507
+ const parsed = await parseSessionBuffer(buffer, signal);
1508
+ if (signal?.aborted) return; // Never cache a partial parse.
1509
+ current.set(filePath, { size: st.size, mtimeMs: st.mtimeMs, parsed });
1510
+ filesParsed++;
1511
+ if (filesParsed % PROGRESS_REPORT_EVERY === 0 || filesParsed === toParse.length) {
1512
+ reportProgress();
1513
+ }
1514
+ }
1515
+ })
1516
+ );
1517
+ }
1518
+
1519
+ if (signal?.aborted) {
1520
+ // Best effort: persist whatever finished so a cancelled cold build makes
1521
+ // the next attempt cheaper. Keep old entries for files not processed yet —
1522
+ // they are re-validated against size/mtime next run anyway.
1523
+ if (cachePath && dirty && current.size > 0) {
1524
+ const merged = new Map(previous);
1525
+ for (const [filePath, state] of current) merged.set(filePath, state);
1526
+ await saveUsageCache(cachePath, merged).catch(() => {});
1527
+ }
1528
+ return null;
1529
+ }
1530
+
1531
+ // 5. Persist the refreshed cache (also evicts entries for deleted files).
1532
+ if (cachePath && dirty) {
1533
+ await saveUsageCache(cachePath, current).catch(() => {
1534
+ // Cache write failures must never break /usage.
1535
+ });
1536
+ }
1537
+
1538
+ // 6. Aggregate in sorted path order with cross-file dedupe.
1539
+ const data = emptyUsageData({ todayMs, weekStartMs, lastWeekStartMs, last30DaysStartMs, nowMs: now.getTime() });
1540
+ const rawByPeriod: Record<TabName, PeriodRawData> = {
1541
+ today: emptyPeriodRawData(),
1542
+ thisWeek: emptyPeriodRawData(),
1543
+ lastWeek: emptyPeriodRawData(),
1544
+ last30Days: emptyPeriodRawData(),
1545
+ allTime: emptyPeriodRawData(),
1546
+ };
1547
+ const costByDayIdx = new Map<number, number>();
1548
+ const seenSessions = new Set<string>();
1549
+ const seenHashes = new Set<string>();
1550
+ const scannedSessions = buildScannedSessionIndex(current);
1551
+ const resolvedToolChildren = resolvedToolChildIdentities(current, scannedSessions);
1552
+ let processedFiles = 0;
1553
+
1554
+ for (const filePath of filePaths) {
1555
+ const state = current.get(filePath);
1556
+ if (!state || !state.parsed.sessionId) continue;
1557
+
1558
+ if (++processedFiles % AGGREGATE_YIELD_EVERY_FILES === 0) {
1559
+ await new Promise<void>((resolve) => setImmediate(resolve));
1560
+ if (signal?.aborted) return null;
1561
+ }
1562
+
1563
+ // Deduplicate copied history across branched session files, computing
1564
+ // adjacency metadata (idle gaps, previous context) on the raw file order
1565
+ // so branch copies do not distort miss classification.
1566
+ const toolMessages = state.parsed.toolUsages.flatMap((tool) => toolUsageMessages(filePath, tool, scannedSessions, resolvedToolChildren));
1567
+ const rawMsgs = toolMessages.length > 0 ? [...state.parsed.messages, ...toolMessages] : state.parsed.messages;
1568
+ const deduped: SessionMessage[] = [];
1569
+ const meta: MessageMeta[] = [];
1570
+ let previousAssistant: SessionMessage | null = null;
1571
+ for (const m of rawMsgs) {
1572
+ // Auxiliary usage is interleaved with conversation entries, but it must
1573
+ // not become the "previous message" for cache-miss classification.
1574
+ const prev = m.source === "assistant" ? previousAssistant : null;
1575
+ if (m.source === "assistant") previousAssistant = m;
1576
+
1577
+ // Pi entry ids survive copied branch history and distinguish parallel
1578
+ // tool results that happen to report identical usage in the same ms.
1579
+ const tokenFingerprint = m.input + m.output + m.cacheRead + m.cacheWrite;
1580
+ const hash =
1581
+ m.source === "auxiliary" && m.sourceId
1582
+ ? `auxiliary:${m.sourceId}:${m.timestamp}:${tokenFingerprint}`
1583
+ : `${m.source}:${m.timestamp}:${tokenFingerprint}`;
1584
+ if (seenHashes.has(hash)) continue;
1585
+ seenHashes.add(hash);
1586
+ deduped.push(m);
1587
+ meta.push({
1588
+ gapMs: prev && prev.timestamp > 0 && m.timestamp > 0 ? m.timestamp - prev.timestamp : -1,
1589
+ prevCtx: prev ? prev.input + prev.cacheRead + prev.cacheWrite : 0,
1590
+ modelSwitched: prev !== null && (prev.provider !== m.provider || prev.model !== m.model),
1591
+ isSessionStart: false,
1592
+ });
1593
+ }
1594
+ if (deduped.length === 0) continue;
1595
+ const firstAssistantIndex = deduped.findIndex((m) => m.source === "assistant");
1596
+ if (firstAssistantIndex !== -1 && !seenSessions.has(state.parsed.sessionId)) {
1597
+ seenSessions.add(state.parsed.sessionId);
1598
+ meta[firstAssistantIndex]!.isSessionStart = true;
1599
+ }
1600
+
1601
+ for (const message of deduped) {
1602
+ if (message.source !== "assistant") continue;
1603
+ const summary = data.sessions.get(state.parsed.sessionId) ?? { id: state.parsed.sessionId, cwd: state.parsed.cwd, timestamp: message.timestamp, messages: 0, cost: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } };
1604
+ summary.timestamp = Math.max(summary.timestamp, message.timestamp);
1605
+ summary.messages++; summary.cost += message.cost;
1606
+ summary.tokens.input += message.input; summary.tokens.output += message.output; summary.tokens.cacheRead += message.cacheRead; summary.tokens.cacheWrite += message.cacheWrite;
1607
+ summary.tokens.total += message.input + message.output + message.cacheRead + message.cacheWrite;
1608
+ data.sessions.set(summary.id, summary);
1609
+ }
1610
+
1611
+ addMessagesToUsageData(
1612
+ data,
1613
+ state.parsed.sessionId,
1614
+ projectLabelFromCwd(state.parsed.cwd),
1615
+ deduped,
1616
+ meta,
1617
+ todayMs,
1618
+ weekStartMs,
1619
+ lastWeekStartMs,
1620
+ last30DaysStartMs,
1621
+ rawByPeriod,
1622
+ costByDayIdx
1623
+ );
1624
+ }
1625
+
1626
+ // Burn trend: last 7 calendar days vs the average weekly pace of the prior 28.
1627
+ let last7 = 0;
1628
+ let prior28 = 0;
1629
+ for (const [idx, c] of costByDayIdx) {
1630
+ if (idx >= -6) last7 += c;
1631
+ else if (idx >= -34) prior28 += c;
1632
+ }
1633
+ const trend: TrendInfo | null = prior28 > 0 ? { last7Cost: last7, priorWeeklyPace: prior28 / 4 } : null;
1634
+
1635
+ for (const period of TAB_ORDER) {
1636
+ data[period].insights = computeInsights(rawByPeriod[period], trend);
1637
+ }
1638
+
1639
+ return data;
1640
+ }
1641
+
1642
+ // =============================================================================
1643
+ // Insights
1644
+ // =============================================================================
1645
+
1646
+ // Context tax (structure)
1647
+ const CTX_TAX_THRESHOLD = 150_000;
1648
+ const CTX_LOW_THRESHOLD = 100_000;
1649
+ // Project mix (structure)
1650
+ const PROJECT_TOP_COUNT = 3;
1651
+ const PROJECT_MAX_DOMINANCE_PERCENT = 90;
1652
+ // Reasoning share (structure)
1653
+ const REASONING_MIN_PERCENT = 5;
1654
+ // Burn trend (structure)
1655
+ const TREND_HIGH_RATIO = 1.5;
1656
+ const TREND_LOW_RATIO = 0.6;
1657
+ // Cache-miss alarms
1658
+ const TTL_GAP_MS = 5 * 60_000;
1659
+ const MISS_MIN_PREV_CONTEXT = 20_000;
1660
+ const MISS_MAX_CACHE_READ = 5_000;
1661
+ /** pi's built-in test providers never send anything to a real API. */
1662
+ const EXCLUDED_PROVIDERS = new Set(["faux-provider", "fake-provider"]);
1663
+
1664
+ const CACHE_MISS_ALARM_PERCENT = 2;
1665
+ const CACHE_MISS_ALARM_MIN_COST = 1;
1666
+ // Concentration / upfront alarms
1667
+ const TOP_SESSION_COUNT = 5;
1668
+ const CONCENTRATION_ALARM_PERCENT = 35;
1669
+ const UPFRONT_ALARM_PERCENT = 8;
1670
+ // Cache-leverage alarm
1671
+ const LEVERAGE_FLOOR = 5;
1672
+ const LEVERAGE_MIN_COST = 5;
1673
+ const LEVERAGE_MIN_FRESH_TOKENS = 1_000_000;
1674
+
1675
+ function fmtMoney(v: number): string {
1676
+ if (v >= 1000) return `$${(v / 1000).toFixed(1)}k`;
1677
+ if (v >= 100) return `$${Math.round(v)}`;
1678
+ return `$${v.toFixed(2)}`;
1679
+ }
1680
+
1681
+ function fmtPercent(p: number): string {
1682
+ return p >= 10 ? `${Math.round(p)}%` : `${p.toFixed(1)}%`;
1683
+ }
1684
+
1685
+ /**
1686
+ * Insights come in two kinds:
1687
+ * - structure: always-on decomposition of where the period's cost went.
1688
+ * - alarm: fires only when a wasteful pattern is material for the period, so
1689
+ * an all-clear period shows a calm panel instead of a wall of 2% factoids.
1690
+ * Periods with zero recorded cost produce an empty list — the UI renders a
1691
+ * distinct empty-state for that case.
1692
+ */
1693
+ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodInsights {
1694
+ if (raw.totalCost <= 0) {
1695
+ return { insights: [] };
1696
+ }
1697
+ const total = raw.totalCost;
1698
+ const assistantTotal = raw.assistantCost;
1699
+ const assistantPctLabel = raw.auxiliaryCost > 0 ? "assistant-message cost" : "this period";
1700
+ const insights: Insight[] = [];
1701
+
1702
+ // --- Alarms (listed first) ---
1703
+
1704
+ const ttlPct = assistantTotal > 0 ? (raw.ttlMissCost / assistantTotal) * 100 : 0;
1705
+ if (ttlPct >= CACHE_MISS_ALARM_PERCENT && raw.ttlMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1706
+ insights.push({
1707
+ kind: "alarm",
1708
+ stat: fmtMoney(raw.ttlMissCost),
1709
+ headline: `spent resuming conversations after a break (${fmtPercent(ttlPct)} of ${assistantPctLabel})`,
1710
+ advice:
1711
+ "Sent context is only reusable for a few minutes. After a longer pause, the next message pays to send the whole conversation again. Replying while a session is fresh avoids this.",
1712
+ });
1713
+ }
1714
+
1715
+ const switchPct = assistantTotal > 0 ? (raw.modelSwitchMissCost / assistantTotal) * 100 : 0;
1716
+ if (switchPct >= CACHE_MISS_ALARM_PERCENT && raw.modelSwitchMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1717
+ insights.push({
1718
+ kind: "alarm",
1719
+ stat: fmtMoney(raw.modelSwitchMissCost),
1720
+ headline: `spent switching models mid-conversation (${fmtPercent(switchPct)} of ${assistantPctLabel})`,
1721
+ advice:
1722
+ "Changing model re-sends the whole conversation at full price — the previous model's saved context doesn't transfer. Switching between tasks instead of mid-conversation avoids this.",
1723
+ });
1724
+ }
1725
+
1726
+ const prefixPct = assistantTotal > 0 ? (raw.prefixMissCost / assistantTotal) * 100 : 0;
1727
+ if (prefixPct >= CACHE_MISS_ALARM_PERCENT && raw.prefixMissCost >= CACHE_MISS_ALARM_MIN_COST) {
1728
+ insights.push({
1729
+ kind: "alarm",
1730
+ stat: fmtMoney(raw.prefixMissCost),
1731
+ headline: `spent re-sending conversations mid-session (${fmtPercent(prefixPct)} of ${assistantPctLabel})`,
1732
+ advice:
1733
+ "These messages paid full price for context that had already been sent — with no break, compaction, or model switch to explain it. Usually a tool or workflow is restarting or rewriting conversations. Worth a look if it stays high.",
1734
+ });
1735
+ }
1736
+
1737
+ if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
1738
+ const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
1739
+ const topWeight = sortedSessions.slice(0, TOP_SESSION_COUNT).reduce((sum, c) => sum + c, 0);
1740
+ const topPct = (topWeight / total) * 100;
1741
+ if (topPct >= CONCENTRATION_ALARM_PERCENT) {
1742
+ insights.push({
1743
+ kind: "alarm",
1744
+ stat: fmtMoney(topWeight),
1745
+ headline: `came from just ${TOP_SESSION_COUNT} of your ${raw.sessionCosts.size} sessions (${fmtPercent(topPct)} of this period)`,
1746
+ advice: "A handful of sessions drove most of the spend. The graph view can show what they were doing.",
1747
+ });
1748
+ }
1749
+ }
1750
+
1751
+ const upfrontPct = assistantTotal > 0 ? (raw.upfrontCost / assistantTotal) * 100 : 0;
1752
+ if (upfrontPct >= UPFRONT_ALARM_PERCENT) {
1753
+ insights.push({
1754
+ kind: "alarm",
1755
+ stat: fmtMoney(raw.upfrontCost),
1756
+ headline: `spent on the opening message of new sessions (${fmtPercent(upfrontPct)} of ${assistantPctLabel})`,
1757
+ advice: "A session's first message sends everything from scratch. Fewer, longer sessions cut this overhead.",
1758
+ });
1759
+ }
1760
+
1761
+ if (assistantTotal >= LEVERAGE_MIN_COST && raw.freshTokens >= LEVERAGE_MIN_FRESH_TOKENS) {
1762
+ const leverage = raw.cacheReadTokens / raw.freshTokens;
1763
+ if (leverage < LEVERAGE_FLOOR) {
1764
+ insights.push({
1765
+ kind: "alarm",
1766
+ stat: `${leverage.toFixed(1)}×`,
1767
+ headline: "tokens reused from history for every token paid at full price",
1768
+ advice:
1769
+ "Typical interactive use reuses 10× or more. A low number means conversations keep being sent from scratch — look for workflows that restart sessions.",
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ // --- Structure (always-on) ---
1775
+
1776
+ if (raw.auxiliaryCost > 0) {
1777
+ const pct = (raw.auxiliaryCost / total) * 100;
1778
+ if (pct >= 1) {
1779
+ insights.push({
1780
+ kind: "structure",
1781
+ stat: fmtPercent(pct),
1782
+ headline: "of your cost came from usage reported by tools and conversation summaries",
1783
+ advice: "Pi records this separately because it cannot be attributed reliably to a specific provider and model.",
1784
+ });
1785
+ }
1786
+ }
1787
+
1788
+ if (raw.ctxHigh.messages > 0 && assistantTotal > 0) {
1789
+ const pct = (raw.ctxHigh.cost / assistantTotal) * 100;
1790
+ if (pct >= 1) {
1791
+ const avgHigh = raw.ctxHigh.cost / raw.ctxHigh.messages;
1792
+ const avgLow = raw.ctxLow.messages > 0 ? raw.ctxLow.cost / raw.ctxLow.messages : 0;
1793
+ const cmp =
1794
+ avgLow > 0
1795
+ ? ` — ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
1796
+ : "";
1797
+ insights.push({
1798
+ kind: "structure",
1799
+ stat: fmtPercent(pct),
1800
+ headline: `of your ${raw.auxiliaryCost > 0 ? "assistant-message cost" : "cost"} came from messages with ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} tokens loaded${cmp}`,
1801
+ advice: "Long conversations cost more per message. /compact mid-task and /clear between tasks keep them lean.",
1802
+ });
1803
+ }
1804
+ }
1805
+
1806
+ if (raw.projectCosts.size >= 2) {
1807
+ const top = [...raw.projectCosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, PROJECT_TOP_COUNT);
1808
+ const topPct = (top[0]![1] / total) * 100;
1809
+ if (topPct < PROJECT_MAX_DOMINANCE_PERCENT) {
1810
+ const rest = top
1811
+ .slice(1)
1812
+ .map(([label, c]) => `${label} ${fmtPercent((c / total) * 100)}`)
1813
+ .join(", ");
1814
+ insights.push({
1815
+ kind: "structure",
1816
+ stat: fmtPercent(topPct),
1817
+ headline: `of your cost was ${top[0]![0]}${rest ? ` — then ${rest}` : ""}`,
1818
+ advice: "",
1819
+ });
1820
+ }
1821
+ }
1822
+
1823
+ if (raw.outputTokens > 0) {
1824
+ const reasoningPct = (raw.reasoningTokens / raw.outputTokens) * 100;
1825
+ if (reasoningPct >= REASONING_MIN_PERCENT) {
1826
+ insights.push({
1827
+ kind: "structure",
1828
+ stat: fmtPercent(reasoningPct),
1829
+ headline: "of your output tokens were hidden reasoning",
1830
+ advice:
1831
+ "Models charge for their behind-the-scenes thinking as output tokens. pi records this only from 0.80.3 (June 2026), so older periods understate it.",
1832
+ });
1833
+ }
1834
+ }
1835
+
1836
+ if (trend && trend.priorWeeklyPace > 0) {
1837
+ const ratio = trend.last7Cost / trend.priorWeeklyPace;
1838
+ const advice =
1839
+ ratio >= TREND_HIGH_RATIO
1840
+ ? "Spending is up against your own baseline — the graph view shows what changed."
1841
+ : ratio <= TREND_LOW_RATIO
1842
+ ? "Spending is well below your recent baseline."
1843
+ : "";
1844
+ insights.push({
1845
+ kind: "structure",
1846
+ stat: `${ratio.toFixed(1)}×`,
1847
+ headline: `your last 7 days (${fmtMoney(trend.last7Cost)}) vs your prior 4-week pace (${fmtMoney(trend.priorWeeklyPace)}/wk)`,
1848
+ advice,
1849
+ });
1850
+ }
1851
+
1852
+ return { insights };
1853
+ }
1854
+
1855
+ function formatThresholdTokens(n: number): string {
1856
+ if (n >= 1_000_000) return `${n / 1_000_000}M`;
1857
+ if (n >= 1_000) return `${n / 1_000}k`;
1858
+ return String(n);
1859
+ }