@ohgodtamit/pi-usage 0.1.0-alpha.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.
@@ -0,0 +1,1838 @@
1
+ /**
2
+ * Session aggregation and usage attribution.
3
+ *
4
+ * The usage panel mirrors Claude Code's `/usage` view: it shows how spend and
5
+ * tokens are distributed across models, skills, plugins, tools, and projects,
6
+ * bucketed by time window (5h / 24h / 7d / all).
7
+ *
8
+ * Data source
9
+ * -----------
10
+ * Pi stores per-turn usage (tokens + cost) on every assistant message across
11
+ * all session JSONL files under ~/.pi/agent/sessions/. We open each file once,
12
+ * walk its entries in append order, and attribute each assistant turn to:
13
+ * - a model (from message.model)
14
+ * - a project (from the session header cwd)
15
+ * - skill(s) (detected via parseSkillBlocks on the preceding user msg)
16
+ * - tools/plugins (from the tool calls inside the assistant message)
17
+ *
18
+ * Important: skills, plugins, tools and models are *independent characteristics*
19
+ * of usage, not a disjoint partition — a single turn can contribute to several
20
+ * buckets at once (exactly like Claude Code's wording). Percentages therefore
21
+ * do not sum to 100% across categories.
22
+ */
23
+ import type { AssistantMessage, ToolCall, Usage } from "@earendil-works/pi-ai";
24
+ import {
25
+ type ExtensionAPI,
26
+ parseSkillBlock,
27
+ type SessionEntry,
28
+ type SessionInfo,
29
+ SessionManager,
30
+ } from "@earendil-works/pi-coding-agent";
31
+ import {
32
+ closeSync,
33
+ lstatSync,
34
+ openSync,
35
+ readdirSync,
36
+ readSync,
37
+ realpathSync,
38
+ statSync,
39
+ } from "node:fs";
40
+ import { basename, dirname, join, normalize, resolve, sep } from "node:path";
41
+ import type { CachedSession, ScanCache } from "./cache.ts";
42
+ import { excludesFingerprint, pricesFingerprint } from "./cache.ts";
43
+ import type { ModelPrice } from "./config.ts";
44
+ import { sourceLabel } from "./format.ts";
45
+
46
+ /** A lookup of manual model prices ($/Mtok), keyed by model ID. */
47
+ export type PriceMap = Record<string, ModelPrice>;
48
+
49
+ /**
50
+ * Resolve a manual price for a model: exact match first, then by base name
51
+ * (after the last `/`) so an entry like `claude-opus-4.7` covers proxied
52
+ * variants such as `kr/claude-opus-4.7` and `cx/claude-opus-4.7`.
53
+ */
54
+ export function resolveModelPrice(
55
+ model: string,
56
+ prices: PriceMap | undefined,
57
+ ): ModelPrice | undefined {
58
+ if (!prices) return undefined;
59
+ if (prices[model]) return prices[model];
60
+ const slash = model.lastIndexOf("/");
61
+ if (slash >= 0) {
62
+ const base = model.slice(slash + 1);
63
+ if (prices[base]) return prices[base];
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /** Compute a USD cost from token usage and a manual price ($/Mtok). */
69
+ export function costFromPrice(usage: Usage, price: ModelPrice): number {
70
+ return (
71
+ (usage.input / 1e6) * (price.input ?? 0) +
72
+ (usage.output / 1e6) * (price.output ?? 0) +
73
+ (usage.cacheRead / 1e6) * (price.cacheRead ?? 0) +
74
+ (usage.cacheWrite / 1e6) * (price.cacheWrite ?? 0)
75
+ );
76
+ }
77
+
78
+ /** Time windows selectable in the panel. */
79
+ export type WindowKey = "5h" | "24h" | "7d" | "all";
80
+
81
+ const HOUR = 60 * 60 * 1000;
82
+ const DAY = 24 * HOUR;
83
+
84
+ export function windowMs(key: WindowKey): number {
85
+ switch (key) {
86
+ case "5h":
87
+ return 5 * HOUR;
88
+ case "24h":
89
+ return DAY;
90
+ case "7d":
91
+ return 7 * DAY;
92
+ case "all":
93
+ return Number.POSITIVE_INFINITY;
94
+ }
95
+ }
96
+
97
+ export function windowLabel(key: WindowKey): string {
98
+ switch (key) {
99
+ case "5h":
100
+ return "Last 5 hours";
101
+ case "24h":
102
+ return "Last 24 hours";
103
+ case "7d":
104
+ return "Last 7 days";
105
+ case "all":
106
+ return "All time";
107
+ }
108
+ }
109
+
110
+ /** Built-in tool names — excluded from the Plugins breakdown but shown in Tools. */
111
+ const BUILTIN_TOOLS = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
112
+
113
+ /** Mutable usage totals for a bucket. */
114
+ export interface Bucket {
115
+ cost: number;
116
+ costInput: number;
117
+ costOutput: number;
118
+ costCacheRead: number;
119
+ costCacheWrite: number;
120
+ input: number;
121
+ output: number;
122
+ cacheRead: number;
123
+ cacheWrite: number;
124
+ cacheWrite1h: number;
125
+ /** Reported reasoning tokens; a labelled subset of output, never additive. */
126
+ reasoning: number;
127
+ turns: number;
128
+ /**
129
+ * Estimated generation time (ms) summed across turns. Used to derive an
130
+ * average output-tokens/second. It's an estimate: pi's Usage carries no
131
+ * duration, so we approximate per-turn time from the gap between an
132
+ * assistant turn and the preceding session entry (idle/tool gaps clamped).
133
+ */
134
+ genMs: number;
135
+ /** Turns that contributed a usable genMs estimate (for tok/s averaging). */
136
+ timedTurns: number;
137
+ }
138
+
139
+ function emptyBucket(): Bucket {
140
+ return {
141
+ cost: 0,
142
+ costInput: 0,
143
+ costOutput: 0,
144
+ costCacheRead: 0,
145
+ costCacheWrite: 0,
146
+ input: 0,
147
+ output: 0,
148
+ cacheRead: 0,
149
+ cacheWrite: 0,
150
+ cacheWrite1h: 0,
151
+ reasoning: 0,
152
+ turns: 0,
153
+ genMs: 0,
154
+ timedTurns: 0,
155
+ };
156
+ }
157
+
158
+ function addBucket(b: Bucket, u: Usage, genMs = 0): void {
159
+ b.cost += u.cost.total;
160
+ b.costInput += u.cost.input ?? 0;
161
+ b.costOutput += u.cost.output ?? 0;
162
+ b.costCacheRead += u.cost.cacheRead ?? 0;
163
+ b.costCacheWrite += u.cost.cacheWrite ?? 0;
164
+ b.input += u.input;
165
+ b.output += u.output;
166
+ b.cacheRead += u.cacheRead;
167
+ b.cacheWrite += u.cacheWrite;
168
+ b.cacheWrite1h += u.cacheWrite1h ?? 0;
169
+ b.reasoning += u.reasoning ?? 0;
170
+ b.turns += 1;
171
+ if (genMs > 0) {
172
+ b.genMs += genMs;
173
+ b.timedTurns += 1;
174
+ }
175
+ }
176
+
177
+ /** Total tokens consumed by a bucket (input + output + cache reads/writes). */
178
+ export function bucketTokens(b: Bucket): number {
179
+ return b.input + b.output + b.cacheRead + b.cacheWrite;
180
+ }
181
+
182
+ /**
183
+ * Average output-tokens/second for a bucket, estimated from `genMs`.
184
+ * Returns 0 when no timed turns are available. Output tokens are used because
185
+ * that's the generation throughput users mean by "tok/s".
186
+ */
187
+ export function tokensPerSecond(b: Bucket): number {
188
+ if (b.genMs <= 0 || b.output <= 0) return 0;
189
+ return b.output / (b.genMs / 1000);
190
+ }
191
+
192
+ /** A single attributed assistant turn on the timeline. */
193
+ export interface TurnEntry {
194
+ ts: number;
195
+ model: string;
196
+ provider: string;
197
+ project: string;
198
+ cost: number;
199
+ usage: Usage;
200
+ /** Primary skill (first in a multi-skill activation). */
201
+ skill: string | null;
202
+ /** All skills from multi-skill or single-skill activation. */
203
+ skills: string[];
204
+ /** Bundle names from multi-skill (@bundle) activation. */
205
+ bundles: string[];
206
+ tools: string[];
207
+ /** Estimated generation time for this turn in ms (0 when not estimable). */
208
+ genMs: number;
209
+ sessionId: string;
210
+ sessionPath: string;
211
+ delegated: boolean;
212
+ parentSessionId: string | null;
213
+ }
214
+
215
+ /**
216
+ * Backfill fields missing on legacy cached turns (pre multi-skill cache entries).
217
+ * Safe to call on every cache hit and before aggregating.
218
+ */
219
+ export function normalizeTurnEntry(entry: TurnEntry): TurnEntry {
220
+ const skills = Array.isArray(entry.skills) ? entry.skills : entry.skill ? [entry.skill] : [];
221
+ const bundles = Array.isArray(entry.bundles) ? entry.bundles : [];
222
+ const tools = Array.isArray(entry.tools) ? entry.tools : [];
223
+ const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : "";
224
+ const sessionPath = typeof entry.sessionPath === "string" ? entry.sessionPath : "";
225
+ const delegated = entry.delegated === true;
226
+ const parentSessionId = typeof entry.parentSessionId === "string" ? entry.parentSessionId : null;
227
+ return { ...entry, skills, bundles, tools, sessionId, sessionPath, delegated, parentSessionId };
228
+ }
229
+
230
+ /** Skills attributed to a turn (multi-skill aware, legacy-safe). */
231
+ export function skillsForTurn(turn: TurnEntry): string[] {
232
+ if (Array.isArray(turn.skills) && turn.skills.length > 0) return turn.skills;
233
+ return turn.skill ? [turn.skill] : [];
234
+ }
235
+
236
+ /** Bundles attributed to a turn (multi-skill @bundle activation). */
237
+ export function bundlesForTurn(turn: TurnEntry): string[] {
238
+ return Array.isArray(turn.bundles) ? turn.bundles : [];
239
+ }
240
+
241
+ /** Soft metadata for one authoritative delegated transcript. */
242
+ export interface ChildSessionSummary {
243
+ id: string;
244
+ path: string;
245
+ parentSessionId: string | null;
246
+ parentLabel: string;
247
+ project: string;
248
+ task: string;
249
+ agentType: string;
250
+ status: string;
251
+ isBackground?: boolean;
252
+ startedAt: number;
253
+ endedAt: number;
254
+ timingInferred: boolean;
255
+ model?: string;
256
+ stack?: string;
257
+ thinking?: string;
258
+ compactions: number;
259
+ }
260
+
261
+ /** Full raw report built once, then windowed on demand. */
262
+ export interface Report {
263
+ computedAt: number;
264
+ sessionCount: number;
265
+ turnCount: number;
266
+ entries: TurnEntry[];
267
+ children: ChildSessionSummary[];
268
+ }
269
+
270
+ /** Runtime-derived maps for attributing tools/skills to plugin labels. */
271
+ export interface AttributionMaps {
272
+ toolToPlugin: Map<string, string>;
273
+ skillToPlugin: Map<string, string>;
274
+ }
275
+
276
+ /** Build tool->plugin and skill->plugin maps from the currently loaded resources. */
277
+ export function buildAttributionMaps(pi: ExtensionAPI): AttributionMaps {
278
+ const toolToPlugin = new Map<string, string>();
279
+ for (const tool of pi.getAllTools()) {
280
+ if (BUILTIN_TOOLS.has(tool.name)) continue;
281
+ toolToPlugin.set(tool.name, sourceLabel(tool.sourceInfo));
282
+ }
283
+
284
+ const skillToPlugin = new Map<string, string>();
285
+ for (const cmd of pi.getCommands()) {
286
+ if (cmd.source !== "skill") continue;
287
+ // Skill command names look like "skill:<name>"; the skill name is what
288
+ // appears in <skill name="..."> blocks stored in sessions.
289
+ const skillName = cmd.name.startsWith("skill:") ? cmd.name.slice("skill:".length) : cmd.name;
290
+ skillToPlugin.set(skillName, sourceLabel(cmd.sourceInfo));
291
+ }
292
+
293
+ return { toolToPlugin, skillToPlugin };
294
+ }
295
+
296
+ /** Extract bundle names from manually_attached_skills bundles="..." attribute. */
297
+ export function parseSkillBundles(text: string): string[] {
298
+ const match = text.match(/<manually_attached_skills[^>]*\sbundles="([^"]+)"/);
299
+ if (!match) return [];
300
+ return match[1]
301
+ .split(",")
302
+ .map((b) => b.trim().replace(/^@/, ""))
303
+ .filter((b) => b.length > 0);
304
+ }
305
+
306
+ /** Extract all skill names from user content (multi-skill aware). */
307
+ export function parseSkillBlocks(text: string): string[] {
308
+ const names: string[] = [];
309
+ const re = /<skill\s+name="([^"]+)"/g;
310
+ for (let match = re.exec(text); match !== null; match = re.exec(text)) {
311
+ names.push(match[1]);
312
+ }
313
+ if (names.length > 0) return [...new Set(names)];
314
+
315
+ const single = parseSkillBlock(text);
316
+ return single ? [single.name] : [];
317
+ }
318
+
319
+ function textOfUserContent(content: AssistantMessage["content"] | string | unknown): string {
320
+ if (typeof content === "string") return content;
321
+ if (Array.isArray(content)) {
322
+ return content
323
+ .map((block) =>
324
+ typeof block === "object" && block !== null && "type" in block && block.type === "text"
325
+ ? (block as { text: string }).text
326
+ : "",
327
+ )
328
+ .join("\n");
329
+ }
330
+ return "";
331
+ }
332
+
333
+ function shouldExclude(project: string, excludes: string[]): boolean {
334
+ if (!excludes || excludes.length === 0) return false;
335
+ const p = project.replace(/\\/g, "/").toLowerCase();
336
+ return excludes.some((ex) => p.startsWith(ex.replace(/\\/g, "/").toLowerCase()));
337
+ }
338
+
339
+ /**
340
+ * Scan every session file and build the timeline of attributed turns.
341
+ *
342
+ * `onProgress` receives (loaded, total) for UI feedback. Resolves even if some
343
+ * files fail to parse — bad files are skipped with a console warning.
344
+ *
345
+ * When a `cache` is supplied, sessions whose file mtime + size are unchanged
346
+ * (and whose cost was computed with the same price table) are reused without
347
+ * re-reading/parsing the file — only new or modified sessions are parsed. The
348
+ * cache object is updated in place; the caller persists it.
349
+ */
350
+ export async function scanSessions(
351
+ maxSessions: number,
352
+ excludes: string[],
353
+ onProgress?: (loaded: number, total: number) => void,
354
+ prices?: PriceMap,
355
+ cache?: ScanCache,
356
+ ): Promise<Report> {
357
+ const all = await SessionManager.listAll();
358
+ // Classify roots vs parented top-level sessions with a bounded header read;
359
+ // a full parse here would defeat the incremental cache on large histories.
360
+ const roots: SessionInfo[] = [];
361
+ const parented: ParentedSession[] = [];
362
+ for (const session of all) {
363
+ if (isChildConventionPath(session.path)) continue;
364
+ try {
365
+ const header = headerFor(session.path);
366
+ if (header.parentSession)
367
+ parented.push({ info: session, parentSession: header.parentSession });
368
+ else roots.push(session);
369
+ } catch {
370
+ roots.push(session);
371
+ }
372
+ }
373
+ const selected = stableSort(roots, (a, b) => b.modified.getTime() - a.modified.getTime()).slice(
374
+ 0,
375
+ maxSessions,
376
+ );
377
+ const discovered = discoverSelectedSessions(selected, parented);
378
+ const pricesKey = pricesFingerprint(prices);
379
+ const excludesKey = excludesFingerprint(excludes);
380
+ const prevSessions =
381
+ cache && cache.pricesKey === pricesKey && cache.excludesKey === excludesKey
382
+ ? cache.sessions
383
+ : {};
384
+ const nextSessions: ScanCache["sessions"] = {};
385
+ const entries: TurnEntry[] = [];
386
+ const rawChildren: ChildSessionSummary[] = [];
387
+ const recordPool: SoftRecord[] = [];
388
+
389
+ for (let i = 0; i < discovered.length; i++) {
390
+ const session = discovered[i];
391
+ onProgress?.(i + 1, discovered.length);
392
+ try {
393
+ const st = statSync(session.info.path);
394
+ const cached = prevSessions[session.info.path];
395
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
396
+ entries.push(...cached.entries.map(normalizeTurnEntry));
397
+ if (cached.child) rawChildren.push(cached.child);
398
+ recordPool.push(...(cached.records ?? []));
399
+ nextSessions[session.info.path] = cached;
400
+ continue;
401
+ }
402
+ const fresh: TurnEntry[] = [];
403
+ const { child, records } = collectFromSession(
404
+ session.info,
405
+ session.info.cwd || session.project,
406
+ excludes,
407
+ fresh,
408
+ prices,
409
+ session.delegated,
410
+ session.parentSessionId,
411
+ session.parentLabel,
412
+ );
413
+ entries.push(...fresh);
414
+ if (child) rawChildren.push(child);
415
+ recordPool.push(...records);
416
+ nextSessions[session.info.path] = {
417
+ mtimeMs: st.mtimeMs,
418
+ size: st.size,
419
+ entries: fresh,
420
+ child,
421
+ records,
422
+ };
423
+ } catch (err) {
424
+ console.error(`[usage] Failed to read session ${session.info.path}: ${err}`);
425
+ }
426
+ }
427
+
428
+ // Record enrichment runs after assembly from the pooled per-file metadata,
429
+ // so a parent's metadata updates a child summary even when the child
430
+ // transcript itself is an unchanged cache hit.
431
+ const children = rawChildren.map((child) =>
432
+ enrichChild(
433
+ child,
434
+ mergeRecords(
435
+ recordPool.filter(
436
+ (record) =>
437
+ record.childSessionId === child.id || pathRefMatches(record.outputFile, child.path),
438
+ ),
439
+ ),
440
+ ),
441
+ );
442
+
443
+ if (cache) {
444
+ cache.version = 6;
445
+ cache.pricesKey = pricesKey;
446
+ cache.excludesKey = excludesKey;
447
+ cache.sessions = nextSessions;
448
+ }
449
+ return {
450
+ computedAt: Date.now(),
451
+ sessionCount: discovered.length,
452
+ turnCount: entries.length,
453
+ entries,
454
+ children: dedupeChildren(children),
455
+ };
456
+ }
457
+
458
+ /** Loose `subagents:record` metadata payload (authored by delegation frameworks). */
459
+ export type SoftRecord = Record<string, unknown>;
460
+
461
+ /** A top-level session excluded from the roots because its header names a parent. */
462
+ export interface ParentedSession {
463
+ info: SessionInfo;
464
+ parentSession: string;
465
+ }
466
+
467
+ interface DiscoveredSession {
468
+ info: SessionInfo;
469
+ project: string;
470
+ delegated: boolean;
471
+ parentSessionId: string | null;
472
+ parentLabel: string;
473
+ }
474
+
475
+ interface SessionHeader {
476
+ id?: string;
477
+ cwd?: string;
478
+ parentSession?: string;
479
+ }
480
+
481
+ /** Cap on the prefix scanned for the session header (mirrors pi's own limit). */
482
+ const HEADER_SCAN_LIMIT = 1024 * 1024;
483
+ const HEADER_CHUNK_BYTES = 64 * 1024;
484
+
485
+ function parseSessionHeaderLine(line: string): SessionHeader | null {
486
+ let parsed: unknown;
487
+ try {
488
+ parsed = JSON.parse(line);
489
+ } catch {
490
+ return null;
491
+ }
492
+ if (typeof parsed !== "object" || parsed === null) return null;
493
+ const record = parsed as Record<string, unknown>;
494
+ if (record.type !== "session") return null;
495
+ return {
496
+ id: typeof record.id === "string" ? record.id : undefined,
497
+ cwd: typeof record.cwd === "string" ? record.cwd : undefined,
498
+ parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined,
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Read just the file's leading lines to find the session header. Roots are
504
+ * classified before `maxSessions`, so parsing every full transcript here
505
+ * would defeat the incremental cache on large histories.
506
+ */
507
+ export function headerFor(path: string): SessionHeader {
508
+ let fd: number | undefined;
509
+ try {
510
+ fd = openSync(path, "r");
511
+ const buffer = Buffer.allocUnsafe(HEADER_CHUNK_BYTES);
512
+ let scanned = 0;
513
+ let pending = "";
514
+ for (;;) {
515
+ const bytes = readSync(fd, buffer, 0, HEADER_CHUNK_BYTES, scanned);
516
+ if (bytes === 0) break;
517
+ scanned += bytes;
518
+ pending += buffer.toString("utf8", 0, bytes);
519
+ const lines = pending.split(/\r?\n/);
520
+ pending = lines.pop() ?? "";
521
+ for (const line of lines) {
522
+ const header = parseSessionHeaderLine(line);
523
+ if (header) return header;
524
+ }
525
+ if (scanned >= HEADER_SCAN_LIMIT) break;
526
+ }
527
+ return parseSessionHeaderLine(pending) ?? {};
528
+ } finally {
529
+ if (fd !== undefined) closeSync(fd);
530
+ }
531
+ }
532
+
533
+ function isChildConventionPath(path: string): boolean {
534
+ const parts = normalize(path).split(sep);
535
+ return parts.includes("tasks") || parts.includes("subagents");
536
+ }
537
+
538
+ export function discoverChildSessionFiles(rootFile: string): string[] {
539
+ const out: string[] = [];
540
+ const dir = dirname(rootFile);
541
+ const stem = basename(rootFile, ".jsonl");
542
+ const starts = [join(dir, stem), join(dir, "tasks"), join(dir, "subagents")];
543
+ const walk = (current: string): void => {
544
+ let names: import("node:fs").Dirent[];
545
+ try {
546
+ names = readdirSync(current, { withFileTypes: true });
547
+ } catch {
548
+ return;
549
+ }
550
+ for (const item of names) {
551
+ const path = join(current, item.name);
552
+ if (item.isSymbolicLink()) continue;
553
+ if (item.isDirectory()) walk(path);
554
+ else if (item.isFile() && item.name.endsWith(".jsonl")) out.push(path);
555
+ }
556
+ };
557
+ for (const start of starts) walk(start);
558
+ return out;
559
+ }
560
+
561
+ /** A selected root (or resolved direct child) whose convention dirs are walked. */
562
+ interface DiscoveryAnchor {
563
+ entry: DiscoveredSession;
564
+ /** The anchor's session id — the fallback parent for walked children. */
565
+ id: string;
566
+ }
567
+
568
+ /** The selected root a parented session transitively resolves to. */
569
+ interface RootAnchor {
570
+ info: SessionInfo;
571
+ id: string;
572
+ label: string;
573
+ }
574
+
575
+ function resolveDirectChild(
576
+ start: ParentedSession,
577
+ rootByKey: Map<string, RootAnchor>,
578
+ candidateById: Map<string, ParentedSession>,
579
+ candidateByPath: Map<string, ParentedSession>,
580
+ ): RootAnchor | null {
581
+ let current = start;
582
+ const visited = new Set<string>();
583
+ for (;;) {
584
+ const ref = current.parentSession;
585
+ const anchor = rootByKey.get(ref) ?? rootByKey.get(resolve(ref));
586
+ if (anchor) return anchor;
587
+ if (visited.has(ref)) return null;
588
+ visited.add(ref);
589
+ const next =
590
+ candidateById.get(ref) ??
591
+ candidateByPath.get(resolve(ref)) ??
592
+ candidateByPath.get(resolve(dirname(current.info.path), ref));
593
+ if (!next) return null;
594
+ current = next;
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Resolve the selected roots plus their delegated child transcripts into the
600
+ * flat session list the scanner walks. `parented` carries top-level sessions
601
+ * excluded from the roots because their header names a parent: those whose
602
+ * parent chain (session id or path) resolves transitively to a selected root
603
+ * are attached as direct delegated children. Exported as a narrow seam so
604
+ * fixture tests can exercise discovery without touching real user sessions.
605
+ */
606
+ export function discoverSelectedSessions(
607
+ roots: SessionInfo[],
608
+ parented: ParentedSession[] = [],
609
+ ): DiscoveredSession[] {
610
+ const seenPaths = new Set<string>();
611
+ const seenChildIds = new Set<string>();
612
+ const anchors: DiscoveryAnchor[] = [];
613
+ const rootByKey = new Map<string, RootAnchor>();
614
+
615
+ for (const root of roots) {
616
+ let rootPath: string;
617
+ try {
618
+ rootPath = realpathSync(root.path);
619
+ } catch {
620
+ rootPath = resolve(root.path);
621
+ }
622
+ if (seenPaths.has(rootPath)) continue;
623
+ seenPaths.add(rootPath);
624
+ // An unreadable root still counts as a (childless, direct) session;
625
+ // only its header metadata is lost.
626
+ let rootHeader: SessionHeader = {};
627
+ try {
628
+ rootHeader = headerFor(root.path);
629
+ } catch {
630
+ // Metadata is optional; the transcript remains authoritative.
631
+ }
632
+ const rootId = rootHeader.id || root.id;
633
+ seenChildIds.add(rootId);
634
+ anchors.push({
635
+ entry: {
636
+ info: root,
637
+ project: root.cwd || rootHeader.cwd || "",
638
+ delegated: !!rootHeader.parentSession,
639
+ parentSessionId: rootHeader.parentSession ?? null,
640
+ parentLabel: root.name || root.firstMessage || rootId,
641
+ },
642
+ id: rootId,
643
+ });
644
+ const anchor: RootAnchor = {
645
+ info: root,
646
+ id: rootId,
647
+ label: root.name || root.firstMessage || rootId,
648
+ };
649
+ rootByKey.set(rootId, anchor);
650
+ rootByKey.set(resolve(root.path), anchor);
651
+ }
652
+
653
+ // Direct top-level children (stored beside their parent, so listAll sees
654
+ // them) attach transitively to a selected root via their header chain.
655
+ const candidateById = new Map<string, ParentedSession>();
656
+ const candidateByPath = new Map<string, ParentedSession>();
657
+ for (const candidate of parented) {
658
+ candidateById.set(candidate.info.id, candidate);
659
+ candidateByPath.set(resolve(candidate.info.path), candidate);
660
+ }
661
+ for (const candidate of parented) {
662
+ const root = resolveDirectChild(candidate, rootByKey, candidateById, candidateByPath);
663
+ if (!root) continue;
664
+ let canonical: string;
665
+ try {
666
+ if (lstatSync(candidate.info.path).isSymbolicLink()) continue;
667
+ canonical = realpathSync(candidate.info.path);
668
+ } catch {
669
+ continue;
670
+ }
671
+ if (seenPaths.has(canonical)) continue;
672
+ let header: SessionHeader = {};
673
+ try {
674
+ header = headerFor(candidate.info.path);
675
+ } catch {
676
+ // Metadata is optional; the transcript remains authoritative.
677
+ }
678
+ const candidateId = header.id || candidate.info.id;
679
+ if (seenChildIds.has(candidateId)) continue;
680
+ seenPaths.add(canonical);
681
+ seenChildIds.add(candidateId);
682
+ anchors.push({
683
+ entry: {
684
+ info: { ...candidate.info, id: candidateId },
685
+ project: candidate.info.cwd || header.cwd || "",
686
+ delegated: true,
687
+ parentSessionId: header.parentSession || candidate.parentSession,
688
+ parentLabel: root.label,
689
+ },
690
+ id: candidateId,
691
+ });
692
+ }
693
+
694
+ const result: DiscoveredSession[] = [];
695
+ for (const anchor of anchors) {
696
+ result.push(anchor.entry);
697
+ const queue = discoverChildSessionFiles(anchor.entry.info.path);
698
+ for (const path of queue) {
699
+ let canonical: string;
700
+ try {
701
+ if (lstatSync(path).isSymbolicLink()) continue;
702
+ canonical = realpathSync(path);
703
+ } catch {
704
+ continue;
705
+ }
706
+ if (seenPaths.has(canonical)) continue;
707
+ let header: SessionHeader;
708
+ try {
709
+ header = headerFor(path);
710
+ } catch {
711
+ continue;
712
+ }
713
+ const id = header.id || canonical;
714
+ if (seenChildIds.has(id)) continue;
715
+ const rawParent = header.parentSession;
716
+ seenPaths.add(canonical);
717
+ seenChildIds.add(id);
718
+ let st: import("node:fs").Stats;
719
+ try {
720
+ st = statSync(path);
721
+ } catch {
722
+ continue;
723
+ }
724
+ const info: SessionInfo = {
725
+ path,
726
+ id,
727
+ cwd: header.cwd || anchor.entry.info.cwd || "",
728
+ created: new Date(st.birthtimeMs),
729
+ modified: new Date(st.mtimeMs),
730
+ messageCount: 0,
731
+ firstMessage: "",
732
+ allMessagesText: "",
733
+ };
734
+ result.push({
735
+ info,
736
+ project: info.cwd,
737
+ delegated: true,
738
+ parentSessionId: rawParent || anchor.id,
739
+ parentLabel: anchor.entry.parentLabel,
740
+ });
741
+ }
742
+ }
743
+ const idByPath = new Map(result.map((session) => [resolve(session.info.path), session.info.id]));
744
+ const labelById = new Map(
745
+ result.map((session) => [
746
+ session.info.id,
747
+ session.info.name || session.info.firstMessage || session.info.id,
748
+ ]),
749
+ );
750
+ for (const session of result) {
751
+ const parent = session.parentSessionId;
752
+ if (parent) {
753
+ const directPath = resolve(parent);
754
+ const relativePath = resolve(dirname(session.info.path), parent);
755
+ const resolvedParent = idByPath.get(directPath) ?? idByPath.get(relativePath) ?? parent;
756
+ session.parentSessionId = resolvedParent;
757
+ session.parentLabel = labelById.get(resolvedParent) ?? session.parentLabel;
758
+ }
759
+ }
760
+ return result;
761
+ }
762
+ function mergeRecords(records: SoftRecord[]): SoftRecord | undefined {
763
+ if (records.length === 0) return undefined;
764
+ const sorted = [...records].sort(
765
+ (a, b) =>
766
+ (numericTime(a.startedAt) || numericTime(a.completedAt)) -
767
+ (numericTime(b.startedAt) || numericTime(b.completedAt)),
768
+ );
769
+ return Object.assign({}, ...sorted);
770
+ }
771
+
772
+ function pathRefMatches(value: unknown, path: string): boolean {
773
+ return typeof value === "string" && resolve(value) === resolve(path);
774
+ }
775
+ function numericTime(value: unknown): number {
776
+ if (typeof value === "number" && Number.isFinite(value)) return value;
777
+ if (typeof value === "string") return Date.parse(value) || 0;
778
+ return 0;
779
+ }
780
+ function stringValue(value: unknown): string | undefined {
781
+ return typeof value === "string" && value.length > 0 ? value : undefined;
782
+ }
783
+ function dedupeChildren(children: ChildSessionSummary[]): ChildSessionSummary[] {
784
+ const byIdentity = new Map<string, ChildSessionSummary>();
785
+ for (const child of children) {
786
+ const key = child.id || child.path;
787
+ const previous = byIdentity.get(key);
788
+ if (!previous || child.endedAt >= previous.endedAt) byIdentity.set(key, child);
789
+ }
790
+ return [...byIdentity.values()];
791
+ }
792
+
793
+ /**
794
+ * Open one session file, attribute its assistant turns into `out`, and return
795
+ * the transcript-derived child summary (if any) plus the file's optional
796
+ * `subagents:record` metadata entries. Record *enrichment* happens later, in
797
+ * scanSessions, so cached transcripts never need re-reading for metadata.
798
+ */
799
+ function collectFromSession(
800
+ info: SessionInfo,
801
+ project: string,
802
+ excludes: string[],
803
+ out: TurnEntry[],
804
+ prices: PriceMap | undefined,
805
+ delegated: boolean,
806
+ parentSessionId: string | null,
807
+ parentLabel: string,
808
+ ): { child?: ChildSessionSummary; records: SoftRecord[] } {
809
+ if (shouldExclude(project, excludes)) return { records: [] };
810
+
811
+ const sm = SessionManager.open(info.path);
812
+ const sessionEntries: SessionEntry[] = sm.getEntries();
813
+ const header = sm.getHeader();
814
+ const sessionId = header?.id || info.id;
815
+ const actualParent = parentSessionId ?? header?.parentSession;
816
+ const isDelegated = delegated || !!actualParent || isChildConventionPath(info.path);
817
+
818
+ let currentSkill: string | null = null;
819
+ let currentSkills: string[] = [];
820
+ let currentBundles: string[] = [];
821
+ let firstTs = Number.POSITIVE_INFINITY;
822
+ let lastTs = 0;
823
+ let fallbackTask = "";
824
+ let compactions = 0;
825
+ const records: SoftRecord[] = [];
826
+ // Timestamp (ms) of the previous session entry, used to estimate how long an
827
+ // assistant turn took to generate (request-start ≈ previous entry time).
828
+ let prevEntryTs = 0;
829
+
830
+ // getEntries() returns append order, which is the natural conversation order
831
+ // along the active path. Branch entries appear after their parents, which is
832
+ // fine for attribution: each assistant entry is counted once.
833
+ for (const entry of sessionEntries) {
834
+ const anyTs = entryTimestamp(entry);
835
+ if (anyTs > 0) {
836
+ firstTs = Math.min(firstTs, anyTs);
837
+ lastTs = Math.max(lastTs, anyTs);
838
+ }
839
+ if (entry.type !== "message") {
840
+ if (entry.type === "compaction") compactions += 1;
841
+ if (entry.type === "custom" && entry.customType === "subagents:record") {
842
+ const data = entry.data;
843
+ if (typeof data === "object" && data !== null) records.push(data as SoftRecord);
844
+ }
845
+ const ts = anyTs;
846
+ if (ts > 0) prevEntryTs = ts;
847
+ continue;
848
+ }
849
+ const message = entry.message;
850
+ const entryTs = entryTimestamp(entry);
851
+
852
+ if (message.role === "user") {
853
+ const text = textOfUserContent(message.content).trimStart();
854
+ if (!fallbackTask && text) fallbackTask = text.replace(/\s+/g, " ");
855
+ currentSkills = parseSkillBlocks(text);
856
+ currentSkill = currentSkills[0] ?? null;
857
+ currentBundles = parseSkillBundles(text);
858
+ if (entryTs > 0) prevEntryTs = entryTs;
859
+ continue;
860
+ }
861
+
862
+ if (message.role !== "assistant") {
863
+ if (entryTs > 0) prevEntryTs = entryTs;
864
+ continue;
865
+ }
866
+ const usage = message.usage;
867
+ if (!usage) {
868
+ if (entryTs > 0) prevEntryTs = entryTs;
869
+ continue;
870
+ }
871
+
872
+ const tools = message.content
873
+ .filter((b): b is ToolCall => b.type === "toolCall")
874
+ .map((b) => b.name);
875
+
876
+ const ts = message.timestamp || entryTs;
877
+ const genMs = estimateGenMs(prevEntryTs, ts);
878
+ const model = message.model || message.provider || "unknown";
879
+
880
+ // Fill in cost for token-priced models pi recorded as 0, using the user's
881
+ // manual price table. The recorded cost always wins when present.
882
+ let effUsage = usage;
883
+ if ((usage.cost?.total ?? 0) <= 0 && prices) {
884
+ const price = resolveModelPrice(model, prices);
885
+ if (price) {
886
+ const input = (usage.input / 1e6) * (price.input ?? 0);
887
+ const output = (usage.output / 1e6) * (price.output ?? 0);
888
+ const cacheRead = (usage.cacheRead / 1e6) * (price.cacheRead ?? 0);
889
+ const cacheWrite = (usage.cacheWrite / 1e6) * (price.cacheWrite ?? 0);
890
+ const total = input + output + cacheRead + cacheWrite;
891
+ if (total > 0) {
892
+ effUsage = {
893
+ ...usage,
894
+ cost: { input, output, cacheRead, cacheWrite, total },
895
+ };
896
+ }
897
+ }
898
+ }
899
+
900
+ out.push({
901
+ ts,
902
+ model,
903
+ provider: message.provider || "",
904
+ project,
905
+ cost: effUsage.cost.total,
906
+ usage: effUsage,
907
+ skill: currentSkill,
908
+ skills: currentSkills,
909
+ bundles: currentBundles,
910
+ tools,
911
+ genMs,
912
+ sessionId,
913
+ sessionPath: info.path,
914
+ delegated: isDelegated,
915
+ parentSessionId: actualParent ?? null,
916
+ });
917
+ if (ts > 0) prevEntryTs = ts;
918
+ }
919
+ if (!isDelegated) return { records };
920
+ return {
921
+ records,
922
+ child: {
923
+ id: sessionId,
924
+ path: info.path,
925
+ parentSessionId: actualParent ?? null,
926
+ parentLabel,
927
+ project,
928
+ task: fallbackTask || "(untitled)",
929
+ agentType: "(unclassified)",
930
+ status: "unknown",
931
+ isBackground: undefined,
932
+ startedAt: Number.isFinite(firstTs) ? firstTs : info.created.getTime(),
933
+ endedAt: lastTs || info.modified.getTime(),
934
+ timingInferred: true,
935
+ model: undefined,
936
+ stack: undefined,
937
+ thinking: undefined,
938
+ compactions,
939
+ },
940
+ };
941
+ }
942
+
943
+ /**
944
+ * Apply merged `subagents:record` metadata to a transcript-derived child
945
+ * summary. Records live in parent transcripts, so this runs after assembly —
946
+ * a parent metadata change then updates a cached child summary.
947
+ */
948
+ function enrichChild(
949
+ child: ChildSessionSummary,
950
+ record: SoftRecord | undefined,
951
+ ): ChildSessionSummary {
952
+ if (!record) return child;
953
+ const preciseStart = numericTime(record.startedAt);
954
+ const preciseEnd = numericTime(record.completedAt);
955
+ return {
956
+ ...child,
957
+ task: stringValue(record.task) || stringValue(record.description) || child.task,
958
+ agentType:
959
+ stringValue(record.type) ||
960
+ stringValue(record.profile) ||
961
+ stringValue(record.agent) ||
962
+ child.agentType,
963
+ status: stringValue(record.status) || child.status,
964
+ isBackground:
965
+ typeof record.isBackground === "boolean" ? record.isBackground : child.isBackground,
966
+ startedAt: preciseStart || child.startedAt,
967
+ endedAt: preciseEnd || child.endedAt,
968
+ timingInferred: !(preciseStart > 0 && preciseEnd > 0),
969
+ model: stringValue(record.model) ?? child.model,
970
+ stack: stringValue(record.stack) ?? child.stack,
971
+ thinking: stringValue(record.thinking) ?? child.thinking,
972
+ compactions:
973
+ typeof record.compactionCount === "number" ? record.compactionCount : child.compactions,
974
+ };
975
+ }
976
+
977
+ /** Upper bound on a plausible generation gap; longer gaps are treated as idle. */
978
+ const MAX_GEN_MS = 10 * 60 * 1000;
979
+
980
+ /**
981
+ * Estimate a turn's generation time from the gap to the previous entry.
982
+ * Returns 0 when the gap is missing, non-positive, or implausibly large
983
+ * (idle time, a long-running tool, or a branch jump) so it doesn't pollute the
984
+ * tokens/second average.
985
+ */
986
+ function estimateGenMs(prevTs: number, ts: number): number {
987
+ if (prevTs <= 0 || ts <= 0) return 0;
988
+ const gap = ts - prevTs;
989
+ if (gap <= 0 || gap > MAX_GEN_MS) return 0;
990
+ return gap;
991
+ }
992
+
993
+ /** Read an entry's timestamp as epoch ms, tolerating Date/string/number forms. */
994
+ function entryTimestamp(entry: SessionEntry): number {
995
+ const raw = (entry as { timestamp?: unknown }).timestamp;
996
+ if (typeof raw === "number") return raw;
997
+ if (raw instanceof Date) return raw.getTime();
998
+ if (typeof raw === "string") {
999
+ const t = Date.parse(raw);
1000
+ return Number.isFinite(t) ? t : 0;
1001
+ }
1002
+ return 0;
1003
+ }
1004
+
1005
+ /** Per-plugin contribution detail for the Plugin usage section. */
1006
+ export interface PluginContribution {
1007
+ bucket: Bucket;
1008
+ /** Skills of this plugin that were invoked (→ turn buckets). */
1009
+ skills: Map<string, Bucket>;
1010
+ /** Tools owned by this plugin that were called (→ turn buckets). */
1011
+ tools: Map<string, Bucket>;
1012
+ }
1013
+
1014
+ /** A windowed view of the report ready for rendering. */
1015
+ export interface WindowedReport {
1016
+ window: WindowKey;
1017
+ total: Bucket;
1018
+ fiveHour: Bucket;
1019
+ weekly: Bucket;
1020
+ byModel: Map<string, Bucket>;
1021
+ bySkill: Map<string, Bucket>;
1022
+ byBundle: Map<string, Bucket>;
1023
+ byPlugin: Map<string, Bucket>;
1024
+ /** Per-plugin detail: which skills/tools drove each plugin's usage. */
1025
+ pluginDetail: Map<string, PluginContribution>;
1026
+ /** Turns that used NO plugin tool/skill (builtin-only, no preceding skill). */
1027
+ byCore: Bucket;
1028
+ byTool: Map<string, Bucket>;
1029
+ byProject: Map<string, Bucket>;
1030
+ direct: Bucket;
1031
+ delegated: Bucket;
1032
+ children: ChildSessionSummary[];
1033
+ concurrency: ConcurrencyStats;
1034
+ turnCount: number;
1035
+ sessionCount: number;
1036
+ earliest: number;
1037
+ latest: number;
1038
+ }
1039
+
1040
+ /** Filter the timeline to a window and roll up all breakdowns. */
1041
+ export function windowize(report: Report, key: WindowKey, maps: AttributionMaps): WindowedReport {
1042
+ const now = Date.now();
1043
+ const horizon = windowMs(key);
1044
+ const cutoff = horizon === Number.POSITIVE_INFINITY ? -1 : now - horizon;
1045
+
1046
+ const total = emptyBucket();
1047
+ const fiveHour = emptyBucket();
1048
+ const weekly = emptyBucket();
1049
+ const byModel = new Map<string, Bucket>();
1050
+ const bySkill = new Map<string, Bucket>();
1051
+ const byBundle = new Map<string, Bucket>();
1052
+ const byPlugin = new Map<string, Bucket>();
1053
+ const pluginDetail = new Map<string, PluginContribution>();
1054
+ const byCore = emptyBucket();
1055
+ const byTool = new Map<string, Bucket>();
1056
+ const byProject = new Map<string, Bucket>();
1057
+ const direct = emptyBucket();
1058
+ const delegated = emptyBucket();
1059
+
1060
+ let earliest = Number.POSITIVE_INFINITY;
1061
+ let latest = 0;
1062
+ let turnCount = 0;
1063
+
1064
+ for (const turn of report.entries) {
1065
+ earliest = Math.min(earliest, turn.ts);
1066
+ latest = Math.max(latest, turn.ts);
1067
+
1068
+ // Always-on quota bars use fixed 5h and 7d horizons regardless of window.
1069
+ if (turn.ts >= now - 5 * HOUR) addBucket(fiveHour, turn.usage);
1070
+ if (turn.ts >= now - 7 * DAY) addBucket(weekly, turn.usage);
1071
+
1072
+ if (cutoff !== -1 && turn.ts < cutoff) continue;
1073
+ turnCount += 1;
1074
+
1075
+ addBucket(total, turn.usage, turn.genMs);
1076
+ addBucket(turn.delegated ? delegated : direct, turn.usage, turn.genMs);
1077
+ bump(byModel, turn.model, turn.usage, turn.genMs);
1078
+
1079
+ // Skill attribution (independent characteristic) — all skills in multi-skill.
1080
+ const turnSkills = skillsForTurn(turn);
1081
+ for (const skillName of turnSkills) {
1082
+ bump(bySkill, skillName, turn.usage);
1083
+ }
1084
+
1085
+ // Bundle attribution from @bundle activations.
1086
+ for (const bundleName of bundlesForTurn(turn)) {
1087
+ bump(byBundle, `@${bundleName}`, turn.usage);
1088
+ }
1089
+
1090
+ // Plugin attribution: each turn counts ONCE per distinct plugin involved,
1091
+ // whether via a tool it owns or a skill it bundles (deduped, no double count).
1092
+ // We also record WHICH skills/tools contributed so the Plugin usage section
1093
+ // can show per-plugin detail.
1094
+ const turnPlugins = new Map<string, { skills: Set<string>; tools: Set<string> }>();
1095
+ const notePlugin = (plugin: string) => {
1096
+ let entry = turnPlugins.get(plugin);
1097
+ if (!entry) {
1098
+ entry = { skills: new Set(), tools: new Set() };
1099
+ turnPlugins.set(plugin, entry);
1100
+ }
1101
+ return entry;
1102
+ };
1103
+ const turnTools = Array.isArray(turn.tools) ? turn.tools : [];
1104
+ for (const toolName of turnTools) {
1105
+ bump(byTool, toolName, turn.usage);
1106
+ const owner = maps.toolToPlugin.get(toolName);
1107
+ if (owner) notePlugin(owner).tools.add(toolName);
1108
+ }
1109
+ for (const skillName of turnSkills) {
1110
+ const skillOwner = maps.skillToPlugin.get(skillName);
1111
+ if (skillOwner) notePlugin(skillOwner).skills.add(skillName);
1112
+ }
1113
+ if (turnPlugins.size === 0) {
1114
+ // No plugin tool or plugin skill involved → core pi usage.
1115
+ addBucket(byCore, turn.usage);
1116
+ } else {
1117
+ for (const [plugin, contrib] of turnPlugins) {
1118
+ bump(byPlugin, plugin, turn.usage);
1119
+ let detail = pluginDetail.get(plugin);
1120
+ if (!detail) {
1121
+ detail = {
1122
+ bucket: emptyBucket(),
1123
+ skills: new Map(),
1124
+ tools: new Map(),
1125
+ };
1126
+ pluginDetail.set(plugin, detail);
1127
+ }
1128
+ addBucket(detail.bucket, turn.usage);
1129
+ for (const s of contrib.skills) bump(detail.skills, s, turn.usage);
1130
+ for (const t of contrib.tools) bump(detail.tools, t, turn.usage);
1131
+ }
1132
+ }
1133
+
1134
+ bump(byProject, turn.project || "(unknown)", turn.usage);
1135
+ }
1136
+
1137
+ const children = (report.children ?? []).filter(
1138
+ (child) => cutoff === -1 || child.endedAt >= cutoff,
1139
+ );
1140
+ return {
1141
+ window: key,
1142
+ total,
1143
+ fiveHour,
1144
+ weekly,
1145
+ byModel,
1146
+ bySkill,
1147
+ byBundle,
1148
+ byPlugin,
1149
+ pluginDetail,
1150
+ byCore,
1151
+ byTool,
1152
+ byProject,
1153
+ direct,
1154
+ delegated,
1155
+ children,
1156
+ concurrency: computeConcurrency(children, cutoff === -1 ? undefined : cutoff, now),
1157
+ turnCount,
1158
+ sessionCount: report.sessionCount,
1159
+ earliest: Number.isFinite(earliest) ? earliest : now,
1160
+ latest,
1161
+ };
1162
+ }
1163
+
1164
+ export interface ConcurrencyStats {
1165
+ childCount: number;
1166
+ parentCount: number;
1167
+ peak: number | null;
1168
+ unionMs: number | null;
1169
+ summedMs: number | null;
1170
+ parallelism: number | null;
1171
+ overlapSavedMs: number | null;
1172
+ inferred: boolean;
1173
+ }
1174
+
1175
+ /** Compute overlap statistics for valid child intervals, optionally window-clamped. */
1176
+ export function computeConcurrency(
1177
+ children: ChildSessionSummary[],
1178
+ windowStart?: number,
1179
+ windowEnd?: number,
1180
+ ): ConcurrencyStats {
1181
+ const intervals = children.flatMap((child) => {
1182
+ let start = child.startedAt;
1183
+ let end = child.endedAt;
1184
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= start) return [];
1185
+ if (windowStart != null) start = Math.max(start, windowStart);
1186
+ if (windowEnd != null) end = Math.min(end, windowEnd);
1187
+ return end > start ? [{ start, end, inferred: child.timingInferred }] : [];
1188
+ });
1189
+ const parents = new Set(children.map((c) => c.parentSessionId).filter(Boolean));
1190
+ if (intervals.length === 0) {
1191
+ return {
1192
+ childCount: children.length,
1193
+ parentCount: parents.size,
1194
+ peak: null,
1195
+ unionMs: null,
1196
+ summedMs: null,
1197
+ parallelism: null,
1198
+ overlapSavedMs: null,
1199
+ inferred: children.some((c) => c.timingInferred),
1200
+ };
1201
+ }
1202
+ const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end);
1203
+ let unionMs = 0;
1204
+ let unionStart = sorted[0].start;
1205
+ let unionEnd = sorted[0].end;
1206
+ for (const interval of sorted.slice(1)) {
1207
+ if (interval.start <= unionEnd) unionEnd = Math.max(unionEnd, interval.end);
1208
+ else {
1209
+ unionMs += unionEnd - unionStart;
1210
+ unionStart = interval.start;
1211
+ unionEnd = interval.end;
1212
+ }
1213
+ }
1214
+ unionMs += unionEnd - unionStart;
1215
+ const summedMs = intervals.reduce((sum, interval) => sum + interval.end - interval.start, 0);
1216
+ const events = intervals.flatMap((i) => [
1217
+ { ts: i.start, delta: 1 },
1218
+ { ts: i.end, delta: -1 },
1219
+ ]);
1220
+ events.sort((a, b) => a.ts - b.ts || a.delta - b.delta);
1221
+ let active = 0;
1222
+ let peak = 0;
1223
+ for (const event of events) {
1224
+ active += event.delta;
1225
+ peak = Math.max(peak, active);
1226
+ }
1227
+ return {
1228
+ childCount: children.length,
1229
+ parentCount: parents.size,
1230
+ peak,
1231
+ unionMs,
1232
+ summedMs,
1233
+ parallelism: unionMs > 0 ? summedMs / unionMs : null,
1234
+ overlapSavedMs: Math.max(0, summedMs - unionMs),
1235
+ inferred: intervals.some((i) => i.inferred),
1236
+ };
1237
+ }
1238
+
1239
+ function bump(map: Map<string, Bucket>, key: string, usage: Usage, genMs = 0): void {
1240
+ let bucket = map.get(key);
1241
+ if (!bucket) {
1242
+ bucket = emptyBucket();
1243
+ map.set(key, bucket);
1244
+ }
1245
+ addBucket(bucket, usage, genMs);
1246
+ }
1247
+
1248
+ /** Map<K, V> sorted (desc) by a numeric extractor → array of [key, V]. */
1249
+ export function ranked<V>(map: Map<string, V>, value: (v: V) => number): Array<[string, V]> {
1250
+ return [...map.entries()].sort((a, b) => value(b[1]) - value(a[1]));
1251
+ }
1252
+
1253
+ function stableSort<T>(arr: T[], cmp: (a: T, b: T) => number): T[] {
1254
+ return arr
1255
+ .map((v, i) => [v, i] as const)
1256
+ .sort((a, b) => cmp(a[0], b[0]) || a[1] - b[1])
1257
+ .map((p) => p[0]);
1258
+ }
1259
+
1260
+ // ---------------------------------------------------------------------------
1261
+ // Daily / Stats aggregation (Tokscale-style Daily Summary + Stats views).
1262
+ //
1263
+ // These are pure functions over the already-scanned `Report.entries` timeline,
1264
+ // so they add new "views" without touching session scanning. They aggregate by
1265
+ // LOCAL calendar day, which is what a human reading a daily summary expects.
1266
+ // ---------------------------------------------------------------------------
1267
+
1268
+ /** A single calendar day's rolled-up usage. */
1269
+ export interface DayStat {
1270
+ /** Local date key `YYYY-MM-DD`. */
1271
+ dateKey: string;
1272
+ /** Epoch ms at local midnight for that day (used for sorting/streaks). */
1273
+ ts: number;
1274
+ bucket: Bucket;
1275
+ /** Per-model usage that day (model → bucket); size = distinct models. */
1276
+ models: Map<string, Bucket>;
1277
+ /** First / last turn timestamp that day. */
1278
+ firstTs: number;
1279
+ lastTs: number;
1280
+ /**
1281
+ * Active working time that day in ms: the sum of gaps between consecutive
1282
+ * turns that are below the idle threshold. Long idle gaps (pi open but not
1283
+ * working) are excluded, so this reflects time pi was actually busy.
1284
+ */
1285
+ activeMs: number;
1286
+ }
1287
+
1288
+ /** Gaps between turns longer than this count as idle (not active work). */
1289
+ const ACTIVE_GAP_MS = 5 * 60 * 1000;
1290
+
1291
+ /** A day's active working time (idle excluded), in ms. */
1292
+ export function dayUptimeMs(d: DayStat): number {
1293
+ return d.activeMs;
1294
+ }
1295
+
1296
+ /**
1297
+ * The day's most-used model — ranked by tokens, not cost. Tokens are the
1298
+ * reliable "how much did I use this model" signal: many providers are
1299
+ * token-priced (cost 0), so ranking by cost would just surface whichever
1300
+ * model happened to run first that day.
1301
+ */
1302
+ export function dayTopModel(d: DayStat): string | null {
1303
+ let best: string | null = null;
1304
+ let bestTok = -1;
1305
+ for (const [model, b] of d.models) {
1306
+ const tok = bucketTokens(b);
1307
+ if (tok > bestTok) {
1308
+ bestTok = tok;
1309
+ best = model;
1310
+ }
1311
+ }
1312
+ return best;
1313
+ }
1314
+
1315
+ /** Local-midnight epoch ms for a timestamp. */
1316
+ function startOfLocalDay(ts: number): number {
1317
+ const d = new Date(ts);
1318
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
1319
+ }
1320
+
1321
+ /** Stable per-local-day ordinal (round handles DST ±1h drift). */
1322
+ function dayOrdinal(sodMs: number): number {
1323
+ return Math.round(sodMs / DAY);
1324
+ }
1325
+
1326
+ /** `YYYY-MM-DD` for a local-midnight epoch ms. */
1327
+ function localDateKey(sodMs: number): string {
1328
+ const d = new Date(sodMs);
1329
+ const m = `${d.getMonth() + 1}`.padStart(2, "0");
1330
+ const day = `${d.getDate()}`.padStart(2, "0");
1331
+ return `${d.getFullYear()}-${m}-${day}`;
1332
+ }
1333
+
1334
+ /** Roll up the full timeline into per-day buckets, sorted ascending by date. */
1335
+ export function dailyStats(report: Report): DayStat[] {
1336
+ const map = new Map<string, DayStat>();
1337
+ const tsByDay = new Map<string, number[]>();
1338
+ for (const turn of report.entries) {
1339
+ const sod = startOfLocalDay(turn.ts);
1340
+ const key = localDateKey(sod);
1341
+ let day = map.get(key);
1342
+ if (!day) {
1343
+ day = {
1344
+ dateKey: key,
1345
+ ts: sod,
1346
+ bucket: emptyBucket(),
1347
+ models: new Map(),
1348
+ firstTs: turn.ts,
1349
+ lastTs: turn.ts,
1350
+ activeMs: 0,
1351
+ };
1352
+ map.set(key, day);
1353
+ }
1354
+ addBucket(day.bucket, turn.usage, turn.genMs);
1355
+ let mb = day.models.get(turn.model);
1356
+ if (!mb) {
1357
+ mb = emptyBucket();
1358
+ day.models.set(turn.model, mb);
1359
+ }
1360
+ addBucket(mb, turn.usage, turn.genMs);
1361
+ if (turn.ts < day.firstTs) day.firstTs = turn.ts;
1362
+ if (turn.ts > day.lastTs) day.lastTs = turn.ts;
1363
+ let times = tsByDay.get(key);
1364
+ if (!times) {
1365
+ times = [];
1366
+ tsByDay.set(key, times);
1367
+ }
1368
+ times.push(turn.ts);
1369
+ }
1370
+
1371
+ // Active working time per day: sum the gaps between consecutive turns that
1372
+ // are short enough to count as "still working" (idle gaps are dropped).
1373
+ for (const [key, times] of tsByDay) {
1374
+ times.sort((a, b) => a - b);
1375
+ let active = 0;
1376
+ for (let i = 1; i < times.length; i++) {
1377
+ const gap = times[i] - times[i - 1];
1378
+ if (gap > 0 && gap <= ACTIVE_GAP_MS) active += gap;
1379
+ }
1380
+ const day = map.get(key);
1381
+ if (day) day.activeMs = active;
1382
+ }
1383
+
1384
+ return [...map.values()].sort((a, b) => a.ts - b.ts);
1385
+ }
1386
+
1387
+ /** Which metric drives the heatmap/stats intensity. */
1388
+ export type Metric = "usd" | "tokens";
1389
+
1390
+ /** Pick the metric value out of a bucket. */
1391
+ export function metricValue(b: Bucket, metric: Metric): number {
1392
+ return metric === "tokens" ? bucketTokens(b) : b.cost;
1393
+ }
1394
+
1395
+ /** Choose the natural metric for a report: USD when there's real pricing. */
1396
+ export function naturalMetric(days: DayStat[]): Metric {
1397
+ const totalCost = days.reduce((s, d) => s + d.bucket.cost, 0);
1398
+ return totalCost > 0 ? "usd" : "tokens";
1399
+ }
1400
+
1401
+ /** One cell (one calendar day) in the contribution graph. */
1402
+ export interface ContribCell {
1403
+ dateKey: string;
1404
+ ts: number;
1405
+ value: number;
1406
+ /** Intensity bucket 0..4 (0 = no activity). */
1407
+ level: number;
1408
+ }
1409
+
1410
+ /** GitHub-style contribution graph: columns = weeks, 7 rows = Sun..Sat. */
1411
+ export interface ContribGraph {
1412
+ /** weeks[col][row] — row 0 = Sunday. Empty cells (future/pre-range) are null. */
1413
+ weeks: Array<Array<ContribCell | null>>;
1414
+ maxValue: number;
1415
+ metric: Metric;
1416
+ }
1417
+
1418
+ /**
1419
+ * Build a ~53-week contribution graph ending on the current week, aligned so
1420
+ * each column is a Sun..Sat week (mirrors GitHub / Tokscale's Stats view).
1421
+ */
1422
+ export function contributionGraph(report: Report, weeks = 53, metric?: Metric): ContribGraph {
1423
+ const days = dailyStats(report);
1424
+ const m = metric ?? naturalMetric(days);
1425
+ const byKey = new Map(days.map((d) => [d.dateKey, d]));
1426
+
1427
+ const now = new Date();
1428
+ const todaySod = startOfLocalDay(now.getTime());
1429
+ // Walk back to the Sunday that starts the earliest visible week.
1430
+ const todayDow = new Date(todaySod).getDay(); // 0 = Sun
1431
+ const startSod = todaySod - (todayDow + (weeks - 1) * 7) * DAY;
1432
+
1433
+ let maxValue = 0;
1434
+ const cols: Array<Array<ContribCell | null>> = [];
1435
+ for (let w = 0; w < weeks; w++) {
1436
+ const col: Array<ContribCell | null> = [];
1437
+ for (let row = 0; row < 7; row++) {
1438
+ const sod = startSod + (w * 7 + row) * DAY;
1439
+ if (sod > todaySod) {
1440
+ col.push(null);
1441
+ continue;
1442
+ }
1443
+ const key = localDateKey(sod);
1444
+ const day = byKey.get(key);
1445
+ // Intensity tracks activity (tokens), not cost: token-priced days have
1446
+ // cost 0 but are still very much "active", so cost would wrongly leave
1447
+ // them blank.
1448
+ const value = day ? bucketTokens(day.bucket) : 0;
1449
+ maxValue = Math.max(maxValue, value);
1450
+ col.push({ dateKey: key, ts: sod, value, level: 0 });
1451
+ }
1452
+ cols.push(col);
1453
+ }
1454
+
1455
+ // Assign intensity levels relative to the max (log-ish thresholds).
1456
+ for (const col of cols) {
1457
+ for (const cell of col) {
1458
+ if (!cell || cell.value <= 0 || maxValue <= 0) continue;
1459
+ const r = cell.value / maxValue;
1460
+ cell.level = r > 0.66 ? 4 : r > 0.33 ? 3 : r > 0.1 ? 2 : 1;
1461
+ }
1462
+ }
1463
+
1464
+ return { weeks: cols, maxValue, metric: m };
1465
+ }
1466
+
1467
+ /** Selectable time range for the Stats view summary. */
1468
+ export type StatsRange = "all" | "30d" | "7d";
1469
+
1470
+ /** Epoch-ms lower bound for a stats range (-1 = all time). */
1471
+ export function rangeSince(range: StatsRange): number {
1472
+ const now = Date.now();
1473
+ if (range === "7d") return now - 7 * DAY;
1474
+ if (range === "30d") return now - 30 * DAY;
1475
+ return -1;
1476
+ }
1477
+
1478
+ /** Human label for a stats range. */
1479
+ export function rangeLabel(range: StatsRange): string {
1480
+ switch (range) {
1481
+ case "7d":
1482
+ return "Last 7 days";
1483
+ case "30d":
1484
+ return "Last 30 days";
1485
+ case "all":
1486
+ return "All time";
1487
+ }
1488
+ }
1489
+
1490
+ /** Lifetime usage statistics for the Stats view. */
1491
+ export interface UsageStats {
1492
+ totalCost: number;
1493
+ totalTokens: number;
1494
+ totalTurns: number;
1495
+ activeDays: number;
1496
+ currentStreak: number;
1497
+ longestStreak: number;
1498
+ busiestDay: { dateKey: string; value: number } | null;
1499
+ firstDay: string | null;
1500
+ lastDay: string | null;
1501
+ avgPerActiveDay: number;
1502
+ /** Most-used model in range (by the active metric). */
1503
+ favoriteModel: string | null;
1504
+ /** Hour-of-day (0-23) with the most usage, or null when no activity. */
1505
+ peakHour: number | null;
1506
+ metric: Metric;
1507
+ }
1508
+
1509
+ /**
1510
+ * Compute usage stats (totals, active days, streaks, busiest day, favorite
1511
+ * model, peak hour). `sinceMs` filters the timeline (-1 = all time).
1512
+ */
1513
+ export function computeStats(report: Report, metric?: Metric, sinceMs = -1): UsageStats {
1514
+ const entries = sinceMs < 0 ? report.entries : report.entries.filter((e) => e.ts >= sinceMs);
1515
+ const scoped: Report = { ...report, entries };
1516
+ const days = dailyStats(scoped);
1517
+ const m = metric ?? naturalMetric(days);
1518
+
1519
+ let totalCost = 0;
1520
+ let totalTokens = 0;
1521
+ let totalTurns = 0;
1522
+ let busiestDay: { dateKey: string; value: number } | null = null;
1523
+ for (const d of days) {
1524
+ totalCost += d.bucket.cost;
1525
+ totalTokens += bucketTokens(d.bucket);
1526
+ totalTurns += d.bucket.turns;
1527
+ const v = metricValue(d.bucket, m);
1528
+ if (!busiestDay || v > busiestDay.value) {
1529
+ busiestDay = { dateKey: d.dateKey, value: v };
1530
+ }
1531
+ }
1532
+
1533
+ // Favorite model + peak hour from the scoped turn timeline.
1534
+ const byModel = new Map<string, number>();
1535
+ const byHour = new Array<number>(24).fill(0);
1536
+ for (const t of entries) {
1537
+ const tok = t.usage.input + t.usage.output + t.usage.cacheRead + t.usage.cacheWrite;
1538
+ const v = m === "tokens" ? tok : t.cost;
1539
+ byModel.set(t.model, (byModel.get(t.model) ?? 0) + v);
1540
+ const hour = new Date(t.ts).getHours();
1541
+ byHour[hour] += v;
1542
+ }
1543
+ let favoriteModel: string | null = null;
1544
+ let favVal = -1;
1545
+ for (const [model, v] of byModel) {
1546
+ if (v > favVal) {
1547
+ favVal = v;
1548
+ favoriteModel = model;
1549
+ }
1550
+ }
1551
+ let peakHour: number | null = null;
1552
+ let peakVal = -1;
1553
+ for (let h = 0; h < 24; h++) {
1554
+ if (byHour[h] > peakVal) {
1555
+ peakVal = byHour[h];
1556
+ peakHour = h;
1557
+ }
1558
+ }
1559
+ if (peakVal <= 0) peakHour = null;
1560
+
1561
+ const ordinals = days.map((d) => dayOrdinal(d.ts));
1562
+ let longestStreak = 0;
1563
+ let run = 0;
1564
+ let prev: number | null = null;
1565
+ for (const o of ordinals) {
1566
+ run = prev !== null && o === prev + 1 ? run + 1 : 1;
1567
+ longestStreak = Math.max(longestStreak, run);
1568
+ prev = o;
1569
+ }
1570
+
1571
+ let currentStreak = 0;
1572
+ if (ordinals.length > 0) {
1573
+ const todayOrd = dayOrdinal(startOfLocalDay(Date.now()));
1574
+ const last = ordinals[ordinals.length - 1];
1575
+ if (last === todayOrd || last === todayOrd - 1) {
1576
+ currentStreak = 1;
1577
+ for (let i = ordinals.length - 2; i >= 0; i--) {
1578
+ if (ordinals[i] === ordinals[i + 1] - 1) currentStreak += 1;
1579
+ else break;
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ const activeDays = days.length;
1585
+ const avgPerActiveDay =
1586
+ activeDays > 0 ? (m === "tokens" ? totalTokens : totalCost) / activeDays : 0;
1587
+
1588
+ return {
1589
+ totalCost,
1590
+ totalTokens,
1591
+ totalTurns,
1592
+ activeDays,
1593
+ currentStreak,
1594
+ longestStreak,
1595
+ busiestDay,
1596
+ firstDay: days[0]?.dateKey ?? null,
1597
+ lastDay: days[days.length - 1]?.dateKey ?? null,
1598
+ avgPerActiveDay,
1599
+ favoriteModel,
1600
+ peakHour,
1601
+ metric: m,
1602
+ };
1603
+ }
1604
+
1605
+ // ---------------------------------------------------------------------------
1606
+ // Hourly / Agents / Wrapped AI aggregation
1607
+ // ---------------------------------------------------------------------------
1608
+
1609
+ /** Usage rolled up by local hour-of-day (0–23), across all days. */
1610
+ export interface HourStat {
1611
+ hour: number;
1612
+ bucket: Bucket;
1613
+ models: Map<string, Bucket>;
1614
+ }
1615
+
1616
+ function topByTokens(models: Map<string, Bucket>): string | null {
1617
+ let best: string | null = null;
1618
+ let bestTok = -1;
1619
+ for (const [name, b] of models) {
1620
+ const tok = bucketTokens(b);
1621
+ if (tok > bestTok) {
1622
+ bestTok = tok;
1623
+ best = name;
1624
+ }
1625
+ }
1626
+ return best;
1627
+ }
1628
+
1629
+ /** Aggregate the timeline by hour-of-day (local clock). Always returns 24 slots. */
1630
+ export function hourlyStats(report: Report): HourStat[] {
1631
+ const slots: HourStat[] = [];
1632
+ for (let h = 0; h < 24; h++) {
1633
+ slots.push({ hour: h, bucket: emptyBucket(), models: new Map() });
1634
+ }
1635
+ for (const turn of report.entries) {
1636
+ const h = new Date(turn.ts).getHours();
1637
+ const slot = slots[h];
1638
+ addBucket(slot.bucket, turn.usage, turn.genMs);
1639
+ let mb = slot.models.get(turn.model);
1640
+ if (!mb) {
1641
+ mb = emptyBucket();
1642
+ slot.models.set(turn.model, mb);
1643
+ }
1644
+ addBucket(mb, turn.usage, turn.genMs);
1645
+ }
1646
+ return slots;
1647
+ }
1648
+
1649
+ /** Top model for an hour slot (by tokens). */
1650
+ export function hourTopModel(h: HourStat): string | null {
1651
+ return topByTokens(h.models);
1652
+ }
1653
+
1654
+ /** Usage rolled up by provider (agent backend). */
1655
+ export interface AgentStat {
1656
+ provider: string;
1657
+ bucket: Bucket;
1658
+ models: Map<string, Bucket>;
1659
+ projects: Set<string>;
1660
+ firstTs: number;
1661
+ lastTs: number;
1662
+ }
1663
+
1664
+ /** Aggregate the timeline by provider, sorted by tokens descending. */
1665
+ export function agentStats(report: Report): AgentStat[] {
1666
+ const map = new Map<string, AgentStat>();
1667
+ for (const turn of report.entries) {
1668
+ const key = turn.provider || "(unknown)";
1669
+ let agent = map.get(key);
1670
+ if (!agent) {
1671
+ agent = {
1672
+ provider: key,
1673
+ bucket: emptyBucket(),
1674
+ models: new Map(),
1675
+ projects: new Set(),
1676
+ firstTs: turn.ts,
1677
+ lastTs: turn.ts,
1678
+ };
1679
+ map.set(key, agent);
1680
+ }
1681
+ addBucket(agent.bucket, turn.usage, turn.genMs);
1682
+ let mb = agent.models.get(turn.model);
1683
+ if (!mb) {
1684
+ mb = emptyBucket();
1685
+ agent.models.set(turn.model, mb);
1686
+ }
1687
+ addBucket(mb, turn.usage, turn.genMs);
1688
+ if (turn.project) agent.projects.add(turn.project);
1689
+ if (turn.ts < agent.firstTs) agent.firstTs = turn.ts;
1690
+ if (turn.ts > agent.lastTs) agent.lastTs = turn.ts;
1691
+ }
1692
+ return stableSort([...map.values()], (a, b) => bucketTokens(b.bucket) - bucketTokens(a.bucket));
1693
+ }
1694
+
1695
+ /** Top model for a provider (by tokens). */
1696
+ export function agentTopModel(a: AgentStat): string | null {
1697
+ return topByTokens(a.models);
1698
+ }
1699
+
1700
+ /** Calendar years present in the report (newest first). */
1701
+ export function availableYears(report: Report): number[] {
1702
+ const years = new Set<number>();
1703
+ for (const e of report.entries) {
1704
+ years.add(new Date(e.ts).getFullYear());
1705
+ }
1706
+ return [...years].sort((a, b) => b - a);
1707
+ }
1708
+
1709
+ /** Default Wrapped AI year: current year if active, else the year with most tokens. */
1710
+ export function defaultWrappedYear(report: Report): number {
1711
+ const years = availableYears(report);
1712
+ if (years.length === 0) return new Date().getFullYear();
1713
+ const current = new Date().getFullYear();
1714
+ if (years.includes(current)) return current;
1715
+ let bestYear = years[0];
1716
+ let bestTok = -1;
1717
+ for (const y of years) {
1718
+ let tok = 0;
1719
+ for (const e of report.entries) {
1720
+ if (new Date(e.ts).getFullYear() !== y) continue;
1721
+ tok += e.usage.input + e.usage.output + e.usage.cacheRead + e.usage.cacheWrite;
1722
+ }
1723
+ if (tok > bestTok) {
1724
+ bestTok = tok;
1725
+ bestYear = y;
1726
+ }
1727
+ }
1728
+ return bestYear;
1729
+ }
1730
+
1731
+ /** Compact year-in-review stats for the Wrapped AI view. */
1732
+ export interface WrappedStats {
1733
+ year: number;
1734
+ totalCost: number;
1735
+ totalTokens: number;
1736
+ totalTurns: number;
1737
+ activeDays: number;
1738
+ currentStreak: number;
1739
+ longestStreak: number;
1740
+ favoriteModel: string | null;
1741
+ favoriteProvider: string | null;
1742
+ topProject: string | null;
1743
+ busiestDay: { dateKey: string; value: number } | null;
1744
+ peakHour: number | null;
1745
+ avgPerActiveDay: number;
1746
+ modelCount: number;
1747
+ providerCount: number;
1748
+ projectCount: number;
1749
+ /** Token totals per calendar month (Jan..Dec) for the selected year. */
1750
+ monthlyTokens: number[];
1751
+ topModels: Array<{ name: string; tokens: number; pct: number }>;
1752
+ topProviders: Array<{ name: string; tokens: number; pct: number }>;
1753
+ metric: Metric;
1754
+ }
1755
+
1756
+ /** Build Wrapped AI stats for a calendar year. Returns null when the year has no activity. */
1757
+ export function wrappedStats(report: Report, year: number): WrappedStats | null {
1758
+ const entries = report.entries.filter((e) => new Date(e.ts).getFullYear() === year);
1759
+ if (entries.length === 0) return null;
1760
+
1761
+ const scoped: Report = {
1762
+ ...report,
1763
+ entries,
1764
+ turnCount: entries.length,
1765
+ };
1766
+ const base = computeStats(scoped);
1767
+ const agents = agentStats(scoped);
1768
+ const favoriteProvider = agents[0]?.provider ?? null;
1769
+
1770
+ const byProject = new Map<string, number>();
1771
+ const modelSet = new Set<string>();
1772
+ const providerSet = new Set<string>();
1773
+ const monthlyTokens = new Array<number>(12).fill(0);
1774
+
1775
+ for (const t of entries) {
1776
+ modelSet.add(t.model);
1777
+ providerSet.add(t.provider || "(unknown)");
1778
+ const tok = t.usage.input + t.usage.output + t.usage.cacheRead + t.usage.cacheWrite;
1779
+ monthlyTokens[new Date(t.ts).getMonth()] += tok;
1780
+ if (t.project) {
1781
+ byProject.set(t.project, (byProject.get(t.project) ?? 0) + tok);
1782
+ }
1783
+ }
1784
+
1785
+ let topProject: string | null = null;
1786
+ let topProjTok = -1;
1787
+ for (const [proj, tok] of byProject) {
1788
+ if (tok > topProjTok) {
1789
+ topProjTok = tok;
1790
+ topProject = proj;
1791
+ }
1792
+ }
1793
+
1794
+ const byModel = new Map<string, number>();
1795
+ for (const t of entries) {
1796
+ const tok = t.usage.input + t.usage.output + t.usage.cacheRead + t.usage.cacheWrite;
1797
+ byModel.set(t.model, (byModel.get(t.model) ?? 0) + tok);
1798
+ }
1799
+ const topModels = ranked(byModel, (v) => v)
1800
+ .slice(0, 3)
1801
+ .map(([name, tokens]) => ({
1802
+ name,
1803
+ tokens,
1804
+ pct: base.totalTokens > 0 ? (tokens / base.totalTokens) * 100 : 0,
1805
+ }));
1806
+
1807
+ const topProviders = agents.slice(0, 3).map((a) => {
1808
+ const tokens = bucketTokens(a.bucket);
1809
+ return {
1810
+ name: a.provider,
1811
+ tokens,
1812
+ pct: base.totalTokens > 0 ? (tokens / base.totalTokens) * 100 : 0,
1813
+ };
1814
+ });
1815
+
1816
+ return {
1817
+ year,
1818
+ totalCost: base.totalCost,
1819
+ totalTokens: base.totalTokens,
1820
+ totalTurns: base.totalTurns,
1821
+ activeDays: base.activeDays,
1822
+ currentStreak: base.currentStreak,
1823
+ longestStreak: base.longestStreak,
1824
+ favoriteModel: base.favoriteModel,
1825
+ favoriteProvider,
1826
+ topProject,
1827
+ busiestDay: base.busiestDay,
1828
+ peakHour: base.peakHour,
1829
+ avgPerActiveDay: base.avgPerActiveDay,
1830
+ modelCount: modelSet.size,
1831
+ providerCount: providerSet.size,
1832
+ projectCount: byProject.size,
1833
+ monthlyTokens,
1834
+ topModels,
1835
+ topProviders,
1836
+ metric: base.metric,
1837
+ };
1838
+ }