@fiale-plus/pi-rogue 0.2.1 → 0.2.3

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 (37) hide show
  1. package/README.md +2 -1
  2. package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
  3. package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +24 -5
  4. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +119 -7
  5. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +124 -16
  6. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +32 -0
  7. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -1
  8. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +37 -0
  9. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +39 -2
  10. package/node_modules/@fiale-plus/pi-rogue-router/README.md +34 -0
  11. package/node_modules/@fiale-plus/pi-rogue-router/package.json +30 -0
  12. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.test.ts +84 -0
  13. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +363 -0
  14. package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +277 -0
  15. package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +34 -0
  16. package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +165 -0
  17. package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +193 -0
  18. package/node_modules/@fiale-plus/pi-rogue-router/src/dataset.ts +154 -0
  19. package/node_modules/@fiale-plus/pi-rogue-router/src/decision-ledger.test.ts +148 -0
  20. package/node_modules/@fiale-plus/pi-rogue-router/src/decision.ts +138 -0
  21. package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +139 -0
  22. package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +134 -0
  23. package/node_modules/@fiale-plus/pi-rogue-router/src/hash.ts +19 -0
  24. package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +15 -0
  25. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.test.ts +241 -0
  26. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.ts +382 -0
  27. package/node_modules/@fiale-plus/pi-rogue-router/src/ledger.ts +94 -0
  28. package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +126 -0
  29. package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +128 -0
  30. package/node_modules/@fiale-plus/pi-rogue-router/src/progress.ts +93 -0
  31. package/node_modules/@fiale-plus/pi-rogue-router/src/session-reader.ts +217 -0
  32. package/node_modules/@fiale-plus/pi-rogue-router/src/subagents.ts +178 -0
  33. package/node_modules/@fiale-plus/pi-rogue-router/src/types.ts +150 -0
  34. package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +297 -0
  35. package/package.json +5 -3
  36. package/src/extension.test.ts +1 -0
  37. package/src/extension.ts +2 -0
@@ -32,6 +32,10 @@ const DEFAULT_BRIEF_BYTES = 2_000;
32
32
  const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
33
33
  const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
34
34
 
35
+ function optionMs(value: number | undefined): number {
36
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : Number.POSITIVE_INFINITY;
37
+ }
38
+
35
39
  function normalizeList(values: string[] | undefined): string[] {
36
40
  return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
37
41
  }
@@ -149,11 +153,32 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
149
153
  warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
150
154
  cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
151
155
  };
156
+ const hotToWarmMs = optionMs(options.hotToWarmMs);
157
+ const warmToColdMs = optionMs(options.warmToColdMs);
152
158
  const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
153
159
  const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
154
160
  let artifacts: Array<ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }> = [];
155
161
  let sequence = 0;
156
162
 
163
+ function cooledTier(artifact: ContextArtifact & { baseTier: ContextArtifactTier }, now = Date.now()): ContextArtifactTier {
164
+ if (artifact.pinned) return "hot";
165
+ if (artifact.baseTier === "cold") return "cold";
166
+ const age = Math.max(0, now - artifact.createdAt);
167
+ if (age >= warmToColdMs) return "cold";
168
+ if (artifact.baseTier === "hot" && age >= hotToWarmMs) return "warm";
169
+ return artifact.baseTier;
170
+ }
171
+
172
+ function applyCooling(now = Date.now(), _protectedIds = new Set<string>()): void {
173
+ for (const artifact of artifacts) {
174
+ const nextTier = cooledTier(artifact, now);
175
+ if (artifact.tier !== nextTier) {
176
+ artifact.tier = nextTier;
177
+ artifact.updatedAt = now;
178
+ }
179
+ }
180
+ }
181
+
157
182
  function currentStatus(): ContextBrokerStatus {
158
183
  const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
159
184
  const pinned = artifacts.filter((artifact) => artifact.pinned);
@@ -174,6 +199,8 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
174
199
  coldBytes: cold.reduce((sum, artifact) => sum + artifact.bytes, 0),
175
200
  maxRecords,
176
201
  maxBytes,
202
+ globalMaxRecords,
203
+ globalMaxBytes,
177
204
  };
178
205
  }
179
206
 
@@ -225,6 +252,7 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
225
252
 
226
253
  function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
227
254
  dropExpired(now, protectedIds);
255
+ applyCooling(now, protectedIds);
228
256
 
229
257
  for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
230
258
  for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
@@ -253,11 +281,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
253
281
 
254
282
  function status(): ContextBrokerStatus {
255
283
  dropExpired();
284
+ applyCooling();
256
285
  return currentStatus();
257
286
  }
258
287
 
259
288
  function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
260
289
  dropExpired();
290
+ applyCooling();
261
291
  const keepPinned = options.keepPinned ?? true;
262
292
  artifacts = artifacts.filter((artifact) => {
263
293
  if (options.sessionId && artifact.sessionId !== options.sessionId) return true;
@@ -305,12 +335,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
305
335
  };
306
336
 
307
337
  artifacts = [artifact, ...artifacts];
308
- prune(now, new Set([artifact.id]));
338
+ prune(Date.now(), new Set([artifact.id]));
309
339
  return artifact;
310
340
  }
311
341
 
312
342
  function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
313
343
  dropExpired();
344
+ applyCooling();
314
345
  const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
315
346
  return artifacts
316
347
  .filter((artifact) => artifactMatches(artifact, query))
@@ -40,6 +40,43 @@ describe("createSqliteContextBroker", () => {
40
40
  }
41
41
  });
42
42
 
43
+ it("persists age-based tier cooling without deleting artifacts", () => {
44
+ const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
45
+ try {
46
+ const path = join(dir, "artifacts.sqlite");
47
+ let broker = createSqliteContextBroker({ path, defaultTtlMs: 0, hotToWarmMs: 100, warmToColdMs: 200, briefBytes: 900 });
48
+ const now = Date.now();
49
+ const oldHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old hot", summary: "old hot", tier: "hot", createdAt: now - 300 });
50
+ const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "pinned", summary: "pinned", tier: "hot", pinned: true, createdAt: now - 300 });
51
+
52
+ broker.prune(now);
53
+ broker = createSqliteContextBroker({ path, defaultTtlMs: 0, hotToWarmMs: 100, warmToColdMs: 200, briefBytes: 900 });
54
+
55
+ expect(broker.lookup({ handle: oldHot.handle })[0]?.tier).toBe("cold");
56
+ expect(broker.lookup({ handle: pinned.handle })[0]?.tier).toBe("hot");
57
+ expect(broker.renderBrief({ sessionId: "s" })).not.toContain(oldHot.handle);
58
+ expect(broker.renderBrief({ sessionId: "s" })).toContain(pinned.handle);
59
+ } finally {
60
+ rmSync(dir, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ it("cools protected new artifacts before enforcing durable tier caps", () => {
65
+ const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
66
+ try {
67
+ const now = Date.now();
68
+ const broker = createSqliteContextBroker({ path: join(dir, "artifacts.sqlite"), defaultTtlMs: 0, maxRecords: 10, hotMaxRecords: 1, hotToWarmMs: 10_000, warmToColdMs: 20_000 });
69
+ const fresh = broker.publish({ sessionId: "s", kind: "tool_output", payload: "fresh", summary: "fresh", tier: "hot", createdAt: now - 1_000 });
70
+ const aged = broker.publish({ sessionId: "s", kind: "tool_output", payload: "aged", summary: "aged", tier: "hot", createdAt: now - 30_000 });
71
+
72
+ expect(aged.tier).toBe("cold");
73
+ expect(broker.lookup({ handle: fresh.handle })[0]?.tier).toBe("hot");
74
+ expect(broker.lookup({ handle: aged.handle })[0]?.tier).toBe("cold");
75
+ } finally {
76
+ rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ });
79
+
43
80
  it("dedupes replayed source artifacts so durable handles survive caps", () => {
44
81
  const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
45
82
  try {
@@ -29,6 +29,10 @@ const DEFAULT_BRIEF_BYTES = 2_000;
29
29
  const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
30
30
  const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
31
31
 
32
+ function optionMs(value: number | undefined): number {
33
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : Number.POSITIVE_INFINITY;
34
+ }
35
+
32
36
  function defaultStoreDir(): string {
33
37
  return join(homedir(), ".pi", "agent", "fiale-plus", "context-broker");
34
38
  }
@@ -226,6 +230,8 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
226
230
  warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
227
231
  cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
228
232
  };
233
+ const hotToWarmMs = optionMs(options.hotToWarmMs);
234
+ const warmToColdMs = optionMs(options.warmToColdMs);
229
235
  const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
230
236
  const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
231
237
 
@@ -241,6 +247,31 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
241
247
  db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
242
248
  }
243
249
 
250
+ function cooledTier(artifact: ContextArtifact & { baseTier: ContextArtifactTier }, now = Date.now()): ContextArtifactTier {
251
+ if (artifact.pinned) return "hot";
252
+ if (artifact.baseTier === "cold") return "cold";
253
+ const age = Math.max(0, now - artifact.createdAt);
254
+ if (age >= warmToColdMs) return "cold";
255
+ if (artifact.baseTier === "hot" && age >= hotToWarmMs) return "warm";
256
+ return artifact.baseTier;
257
+ }
258
+
259
+ function applyCooling(now = Date.now(), _protectedIds = new Set<string>()): void {
260
+ const rows = db.prepare("SELECT id, createdAt, tier, baseTier, pinned FROM artifacts WHERE pinned = 0").all();
261
+ const update = db.prepare("UPDATE artifacts SET tier = ?, updatedAt = ? WHERE id = ?");
262
+ for (const row of rows) {
263
+ const artifact = {
264
+ id: String(row.id),
265
+ createdAt: Number(row.createdAt),
266
+ tier: String(row.tier) as ContextArtifactTier,
267
+ baseTier: String(row.baseTier ?? row.tier) as ContextArtifactTier,
268
+ pinned: Boolean(row.pinned),
269
+ };
270
+ const nextTier = cooledTier(artifact as ContextArtifact & { baseTier: ContextArtifactTier }, now);
271
+ if (artifact.tier !== nextTier) update.run(nextTier, now, artifact.id);
272
+ }
273
+ }
274
+
244
275
  function currentStatus(): ContextBrokerStatus {
245
276
  const row = db.prepare(`
246
277
  SELECT
@@ -269,6 +300,8 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
269
300
  coldBytes: Number(row.coldBytes ?? 0),
270
301
  maxRecords,
271
302
  maxBytes,
303
+ globalMaxRecords,
304
+ globalMaxBytes,
272
305
  };
273
306
  }
274
307
 
@@ -329,6 +362,7 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
329
362
 
330
363
  function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
331
364
  dropExpired(now, protectedIds);
365
+ applyCooling(now, protectedIds);
332
366
  const sessions = db.prepare("SELECT DISTINCT sessionId FROM artifacts").all().map((row) => String(row.sessionId));
333
367
  for (const sessionId of sessions) {
334
368
  for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
@@ -356,11 +390,13 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
356
390
 
357
391
  function status(): ContextBrokerStatus {
358
392
  dropExpired();
393
+ applyCooling();
359
394
  return currentStatus();
360
395
  }
361
396
 
362
397
  function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
363
398
  dropExpired();
399
+ applyCooling();
364
400
  const keepPinned = options.keepPinned ?? true;
365
401
  const clauses: string[] = [];
366
402
  const params: Array<string | number> = [];
@@ -468,12 +504,13 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
468
504
  throw error;
469
505
  }
470
506
 
471
- prune(now, new Set([artifact.id]));
472
- return artifact;
507
+ prune(Date.now(), new Set([artifact.id]));
508
+ return lookup({ id: artifact.id })[0] ?? artifact;
473
509
  }
474
510
 
475
511
  function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
476
512
  dropExpired();
513
+ applyCooling();
477
514
  const storedCount = Number(db.prepare("SELECT COUNT(*) AS count FROM artifacts").get()?.count ?? 1) || 1;
478
515
  const limit = Math.max(1, Math.floor(query.limit ?? storedCount));
479
516
  const clauses: string[] = [];
@@ -0,0 +1,34 @@
1
+ # Pi-Rogue Router
2
+
3
+ Local-only offline trajectory router experiments for Pi-Rogue.
4
+
5
+ This package intentionally does **not** change live advisor or orchestration behavior. It reads existing Pi session JSONL files, derives compact checkpoints, and computes cheap progress/loop signals without copying raw transcript content into derived artifacts.
6
+
7
+ ```bash
8
+ npm run router:rebuild -- --session ~/.pi/agent/sessions/.../session.jsonl --output .pi/router/checkpoints.jsonl
9
+ npm run router:rebuild -- --session-dir ~/.pi/agent/sessions/... --output .pi/router/checkpoints.jsonl
10
+ npm run router:rebuild -- --session ./current-session.jsonl --workspace-diff --output .pi/router/checkpoints-with-live-diff.jsonl
11
+ npm run router:decide -- --checkpoint-file .pi/router/checkpoints.jsonl --ledger .pi/router/events.jsonl
12
+ npm run router:cards -- --events .pi/router/events.jsonl --output .pi/router/model-cards.jsonl
13
+ npm run router:outcomes -- --checkpoint-file .pi/router/checkpoints.jsonl --events .pi/router/events.jsonl --output .pi/router/outcomes.jsonl
14
+ npm run router:teacher-requests -- --checkpoint-file .pi/router/checkpoints.jsonl --output .pi/router/teacher-requests.jsonl --teacher openai-codex/gpt-5.5
15
+ npm run router:reflect -- --checkpoint-file .pi/router/checkpoints.jsonl --labels .pi/router/labels/teacher-labels.jsonl --reflection .pi/router/reflections/session.md --teacher local-rule
16
+ npm run router:dataset -- --checkpoint-file .pi/router/checkpoints.jsonl --events .pi/router/events.jsonl --outcomes .pi/router/outcomes.jsonl --labels .pi/router/labels/teacher-labels.jsonl --output .pi/router/training.jsonl
17
+ npm run router:shadow -- --checkpoint-file .pi/router/checkpoints.jsonl --ledger .pi/router/events.jsonl --output .pi/router/shadow-report.json
18
+
19
+ # Live observe-only extension commands:
20
+ # /router on|off|status|profile|profiles|models|configure|cycle
21
+ # ctrl+alt+p cycles router profiles (Ctrl-P is reserved by Pi model cycling).
22
+ ```
23
+
24
+ ## V1 telemetry notes
25
+
26
+ Router v1 is still observe-only. It adds outcome skeletons, stronger diff/error fingerprints, teacher-label request export, binary gate dataset export, and subagent-aware telemetry schemas. It does not switch models, spawn agents, or promote policies automatically.
27
+
28
+ Live config is repo-global at `.pi/router/config.json`, while mutable live state and route ledgers are isolated per Pi session under `.pi/router/sessions/<session-key>/state.json` and `events.jsonl`.
29
+
30
+ - Diff telemetry stores counts and hashes from `git diff`, not raw patches. Offline rebuilds remain deterministic by default; use `--workspace-diff` only with one current live session/worktree snapshot.
31
+ - Error fingerprints normalize paths, line numbers, timestamps, UUIDs, ports, and object ids before hashing.
32
+ - `router:teacher-requests` writes local JSONL requests for an explicit teacher model; imported teacher decisions are still required before labels become training truth.
33
+ - `router:dataset` excludes `local-rule` labels by default so a future model does not merely imitate the current rules.
34
+ - Subagent route/ledger schemas describe parent-child evidence flow, but live autonomous spawning remains out of scope.
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@fiale-plus/pi-rogue-router",
3
+ "version": "0.1.0",
4
+ "description": "Local-only offline trajectory router experiments for Pi-Rogue.",
5
+ "private": true,
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "scripts": {
9
+ "check": "tsc -p ../../tsconfig.json --noEmit",
10
+ "test": "cd ../.. && vitest run packages/router/src/*.test.ts"
11
+ },
12
+ "main": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./extension": "./src/extension.ts"
16
+ },
17
+ "pi": {
18
+ "extensions": [
19
+ "./src/extension.ts"
20
+ ]
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "^0.74.0"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "README.md",
28
+ "package.json"
29
+ ]
30
+ }
@@ -0,0 +1,84 @@
1
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { buildCheckpoints, rebuildCheckpointsFromSession, streamCheckpointsFromSessionPath, writeCheckpointsJsonl } from "./checkpoints.js";
6
+ import { readPiSession } from "./session-reader.js";
7
+
8
+ function writeFixture(lines: Array<Record<string, unknown>>): string {
9
+ const dir = mkdtempSync(join(tmpdir(), "pi-router-"));
10
+ const path = join(dir, "2026-06-12T00-00-00Z_fixture.jsonl");
11
+ writeFileSync(path, lines.map((line) => JSON.stringify(line)).join("\n") + "\n");
12
+ return path;
13
+ }
14
+
15
+ function fixtureSession(): string {
16
+ return writeFixture([
17
+ { type: "session", version: 1, id: "session-1", timestamp: "2026-06-12T00:00:00.000Z", cwd: "/repo/example" },
18
+ { type: "model_change", id: "m1", timestamp: "2026-06-12T00:00:01.000Z", provider: "local", modelId: "qwen-local" },
19
+ { type: "message", id: "u1", timestamp: "2026-06-12T00:00:02.000Z", message: { role: "user", content: [{ type: "text", text: "please fix the failing tests" }] } },
20
+ { type: "message", id: "a1", timestamp: "2026-06-12T00:00:03.000Z", message: { role: "assistant", provider: "local", model: "qwen-local", usage: { inputTokens: 1234 }, content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "npm test -- --runInBand src/foo.test.ts" } }] } },
21
+ { type: "message", id: "t1", timestamp: "2026-06-12T00:00:04.000Z", message: { role: "toolResult", toolCallId: "call-1", toolName: "bash", isError: true, content: [{ type: "text", text: "FAIL src/foo.test.ts\nError: boom" }] } },
22
+ { type: "message", id: "a2", timestamp: "2026-06-12T00:00:05.000Z", message: { role: "assistant", provider: "local", model: "qwen-local", content: [{ type: "toolCall", id: "call-2", name: "bash", arguments: { command: "npm test -- --runInBand src/foo.test.ts" } }] } },
23
+ { type: "message", id: "t2", timestamp: "2026-06-12T00:00:06.000Z", message: { role: "toolResult", toolCallId: "call-2", toolName: "bash", isError: true, content: [{ type: "text", text: "FAIL src/foo.test.ts\nError: boom" }] } },
24
+ ]);
25
+ }
26
+
27
+ describe("trajectory router checkpoint rebuild", () => {
28
+ it("reads Pi session JSONL and extracts command/tool metadata", () => {
29
+ const session = readPiSession(fixtureSession());
30
+
31
+ expect(session.id).toBe("2026-06-12T00-00-00Z_fixture");
32
+ expect(session.cwd).toBe("/repo/example");
33
+ expect(session.events).toHaveLength(7);
34
+ expect(session.events[3].commandEvents[0]).toMatchObject({ toolName: "bash", isVerifier: true });
35
+ expect(session.events[4].toolResult).toMatchObject({ toolName: "bash", isError: true });
36
+ });
37
+
38
+ it("builds compact derived checkpoints without raw transcript content", () => {
39
+ const checkpoints = rebuildCheckpointsFromSession(fixtureSession());
40
+ const last = checkpoints.at(-1);
41
+
42
+ expect(last?.schema).toBe("pi-router.checkpoint.v1");
43
+ expect(last?.rawSessionRef).toMatchObject({ schema: "pi-router.raw-session-ref.v1", fromEvent: 0, toEvent: 6 });
44
+ expect(last?.activeModel).toBe("qwen-local");
45
+ expect(last?.provider).toBe("local");
46
+ expect(last?.phase).toBe("debug");
47
+ expect(last?.features.contextTokensApprox).toBe(1234);
48
+ expect(last?.features.sameCommandRepeatedCount).toBe(2);
49
+ expect(last?.features.sameErrorRepeatedCount).toBe(2);
50
+ expect(last?.features.verifierUsed).toBe(true);
51
+ expect(last?.features.loopScore).toBeGreaterThan(0);
52
+ expect(last?.recent.lastCommandHash).toBeTruthy();
53
+ expect(last?.recent.lastErrorHash).toBeTruthy();
54
+ expect(last?.recent.touchedFileHashes).toHaveLength(1);
55
+
56
+ const serialized = JSON.stringify(last);
57
+ expect(serialized).not.toContain("please fix the failing tests");
58
+ expect(serialized).not.toContain("npm test");
59
+ expect(serialized).not.toContain("Error: boom");
60
+ expect(serialized).not.toContain("src/foo.test.ts");
61
+ });
62
+
63
+ it("streams checkpoints equivalent to the sync fixture API", async () => {
64
+ const path = fixtureSession();
65
+ const sync = rebuildCheckpointsFromSession(path).map((checkpoint) => checkpoint.checkpointId);
66
+ const streamed: string[] = [];
67
+
68
+ for await (const checkpoint of streamCheckpointsFromSessionPath(path)) streamed.push(checkpoint.checkpointId);
69
+
70
+ expect(streamed).toEqual(sync);
71
+ });
72
+
73
+ it("writes checkpoints as JSONL", () => {
74
+ const session = readPiSession(fixtureSession());
75
+ const checkpoints = buildCheckpoints(session);
76
+ const output = join(mkdtempSync(join(tmpdir(), "pi-router-out-")), "checkpoints.jsonl");
77
+
78
+ writeCheckpointsJsonl(checkpoints, output);
79
+
80
+ const lines = readFileSync(output, "utf8").trim().split("\n");
81
+ expect(lines).toHaveLength(checkpoints.length);
82
+ expect(JSON.parse(lines.at(-1) || "{}").checkpointId).toBe(checkpoints.at(-1)?.checkpointId);
83
+ });
84
+ });