@nredd/pi-session-kit 0.0.0-stage → 0.4.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 (52) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +191 -2
  3. package/dist/pi-stats-export.mjs +657 -0
  4. package/docs/stats-snapshots.md +122 -0
  5. package/package.json +55 -4
  6. package/src/child.ts +79 -0
  7. package/src/clock.ts +25 -0
  8. package/src/config.ts +64 -0
  9. package/src/diff/component.ts +214 -0
  10. package/src/diff/git.ts +289 -0
  11. package/src/diff/index.ts +65 -0
  12. package/src/diff/parse.ts +180 -0
  13. package/src/diff/view.ts +98 -0
  14. package/src/goal/index.ts +343 -0
  15. package/src/goal/state.ts +270 -0
  16. package/src/goal/subagents.ts +77 -0
  17. package/src/index.ts +52 -0
  18. package/src/loop/index.ts +151 -0
  19. package/src/loop/state.ts +107 -0
  20. package/src/monitor/README.md +12 -0
  21. package/src/monitor/coalescer.ts +76 -0
  22. package/src/monitor/index.ts +220 -0
  23. package/src/monitor/manager.ts +336 -0
  24. package/src/monitor/matcher.ts +113 -0
  25. package/src/monitor/messages.ts +93 -0
  26. package/src/monitor/output.ts +41 -0
  27. package/src/monitor/pidfiles.ts +415 -0
  28. package/src/monitor/process.ts +125 -0
  29. package/src/monitor/ring.ts +52 -0
  30. package/src/monitor/splitter.ts +57 -0
  31. package/src/monitor/types.ts +35 -0
  32. package/src/monitor/validate.ts +59 -0
  33. package/src/monitor/widget.ts +75 -0
  34. package/src/output.ts +25 -0
  35. package/src/rules/index.ts +94 -0
  36. package/src/rules/logic.ts +96 -0
  37. package/src/shared.ts +177 -0
  38. package/src/stats/aggregate.ts +287 -0
  39. package/src/stats/chart.ts +247 -0
  40. package/src/stats/cli.ts +50 -0
  41. package/src/stats/component.ts +496 -0
  42. package/src/stats/days.ts +46 -0
  43. package/src/stats/format.ts +49 -0
  44. package/src/stats/heatmap.ts +98 -0
  45. package/src/stats/index.ts +88 -0
  46. package/src/stats/library.ts +4 -0
  47. package/src/stats/load.ts +512 -0
  48. package/src/stats/palette.ts +22 -0
  49. package/src/stats/snapshot.ts +497 -0
  50. package/src/titles/aliases.ts +20 -0
  51. package/src/titles/index.ts +87 -0
  52. package/src/titles/logic.ts +160 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `/stats`: Claude Code's usage stats pane over every pi session on this machine. A command, not a
3
+ * tool, so Plan mode allows it. Outside the TUI it prints a per-model table instead.
4
+ */
5
+
6
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { type Clock, realClock } from "../clock.ts";
8
+ import type { KitConfig } from "../config.ts";
9
+ import { say } from "../output.ts";
10
+ import { StatsView, summary } from "./component.ts";
11
+ import type { Loaded } from "./load.ts";
12
+ import { type Combined, coverageLabel, loadCombined } from "./snapshot.ts";
13
+
14
+ const STATUS_KEY = "session-kit:stats";
15
+ export const MESSAGE_TYPE = "session-kit:stats";
16
+
17
+ /** What could not be read, so partial totals never pass for complete ones. */
18
+ function problems({ errors, badLines }: Pick<Loaded, "errors" | "badLines">): string[] {
19
+ return [
20
+ ...(errors > 0 ? [`/stats: ${errors} local session/cache errors`] : []),
21
+ ...(badLines > 0 ? [`/stats: ! ${badLines} unreadable lines`] : []),
22
+ ];
23
+ }
24
+
25
+ export function register(
26
+ pi: ExtensionAPI,
27
+ _config: KitConfig,
28
+ options: { agentDir?: string; clock?: Clock } = {},
29
+ ): void {
30
+ const clock = options.clock ?? realClock;
31
+ pi.registerCommand("stats", {
32
+ description: "Usage stats: activity heatmap, tokens per day and per-model breakdown across all sessions",
33
+ async handler(args: string, ctx: ExtensionCommandContext) {
34
+ if (args.trim()) {
35
+ ctx.ui.notify("usage: /stats", "warning");
36
+ return;
37
+ }
38
+ const tui = ctx.mode === "tui";
39
+ if (tui) ctx.ui.setStatus(STATUS_KEY, "stats: loading...");
40
+ let loaded: Combined;
41
+ try {
42
+ loaded = await loadCombined(
43
+ options.agentDir,
44
+ tui ? (done, total) => ctx.ui.setStatus(STATUS_KEY, `stats: loading ${done}/${total}...`) : undefined,
45
+ );
46
+ } catch (error) {
47
+ say(pi, ctx, MESSAGE_TYPE, `/stats: ${(error as Error).message}`, "error");
48
+ return;
49
+ } finally {
50
+ if (tui) ctx.ui.setStatus(STATUS_KEY, undefined);
51
+ }
52
+
53
+ const damage = [...problems(loaded), ...loaded.coverage.problems.map((p) => `/stats: ${p}`)];
54
+ const coverage = coverageLabel(loaded.coverage);
55
+ if (!tui) {
56
+ say(
57
+ pi,
58
+ ctx,
59
+ MESSAGE_TYPE,
60
+ [summary(loaded.calls.filter((c) => c.ms <= clock.now())), coverage, ...damage].join("\n"),
61
+ "info",
62
+ );
63
+ return;
64
+ }
65
+ for (const line of damage) ctx.ui.notify(line, "warning");
66
+ if (loaded.calls.length === 0) {
67
+ ctx.ui.notify(`/stats: no usage recorded yet\n${coverage}`, "info");
68
+ return;
69
+ }
70
+ const { calls, sessions, badLines } = loaded;
71
+ await ctx.ui.custom<void>(
72
+ (tuiHost, theme, _kb, done) =>
73
+ new StatsView({
74
+ calls,
75
+ sessions,
76
+ badLines,
77
+ coverage,
78
+ now: clock.now(),
79
+ theme,
80
+ rows: () => tuiHost.terminal.rows,
81
+ requestRender: () => tuiHost.requestRender(),
82
+ onClose: () => done(),
83
+ }),
84
+ { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 } },
85
+ );
86
+ },
87
+ });
88
+ }
@@ -0,0 +1,4 @@
1
+ /** Pi-independent stats API for portable producers and deterministic harness fixtures. */
2
+ export * from "./aggregate.ts";
3
+ export * from "./load.ts";
4
+ export * from "./snapshot.ts";
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Scan pi session JSONL files into compact per-call usage records, with an mtime+size cache.
3
+ *
4
+ * Only assistant messages are counted, attributed to the PHYSICAL `provider`/`model` that answered.
5
+ * Skipped on purpose:
6
+ * - tool-result `usage` (pi-subagents' parent-side report), compaction and branch-summary usage:
7
+ * persisted child sessions already hold subagent calls with full model attribution, so counting
8
+ * the report too would double the spend
9
+ * - `router/*` rows: the subscription router's virtual model never answers itself; its rows are
10
+ * aborted zero-usage stubs
11
+ * - pi's test providers and zero-token records
12
+ */
13
+
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import { createReadStream, type Dirent } from "node:fs";
16
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join, resolve, sep } from "node:path";
19
+ import { createInterface } from "node:readline";
20
+ import { setImmediate as tick } from "node:timers/promises";
21
+ export const CACHE_VERSION = 2;
22
+
23
+ /** Stable opaque identifier: no transcript IDs or paths leave the machine. */
24
+ export const opaqueId = (value: string): string => createHash("sha256").update(value).digest("hex");
25
+
26
+ /** Providers that never call a real API, plus the router's virtual provider. */
27
+ const EXCLUDED_PROVIDERS = new Set(["faux", "faux-provider", "fake-provider", "router"]);
28
+ /** pi-subagents names child sessions `<agent type>#<8 hex>`; core's `/resume` filter uses the same rule. */
29
+ const CHILD_NAME = /#[0-9a-f]{8}$/;
30
+ /** Entries open with `{"type":..,"id":..,"parentId":..,"timestamp":".."`: read it without a full parse. */
31
+ const LEADING_TIMESTAMP = /^.{0,200}?"timestamp"\s*:\s*"([^"]+)"/;
32
+ const YIELD_EVERY_LINES = 2000;
33
+
34
+ /** One assistant call, compact so the cache stays small: `[ms, model index, in, out, cacheRead, cacheWrite, cost]`. */
35
+ export type CallTuple = [number, number, number, number, number, number, number];
36
+
37
+ export interface FileStats {
38
+ size: number;
39
+ mtimeMs: number;
40
+ /** Header timestamp. */
41
+ startMs: number;
42
+ /** Last entry timestamp. */
43
+ endMs: number;
44
+ /** A pi-subagents child: parent link plus a child name. A `/fork` has the link but not the name. */
45
+ child: boolean;
46
+ /** `provider/model` names, indexed by `CallTuple[1]`. */
47
+ models: string[];
48
+ /** Unambiguous raw provider/model pairs for new records; models remains for v1 migration. */
49
+ modelPairs?: [string, string][];
50
+ calls: CallTuple[];
51
+ /** Opaque preserved entry IDs, parallel to calls; null for legacy messages. */
52
+ entryIds?: (string | null)[];
53
+ sessionId?: string;
54
+ /** Lines that are not a JSON object. */
55
+ badLines: number;
56
+ }
57
+
58
+ export interface Call {
59
+ /** Opaque owning session ID (the oldest copy, after fork dedup). */
60
+ session: string;
61
+ /** Opaque preserved entry identity; absent on old transcripts. */
62
+ entryId?: string;
63
+ ms: number;
64
+ provider: string;
65
+ model: string;
66
+ input: number;
67
+ output: number;
68
+ cacheRead: number;
69
+ cacheWrite: number;
70
+ cost: number;
71
+ }
72
+
73
+ export interface Session {
74
+ /** Compatibility field name; an opaque ID, not a filesystem path. */
75
+ path: string;
76
+ startMs: number;
77
+ endMs: number;
78
+ child?: boolean;
79
+ }
80
+
81
+ export interface Loaded {
82
+ calls: Call[];
83
+ /** Top-level sessions with at least one counted call. Subagent children are excluded. */
84
+ sessions: Session[];
85
+ /** Privacy-safe metadata including children, independent of event ownership dedup. */
86
+ sessionMetadata?: Session[];
87
+ /** Failed session/directory reads or durable local cache writes. */
88
+ errors: number;
89
+ /** Lines that are not a JSON object, across all files. */
90
+ badLines: number;
91
+ }
92
+
93
+ interface CacheDoc {
94
+ version: number;
95
+ files: Record<string, FileStats>;
96
+ }
97
+
98
+ export function agentDir(): string {
99
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
100
+ }
101
+
102
+ export function sessionsDir(dir = agentDir()): string {
103
+ return process.env.PI_CODING_AGENT_SESSION_DIR ?? join(dir, "sessions");
104
+ }
105
+
106
+ export function cachePath(dir = agentDir()): string {
107
+ return join(dir, "pi-session-kit", "stats-cache.json");
108
+ }
109
+
110
+ function num(value: unknown): number {
111
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
112
+ }
113
+
114
+ function timeOf(value: unknown): number {
115
+ if (typeof value === "number")
116
+ return Number.isSafeInteger(value) && value > 0 && value <= 8640000000000000 ? value : 0;
117
+ if (typeof value !== "string") return 0;
118
+ const ms = Date.parse(value);
119
+ return Number.isFinite(ms) ? ms : 0;
120
+ }
121
+
122
+ /** Cheap structural check for lines that are not parsed: one JSON object per line. */
123
+ function looksLikeObject(line: string): boolean {
124
+ const text = line.trim();
125
+ return text.startsWith("{") && text.endsWith("}");
126
+ }
127
+
128
+ /** Line-at-a-time parser for one session file. */
129
+ export class SessionParser {
130
+ private readonly out: FileStats;
131
+ private readonly modelIndex = new Map<string, number>();
132
+ private parent = false;
133
+ private childName = false;
134
+ private markedChild = false;
135
+ private lines = 0;
136
+
137
+ constructor(size: number, mtimeMs: number) {
138
+ this.out = {
139
+ size,
140
+ mtimeMs,
141
+ startMs: 0,
142
+ endMs: 0,
143
+ child: false,
144
+ models: [],
145
+ modelPairs: [],
146
+ calls: [],
147
+ entryIds: [],
148
+ badLines: 0,
149
+ };
150
+ }
151
+
152
+ accept(line: string): void {
153
+ if (!line) return;
154
+ // Cheap supersets, whitespace-agnostic: a false positive only costs a JSON.parse.
155
+ const isHeader = this.lines++ === 0;
156
+ const isAssistant = line.includes('"assistant"');
157
+ const isInfo = line.includes('"session_info"') || line.includes('"session-kit:child"');
158
+ if (!isHeader && !isAssistant && !isInfo) {
159
+ // Every other entry only moves the session's end time: skip the JSON.parse, but still
160
+ // catch the usual damage (a truncated or spliced line) by its missing braces.
161
+ if (!looksLikeObject(line)) this.out.badLines++;
162
+ else this.seen(timeOf(LEADING_TIMESTAMP.exec(line)?.[1]));
163
+ return;
164
+ }
165
+ let entry: Record<string, unknown>;
166
+ try {
167
+ entry = JSON.parse(line) as Record<string, unknown>;
168
+ } catch {
169
+ this.out.badLines++;
170
+ return;
171
+ }
172
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
173
+ this.out.badLines++;
174
+ return;
175
+ }
176
+ const ts = timeOf(entry.timestamp);
177
+ this.seen(ts);
178
+ if (entry.type === "session") {
179
+ if (typeof entry.id === "string" && entry.id) this.out.sessionId = opaqueId(`session:${entry.id}`);
180
+ if (this.out.startMs === 0 || (ts > 0 && ts < this.out.startMs)) this.out.startMs = ts;
181
+ if (typeof entry.parentSession === "string" && entry.parentSession) this.parent = true;
182
+ return;
183
+ }
184
+ if (entry.type === "custom" && entry.customType === "session-kit:child") {
185
+ const data = entry.data as Record<string, unknown> | undefined;
186
+ if (
187
+ data?.version === 1 &&
188
+ typeof data.sessionId === "string" &&
189
+ opaqueId(`session:${data.sessionId}`) === this.out.sessionId
190
+ )
191
+ this.markedChild = true;
192
+ return;
193
+ }
194
+ if (entry.type === "session_info") {
195
+ this.childName ||= typeof entry.name === "string" && CHILD_NAME.test(entry.name);
196
+ return;
197
+ }
198
+ if (entry.type !== "message") return;
199
+ const message = entry.message as Record<string, unknown> | undefined;
200
+ if (message?.role !== "assistant") return;
201
+ this.call(message, ts, typeof entry.id === "string" && entry.id ? opaqueId(`entry:${entry.id}`) : null);
202
+ }
203
+
204
+ finish(): FileStats {
205
+ this.out.child = this.markedChild || (this.parent && this.childName);
206
+ return this.out;
207
+ }
208
+
209
+ private seen(ts: number): void {
210
+ if (ts <= 0) return;
211
+ if (this.out.startMs === 0) this.out.startMs = ts;
212
+ if (ts > this.out.endMs) this.out.endMs = ts;
213
+ }
214
+
215
+ private call(message: Record<string, unknown>, entryTs: number, entryId: string | null): void {
216
+ const provider = typeof message.provider === "string" ? message.provider : "";
217
+ const model = typeof message.model === "string" ? message.model : "";
218
+ if (!provider || !model || EXCLUDED_PROVIDERS.has(provider)) return;
219
+ const usage = message.usage as Record<string, unknown> | undefined;
220
+ if (!usage) return;
221
+ if (
222
+ [usage.input, usage.output, usage.cacheRead, usage.cacheWrite].some(
223
+ (v) => v !== undefined && (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0),
224
+ )
225
+ ) {
226
+ this.out.badLines++;
227
+ return;
228
+ }
229
+ const input = num(usage.input);
230
+ const output = num(usage.output);
231
+ const cacheRead = num(usage.cacheRead);
232
+ const cacheWrite = num(usage.cacheWrite);
233
+ if (input + output + cacheRead + cacheWrite === 0) return;
234
+ const costValue = usage.cost;
235
+ const cost =
236
+ typeof costValue === "number" ? num(costValue) : num((costValue as Record<string, unknown> | undefined)?.total);
237
+ if (cost < 0) {
238
+ this.out.badLines++;
239
+ return;
240
+ }
241
+ const ms = timeOf(message.timestamp) || entryTs;
242
+ if (ms <= 0) return;
243
+ const name = JSON.stringify([provider, model]);
244
+ let index = this.modelIndex.get(name);
245
+ if (index === undefined) {
246
+ index = this.out.models.length;
247
+ this.out.models.push(`${provider}/${model}`);
248
+ this.out.modelPairs?.push([provider, model]);
249
+ this.modelIndex.set(name, index);
250
+ }
251
+ this.out.calls.push([ms, index, input, output, cacheRead, cacheWrite, cost]);
252
+ this.out.entryIds?.push(entryId);
253
+ }
254
+ }
255
+
256
+ /** Parse a whole session file's text in one go (tests and small inputs). */
257
+ export function parseSession(text: string, size = text.length, mtimeMs = 0): FileStats {
258
+ const parser = new SessionParser(size, mtimeMs);
259
+ for (const line of text.split("\n")) parser.accept(line);
260
+ return parser.finish();
261
+ }
262
+
263
+ /** Stream a file through `SessionParser`, yielding to the event loop every 2000 lines so the TUI keeps painting. */
264
+ export async function parseFile(path: string, size: number, mtimeMs: number): Promise<FileStats> {
265
+ const parser = new SessionParser(size, mtimeMs);
266
+ const rl = createInterface({
267
+ input: createReadStream(path, { encoding: "utf8" }),
268
+ crlfDelay: Number.POSITIVE_INFINITY,
269
+ });
270
+ let n = 0;
271
+ for await (const line of rl) {
272
+ parser.accept(line);
273
+ if (++n % YIELD_EVERY_LINES === 0) await tick();
274
+ }
275
+ return parser.finish();
276
+ }
277
+
278
+ /**
279
+ * Every `*.jsonl` under `root`. A missing root is an empty history; any other directory that cannot
280
+ * be listed (EACCES, ...) is counted in `errors` so partial totals are never presented as complete.
281
+ */
282
+ async function listJsonl(root: string): Promise<{ paths: string[]; errors: number }> {
283
+ const out: string[] = [];
284
+ let errors = 0;
285
+ const walk = async (dir: string): Promise<void> => {
286
+ let entries: Dirent[];
287
+ try {
288
+ entries = await readdir(dir, { withFileTypes: true });
289
+ } catch (error) {
290
+ if (!(dir === root && (error as NodeJS.ErrnoException).code === "ENOENT")) errors++;
291
+ return;
292
+ }
293
+ for (const entry of entries) {
294
+ const path = join(dir, entry.name);
295
+ if (entry.isDirectory()) await walk(path);
296
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
297
+ }
298
+ };
299
+ await walk(root);
300
+ return { paths: out.sort(), errors };
301
+ }
302
+
303
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
304
+
305
+ /** Whether a cached record has exactly the shape `flatten` relies on. */
306
+ export function isFileStats(value: unknown): value is FileStats {
307
+ if (typeof value !== "object" || value === null) return false;
308
+ const f = value as Record<string, unknown>;
309
+ if (!isNum(f.size) || !isNum(f.mtimeMs) || !isNum(f.startMs) || !isNum(f.endMs) || !isNum(f.badLines)) return false;
310
+ if (typeof f.child !== "boolean") return false;
311
+ if (f.sessionId !== undefined && (typeof f.sessionId !== "string" || !/^[a-f0-9]{64}$/.test(f.sessionId)))
312
+ return false;
313
+ if (
314
+ f.entryIds !== undefined &&
315
+ (!Array.isArray(f.entryIds) ||
316
+ !Array.isArray(f.calls) ||
317
+ f.entryIds.length !== f.calls.length ||
318
+ !f.entryIds.every((id) => id === null || (typeof id === "string" && /^[a-f0-9]{64}$/.test(id))))
319
+ )
320
+ return false;
321
+ const models = f.models;
322
+ if (!Array.isArray(models) || !models.every((m) => typeof m === "string" && m.includes("/"))) return false;
323
+ if (
324
+ f.modelPairs !== undefined &&
325
+ (!Array.isArray(f.modelPairs) ||
326
+ f.modelPairs.length !== models.length ||
327
+ !f.modelPairs.every((p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === "string")))
328
+ )
329
+ return false;
330
+ return (
331
+ Array.isArray(f.calls) &&
332
+ f.calls.every(
333
+ (c) =>
334
+ Array.isArray(c) &&
335
+ c.length === 7 &&
336
+ c.every(isNum) &&
337
+ Number.isInteger(c[1]) &&
338
+ (c[1] as number) >= 0 &&
339
+ (c[1] as number) < models.length,
340
+ )
341
+ );
342
+ }
343
+
344
+ /**
345
+ * Cached records keyed by path. Unreadable JSON, a wrong version, or a non-object `files` yields an
346
+ * empty cache; a malformed record is dropped on its own (its file is re-parsed if still on disk).
347
+ */
348
+ async function readCache(path: string): Promise<{ files: Record<string, FileStats>; migrate: boolean }> {
349
+ let doc: Partial<CacheDoc> | null;
350
+ try {
351
+ doc = JSON.parse(await readFile(path, "utf8")) as Partial<CacheDoc> | null;
352
+ } catch {
353
+ return { files: {}, migrate: false };
354
+ }
355
+ if ((doc?.version !== CACHE_VERSION && doc?.version !== 1) || typeof doc.files !== "object" || doc.files === null)
356
+ return { files: {}, migrate: false };
357
+ return {
358
+ files: Object.fromEntries(Object.entries(doc.files).filter(([, record]) => isFileStats(record))),
359
+ migrate: doc.version === 1,
360
+ };
361
+ }
362
+
363
+ /** Atomic write: a crash mid-write never leaves a truncated cache behind. */
364
+ async function writeCache(path: string, files: Record<string, FileStats>): Promise<void> {
365
+ await mkdir(dirname(path), { recursive: true });
366
+ const tmp = `${path}.${randomUUID()}.tmp`;
367
+ await writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files } satisfies CacheDoc));
368
+ await rename(tmp, path);
369
+ }
370
+
371
+ /** Old caches deduplicated this tuple, without cost or entry identity. */
372
+ export function legacyKey(c: Call): string {
373
+ return JSON.stringify([c.provider, c.model, c.ms, c.input, c.output, c.cacheRead, c.cacheWrite]);
374
+ }
375
+
376
+ /** Prefer identified records over their legacy copies, but retain distinct preserved IDs. */
377
+ export function dedupCalls(candidates: readonly Call[]): Call[] {
378
+ const identified = new Set(candidates.filter((c) => c.entryId).map(legacyKey));
379
+ const seen = new Set<string>();
380
+ return [...candidates]
381
+ .sort((a, b) => a.ms - b.ms)
382
+ .filter((c) => {
383
+ const legacy = legacyKey(c);
384
+ if (!c.entryId && identified.has(legacy)) return false;
385
+ const key = c.entryId ? JSON.stringify([c.entryId, legacy]) : legacy;
386
+ if (seen.has(key)) return false;
387
+ seen.add(key);
388
+ return true;
389
+ });
390
+ }
391
+
392
+ export interface LoadOptions {
393
+ root?: string;
394
+ cache?: string;
395
+ /** Machine views include history cached under formerly configured session roots. */
396
+ includeAllRoots?: boolean;
397
+ /** Called after each re-parsed file. */
398
+ onProgress?: (done: number, total: number) => void;
399
+ }
400
+
401
+ /**
402
+ * Load every session under `root`, re-parsing only files whose size or mtime changed. Cache entries
403
+ * for deleted files are kept, so pruning old sessions does not erase history; entries outside `root`
404
+ * stay cached but are not reported.
405
+ */
406
+ export async function loadUsage(options: LoadOptions = {}): Promise<Loaded> {
407
+ const root = resolve(options.root ?? sessionsDir());
408
+ const cacheFile = options.cache ?? cachePath();
409
+ const { files, migrate } = await readCache(cacheFile);
410
+ const listed = await listJsonl(root);
411
+ const paths = listed.paths;
412
+ let errors = listed.errors;
413
+ const stale: { path: string; size: number; mtimeMs: number }[] = [];
414
+ for (const path of paths) {
415
+ try {
416
+ const s = await stat(path);
417
+ const hit = files[path];
418
+ if (!hit || hit.entryIds === undefined || hit.size !== s.size || hit.mtimeMs !== s.mtimeMs)
419
+ stale.push({ path, size: s.size, mtimeMs: s.mtimeMs });
420
+ } catch {
421
+ errors++;
422
+ }
423
+ }
424
+ let done = 0;
425
+ let dirty = migrate;
426
+ for (const { path, size, mtimeMs } of stale) {
427
+ try {
428
+ files[path] = await parseFile(path, size, mtimeMs);
429
+ dirty = true;
430
+ } catch {
431
+ errors++;
432
+ }
433
+ options.onProgress?.(++done, stale.length);
434
+ }
435
+ if (dirty) {
436
+ try {
437
+ await writeCache(cacheFile, files);
438
+ } catch {
439
+ // The view can still render, but a producer must not claim durable history.
440
+ errors++;
441
+ }
442
+ }
443
+ // The cache is shared by every sessions dir this agent dir has used; only this root's files
444
+ // count, deleted ones included.
445
+ const prefix = `${resolve(root)}${sep}`;
446
+ const scoped = Object.fromEntries(
447
+ Object.entries(files).filter(([path]) => options.includeAllRoots || path.startsWith(prefix)),
448
+ );
449
+ return { ...flatten(scoped), errors };
450
+ }
451
+
452
+ /** Merge preserved session IDs, spanning all copies and propagating child attribution. */
453
+ export function mergeSessionMetadata(sessions: readonly Session[]): Map<string, Session> {
454
+ const metadata = new Map<string, Session>();
455
+ for (const session of sessions) {
456
+ const old = metadata.get(session.path);
457
+ metadata.set(session.path, {
458
+ ...session,
459
+ startMs: Math.min(session.startMs, old?.startMs ?? session.startMs),
460
+ endMs: Math.max(session.endMs, old?.endMs ?? session.endMs),
461
+ child: session.child || old?.child || false,
462
+ });
463
+ }
464
+ return metadata;
465
+ }
466
+
467
+ /**
468
+ * Turn cached file stats into calls and sessions. Calls copied into a `/fork` or a branch keep their
469
+ * timestamp, model and token counts. Preserved entry IDs distinguish identical-value calls;
470
+ * legacy tuple copies are discarded when an identified counterpart exists. Files are visited oldest first
471
+ * so the original session, not a later copy, owns each call.
472
+ */
473
+ export function flatten(files: Record<string, FileStats>): Omit<Loaded, "errors"> {
474
+ const sessions: Session[] = [];
475
+ const candidates: Call[] = [];
476
+ let badLines = 0;
477
+ const ordered = Object.entries(files).sort(([pa, a], [pb, b]) => a.startMs - b.startMs || pa.localeCompare(pb));
478
+ for (const [path, file] of ordered) {
479
+ badLines += file.badLines ?? 0;
480
+ const session = file.sessionId ?? opaqueId(`legacy-session:${path}`);
481
+ for (const [i, tuple] of file.calls.entries()) {
482
+ const [ms, index, input, output, cacheRead, cacheWrite, cost] = tuple;
483
+ // Preserve raw attribution for dedup and export; sanitize only in display code.
484
+ const raw = file.models[index] ?? "unknown/unknown";
485
+
486
+ const slash = raw.indexOf("/");
487
+ candidates.push({
488
+ session,
489
+ entryId: file.entryIds?.[i] ?? undefined,
490
+ ms,
491
+ provider: file.modelPairs?.[index]?.[0] ?? raw.slice(0, slash),
492
+ model: file.modelPairs?.[index]?.[1] ?? raw.slice(slash + 1),
493
+ input,
494
+ output,
495
+ cacheRead,
496
+ cacheWrite,
497
+ cost,
498
+ });
499
+ }
500
+ if (file.calls.length > 0)
501
+ sessions.push({ path: session, startMs: file.startMs, endMs: file.endMs, child: file.child });
502
+ }
503
+ const calls = dedupCalls(candidates);
504
+ const owners = new Set(calls.map((c) => c.session));
505
+ const metadata = mergeSessionMetadata(sessions);
506
+ return {
507
+ calls,
508
+ sessionMetadata: [...metadata.values()],
509
+ sessions: [...metadata.values()].filter((s) => !s.child && owners.has(s.path)),
510
+ badLines,
511
+ };
512
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Chart series colours derived from the theme's `accent`: same lightness (so they read on the
3
+ * theme's background), saturation raised to at least `MIN_SATURATION`, hues spread evenly around the
4
+ * OKHSL wheel. Theme tokens like `success`/`warning` are often muted variants of one hue (belafonte-day's
5
+ * blue/olive/brown), which made provider lines nearly indistinguishable.
6
+ */
7
+
8
+ import { type Color, colorToOkhsl, okhslColor } from "@earendil-works/pi-tui";
9
+
10
+ export const MIN_SATURATION = 0.75;
11
+ /** Keep generated colours away from the extremes, where hue stops being visible. */
12
+ const LIGHTNESS_RANGE: [number, number] = [0.35, 0.75];
13
+
14
+ /** `count` colours, the first at the base hue, the rest `360 / count` degrees apart. */
15
+ export function seriesPalette(base: Color, count: number): Color[] {
16
+ const { h, s, l } = colorToOkhsl(base);
17
+ const hue = Number.isFinite(h) ? h : 250;
18
+ const sat = Math.max(Number.isFinite(s) ? s : 0, MIN_SATURATION);
19
+ const light = Math.min(LIGHTNESS_RANGE[1], Math.max(LIGHTNESS_RANGE[0], l));
20
+ const step = 360 / Math.max(1, count);
21
+ return Array.from({ length: count }, (_, i) => okhslColor((hue + i * step) % 360, sat, light));
22
+ }