@nredd/pi-session-kit 0.0.0-stage → 0.3.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 (51) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +167 -2
  3. package/dist/pi-stats-export.mjs +657 -0
  4. package/docs/stats-snapshots.md +122 -0
  5. package/package.json +45 -4
  6. package/src/child.ts +79 -0
  7. package/src/config.ts +64 -0
  8. package/src/diff/component.ts +214 -0
  9. package/src/diff/git.ts +289 -0
  10. package/src/diff/index.ts +65 -0
  11. package/src/diff/parse.ts +180 -0
  12. package/src/diff/view.ts +98 -0
  13. package/src/goal/index.ts +343 -0
  14. package/src/goal/state.ts +270 -0
  15. package/src/goal/subagents.ts +77 -0
  16. package/src/index.ts +31 -0
  17. package/src/loop/index.ts +151 -0
  18. package/src/loop/state.ts +107 -0
  19. package/src/monitor/README.md +12 -0
  20. package/src/monitor/coalescer.ts +75 -0
  21. package/src/monitor/index.ts +213 -0
  22. package/src/monitor/manager.ts +322 -0
  23. package/src/monitor/matcher.ts +113 -0
  24. package/src/monitor/messages.ts +93 -0
  25. package/src/monitor/output.ts +41 -0
  26. package/src/monitor/pidfiles.ts +410 -0
  27. package/src/monitor/process.ts +124 -0
  28. package/src/monitor/ring.ts +52 -0
  29. package/src/monitor/splitter.ts +57 -0
  30. package/src/monitor/types.ts +35 -0
  31. package/src/monitor/validate.ts +59 -0
  32. package/src/monitor/widget.ts +75 -0
  33. package/src/output.ts +25 -0
  34. package/src/rules/index.ts +94 -0
  35. package/src/rules/logic.ts +96 -0
  36. package/src/shared.ts +177 -0
  37. package/src/stats/aggregate.ts +287 -0
  38. package/src/stats/chart.ts +247 -0
  39. package/src/stats/cli.ts +50 -0
  40. package/src/stats/component.ts +496 -0
  41. package/src/stats/days.ts +46 -0
  42. package/src/stats/format.ts +49 -0
  43. package/src/stats/heatmap.ts +98 -0
  44. package/src/stats/index.ts +81 -0
  45. package/src/stats/library.ts +4 -0
  46. package/src/stats/load.ts +512 -0
  47. package/src/stats/palette.ts +22 -0
  48. package/src/stats/snapshot.ts +497 -0
  49. package/src/titles/aliases.ts +20 -0
  50. package/src/titles/index.ts +82 -0
  51. package/src/titles/logic.ts +160 -0
@@ -0,0 +1,497 @@
1
+ /** Privacy-only monthly snapshots and offline, atomic last-good imports. Node builtins only. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { link, lstat, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import {
6
+ agentDir,
7
+ cachePath,
8
+ dedupCalls,
9
+ type Loaded,
10
+ loadUsage,
11
+ mergeSessionMetadata,
12
+ opaqueId,
13
+ sessionsDir,
14
+ } from "./load.ts";
15
+
16
+ export const SNAPSHOT_VERSION = 1;
17
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
18
+ const HASH = /^[a-f0-9]{64}$/;
19
+ const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;
20
+ const MAX_BYTES = 128 * 1024 * 1024;
21
+ export interface Identity {
22
+ machineId: string;
23
+ label: string;
24
+ }
25
+ export interface SnapshotEvent {
26
+ id: string;
27
+ entryId: string | null;
28
+ sessionId: string;
29
+ ms: number;
30
+ provider: string;
31
+ model: string;
32
+ input: number;
33
+ output: number;
34
+ cacheRead: number;
35
+ cacheWrite: number;
36
+ cost: number;
37
+ }
38
+ export interface SnapshotSession {
39
+ id: string;
40
+ startMs: number;
41
+ endMs: number;
42
+ child: boolean;
43
+ }
44
+ export interface Shard {
45
+ version: number;
46
+ machineId: string;
47
+ month: string;
48
+ events: SnapshotEvent[];
49
+ sessions: SnapshotSession[];
50
+ }
51
+ export interface Manifest extends Identity {
52
+ version: number;
53
+ exportedAt: number;
54
+ shards: { month: string; file: string; sha256: string; bytes: number }[];
55
+ }
56
+ export interface MachineSnapshot {
57
+ manifest: Manifest;
58
+ shards: Shard[];
59
+ }
60
+ export interface GlobalCache {
61
+ version: number;
62
+ importedAt: number;
63
+ machines: Record<string, MachineSnapshot>;
64
+ problems: string[];
65
+ }
66
+ export interface Coverage {
67
+ machines: number;
68
+ remoteMachines: number;
69
+ importedAt?: number;
70
+ incomplete: boolean;
71
+ problems: string[];
72
+ }
73
+ export interface Combined extends Loaded {
74
+ coverage: Coverage;
75
+ }
76
+
77
+ /** Atomic replacement, also safe against concurrent writers' temporary-file collisions. */
78
+ export async function atomicJson(path: string, value: unknown): Promise<void> {
79
+ await mkdir(dirname(path), { recursive: true });
80
+ const tmp = `${path}.${randomUUID()}.tmp`;
81
+ try {
82
+ await writeFile(tmp, JSON.stringify(value), { mode: 0o600 });
83
+ await rename(tmp, path);
84
+ } catch (error) {
85
+ // Cleanup is best effort; preserve the actual publication failure for diagnostics.
86
+ await unlink(tmp).catch(() => {});
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ const record = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
92
+ function exact(v: unknown, keys: string[]): asserts v is Record<string, unknown> {
93
+ if (!record(v) || Object.keys(v).length !== keys.length || !Object.keys(v).every((key) => keys.includes(key)))
94
+ throw new Error("Unexpected snapshot fields");
95
+ }
96
+ const positive = (v: unknown): v is number =>
97
+ typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 8640000000000000;
98
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
99
+ const hash = (v: unknown): v is string => typeof v === "string" && HASH.test(v);
100
+ const name = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 1024;
101
+ const monthOf = (ms: number): string => new Date(ms).toISOString().slice(0, 7);
102
+
103
+ function validateIdentity(value: unknown): Identity {
104
+ exact(value, ["machineId", "label"]);
105
+ if (typeof value.machineId !== "string" || !UUID.test(value.machineId) || !name(value.label))
106
+ throw new Error("Invalid installation identity");
107
+ return value as unknown as Identity;
108
+ }
109
+
110
+ /** An installation identity belongs to the agent dir, never to the sync checkout. */
111
+ export async function machineIdentity(dir = agentDir()): Promise<Identity> {
112
+ const path = join(dir, "pi-session-kit", "machine.json");
113
+ try {
114
+ return validateIdentity(await jsonFile(path));
115
+ } catch (error) {
116
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
117
+ }
118
+ await mkdir(dirname(path), { recursive: true });
119
+ const identity = { machineId: randomUUID(), label: "Pi machine" };
120
+ const tmp = `${path}.${randomUUID()}.tmp`;
121
+ await writeFile(tmp, JSON.stringify(identity), { flag: "wx", mode: 0o600 });
122
+ try {
123
+ // Hard-link publication is atomic and exclusive: concurrent readers never see partial JSON.
124
+ await link(tmp, path);
125
+ } catch (error) {
126
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
127
+ } finally {
128
+ await unlink(tmp);
129
+ }
130
+ return validateIdentity(await jsonFile(path));
131
+ }
132
+
133
+ /** Canonical event fingerprint includes identity, timestamp, raw attribution and every counter. */
134
+ export function eventId(event: Omit<SnapshotEvent, "id">): string {
135
+ if (event.entryId === null)
136
+ return opaqueId(
137
+ JSON.stringify([
138
+ event.provider,
139
+ event.model,
140
+ event.ms,
141
+ event.input,
142
+ event.output,
143
+ event.cacheRead,
144
+ event.cacheWrite,
145
+ ]),
146
+ );
147
+ return opaqueId(
148
+ JSON.stringify([
149
+ event.entryId,
150
+ event.ms,
151
+ event.provider,
152
+ event.model,
153
+ event.input,
154
+ event.output,
155
+ event.cacheRead,
156
+ event.cacheWrite,
157
+ ]),
158
+ );
159
+ }
160
+
161
+ /** Allowlist projection. No source objects, paths, names or message payloads are serialized. */
162
+ export function makeSnapshot(loaded: Loaded, identity: Identity, exportedAt = Date.now()): MachineSnapshot {
163
+ const shards = new Map<string, Shard>();
164
+ const shard = (month: string): Shard => {
165
+ let s = shards.get(month);
166
+ if (!s) {
167
+ s = { version: SNAPSHOT_VERSION, machineId: identity.machineId, month, events: [], sessions: [] };
168
+ shards.set(month, s);
169
+ }
170
+ return s;
171
+ };
172
+ const sessionId = (value: string): string => (HASH.test(value) ? value : opaqueId(`session:${value}`));
173
+ for (const c of loaded.calls) {
174
+ const e = {
175
+ entryId: c.entryId ?? null,
176
+ sessionId: sessionId(c.session),
177
+ ms: c.ms,
178
+ provider: c.provider,
179
+ model: c.model,
180
+ input: c.input,
181
+ output: c.output,
182
+ cacheRead: c.cacheRead,
183
+ cacheWrite: c.cacheWrite,
184
+ cost: c.cost,
185
+ };
186
+ shard(monthOf(c.ms)).events.push({ id: eventId(e), ...e });
187
+ }
188
+ for (const s of loaded.sessionMetadata ?? loaded.sessions)
189
+ shard(monthOf(s.startMs)).sessions.push({
190
+ id: sessionId(s.path),
191
+ startMs: s.startMs,
192
+ endMs: s.endMs,
193
+ child: s.child ?? false,
194
+ });
195
+ return {
196
+ manifest: {
197
+ version: SNAPSHOT_VERSION,
198
+ machineId: identity.machineId,
199
+ label: identity.label,
200
+ exportedAt,
201
+ shards: [],
202
+ },
203
+ shards: [...shards.values()].sort((a, b) => a.month.localeCompare(b.month)),
204
+ };
205
+ }
206
+
207
+ export function validateManifest(value: unknown): Manifest {
208
+ exact(value, ["version", "machineId", "label", "exportedAt", "shards"]);
209
+ if (
210
+ value.version !== SNAPSHOT_VERSION ||
211
+ typeof value.machineId !== "string" ||
212
+ !UUID.test(value.machineId) ||
213
+ !name(value.label) ||
214
+ !positive(value.exportedAt) ||
215
+ !Array.isArray(value.shards)
216
+ )
217
+ throw new Error("Invalid or unsupported manifest");
218
+ const months = new Set<string>();
219
+ for (const ref of value.shards) {
220
+ exact(ref, ["month", "file", "sha256", "bytes"]);
221
+ if (
222
+ typeof ref.month !== "string" ||
223
+ !MONTH.test(ref.month) ||
224
+ months.has(ref.month) ||
225
+ !hash(ref.sha256) ||
226
+ ref.file !== `${ref.month}-${ref.sha256}.json` ||
227
+ !count(ref.bytes) ||
228
+ ref.bytes > MAX_BYTES
229
+ )
230
+ throw new Error("Invalid shard reference");
231
+ months.add(ref.month);
232
+ }
233
+ return value as unknown as Manifest;
234
+ }
235
+ export function validateShard(value: unknown, manifest: Manifest, month: string): Shard {
236
+ exact(value, ["version", "machineId", "month", "events", "sessions"]);
237
+ if (
238
+ value.version !== SNAPSHOT_VERSION ||
239
+ value.machineId !== manifest.machineId ||
240
+ value.month !== month ||
241
+ !Array.isArray(value.events) ||
242
+ !Array.isArray(value.sessions)
243
+ )
244
+ throw new Error("Invalid shard header");
245
+ const seenEvents = new Set<string>();
246
+ for (const e of value.events) {
247
+ exact(e, [
248
+ "id",
249
+ "entryId",
250
+ "sessionId",
251
+ "ms",
252
+ "provider",
253
+ "model",
254
+ "input",
255
+ "output",
256
+ "cacheRead",
257
+ "cacheWrite",
258
+ "cost",
259
+ ]);
260
+ if (
261
+ !hash(e.id) ||
262
+ seenEvents.has(e.id) ||
263
+ !(e.entryId === null || hash(e.entryId)) ||
264
+ !hash(e.sessionId) ||
265
+ !positive(e.ms) ||
266
+ monthOf(e.ms) !== month ||
267
+ !name(e.provider) ||
268
+ !name(e.model) ||
269
+ ["router", "faux", "faux-provider", "fake-provider"].includes(e.provider as string) ||
270
+ ![e.input, e.output, e.cacheRead, e.cacheWrite].every(count) ||
271
+ (e.input as number) + (e.output as number) + (e.cacheRead as number) + (e.cacheWrite as number) === 0 ||
272
+ typeof e.cost !== "number" ||
273
+ !Number.isFinite(e.cost) ||
274
+ e.cost < 0 ||
275
+ eventId(e as unknown as SnapshotEvent) !== e.id
276
+ )
277
+ throw new Error("Invalid snapshot event");
278
+ seenEvents.add(e.id);
279
+ }
280
+ const seenSessions = new Set<string>();
281
+ for (const s of value.sessions) {
282
+ exact(s, ["id", "startMs", "endMs", "child"]);
283
+ if (
284
+ !hash(s.id) ||
285
+ seenSessions.has(s.id) ||
286
+ !positive(s.startMs) ||
287
+ !positive(s.endMs) ||
288
+ s.endMs < s.startMs ||
289
+ monthOf(s.startMs) !== month ||
290
+ typeof s.child !== "boolean"
291
+ )
292
+ throw new Error("Invalid snapshot session");
293
+ seenSessions.add(s.id);
294
+ }
295
+ return value as unknown as Shard;
296
+ }
297
+
298
+ /** Cross-shard session references and identifiers are validated as one machine transaction. */
299
+ export function validateSnapshot(snapshot: MachineSnapshot): void {
300
+ const metadata = new Set<string>();
301
+ const events = new Set<string>();
302
+ for (const s of snapshot.shards) {
303
+ for (const session of s.sessions) {
304
+ if (metadata.has(session.id)) throw new Error("Duplicate session metadata");
305
+ metadata.add(session.id);
306
+ }
307
+ for (const e of s.events) {
308
+ if (events.has(e.id)) throw new Error("Duplicate event identity");
309
+ events.add(e.id);
310
+ }
311
+ }
312
+ for (const s of snapshot.shards)
313
+ for (const e of s.events) if (!metadata.has(e.sessionId)) throw new Error("Missing session metadata");
314
+ }
315
+
316
+ /** Write only this machine's immutable shards, publishing its manifest last. */
317
+ export async function exportStats(
318
+ dir: string,
319
+ output: string,
320
+ root = sessionsDir(dir),
321
+ ): Promise<{ machineId: string; snapshotDir: string }> {
322
+ const identity = await machineIdentity(dir);
323
+ const loaded = await loadUsage({ root, cache: cachePath(dir), includeAllRoots: true });
324
+ if (loaded.errors || loaded.badLines)
325
+ throw new Error(`Incomplete local scan: ${loaded.errors} errors, ${loaded.badLines} bad lines`);
326
+ const snapshot = makeSnapshot(loaded, identity);
327
+ validateSnapshot(snapshot);
328
+ const snapshotDir = join(output, "machines", identity.machineId);
329
+ await mkdir(snapshotDir, { recursive: true });
330
+ for (const s of snapshot.shards) {
331
+ const text = JSON.stringify(s);
332
+ const sha256 = opaqueId(text);
333
+ const file = `${s.month}-${sha256}.json`;
334
+ validateShard(s, snapshot.manifest, s.month);
335
+ await atomicJson(join(snapshotDir, file), s);
336
+ snapshot.manifest.shards.push({ month: s.month, file, sha256, bytes: Buffer.byteLength(text) });
337
+ }
338
+ await atomicJson(join(snapshotDir, "manifest.json"), validateManifest(snapshot.manifest));
339
+ return { machineId: identity.machineId, snapshotDir };
340
+ }
341
+
342
+ const globalPath = (dir: string): string => join(dir, "pi-session-kit", "stats-global.json");
343
+ async function jsonFile(path: string): Promise<unknown> {
344
+ const info = await lstat(path);
345
+ if (!info.isFile() || info.size > MAX_BYTES) throw new Error("Not a bounded regular snapshot file");
346
+ return JSON.parse(await readFile(path, "utf8")) as unknown;
347
+ }
348
+ /** Validate even the local cache: tampering never reaches aggregation. */
349
+ export async function readGlobal(dir: string): Promise<GlobalCache> {
350
+ const empty: GlobalCache = { version: SNAPSHOT_VERSION, importedAt: 0, machines: {}, problems: [] };
351
+ try {
352
+ const v = await jsonFile(globalPath(dir));
353
+ exact(v, ["version", "importedAt", "machines", "problems"]);
354
+ if (
355
+ v.version !== SNAPSHOT_VERSION ||
356
+ !count(v.importedAt) ||
357
+ !record(v.machines) ||
358
+ !Array.isArray(v.problems) ||
359
+ !v.problems.every((p) => typeof p === "string")
360
+ )
361
+ throw new Error("Invalid global cache");
362
+ for (const [id, machine] of Object.entries(v.machines)) {
363
+ exact(machine, ["manifest", "shards"]);
364
+ const m = validateManifest(machine.manifest);
365
+ if (m.machineId !== id || !Array.isArray(machine.shards) || machine.shards.length !== m.shards.length)
366
+ throw new Error("Invalid cached machine");
367
+ for (const [i, s] of machine.shards.entries()) {
368
+ const ref = m.shards[i];
369
+ if (!ref) throw new Error("Missing shard");
370
+ validateShard(s, m, ref.month);
371
+ if (opaqueId(JSON.stringify(s)) !== ref.sha256) throw new Error("Cached shard digest mismatch");
372
+ }
373
+ validateSnapshot(machine as unknown as MachineSnapshot);
374
+ }
375
+ return v as unknown as GlobalCache;
376
+ } catch (error) {
377
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return empty;
378
+ return { ...empty, problems: ["Unreadable global cache"] };
379
+ }
380
+ }
381
+
382
+ /** Independent machine transactions: invalid/missing machines retain last-good data; one atomic publication. */
383
+ export async function importStats(
384
+ dir: string,
385
+ input: string,
386
+ ): Promise<{ imported: number; retained: number; problems: string[]; importedAt: number }> {
387
+ const previous = await readGlobal(dir);
388
+ const machines = { ...previous.machines };
389
+ const problems: string[] = [];
390
+ let imported = 0;
391
+ const present = new Set<string>();
392
+ try {
393
+ for (const entry of await readdir(join(input, "machines"), { withFileTypes: true })) {
394
+ if (!entry.isDirectory() || !UUID.test(entry.name)) {
395
+ problems.push("Invalid machine directory");
396
+ continue;
397
+ }
398
+ const id = entry.name;
399
+ present.add(id);
400
+ try {
401
+ const base = join(input, "machines", id);
402
+ const manifest = validateManifest(await jsonFile(join(base, "manifest.json")));
403
+ if (manifest.machineId !== id) throw new Error("Machine identity mismatch");
404
+ const shards: Shard[] = [];
405
+ for (const ref of manifest.shards) {
406
+ const path = join(base, ref.file);
407
+ const info = await lstat(path);
408
+ if (!info.isFile() || info.size !== ref.bytes || info.size > MAX_BYTES)
409
+ throw new Error("Invalid shard size/type");
410
+ const text = await readFile(path, "utf8");
411
+ if (opaqueId(text) !== ref.sha256) throw new Error("Shard digest mismatch");
412
+ const s = validateShard(JSON.parse(text) as unknown, manifest, ref.month);
413
+ if (JSON.stringify(s) !== text) throw new Error("Noncanonical shard JSON");
414
+ shards.push(s);
415
+ }
416
+ validateSnapshot({ manifest, shards });
417
+ if (machines[id] && manifest.exportedAt < (machines[id]?.manifest.exportedAt ?? 0))
418
+ throw new Error("Older machine snapshot");
419
+ machines[id] = { manifest, shards };
420
+ imported++;
421
+ } catch {
422
+ problems.push(`Incomplete snapshot ${id}`);
423
+ }
424
+ }
425
+ } catch {
426
+ problems.push("Unreadable machines directory");
427
+ }
428
+ for (const id of Object.keys(previous.machines)) if (!present.has(id)) problems.push(`Missing snapshot ${id}`);
429
+ const importedAt = imported > 0 ? Date.now() : previous.importedAt;
430
+ const next = { version: SNAPSHOT_VERSION, importedAt, machines, problems } satisfies GlobalCache;
431
+ if (Buffer.byteLength(JSON.stringify(next)) > MAX_BYTES)
432
+ throw new Error("Global cache exceeds 128 MiB; last-good cache retained");
433
+ await atomicJson(globalPath(dir), next);
434
+ return { imported, retained: Object.keys(machines).length - imported, problems, importedAt };
435
+ }
436
+
437
+ /** Shared offline loader for /stats and harness fixtures. Local usage is always freshly scanned. */
438
+ export async function loadCombined(
439
+ dir = agentDir(),
440
+ onProgress?: (done: number, total: number) => void,
441
+ ): Promise<Combined> {
442
+ const identity = await machineIdentity(dir);
443
+ const local = await loadUsage({ root: sessionsDir(dir), cache: cachePath(dir), includeAllRoots: true, onProgress });
444
+ const global = await readGlobal(dir);
445
+ const remotes = Object.values(global.machines).filter((m) => m.manifest.machineId !== identity.machineId);
446
+ const candidates = [...local.calls];
447
+ const sessionCandidates = [...(local.sessionMetadata ?? local.sessions)];
448
+ for (const machine of remotes)
449
+ for (const s of machine.shards) {
450
+ for (const e of s.events)
451
+ candidates.push({
452
+ session: e.sessionId,
453
+ entryId: e.entryId ?? undefined,
454
+ ms: e.ms,
455
+ provider: e.provider,
456
+ model: e.model,
457
+ input: e.input,
458
+ output: e.output,
459
+ cacheRead: e.cacheRead,
460
+ cacheWrite: e.cacheWrite,
461
+ cost: e.cost,
462
+ });
463
+ for (const session of s.sessions)
464
+ sessionCandidates.push({
465
+ path: session.id,
466
+ startMs: session.startMs,
467
+ endMs: session.endMs,
468
+ child: session.child,
469
+ });
470
+ }
471
+ const sessions = mergeSessionMetadata(sessionCandidates);
472
+ // Across machines, the oldest session owns copies too, not whichever UUID was read first.
473
+ const calls = dedupCalls(
474
+ candidates.sort(
475
+ (a, b) =>
476
+ (sessions.get(a.session)?.startMs ?? a.ms) - (sessions.get(b.session)?.startMs ?? b.ms) ||
477
+ a.session.localeCompare(b.session),
478
+ ),
479
+ );
480
+ const owners = new Set(calls.map((c) => c.session));
481
+ return {
482
+ ...local,
483
+ calls,
484
+ sessionMetadata: [...sessions.values()],
485
+ sessions: [...sessions.values()].filter((s) => !s.child && owners.has(s.path)),
486
+ coverage: {
487
+ machines: remotes.length + 1,
488
+ remoteMachines: remotes.length,
489
+ importedAt: global.importedAt || undefined,
490
+ incomplete: global.problems.length > 0 || local.errors > 0 || local.badLines > 0,
491
+ problems: global.problems,
492
+ },
493
+ };
494
+ }
495
+ export function coverageLabel(c: Coverage): string {
496
+ return `${c.incomplete ? "incomplete coverage · " : ""}${c.machines} machine${c.machines === 1 ? "" : "s"} · ${c.remoteMachines ? "cached" : "local-only"} · ${c.importedAt ? `last remote sync ${new Date(c.importedAt).toISOString()}` : "never synced"}`;
497
+ }
@@ -0,0 +1,20 @@
1
+ /** Reads machine-local model aliases from `<agent dir>/enterprise.local.json`; nothing else is read. */
2
+
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ import { type ModelAliases, parseModelAliases } from "./logic.ts";
7
+
8
+ const MANIFEST_NAME = "enterprise.local.json";
9
+
10
+ /** Aliases from the manifest in `agentDir`; empty when missing, unreadable, or invalid. Never logs. */
11
+ export function loadModelAliases(
12
+ agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(homedir(), ".pi", "agent"),
13
+ ): ModelAliases {
14
+ try {
15
+ const file = path.join(agentDir, MANIFEST_NAME);
16
+ return parseModelAliases(readFileSync(file, "utf8"), statSync(file).mode & 0o777);
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Auto session titles: after the first `agent_end` of an unnamed TUI session, one nested model call
3
+ * names it. One attempt, silent on any failure, never overwrites a name.
4
+ */
5
+
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { isChildSession } from "../child.ts";
8
+ import type { KitConfig } from "../config.ts";
9
+ import { loadModelAliases } from "./aliases.ts";
10
+ import {
11
+ buildTitlePrompt,
12
+ contentText,
13
+ firstExchange,
14
+ modelCandidates,
15
+ planModeActive,
16
+ sanitizeTitle,
17
+ TITLE_SYSTEM_PROMPT,
18
+ } from "./logic.ts";
19
+
20
+ const TITLE_TIMEOUT_MS = 20_000;
21
+ const TITLE_MAX_TOKENS = 128;
22
+
23
+ /** First candidate the registry can resolve and has credentials for. */
24
+ function resolveModel(ctx: ExtensionContext, titleModel?: string) {
25
+ for (const { provider, id } of modelCandidates(titleModel, loadModelAliases())) {
26
+ const model = ctx.modelRegistry.find(provider, id);
27
+ if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return model;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ /** Ask the model for a title; undefined on any failure. */
33
+ async function generateTitle(ctx: ExtensionContext, prompt: string, titleModel?: string): Promise<string | undefined> {
34
+ try {
35
+ const model = resolveModel(ctx, titleModel);
36
+ if (!model) return undefined;
37
+ const stream = ctx.modelRegistry.streamSimple(
38
+ model,
39
+ {
40
+ systemPrompt: TITLE_SYSTEM_PROMPT,
41
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
42
+ },
43
+ { maxTokens: TITLE_MAX_TOKENS, signal: AbortSignal.timeout(TITLE_TIMEOUT_MS) },
44
+ );
45
+ const message = await stream.result();
46
+ if (message.stopReason === "error" || message.stopReason === "aborted") return undefined;
47
+ return sanitizeTitle(contentText(message.content));
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ export function register(pi: ExtensionAPI, config: KitConfig): void {
54
+ let attempted = false;
55
+ let generation = 0;
56
+
57
+ pi.on("session_start", () => {
58
+ attempted = false;
59
+ generation++;
60
+ });
61
+
62
+ pi.on("agent_end", async (_event, ctx) => {
63
+ if (attempted || ctx.mode !== "tui") return;
64
+ if (ctx.sessionManager.getSessionFile() === undefined) return;
65
+ if (pi.getSessionName()?.trim() || isChildSession(pi)) return;
66
+ const branch = ctx.sessionManager.getBranch();
67
+ // Planning keeps the title for the implementation turn, which names the session from the same prompt.
68
+ if (planModeActive(branch)) return;
69
+ const exchange = firstExchange(branch);
70
+ if (!exchange) return;
71
+
72
+ attempted = true;
73
+ const started = generation;
74
+ const title = await generateTitle(ctx, buildTitlePrompt(exchange), config.titleModel);
75
+ if (!title || started !== generation || pi.getSessionName()?.trim()) return;
76
+ try {
77
+ pi.setSessionName(title);
78
+ } catch {
79
+ // The session may have been replaced while the model call ran.
80
+ }
81
+ });
82
+ }