@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,657 @@
1
+ // src/stats/cli.ts
2
+ import { resolve as resolve2 } from "node:path";
3
+
4
+ // src/stats/load.ts
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { createReadStream } from "node:fs";
7
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join, resolve, sep } from "node:path";
10
+ import { createInterface } from "node:readline";
11
+ import { setImmediate as tick } from "node:timers/promises";
12
+ var CACHE_VERSION = 2;
13
+ var opaqueId = (value) => createHash("sha256").update(value).digest("hex");
14
+ var EXCLUDED_PROVIDERS = /* @__PURE__ */ new Set(["faux", "faux-provider", "fake-provider", "router"]);
15
+ var CHILD_NAME = /#[0-9a-f]{8}$/;
16
+ var LEADING_TIMESTAMP = /^.{0,200}?"timestamp"\s*:\s*"([^"]+)"/;
17
+ var YIELD_EVERY_LINES = 2e3;
18
+ function agentDir() {
19
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
20
+ }
21
+ function sessionsDir(dir = agentDir()) {
22
+ return process.env.PI_CODING_AGENT_SESSION_DIR ?? join(dir, "sessions");
23
+ }
24
+ function cachePath(dir = agentDir()) {
25
+ return join(dir, "pi-session-kit", "stats-cache.json");
26
+ }
27
+ function num(value) {
28
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
29
+ }
30
+ function timeOf(value) {
31
+ if (typeof value === "number")
32
+ return Number.isSafeInteger(value) && value > 0 && value <= 864e13 ? value : 0;
33
+ if (typeof value !== "string") return 0;
34
+ const ms = Date.parse(value);
35
+ return Number.isFinite(ms) ? ms : 0;
36
+ }
37
+ function looksLikeObject(line) {
38
+ const text = line.trim();
39
+ return text.startsWith("{") && text.endsWith("}");
40
+ }
41
+ var SessionParser = class {
42
+ out;
43
+ modelIndex = /* @__PURE__ */ new Map();
44
+ parent = false;
45
+ childName = false;
46
+ markedChild = false;
47
+ lines = 0;
48
+ constructor(size, mtimeMs) {
49
+ this.out = {
50
+ size,
51
+ mtimeMs,
52
+ startMs: 0,
53
+ endMs: 0,
54
+ child: false,
55
+ models: [],
56
+ modelPairs: [],
57
+ calls: [],
58
+ entryIds: [],
59
+ badLines: 0
60
+ };
61
+ }
62
+ accept(line) {
63
+ if (!line) return;
64
+ const isHeader = this.lines++ === 0;
65
+ const isAssistant = line.includes('"assistant"');
66
+ const isInfo = line.includes('"session_info"') || line.includes('"session-kit:child"');
67
+ if (!isHeader && !isAssistant && !isInfo) {
68
+ if (!looksLikeObject(line)) this.out.badLines++;
69
+ else this.seen(timeOf(LEADING_TIMESTAMP.exec(line)?.[1]));
70
+ return;
71
+ }
72
+ let entry;
73
+ try {
74
+ entry = JSON.parse(line);
75
+ } catch {
76
+ this.out.badLines++;
77
+ return;
78
+ }
79
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
80
+ this.out.badLines++;
81
+ return;
82
+ }
83
+ const ts = timeOf(entry.timestamp);
84
+ this.seen(ts);
85
+ if (entry.type === "session") {
86
+ if (typeof entry.id === "string" && entry.id) this.out.sessionId = opaqueId(`session:${entry.id}`);
87
+ if (this.out.startMs === 0 || ts > 0 && ts < this.out.startMs) this.out.startMs = ts;
88
+ if (typeof entry.parentSession === "string" && entry.parentSession) this.parent = true;
89
+ return;
90
+ }
91
+ if (entry.type === "custom" && entry.customType === "session-kit:child") {
92
+ const data = entry.data;
93
+ if (data?.version === 1 && typeof data.sessionId === "string" && opaqueId(`session:${data.sessionId}`) === this.out.sessionId)
94
+ this.markedChild = true;
95
+ return;
96
+ }
97
+ if (entry.type === "session_info") {
98
+ this.childName ||= typeof entry.name === "string" && CHILD_NAME.test(entry.name);
99
+ return;
100
+ }
101
+ if (entry.type !== "message") return;
102
+ const message = entry.message;
103
+ if (message?.role !== "assistant") return;
104
+ this.call(message, ts, typeof entry.id === "string" && entry.id ? opaqueId(`entry:${entry.id}`) : null);
105
+ }
106
+ finish() {
107
+ this.out.child = this.markedChild || this.parent && this.childName;
108
+ return this.out;
109
+ }
110
+ seen(ts) {
111
+ if (ts <= 0) return;
112
+ if (this.out.startMs === 0) this.out.startMs = ts;
113
+ if (ts > this.out.endMs) this.out.endMs = ts;
114
+ }
115
+ call(message, entryTs, entryId) {
116
+ const provider = typeof message.provider === "string" ? message.provider : "";
117
+ const model = typeof message.model === "string" ? message.model : "";
118
+ if (!provider || !model || EXCLUDED_PROVIDERS.has(provider)) return;
119
+ const usage = message.usage;
120
+ if (!usage) return;
121
+ if ([usage.input, usage.output, usage.cacheRead, usage.cacheWrite].some(
122
+ (v) => v !== void 0 && (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
123
+ )) {
124
+ this.out.badLines++;
125
+ return;
126
+ }
127
+ const input = num(usage.input);
128
+ const output = num(usage.output);
129
+ const cacheRead = num(usage.cacheRead);
130
+ const cacheWrite = num(usage.cacheWrite);
131
+ if (input + output + cacheRead + cacheWrite === 0) return;
132
+ const costValue = usage.cost;
133
+ const cost = typeof costValue === "number" ? num(costValue) : num(costValue?.total);
134
+ if (cost < 0) {
135
+ this.out.badLines++;
136
+ return;
137
+ }
138
+ const ms = timeOf(message.timestamp) || entryTs;
139
+ if (ms <= 0) return;
140
+ const name2 = JSON.stringify([provider, model]);
141
+ let index = this.modelIndex.get(name2);
142
+ if (index === void 0) {
143
+ index = this.out.models.length;
144
+ this.out.models.push(`${provider}/${model}`);
145
+ this.out.modelPairs?.push([provider, model]);
146
+ this.modelIndex.set(name2, index);
147
+ }
148
+ this.out.calls.push([ms, index, input, output, cacheRead, cacheWrite, cost]);
149
+ this.out.entryIds?.push(entryId);
150
+ }
151
+ };
152
+ async function parseFile(path, size, mtimeMs) {
153
+ const parser = new SessionParser(size, mtimeMs);
154
+ const rl = createInterface({
155
+ input: createReadStream(path, { encoding: "utf8" }),
156
+ crlfDelay: Number.POSITIVE_INFINITY
157
+ });
158
+ let n = 0;
159
+ for await (const line of rl) {
160
+ parser.accept(line);
161
+ if (++n % YIELD_EVERY_LINES === 0) await tick();
162
+ }
163
+ return parser.finish();
164
+ }
165
+ async function listJsonl(root) {
166
+ const out = [];
167
+ let errors = 0;
168
+ const walk = async (dir) => {
169
+ let entries;
170
+ try {
171
+ entries = await readdir(dir, { withFileTypes: true });
172
+ } catch (error) {
173
+ if (!(dir === root && error.code === "ENOENT")) errors++;
174
+ return;
175
+ }
176
+ for (const entry of entries) {
177
+ const path = join(dir, entry.name);
178
+ if (entry.isDirectory()) await walk(path);
179
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
180
+ }
181
+ };
182
+ await walk(root);
183
+ return { paths: out.sort(), errors };
184
+ }
185
+ var isNum = (v) => typeof v === "number" && Number.isFinite(v);
186
+ function isFileStats(value) {
187
+ if (typeof value !== "object" || value === null) return false;
188
+ const f = value;
189
+ if (!isNum(f.size) || !isNum(f.mtimeMs) || !isNum(f.startMs) || !isNum(f.endMs) || !isNum(f.badLines)) return false;
190
+ if (typeof f.child !== "boolean") return false;
191
+ if (f.sessionId !== void 0 && (typeof f.sessionId !== "string" || !/^[a-f0-9]{64}$/.test(f.sessionId)))
192
+ return false;
193
+ if (f.entryIds !== void 0 && (!Array.isArray(f.entryIds) || !Array.isArray(f.calls) || f.entryIds.length !== f.calls.length || !f.entryIds.every((id) => id === null || typeof id === "string" && /^[a-f0-9]{64}$/.test(id))))
194
+ return false;
195
+ const models = f.models;
196
+ if (!Array.isArray(models) || !models.every((m) => typeof m === "string" && m.includes("/"))) return false;
197
+ if (f.modelPairs !== void 0 && (!Array.isArray(f.modelPairs) || f.modelPairs.length !== models.length || !f.modelPairs.every((p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === "string"))))
198
+ return false;
199
+ return Array.isArray(f.calls) && f.calls.every(
200
+ (c) => Array.isArray(c) && c.length === 7 && c.every(isNum) && Number.isInteger(c[1]) && c[1] >= 0 && c[1] < models.length
201
+ );
202
+ }
203
+ async function readCache(path) {
204
+ let doc;
205
+ try {
206
+ doc = JSON.parse(await readFile(path, "utf8"));
207
+ } catch {
208
+ return { files: {}, migrate: false };
209
+ }
210
+ if (doc?.version !== CACHE_VERSION && doc?.version !== 1 || typeof doc.files !== "object" || doc.files === null)
211
+ return { files: {}, migrate: false };
212
+ return {
213
+ files: Object.fromEntries(Object.entries(doc.files).filter(([, record2]) => isFileStats(record2))),
214
+ migrate: doc.version === 1
215
+ };
216
+ }
217
+ async function writeCache(path, files) {
218
+ await mkdir(dirname(path), { recursive: true });
219
+ const tmp = `${path}.${randomUUID()}.tmp`;
220
+ await writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files }));
221
+ await rename(tmp, path);
222
+ }
223
+ function legacyKey(c) {
224
+ return JSON.stringify([c.provider, c.model, c.ms, c.input, c.output, c.cacheRead, c.cacheWrite]);
225
+ }
226
+ function dedupCalls(candidates) {
227
+ const identified = new Set(candidates.filter((c) => c.entryId).map(legacyKey));
228
+ const seen = /* @__PURE__ */ new Set();
229
+ return [...candidates].sort((a, b) => a.ms - b.ms).filter((c) => {
230
+ const legacy = legacyKey(c);
231
+ if (!c.entryId && identified.has(legacy)) return false;
232
+ const key = c.entryId ? JSON.stringify([c.entryId, legacy]) : legacy;
233
+ if (seen.has(key)) return false;
234
+ seen.add(key);
235
+ return true;
236
+ });
237
+ }
238
+ async function loadUsage(options = {}) {
239
+ const root = resolve(options.root ?? sessionsDir());
240
+ const cacheFile = options.cache ?? cachePath();
241
+ const { files, migrate } = await readCache(cacheFile);
242
+ const listed = await listJsonl(root);
243
+ const paths = listed.paths;
244
+ let errors = listed.errors;
245
+ const stale = [];
246
+ for (const path of paths) {
247
+ try {
248
+ const s = await stat(path);
249
+ const hit = files[path];
250
+ if (!hit || hit.entryIds === void 0 || hit.size !== s.size || hit.mtimeMs !== s.mtimeMs)
251
+ stale.push({ path, size: s.size, mtimeMs: s.mtimeMs });
252
+ } catch {
253
+ errors++;
254
+ }
255
+ }
256
+ let done = 0;
257
+ let dirty = migrate;
258
+ for (const { path, size, mtimeMs } of stale) {
259
+ try {
260
+ files[path] = await parseFile(path, size, mtimeMs);
261
+ dirty = true;
262
+ } catch {
263
+ errors++;
264
+ }
265
+ options.onProgress?.(++done, stale.length);
266
+ }
267
+ if (dirty) {
268
+ try {
269
+ await writeCache(cacheFile, files);
270
+ } catch {
271
+ errors++;
272
+ }
273
+ }
274
+ const prefix = `${resolve(root)}${sep}`;
275
+ const scoped = Object.fromEntries(
276
+ Object.entries(files).filter(([path]) => options.includeAllRoots || path.startsWith(prefix))
277
+ );
278
+ return { ...flatten(scoped), errors };
279
+ }
280
+ function mergeSessionMetadata(sessions) {
281
+ const metadata = /* @__PURE__ */ new Map();
282
+ for (const session of sessions) {
283
+ const old = metadata.get(session.path);
284
+ metadata.set(session.path, {
285
+ ...session,
286
+ startMs: Math.min(session.startMs, old?.startMs ?? session.startMs),
287
+ endMs: Math.max(session.endMs, old?.endMs ?? session.endMs),
288
+ child: session.child || old?.child || false
289
+ });
290
+ }
291
+ return metadata;
292
+ }
293
+ function flatten(files) {
294
+ const sessions = [];
295
+ const candidates = [];
296
+ let badLines = 0;
297
+ const ordered = Object.entries(files).sort(([pa, a], [pb, b]) => a.startMs - b.startMs || pa.localeCompare(pb));
298
+ for (const [path, file] of ordered) {
299
+ badLines += file.badLines ?? 0;
300
+ const session = file.sessionId ?? opaqueId(`legacy-session:${path}`);
301
+ for (const [i, tuple] of file.calls.entries()) {
302
+ const [ms, index, input, output, cacheRead, cacheWrite, cost] = tuple;
303
+ const raw = file.models[index] ?? "unknown/unknown";
304
+ const slash = raw.indexOf("/");
305
+ candidates.push({
306
+ session,
307
+ entryId: file.entryIds?.[i] ?? void 0,
308
+ ms,
309
+ provider: file.modelPairs?.[index]?.[0] ?? raw.slice(0, slash),
310
+ model: file.modelPairs?.[index]?.[1] ?? raw.slice(slash + 1),
311
+ input,
312
+ output,
313
+ cacheRead,
314
+ cacheWrite,
315
+ cost
316
+ });
317
+ }
318
+ if (file.calls.length > 0)
319
+ sessions.push({ path: session, startMs: file.startMs, endMs: file.endMs, child: file.child });
320
+ }
321
+ const calls = dedupCalls(candidates);
322
+ const owners = new Set(calls.map((c) => c.session));
323
+ const metadata = mergeSessionMetadata(sessions);
324
+ return {
325
+ calls,
326
+ sessionMetadata: [...metadata.values()],
327
+ sessions: [...metadata.values()].filter((s) => !s.child && owners.has(s.path)),
328
+ badLines
329
+ };
330
+ }
331
+
332
+ // src/stats/snapshot.ts
333
+ import { randomUUID as randomUUID2 } from "node:crypto";
334
+ import { link, lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile2, rename as rename2, unlink, writeFile as writeFile2 } from "node:fs/promises";
335
+ import { dirname as dirname2, join as join2 } from "node:path";
336
+ var SNAPSHOT_VERSION = 1;
337
+ var UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
338
+ var HASH = /^[a-f0-9]{64}$/;
339
+ var MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;
340
+ var MAX_BYTES = 128 * 1024 * 1024;
341
+ async function atomicJson(path, value) {
342
+ await mkdir2(dirname2(path), { recursive: true });
343
+ const tmp = `${path}.${randomUUID2()}.tmp`;
344
+ try {
345
+ await writeFile2(tmp, JSON.stringify(value), { mode: 384 });
346
+ await rename2(tmp, path);
347
+ } catch (error) {
348
+ await unlink(tmp).catch(() => {
349
+ });
350
+ throw error;
351
+ }
352
+ }
353
+ var record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
354
+ function exact(v, keys) {
355
+ if (!record(v) || Object.keys(v).length !== keys.length || !Object.keys(v).every((key) => keys.includes(key)))
356
+ throw new Error("Unexpected snapshot fields");
357
+ }
358
+ var positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 864e13;
359
+ var count = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
360
+ var hash = (v) => typeof v === "string" && HASH.test(v);
361
+ var name = (v) => typeof v === "string" && v.length > 0 && v.length <= 1024;
362
+ var monthOf = (ms) => new Date(ms).toISOString().slice(0, 7);
363
+ function validateIdentity(value) {
364
+ exact(value, ["machineId", "label"]);
365
+ if (typeof value.machineId !== "string" || !UUID.test(value.machineId) || !name(value.label))
366
+ throw new Error("Invalid installation identity");
367
+ return value;
368
+ }
369
+ async function machineIdentity(dir = agentDir()) {
370
+ const path = join2(dir, "pi-session-kit", "machine.json");
371
+ try {
372
+ return validateIdentity(await jsonFile(path));
373
+ } catch (error) {
374
+ if (error.code !== "ENOENT") throw error;
375
+ }
376
+ await mkdir2(dirname2(path), { recursive: true });
377
+ const identity = { machineId: randomUUID2(), label: "Pi machine" };
378
+ const tmp = `${path}.${randomUUID2()}.tmp`;
379
+ await writeFile2(tmp, JSON.stringify(identity), { flag: "wx", mode: 384 });
380
+ try {
381
+ await link(tmp, path);
382
+ } catch (error) {
383
+ if (error.code !== "EEXIST") throw error;
384
+ } finally {
385
+ await unlink(tmp);
386
+ }
387
+ return validateIdentity(await jsonFile(path));
388
+ }
389
+ function eventId(event) {
390
+ if (event.entryId === null)
391
+ return opaqueId(
392
+ JSON.stringify([
393
+ event.provider,
394
+ event.model,
395
+ event.ms,
396
+ event.input,
397
+ event.output,
398
+ event.cacheRead,
399
+ event.cacheWrite
400
+ ])
401
+ );
402
+ return opaqueId(
403
+ JSON.stringify([
404
+ event.entryId,
405
+ event.ms,
406
+ event.provider,
407
+ event.model,
408
+ event.input,
409
+ event.output,
410
+ event.cacheRead,
411
+ event.cacheWrite
412
+ ])
413
+ );
414
+ }
415
+ function makeSnapshot(loaded, identity, exportedAt = Date.now()) {
416
+ const shards = /* @__PURE__ */ new Map();
417
+ const shard = (month) => {
418
+ let s = shards.get(month);
419
+ if (!s) {
420
+ s = { version: SNAPSHOT_VERSION, machineId: identity.machineId, month, events: [], sessions: [] };
421
+ shards.set(month, s);
422
+ }
423
+ return s;
424
+ };
425
+ const sessionId = (value) => HASH.test(value) ? value : opaqueId(`session:${value}`);
426
+ for (const c of loaded.calls) {
427
+ const e = {
428
+ entryId: c.entryId ?? null,
429
+ sessionId: sessionId(c.session),
430
+ ms: c.ms,
431
+ provider: c.provider,
432
+ model: c.model,
433
+ input: c.input,
434
+ output: c.output,
435
+ cacheRead: c.cacheRead,
436
+ cacheWrite: c.cacheWrite,
437
+ cost: c.cost
438
+ };
439
+ shard(monthOf(c.ms)).events.push({ id: eventId(e), ...e });
440
+ }
441
+ for (const s of loaded.sessionMetadata ?? loaded.sessions)
442
+ shard(monthOf(s.startMs)).sessions.push({
443
+ id: sessionId(s.path),
444
+ startMs: s.startMs,
445
+ endMs: s.endMs,
446
+ child: s.child ?? false
447
+ });
448
+ return {
449
+ manifest: {
450
+ version: SNAPSHOT_VERSION,
451
+ machineId: identity.machineId,
452
+ label: identity.label,
453
+ exportedAt,
454
+ shards: []
455
+ },
456
+ shards: [...shards.values()].sort((a, b) => a.month.localeCompare(b.month))
457
+ };
458
+ }
459
+ function validateManifest(value) {
460
+ exact(value, ["version", "machineId", "label", "exportedAt", "shards"]);
461
+ if (value.version !== SNAPSHOT_VERSION || typeof value.machineId !== "string" || !UUID.test(value.machineId) || !name(value.label) || !positive(value.exportedAt) || !Array.isArray(value.shards))
462
+ throw new Error("Invalid or unsupported manifest");
463
+ const months = /* @__PURE__ */ new Set();
464
+ for (const ref of value.shards) {
465
+ exact(ref, ["month", "file", "sha256", "bytes"]);
466
+ if (typeof ref.month !== "string" || !MONTH.test(ref.month) || months.has(ref.month) || !hash(ref.sha256) || ref.file !== `${ref.month}-${ref.sha256}.json` || !count(ref.bytes) || ref.bytes > MAX_BYTES)
467
+ throw new Error("Invalid shard reference");
468
+ months.add(ref.month);
469
+ }
470
+ return value;
471
+ }
472
+ function validateShard(value, manifest, month) {
473
+ exact(value, ["version", "machineId", "month", "events", "sessions"]);
474
+ if (value.version !== SNAPSHOT_VERSION || value.machineId !== manifest.machineId || value.month !== month || !Array.isArray(value.events) || !Array.isArray(value.sessions))
475
+ throw new Error("Invalid shard header");
476
+ const seenEvents = /* @__PURE__ */ new Set();
477
+ for (const e of value.events) {
478
+ exact(e, [
479
+ "id",
480
+ "entryId",
481
+ "sessionId",
482
+ "ms",
483
+ "provider",
484
+ "model",
485
+ "input",
486
+ "output",
487
+ "cacheRead",
488
+ "cacheWrite",
489
+ "cost"
490
+ ]);
491
+ if (!hash(e.id) || seenEvents.has(e.id) || !(e.entryId === null || hash(e.entryId)) || !hash(e.sessionId) || !positive(e.ms) || monthOf(e.ms) !== month || !name(e.provider) || !name(e.model) || ["router", "faux", "faux-provider", "fake-provider"].includes(e.provider) || ![e.input, e.output, e.cacheRead, e.cacheWrite].every(count) || e.input + e.output + e.cacheRead + e.cacheWrite === 0 || typeof e.cost !== "number" || !Number.isFinite(e.cost) || e.cost < 0 || eventId(e) !== e.id)
492
+ throw new Error("Invalid snapshot event");
493
+ seenEvents.add(e.id);
494
+ }
495
+ const seenSessions = /* @__PURE__ */ new Set();
496
+ for (const s of value.sessions) {
497
+ exact(s, ["id", "startMs", "endMs", "child"]);
498
+ if (!hash(s.id) || seenSessions.has(s.id) || !positive(s.startMs) || !positive(s.endMs) || s.endMs < s.startMs || monthOf(s.startMs) !== month || typeof s.child !== "boolean")
499
+ throw new Error("Invalid snapshot session");
500
+ seenSessions.add(s.id);
501
+ }
502
+ return value;
503
+ }
504
+ function validateSnapshot(snapshot) {
505
+ const metadata = /* @__PURE__ */ new Set();
506
+ const events = /* @__PURE__ */ new Set();
507
+ for (const s of snapshot.shards) {
508
+ for (const session of s.sessions) {
509
+ if (metadata.has(session.id)) throw new Error("Duplicate session metadata");
510
+ metadata.add(session.id);
511
+ }
512
+ for (const e of s.events) {
513
+ if (events.has(e.id)) throw new Error("Duplicate event identity");
514
+ events.add(e.id);
515
+ }
516
+ }
517
+ for (const s of snapshot.shards)
518
+ for (const e of s.events) if (!metadata.has(e.sessionId)) throw new Error("Missing session metadata");
519
+ }
520
+ async function exportStats(dir, output, root = sessionsDir(dir)) {
521
+ const identity = await machineIdentity(dir);
522
+ const loaded = await loadUsage({ root, cache: cachePath(dir), includeAllRoots: true });
523
+ if (loaded.errors || loaded.badLines)
524
+ throw new Error(`Incomplete local scan: ${loaded.errors} errors, ${loaded.badLines} bad lines`);
525
+ const snapshot = makeSnapshot(loaded, identity);
526
+ validateSnapshot(snapshot);
527
+ const snapshotDir = join2(output, "machines", identity.machineId);
528
+ await mkdir2(snapshotDir, { recursive: true });
529
+ for (const s of snapshot.shards) {
530
+ const text = JSON.stringify(s);
531
+ const sha256 = opaqueId(text);
532
+ const file = `${s.month}-${sha256}.json`;
533
+ validateShard(s, snapshot.manifest, s.month);
534
+ await atomicJson(join2(snapshotDir, file), s);
535
+ snapshot.manifest.shards.push({ month: s.month, file, sha256, bytes: Buffer.byteLength(text) });
536
+ }
537
+ await atomicJson(join2(snapshotDir, "manifest.json"), validateManifest(snapshot.manifest));
538
+ return { machineId: identity.machineId, snapshotDir };
539
+ }
540
+ var globalPath = (dir) => join2(dir, "pi-session-kit", "stats-global.json");
541
+ async function jsonFile(path) {
542
+ const info = await lstat(path);
543
+ if (!info.isFile() || info.size > MAX_BYTES) throw new Error("Not a bounded regular snapshot file");
544
+ return JSON.parse(await readFile2(path, "utf8"));
545
+ }
546
+ async function readGlobal(dir) {
547
+ const empty = { version: SNAPSHOT_VERSION, importedAt: 0, machines: {}, problems: [] };
548
+ try {
549
+ const v = await jsonFile(globalPath(dir));
550
+ exact(v, ["version", "importedAt", "machines", "problems"]);
551
+ if (v.version !== SNAPSHOT_VERSION || !count(v.importedAt) || !record(v.machines) || !Array.isArray(v.problems) || !v.problems.every((p) => typeof p === "string"))
552
+ throw new Error("Invalid global cache");
553
+ for (const [id, machine] of Object.entries(v.machines)) {
554
+ exact(machine, ["manifest", "shards"]);
555
+ const m = validateManifest(machine.manifest);
556
+ if (m.machineId !== id || !Array.isArray(machine.shards) || machine.shards.length !== m.shards.length)
557
+ throw new Error("Invalid cached machine");
558
+ for (const [i, s] of machine.shards.entries()) {
559
+ const ref = m.shards[i];
560
+ if (!ref) throw new Error("Missing shard");
561
+ validateShard(s, m, ref.month);
562
+ if (opaqueId(JSON.stringify(s)) !== ref.sha256) throw new Error("Cached shard digest mismatch");
563
+ }
564
+ validateSnapshot(machine);
565
+ }
566
+ return v;
567
+ } catch (error) {
568
+ if (error.code === "ENOENT") return empty;
569
+ return { ...empty, problems: ["Unreadable global cache"] };
570
+ }
571
+ }
572
+ async function importStats(dir, input) {
573
+ const previous = await readGlobal(dir);
574
+ const machines = { ...previous.machines };
575
+ const problems = [];
576
+ let imported = 0;
577
+ const present = /* @__PURE__ */ new Set();
578
+ try {
579
+ for (const entry of await readdir2(join2(input, "machines"), { withFileTypes: true })) {
580
+ if (!entry.isDirectory() || !UUID.test(entry.name)) {
581
+ problems.push("Invalid machine directory");
582
+ continue;
583
+ }
584
+ const id = entry.name;
585
+ present.add(id);
586
+ try {
587
+ const base = join2(input, "machines", id);
588
+ const manifest = validateManifest(await jsonFile(join2(base, "manifest.json")));
589
+ if (manifest.machineId !== id) throw new Error("Machine identity mismatch");
590
+ const shards = [];
591
+ for (const ref of manifest.shards) {
592
+ const path = join2(base, ref.file);
593
+ const info = await lstat(path);
594
+ if (!info.isFile() || info.size !== ref.bytes || info.size > MAX_BYTES)
595
+ throw new Error("Invalid shard size/type");
596
+ const text = await readFile2(path, "utf8");
597
+ if (opaqueId(text) !== ref.sha256) throw new Error("Shard digest mismatch");
598
+ const s = validateShard(JSON.parse(text), manifest, ref.month);
599
+ if (JSON.stringify(s) !== text) throw new Error("Noncanonical shard JSON");
600
+ shards.push(s);
601
+ }
602
+ validateSnapshot({ manifest, shards });
603
+ if (machines[id] && manifest.exportedAt < (machines[id]?.manifest.exportedAt ?? 0))
604
+ throw new Error("Older machine snapshot");
605
+ machines[id] = { manifest, shards };
606
+ imported++;
607
+ } catch {
608
+ problems.push(`Incomplete snapshot ${id}`);
609
+ }
610
+ }
611
+ } catch {
612
+ problems.push("Unreadable machines directory");
613
+ }
614
+ for (const id of Object.keys(previous.machines)) if (!present.has(id)) problems.push(`Missing snapshot ${id}`);
615
+ const importedAt = imported > 0 ? Date.now() : previous.importedAt;
616
+ const next = { version: SNAPSHOT_VERSION, importedAt, machines, problems };
617
+ if (Buffer.byteLength(JSON.stringify(next)) > MAX_BYTES)
618
+ throw new Error("Global cache exceeds 128 MiB; last-good cache retained");
619
+ await atomicJson(globalPath(dir), next);
620
+ return { imported, retained: Object.keys(machines).length - imported, problems, importedAt };
621
+ }
622
+
623
+ // src/stats/cli.ts
624
+ async function main() {
625
+ const [command, ...args] = process.argv.slice(2);
626
+ const options = /* @__PURE__ */ new Map();
627
+ for (let i = 0; i < args.length; i += 2) {
628
+ const flag = args[i];
629
+ const value = args[i + 1];
630
+ if (!flag || !value || value.startsWith("--") || options.has(flag) || !["--agent-dir", "--output", "--input", "--sessions-dir"].includes(flag))
631
+ throw new Error(
632
+ "usage: export --agent-dir DIR --output OUT [--sessions-dir DIR] | import --agent-dir DIR --input REPO"
633
+ );
634
+ options.set(flag, value);
635
+ }
636
+ const dir = resolve2(options.get("--agent-dir") ?? agentDir());
637
+ if (command === "export" && options.has("--output") && !options.has("--input")) {
638
+ const root = options.get("--sessions-dir");
639
+ console.log(
640
+ JSON.stringify(await exportStats(dir, resolve2(options.get("--output") ?? ""), root ? resolve2(root) : void 0))
641
+ );
642
+ } else if (command === "import" && options.has("--input") && !options.has("--output") && !options.has("--sessions-dir")) {
643
+ const result = await importStats(dir, resolve2(options.get("--input") ?? ""));
644
+ console.log(JSON.stringify(result));
645
+ if (result.problems.length) {
646
+ console.error(result.problems.join("\n"));
647
+ process.exitCode = 1;
648
+ }
649
+ } else
650
+ throw new Error(
651
+ "usage: export --agent-dir DIR --output OUT [--sessions-dir DIR] | import --agent-dir DIR --input REPO"
652
+ );
653
+ }
654
+ main().catch((error) => {
655
+ console.error(error instanceof Error ? error.message : "Stats operation failed");
656
+ process.exitCode = 1;
657
+ });