@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, 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 { routerArgumentCompletions } from "./completions.js";
|
|
6
|
+
import { activeProfile, cycleRouterProfile, ensureRouterConfig, loadRouterConfig, routerConfigPath, routerEventsPath, routerSessionDir, routerStatePath, saveRouterConfig, setRouterProfile } from "./config.js";
|
|
7
|
+
import { registerRouter } from "./extension.js";
|
|
8
|
+
import { decideRoute } from "./decision.js";
|
|
9
|
+
import { observeRouterTurn, summarizeRouterDecision } from "./observe.js";
|
|
10
|
+
import type { RouterCheckpoint } from "./types.js";
|
|
11
|
+
|
|
12
|
+
function ctxMock(sessionPath?: string) {
|
|
13
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-router-ext-"));
|
|
14
|
+
const notifications: Array<{ text: string; level: string }> = [];
|
|
15
|
+
return {
|
|
16
|
+
cwd,
|
|
17
|
+
notifications,
|
|
18
|
+
sessionManager: sessionPath ? { getSessionFile: () => sessionPath } : undefined,
|
|
19
|
+
ui: {
|
|
20
|
+
notify(text: string, level: string) {
|
|
21
|
+
notifications.push({ text, level });
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeSessionFixture(dir: string, name: string): string {
|
|
28
|
+
const path = join(dir, name);
|
|
29
|
+
writeFileSync(path, [
|
|
30
|
+
JSON.stringify({ type: "session", id: name, cwd: dir }),
|
|
31
|
+
JSON.stringify({ type: "message", id: `${name}-user`, message: { role: "user", content: [{ type: "text", text: "please implement a small fix" }] } }),
|
|
32
|
+
].join("\n") + "\n");
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function piMock() {
|
|
37
|
+
const commands = new Map<string, any>();
|
|
38
|
+
const shortcuts = new Map<string, any>();
|
|
39
|
+
const handlers = new Map<string, any[]>();
|
|
40
|
+
const pi: any = {
|
|
41
|
+
registerCommand(name: string, options: any) { commands.set(name, options); },
|
|
42
|
+
registerShortcut(key: string, options: any) { shortcuts.set(key, options); },
|
|
43
|
+
on(name: string, handler: any) { handlers.set(name, [...(handlers.get(name) ?? []), handler]); },
|
|
44
|
+
};
|
|
45
|
+
return { pi, commands, shortcuts, handlers };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkpoint(overrides: Partial<RouterCheckpoint> = {}): RouterCheckpoint {
|
|
49
|
+
const base: RouterCheckpoint = {
|
|
50
|
+
schema: "pi-router.checkpoint.v1",
|
|
51
|
+
sessionId: "session-1",
|
|
52
|
+
checkpointId: "session-1:event-1",
|
|
53
|
+
createdAt: "2026-06-12T00:00:00.000Z",
|
|
54
|
+
rawSessionRef: { schema: "pi-router.raw-session-ref.v1", path: "/tmp/session.jsonl", fromEvent: 0, toEvent: 1, fromByte: 0, toByte: 10, contentHash: "hash" },
|
|
55
|
+
harness: "pi",
|
|
56
|
+
phase: "debug",
|
|
57
|
+
activeModel: "gpt-5.3-codex-spark",
|
|
58
|
+
provider: "openai-codex",
|
|
59
|
+
features: {
|
|
60
|
+
turnIndex: 1,
|
|
61
|
+
sameCommandRepeatedCount: 2,
|
|
62
|
+
sameErrorRepeatedCount: 2,
|
|
63
|
+
errorChanged: false,
|
|
64
|
+
testsImproved: null,
|
|
65
|
+
filesTouched: 1,
|
|
66
|
+
diffLines: 0,
|
|
67
|
+
diffFilesChanged: 0,
|
|
68
|
+
diffLinesAdded: 0,
|
|
69
|
+
diffLinesDeleted: 0,
|
|
70
|
+
diffChurnScore: 0,
|
|
71
|
+
toolThrashScore: 0.2,
|
|
72
|
+
goalDriftScore: 0,
|
|
73
|
+
loopScore: 0.55,
|
|
74
|
+
progressScore: 0.45,
|
|
75
|
+
verifierUsed: true,
|
|
76
|
+
noVerifierUsed: false,
|
|
77
|
+
toolCallsLast10Turns: 4,
|
|
78
|
+
contextTokensApprox: 1000,
|
|
79
|
+
gitDirty: null,
|
|
80
|
+
},
|
|
81
|
+
recent: { touchedFileHashes: [] },
|
|
82
|
+
sourceEvent: { index: 1, byteStart: 0, byteEnd: 10, type: "message", role: "toolResult" },
|
|
83
|
+
};
|
|
84
|
+
return { ...base, ...overrides, features: { ...base.features, ...(overrides.features ?? {}) } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("router config profiles", () => {
|
|
88
|
+
it("creates default all-smart/spark/local profiles", () => {
|
|
89
|
+
const ctx = ctxMock();
|
|
90
|
+
const config = ensureRouterConfig(ctx);
|
|
91
|
+
|
|
92
|
+
expect(config.activeProfile).toBe("all-smart");
|
|
93
|
+
expect(config.profileOrder).toEqual(["all-smart", "spark-smart", "local-smart"]);
|
|
94
|
+
expect(activeProfile(config).worker).toBe("openai-codex/gpt-5.5");
|
|
95
|
+
expect(readFileSync(routerConfigPath(ctx), "utf8")).toContain("spark-smart");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("sets and cycles profiles", () => {
|
|
99
|
+
const config = loadRouterConfig(ctxMock());
|
|
100
|
+
const spark = setRouterProfile(config, "spark-smart");
|
|
101
|
+
|
|
102
|
+
expect(spark?.activeProfile).toBe("spark-smart");
|
|
103
|
+
expect(cycleRouterProfile(spark!, 1).activeProfile).toBe("local-smart");
|
|
104
|
+
expect(setRouterProfile(config, "missing")).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("completes router commands and profile names", () => {
|
|
108
|
+
expect(routerArgumentCompletions("")?.map((item) => item.value)).toEqual(expect.arrayContaining(["on", "off", "status", "profile"]));
|
|
109
|
+
expect(routerArgumentCompletions("profile s")?.map((item) => item.value)).toEqual(["profile spark-smart"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps config repo-global while state and live events are session-scoped", async () => {
|
|
113
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-router-sessions-"));
|
|
114
|
+
const firstSession = writeSessionFixture(cwd, "session-a.jsonl");
|
|
115
|
+
const secondSession = writeSessionFixture(cwd, "session-b.jsonl");
|
|
116
|
+
const firstCtx = { ...ctxMock(firstSession), cwd };
|
|
117
|
+
const secondCtx = { ...ctxMock(secondSession), cwd };
|
|
118
|
+
saveRouterConfig(firstCtx, { ...loadRouterConfig(firstCtx), enabled: true, print: "all" });
|
|
119
|
+
|
|
120
|
+
expect(routerConfigPath(firstCtx)).toBe(routerConfigPath(secondCtx));
|
|
121
|
+
expect(routerStatePath(firstCtx, firstSession)).not.toBe(routerStatePath(secondCtx, secondSession));
|
|
122
|
+
expect(routerEventsPath(firstCtx, firstSession)).not.toBe(routerEventsPath(secondCtx, secondSession));
|
|
123
|
+
expect(routerSessionDir(firstCtx, firstSession)).toContain("session-a");
|
|
124
|
+
|
|
125
|
+
await observeRouterTurn(firstCtx);
|
|
126
|
+
await observeRouterTurn(secondCtx);
|
|
127
|
+
|
|
128
|
+
expect(existsSync(routerStatePath(firstCtx, firstSession))).toBe(true);
|
|
129
|
+
expect(existsSync(routerStatePath(secondCtx, secondSession))).toBe(true);
|
|
130
|
+
expect(readFileSync(routerEventsPath(firstCtx, firstSession), "utf8").trim().split("\n")).toHaveLength(1);
|
|
131
|
+
expect(readFileSync(routerEventsPath(secondCtx, secondSession), "utf8").trim().split("\n")).toHaveLength(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("router extension", () => {
|
|
136
|
+
it("registers slash command, ctrl-alt-p profile cycling, and observe hook", async () => {
|
|
137
|
+
const { pi, commands, shortcuts, handlers } = piMock();
|
|
138
|
+
const ctx = ctxMock();
|
|
139
|
+
|
|
140
|
+
registerRouter(pi);
|
|
141
|
+
|
|
142
|
+
expect(commands.has("router")).toBe(true);
|
|
143
|
+
expect(shortcuts.has("ctrl+alt+p")).toBe(true);
|
|
144
|
+
expect(handlers.has("turn_end")).toBe(true);
|
|
145
|
+
|
|
146
|
+
await commands.get("router").handler("on", ctx);
|
|
147
|
+
expect(loadRouterConfig(ctx).enabled).toBe(true);
|
|
148
|
+
|
|
149
|
+
await commands.get("router").handler("profile spark-smart", ctx);
|
|
150
|
+
expect(loadRouterConfig(ctx).activeProfile).toBe("spark-smart");
|
|
151
|
+
|
|
152
|
+
await shortcuts.get("ctrl+alt+p").handler(ctx);
|
|
153
|
+
expect(loadRouterConfig(ctx).activeProfile).toBe("local-smart");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("formats observe-only mismatch summaries without changing models", () => {
|
|
157
|
+
const config = { ...loadRouterConfig(ctxMock()), enabled: true, activeProfile: "spark-smart" };
|
|
158
|
+
const item = checkpoint();
|
|
159
|
+
const summary = summarizeRouterDecision(item, decideRoute(item), config);
|
|
160
|
+
|
|
161
|
+
expect(summary.text).toContain("MISMATCH");
|
|
162
|
+
expect(summary.text).toContain("smart(openai-codex/gpt-5.5)");
|
|
163
|
+
expect(summary.text).toContain("current=gpt-5.3-codex-spark");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { hashText } from "./hash.js";
|
|
4
|
+
|
|
5
|
+
export type RouterMode = "observe";
|
|
6
|
+
export type RouterPrintMode = "all" | "mismatch_only" | "off";
|
|
7
|
+
|
|
8
|
+
export interface RouterProfile {
|
|
9
|
+
main?: string;
|
|
10
|
+
worker: string;
|
|
11
|
+
smart: string;
|
|
12
|
+
teacher: string;
|
|
13
|
+
reviewer: string;
|
|
14
|
+
explore?: string;
|
|
15
|
+
debug_diagnose?: string;
|
|
16
|
+
review?: string;
|
|
17
|
+
verify?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RouterConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
mode: RouterMode;
|
|
23
|
+
print: RouterPrintMode;
|
|
24
|
+
activeProfile: string;
|
|
25
|
+
profileOrder: string[];
|
|
26
|
+
profiles: Record<string, RouterProfile>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RouterState {
|
|
30
|
+
lastObservedCheckpointId?: string;
|
|
31
|
+
lastDecisionAction?: string;
|
|
32
|
+
lastSummary?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_ROUTER_CONFIG: RouterConfig = {
|
|
36
|
+
enabled: false,
|
|
37
|
+
mode: "observe",
|
|
38
|
+
print: "mismatch_only",
|
|
39
|
+
activeProfile: "all-smart",
|
|
40
|
+
profileOrder: ["all-smart", "spark-smart", "local-smart"],
|
|
41
|
+
profiles: {
|
|
42
|
+
"all-smart": {
|
|
43
|
+
worker: "openai-codex/gpt-5.5",
|
|
44
|
+
smart: "openai-codex/gpt-5.5",
|
|
45
|
+
teacher: "openai-codex/gpt-5.5",
|
|
46
|
+
reviewer: "openai-codex/gpt-5.5",
|
|
47
|
+
explore: "openai-codex/gpt-5.5",
|
|
48
|
+
debug_diagnose: "openai-codex/gpt-5.5",
|
|
49
|
+
review: "openai-codex/gpt-5.5",
|
|
50
|
+
verify: "openai-codex/gpt-5.5",
|
|
51
|
+
},
|
|
52
|
+
"spark-smart": {
|
|
53
|
+
worker: "openai-codex/gpt-5.3-codex-spark",
|
|
54
|
+
smart: "openai-codex/gpt-5.5",
|
|
55
|
+
teacher: "openai-codex/gpt-5.5",
|
|
56
|
+
reviewer: "openai-codex/gpt-5.5",
|
|
57
|
+
explore: "openai-codex/gpt-5.3-codex-spark",
|
|
58
|
+
debug_diagnose: "openai-codex/gpt-5.5",
|
|
59
|
+
review: "openai-codex/gpt-5.5",
|
|
60
|
+
verify: "openai-codex/gpt-5.3-codex-spark",
|
|
61
|
+
},
|
|
62
|
+
"local-smart": {
|
|
63
|
+
worker: "qwen3.6-35b-a3b-128k",
|
|
64
|
+
smart: "openai-codex/gpt-5.5",
|
|
65
|
+
teacher: "openai-codex/gpt-5.5",
|
|
66
|
+
reviewer: "openai-codex/gpt-5.5",
|
|
67
|
+
explore: "qwen3.6-35b-a3b-128k",
|
|
68
|
+
debug_diagnose: "openai-codex/gpt-5.5",
|
|
69
|
+
review: "openai-codex/gpt-5.5",
|
|
70
|
+
verify: "qwen3.6-35b-a3b-128k",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function cwdFromCtx(ctx: any): string {
|
|
76
|
+
return resolve(String(ctx?.cwd ?? process.cwd()));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function routerDir(ctx: any): string {
|
|
80
|
+
return join(cwdFromCtx(ctx), ".pi", "router");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function routerConfigPath(ctx: any): string {
|
|
84
|
+
return join(routerDir(ctx), "config.json");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sessionPathFromCtx(ctx: any): string | undefined {
|
|
88
|
+
const value = ctx?.sessionManager?.getSessionFile?.();
|
|
89
|
+
return value ? String(value) : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeSegment(value: string): string {
|
|
93
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "session";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function routerSessionKey(sessionPath: string): string {
|
|
97
|
+
const resolved = resolve(sessionPath);
|
|
98
|
+
const name = safeSegment(basename(resolved).replace(/\.jsonl$/i, ""));
|
|
99
|
+
return `${name}-${hashText(resolved).slice(0, 8)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function routerSessionsDir(ctx: any): string {
|
|
103
|
+
return join(routerDir(ctx), "sessions");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function routerSessionDir(ctx: any, sessionPath = sessionPathFromCtx(ctx)): string {
|
|
107
|
+
const key = sessionPath ? routerSessionKey(sessionPath) : "no-session";
|
|
108
|
+
return join(routerSessionsDir(ctx), key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function routerStatePath(ctx: any, sessionPath = sessionPathFromCtx(ctx)): string {
|
|
112
|
+
return join(routerSessionDir(ctx, sessionPath), "state.json");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function routerEventsPath(ctx: any, sessionPath = sessionPathFromCtx(ctx)): string {
|
|
116
|
+
return join(routerSessionDir(ctx, sessionPath), "events.jsonl");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readJson<T>(path: string, fallback: T): T {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
122
|
+
} catch {
|
|
123
|
+
return fallback;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function normalizeRouterConfig(raw: Partial<RouterConfig> | null | undefined): RouterConfig {
|
|
128
|
+
const mergedProfiles = { ...DEFAULT_ROUTER_CONFIG.profiles, ...(raw?.profiles ?? {}) };
|
|
129
|
+
const profileOrder = Array.isArray(raw?.profileOrder) && raw.profileOrder.length > 0
|
|
130
|
+
? raw.profileOrder.filter((name) => typeof name === "string" && mergedProfiles[name])
|
|
131
|
+
: DEFAULT_ROUTER_CONFIG.profileOrder;
|
|
132
|
+
const activeProfile = raw?.activeProfile && mergedProfiles[raw.activeProfile]
|
|
133
|
+
? raw.activeProfile
|
|
134
|
+
: profileOrder[0] ?? DEFAULT_ROUTER_CONFIG.activeProfile;
|
|
135
|
+
const print = raw?.print === "all" || raw?.print === "off" || raw?.print === "mismatch_only" ? raw.print : DEFAULT_ROUTER_CONFIG.print;
|
|
136
|
+
return {
|
|
137
|
+
enabled: Boolean(raw?.enabled ?? DEFAULT_ROUTER_CONFIG.enabled),
|
|
138
|
+
mode: "observe",
|
|
139
|
+
print,
|
|
140
|
+
activeProfile,
|
|
141
|
+
profileOrder,
|
|
142
|
+
profiles: mergedProfiles,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function loadRouterConfig(ctx: any): RouterConfig {
|
|
147
|
+
return normalizeRouterConfig(readJson<Partial<RouterConfig>>(routerConfigPath(ctx), {}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function saveRouterConfig(ctx: any, config: RouterConfig): void {
|
|
151
|
+
const path = routerConfigPath(ctx);
|
|
152
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
153
|
+
writeFileSync(path, `${JSON.stringify(normalizeRouterConfig(config), null, 2)}\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function ensureRouterConfig(ctx: any): RouterConfig {
|
|
157
|
+
const path = routerConfigPath(ctx);
|
|
158
|
+
const config = loadRouterConfig(ctx);
|
|
159
|
+
if (!existsSync(path)) saveRouterConfig(ctx, config);
|
|
160
|
+
return config;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function loadRouterState(ctx: any, sessionPath?: string): RouterState {
|
|
164
|
+
return readJson<RouterState>(routerStatePath(ctx, sessionPath), {});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function saveRouterState(ctx: any, state: RouterState, sessionPath?: string): void {
|
|
168
|
+
const path = routerStatePath(ctx, sessionPath);
|
|
169
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
170
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function activeProfile(config: RouterConfig): RouterProfile {
|
|
174
|
+
return config.profiles[config.activeProfile] ?? config.profiles[config.profileOrder[0]] ?? DEFAULT_ROUTER_CONFIG.profiles[DEFAULT_ROUTER_CONFIG.activeProfile];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function cycleRouterProfile(config: RouterConfig, direction: 1 | -1 = 1): RouterConfig {
|
|
178
|
+
const order = config.profileOrder.filter((name) => config.profiles[name]);
|
|
179
|
+
if (order.length === 0) return normalizeRouterConfig(config);
|
|
180
|
+
const currentIndex = Math.max(0, order.indexOf(config.activeProfile));
|
|
181
|
+
const nextIndex = (currentIndex + direction + order.length) % order.length;
|
|
182
|
+
return { ...config, activeProfile: order[nextIndex] };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function setRouterProfile(config: RouterConfig, name: string): RouterConfig | null {
|
|
186
|
+
if (!config.profiles[name]) return null;
|
|
187
|
+
return { ...config, activeProfile: name, profileOrder: config.profileOrder.includes(name) ? config.profileOrder : [...config.profileOrder, name] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function formatProfile(name: string, profile: RouterProfile): string {
|
|
191
|
+
const subagents = [`explore=${profile.explore ?? profile.worker}`, `debug=${profile.debug_diagnose ?? profile.smart}`, `review=${profile.review ?? profile.reviewer}`, `verify=${profile.verify ?? profile.worker}`].join(" ");
|
|
192
|
+
return `${name}: worker=${profile.worker} smart=${profile.smart} teacher=${profile.teacher} reviewer=${profile.reviewer} ${subagents}`;
|
|
193
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { decideRoute, readCheckpointJsonl } from "./decision.js";
|
|
4
|
+
import { readRouteEvents, type RouteEvent } from "./ledger.js";
|
|
5
|
+
import { readOutcomes, type RouterOutcome } from "./outcomes.js";
|
|
6
|
+
import type { RouterCheckpoint, RouteAction } from "./types.js";
|
|
7
|
+
import { readTeacherLabels, type TeacherLabel } from "./learning.js";
|
|
8
|
+
|
|
9
|
+
export const ROUTER_TRAINING_ROW_SCHEMA = "pi-router.training-row.v1" as const;
|
|
10
|
+
|
|
11
|
+
export type BinaryGateLabel = "continue" | "intervene" | "unknown";
|
|
12
|
+
|
|
13
|
+
export interface RouterTrainingRow {
|
|
14
|
+
schema: typeof ROUTER_TRAINING_ROW_SCHEMA;
|
|
15
|
+
checkpointId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
rawSessionRef: RouterCheckpoint["rawSessionRef"];
|
|
18
|
+
features: {
|
|
19
|
+
phase: RouterCheckpoint["phase"];
|
|
20
|
+
activeModel?: string;
|
|
21
|
+
provider?: string;
|
|
22
|
+
contextTokensApprox: number | null;
|
|
23
|
+
sameCommandRepeatedCount: number;
|
|
24
|
+
sameErrorRepeatedCount: number;
|
|
25
|
+
loopScore: number;
|
|
26
|
+
progressScore: number;
|
|
27
|
+
verifierUsed: boolean;
|
|
28
|
+
noVerifierUsed: boolean;
|
|
29
|
+
diffLines: number;
|
|
30
|
+
diffFilesChanged: number;
|
|
31
|
+
diffChurnScore: number;
|
|
32
|
+
filesTouched: number;
|
|
33
|
+
};
|
|
34
|
+
labels: {
|
|
35
|
+
routeAction: RouteAction | null;
|
|
36
|
+
binaryGate: BinaryGateLabel;
|
|
37
|
+
source: "teacher" | "human" | "outcome" | "local-rule" | "unknown";
|
|
38
|
+
confidence: number | null;
|
|
39
|
+
};
|
|
40
|
+
outcome: {
|
|
41
|
+
taskStatus: RouterOutcome["taskStatus"] | "unknown";
|
|
42
|
+
testsPassedAfter: boolean | null;
|
|
43
|
+
acceptedDiff: boolean | null;
|
|
44
|
+
userOverrodeDecision: boolean | null;
|
|
45
|
+
reworkTurns: number | null;
|
|
46
|
+
};
|
|
47
|
+
provenance: {
|
|
48
|
+
routeEventId?: string;
|
|
49
|
+
teacherLabelId?: string;
|
|
50
|
+
localRuleAction: RouteAction;
|
|
51
|
+
excludedLocalRuleAsTruth: boolean;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function routeToGate(action: RouteAction | null | undefined): BinaryGateLabel {
|
|
56
|
+
if (!action) return "unknown";
|
|
57
|
+
return action === "continue_current" || action === "continue_local" ? "continue" : "intervene";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function labelSource(label?: TeacherLabel): RouterTrainingRow["labels"]["source"] {
|
|
61
|
+
if (!label) return "unknown";
|
|
62
|
+
if (label.source === "local-rule") return "local-rule";
|
|
63
|
+
return label.teacher === "human" ? "human" : "teacher";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildTrainingRows(options: {
|
|
67
|
+
checkpoints: RouterCheckpoint[];
|
|
68
|
+
routeEvents?: RouteEvent[];
|
|
69
|
+
outcomes?: RouterOutcome[];
|
|
70
|
+
labels?: TeacherLabel[];
|
|
71
|
+
includeLocalRuleLabels?: boolean;
|
|
72
|
+
}): RouterTrainingRow[] {
|
|
73
|
+
const eventByCheckpoint = new Map((options.routeEvents ?? []).map((event) => [event.checkpointId, event]));
|
|
74
|
+
const outcomeByCheckpoint = new Map((options.outcomes ?? []).flatMap((outcome) => outcome.checkpointId && !outcome.routeEventId ? [[outcome.checkpointId, outcome] as const] : []));
|
|
75
|
+
const outcomeByRouteEvent = new Map((options.outcomes ?? []).flatMap((outcome) => outcome.routeEventId ? [[outcome.routeEventId, outcome] as const] : []));
|
|
76
|
+
const labelByCheckpoint = new Map((options.labels ?? []).map((label) => [label.checkpointId, label]));
|
|
77
|
+
|
|
78
|
+
return options.checkpoints.map((checkpoint) => {
|
|
79
|
+
const routeEvent = eventByCheckpoint.get(checkpoint.checkpointId);
|
|
80
|
+
const outcome = (routeEvent ? outcomeByRouteEvent.get(routeEvent.eventId) : undefined) ?? outcomeByCheckpoint.get(checkpoint.checkpointId);
|
|
81
|
+
const teacherLabel = labelByCheckpoint.get(checkpoint.checkpointId);
|
|
82
|
+
const canUseLabel = Boolean(teacherLabel && (options.includeLocalRuleLabels || teacherLabel.source !== "local-rule"));
|
|
83
|
+
const routeAction = canUseLabel ? teacherLabel!.suggestedAction : null;
|
|
84
|
+
const ruleAction = decideRoute(checkpoint).action;
|
|
85
|
+
return {
|
|
86
|
+
schema: ROUTER_TRAINING_ROW_SCHEMA,
|
|
87
|
+
checkpointId: checkpoint.checkpointId,
|
|
88
|
+
sessionId: checkpoint.sessionId,
|
|
89
|
+
rawSessionRef: checkpoint.rawSessionRef,
|
|
90
|
+
features: {
|
|
91
|
+
phase: checkpoint.phase,
|
|
92
|
+
activeModel: checkpoint.activeModel,
|
|
93
|
+
provider: checkpoint.provider,
|
|
94
|
+
contextTokensApprox: checkpoint.features.contextTokensApprox,
|
|
95
|
+
sameCommandRepeatedCount: checkpoint.features.sameCommandRepeatedCount,
|
|
96
|
+
sameErrorRepeatedCount: checkpoint.features.sameErrorRepeatedCount,
|
|
97
|
+
loopScore: checkpoint.features.loopScore,
|
|
98
|
+
progressScore: checkpoint.features.progressScore,
|
|
99
|
+
verifierUsed: checkpoint.features.verifierUsed,
|
|
100
|
+
noVerifierUsed: checkpoint.features.noVerifierUsed,
|
|
101
|
+
diffLines: checkpoint.features.diffLines ?? 0,
|
|
102
|
+
diffFilesChanged: checkpoint.features.diffFilesChanged ?? 0,
|
|
103
|
+
diffChurnScore: checkpoint.features.diffChurnScore ?? 0,
|
|
104
|
+
filesTouched: checkpoint.features.filesTouched,
|
|
105
|
+
},
|
|
106
|
+
labels: {
|
|
107
|
+
routeAction,
|
|
108
|
+
binaryGate: routeToGate(routeAction),
|
|
109
|
+
source: canUseLabel ? labelSource(teacherLabel) : "unknown",
|
|
110
|
+
confidence: canUseLabel ? teacherLabel!.confidence : null,
|
|
111
|
+
},
|
|
112
|
+
outcome: {
|
|
113
|
+
taskStatus: outcome?.taskStatus ?? "unknown",
|
|
114
|
+
testsPassedAfter: outcome?.testsPassedAfter ?? null,
|
|
115
|
+
acceptedDiff: outcome?.acceptedDiff ?? null,
|
|
116
|
+
userOverrodeDecision: outcome?.userOverrodeDecision ?? null,
|
|
117
|
+
reworkTurns: outcome?.reworkTurns ?? null,
|
|
118
|
+
},
|
|
119
|
+
provenance: {
|
|
120
|
+
routeEventId: routeEvent?.eventId,
|
|
121
|
+
teacherLabelId: canUseLabel ? teacherLabel!.labelId : undefined,
|
|
122
|
+
localRuleAction: ruleAction,
|
|
123
|
+
excludedLocalRuleAsTruth: Boolean(teacherLabel?.source === "local-rule" && !options.includeLocalRuleLabels),
|
|
124
|
+
},
|
|
125
|
+
} satisfies RouterTrainingRow;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function writeTrainingRows(options: {
|
|
130
|
+
checkpointPath: string;
|
|
131
|
+
outputPath: string;
|
|
132
|
+
eventsPath?: string;
|
|
133
|
+
outcomesPath?: string;
|
|
134
|
+
labelsPath?: string;
|
|
135
|
+
includeLocalRuleLabels?: boolean;
|
|
136
|
+
}): { schema: "pi-router.dataset-summary.v1"; output: string; rows: number; labeledRows: number } {
|
|
137
|
+
if (options.eventsPath && !existsSync(options.eventsPath)) throw new Error(`route events file not found: ${options.eventsPath}`);
|
|
138
|
+
const rows = buildTrainingRows({
|
|
139
|
+
checkpoints: readCheckpointJsonl(options.checkpointPath),
|
|
140
|
+
routeEvents: options.eventsPath ? readRouteEvents(options.eventsPath) : [],
|
|
141
|
+
outcomes: readOutcomes(options.outcomesPath),
|
|
142
|
+
labels: options.labelsPath ? readTeacherLabels(options.labelsPath) : [],
|
|
143
|
+
includeLocalRuleLabels: options.includeLocalRuleLabels,
|
|
144
|
+
});
|
|
145
|
+
const resolved = resolve(options.outputPath);
|
|
146
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
147
|
+
writeFileSync(resolved, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""));
|
|
148
|
+
return {
|
|
149
|
+
schema: "pi-router.dataset-summary.v1",
|
|
150
|
+
output: resolved,
|
|
151
|
+
rows: rows.length,
|
|
152
|
+
labeledRows: rows.filter((row) => row.labels.binaryGate !== "unknown").length,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { decideRoute, readCheckpointJsonl, selectCheckpoint } from "./decision.js";
|
|
6
|
+
import { appendRouteEvent, buildRouteEvent, readRouteEvents } from "./ledger.js";
|
|
7
|
+
import type { RouterCheckpoint } from "./types.js";
|
|
8
|
+
|
|
9
|
+
type CheckpointOverrides = Partial<Omit<RouterCheckpoint, "features" | "recent">> & {
|
|
10
|
+
features?: Partial<RouterCheckpoint["features"]>;
|
|
11
|
+
recent?: Partial<RouterCheckpoint["recent"]>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function checkpoint(overrides: CheckpointOverrides = {}): RouterCheckpoint {
|
|
15
|
+
const base: RouterCheckpoint = {
|
|
16
|
+
schema: "pi-router.checkpoint.v1",
|
|
17
|
+
sessionId: "session-1",
|
|
18
|
+
checkpointId: "session-1:event-10",
|
|
19
|
+
createdAt: "2026-06-12T00:00:00.000Z",
|
|
20
|
+
rawSessionRef: {
|
|
21
|
+
schema: "pi-router.raw-session-ref.v1",
|
|
22
|
+
path: "/tmp/raw-session.jsonl",
|
|
23
|
+
fromEvent: 1,
|
|
24
|
+
toEvent: 10,
|
|
25
|
+
fromByte: 100,
|
|
26
|
+
toByte: 200,
|
|
27
|
+
contentHash: "hash-only",
|
|
28
|
+
},
|
|
29
|
+
harness: "pi",
|
|
30
|
+
repoHash: "repo-hash",
|
|
31
|
+
goalHash: "goal-hash",
|
|
32
|
+
phase: "debug",
|
|
33
|
+
activeModel: "local/qwen",
|
|
34
|
+
provider: "local",
|
|
35
|
+
features: {
|
|
36
|
+
turnIndex: 10,
|
|
37
|
+
sameCommandRepeatedCount: 2,
|
|
38
|
+
sameErrorRepeatedCount: 2,
|
|
39
|
+
errorChanged: false,
|
|
40
|
+
testsImproved: null,
|
|
41
|
+
filesTouched: 1,
|
|
42
|
+
diffLines: 12,
|
|
43
|
+
diffFilesChanged: 1,
|
|
44
|
+
diffLinesAdded: 8,
|
|
45
|
+
diffLinesDeleted: 4,
|
|
46
|
+
diffChurnScore: 0,
|
|
47
|
+
toolThrashScore: 0.25,
|
|
48
|
+
goalDriftScore: 0,
|
|
49
|
+
loopScore: 0.55,
|
|
50
|
+
progressScore: 0.45,
|
|
51
|
+
verifierUsed: true,
|
|
52
|
+
noVerifierUsed: false,
|
|
53
|
+
toolCallsLast10Turns: 4,
|
|
54
|
+
contextTokensApprox: 1234,
|
|
55
|
+
gitDirty: null,
|
|
56
|
+
},
|
|
57
|
+
recent: {
|
|
58
|
+
lastUserGoalHash: "goal-hash",
|
|
59
|
+
lastCommandHash: "command-hash",
|
|
60
|
+
lastErrorHash: "error-hash",
|
|
61
|
+
touchedFileHashes: ["file-hash"],
|
|
62
|
+
},
|
|
63
|
+
sourceEvent: {
|
|
64
|
+
index: 10,
|
|
65
|
+
byteStart: 100,
|
|
66
|
+
byteEnd: 200,
|
|
67
|
+
id: "event-id",
|
|
68
|
+
timestamp: "2026-06-12T00:00:01.000Z",
|
|
69
|
+
type: "message",
|
|
70
|
+
role: "toolResult",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return { ...base, ...overrides, features: { ...base.features, ...overrides.features }, recent: { ...base.recent, ...overrides.recent } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("trajectory router decision and ledger", () => {
|
|
77
|
+
it("emits strict JSON decisions from conservative local-first rules", () => {
|
|
78
|
+
const decision = decideRoute(checkpoint());
|
|
79
|
+
|
|
80
|
+
expect(decision).toEqual({
|
|
81
|
+
schema: "pi-router.decision.v1",
|
|
82
|
+
checkpointId: "session-1:event-10",
|
|
83
|
+
action: "escalate_debug_diagnosis",
|
|
84
|
+
adviceShape: "debug_diagnosis",
|
|
85
|
+
contextPolicy: "focused_error_and_diff",
|
|
86
|
+
confidence: 0.82,
|
|
87
|
+
reason: "same error repeated in debug phase; ask stronger/different model for diagnosis",
|
|
88
|
+
policyVersion: "pi-router.rule-policy.v0",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps normal progress local/current", () => {
|
|
93
|
+
const decision = decideRoute(checkpoint({
|
|
94
|
+
phase: "implementation",
|
|
95
|
+
features: {
|
|
96
|
+
sameCommandRepeatedCount: 1,
|
|
97
|
+
sameErrorRepeatedCount: 0,
|
|
98
|
+
loopScore: 0.1,
|
|
99
|
+
progressScore: 0.8,
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
expect(decision.action).toBe("continue_current");
|
|
104
|
+
expect(decision.adviceShape).toBe("none");
|
|
105
|
+
expect(decision.contextPolicy).toBe("none");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("selects the last checkpoint by default or an explicit checkpoint id", () => {
|
|
109
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-router-decision-"));
|
|
110
|
+
const file = join(dir, "checkpoints.jsonl");
|
|
111
|
+
const first = checkpoint({ checkpointId: "first" });
|
|
112
|
+
const second = checkpoint({ checkpointId: "second" });
|
|
113
|
+
writeFileSync(file, `${JSON.stringify(first)}\n${JSON.stringify(second)}\n`);
|
|
114
|
+
|
|
115
|
+
const checkpoints = readCheckpointJsonl(file);
|
|
116
|
+
|
|
117
|
+
expect(selectCheckpoint(checkpoints).checkpointId).toBe("second");
|
|
118
|
+
expect(selectCheckpoint(checkpoints, "first").checkpointId).toBe("first");
|
|
119
|
+
expect(() => selectCheckpoint(checkpoints, "missing")).toThrow(/checkpoint not found/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("appends route ledger events without raw transcript content", () => {
|
|
123
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-router-ledger-"));
|
|
124
|
+
const file = join(dir, "events.jsonl");
|
|
125
|
+
const sensitiveText = "npm test src/secret.test.ts failed with API_TOKEN=abc";
|
|
126
|
+
const routeCheckpoint = checkpoint();
|
|
127
|
+
const decision = decideRoute(routeCheckpoint);
|
|
128
|
+
const event = buildRouteEvent(routeCheckpoint, decision, "2026-06-12T00:00:02.000Z");
|
|
129
|
+
|
|
130
|
+
appendRouteEvent(file, event);
|
|
131
|
+
const events = readRouteEvents(file);
|
|
132
|
+
const raw = readFileSync(file, "utf8");
|
|
133
|
+
|
|
134
|
+
expect(events).toHaveLength(1);
|
|
135
|
+
expect(events[0]).toMatchObject({
|
|
136
|
+
schema: "pi-router.route-event.v1",
|
|
137
|
+
checkpointId: routeCheckpoint.checkpointId,
|
|
138
|
+
sessionId: routeCheckpoint.sessionId,
|
|
139
|
+
decision,
|
|
140
|
+
runtime: { activeModel: "local/qwen", provider: "local", contextTokensApprox: 1234 },
|
|
141
|
+
observed: { followed: null },
|
|
142
|
+
});
|
|
143
|
+
expect(raw).not.toContain(sensitiveText);
|
|
144
|
+
expect(raw).not.toContain("npm test");
|
|
145
|
+
expect(raw).not.toContain("API_TOKEN");
|
|
146
|
+
expect(raw).toContain("hash-only");
|
|
147
|
+
});
|
|
148
|
+
});
|