@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.
- package/README.md +2 -1
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +24 -5
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +119 -7
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +124 -16
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +32 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +37 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +39 -2
- package/node_modules/@fiale-plus/pi-rogue-router/README.md +34 -0
- package/node_modules/@fiale-plus/pi-rogue-router/package.json +30 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.test.ts +84 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +363 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +277 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +34 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +165 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +193 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/dataset.ts +154 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/decision-ledger.test.ts +148 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/decision.ts +138 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +139 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +134 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/hash.ts +19 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +15 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/learning.test.ts +241 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/learning.ts +382 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/ledger.ts +94 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +126 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +128 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/progress.ts +93 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/session-reader.ts +217 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/subagents.ts +178 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/types.ts +150 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +297 -0
- package/package.json +5 -3
- package/src/extension.test.ts +1 -0
- 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
|
+
});
|