@fiale-plus/pi-rogue 0.2.2 → 0.2.4
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/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/README.md +1 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.test.ts +8 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.ts +7 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.test.ts +26 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.ts +10 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +20 -2
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +81 -3
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +72 -10
- 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-orchestration/README.md +3 -3
- package/node_modules/@fiale-plus/pi-rogue-orchestration/package.json +3 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/skills/orchestration/SKILL.md +3 -2
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.test.ts +65 -2
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +84 -4
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/loop.ts +3 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.test.ts +43 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.ts +96 -11
- package/node_modules/@fiale-plus/pi-rogue-router/README.md +46 -5
- package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.test.ts +88 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.ts +232 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +9 -1
- package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +123 -9
- package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +39 -16
- package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +145 -6
- package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +51 -11
- package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +67 -7
- package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +27 -12
- package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +4 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +87 -9
- package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +130 -6
- package/node_modules/@fiale-plus/pi-rogue-router/src/reports.test.ts +92 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/reports.ts +116 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.test.ts +223 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.ts +344 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.test.ts +126 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.ts +238 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +59 -2
- package/package.json +1 -1
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
routerConfigPath,
|
|
9
9
|
routerEventsPath,
|
|
10
10
|
saveRouterConfig,
|
|
11
|
+
setRouterMode,
|
|
12
|
+
setRouterPrint,
|
|
11
13
|
setRouterProfile,
|
|
12
14
|
type RouterConfig,
|
|
13
15
|
} from "./config.js";
|
|
@@ -17,8 +19,8 @@ import { routerArgumentCompletions } from "./completions.js";
|
|
|
17
19
|
function statusText(ctx: any, config: RouterConfig): string {
|
|
18
20
|
const profile = activeProfile(config);
|
|
19
21
|
return [
|
|
20
|
-
`router: ${config.enabled ? "
|
|
21
|
-
`
|
|
22
|
+
`router: ${config.enabled ? "on" : "off"}`,
|
|
23
|
+
`model routing: ${config.mode === "auto_model" ? "auto_model (applies model switches only)" : "observe (recommendations only)"}`,
|
|
22
24
|
`print: ${config.print}`,
|
|
23
25
|
`profile: ${config.activeProfile}`,
|
|
24
26
|
`worker: ${profile.worker}`,
|
|
@@ -35,11 +37,35 @@ function notifyProfile(ctx: any, config: RouterConfig, prefix = "router profile"
|
|
|
35
37
|
ctx.ui.notify(`${prefix}: ${config.activeProfile}\nworker: ${profile.worker}\nsmart: ${profile.smart}\nteacher: ${profile.teacher}\nreviewer: ${profile.reviewer}`, "info");
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
function helpText(ctx: any, config: RouterConfig): string {
|
|
41
|
+
return [
|
|
42
|
+
"router command tree:",
|
|
43
|
+
" /router status show current router state",
|
|
44
|
+
" /router help show this help",
|
|
45
|
+
" /router on enable router using current explicit mode",
|
|
46
|
+
" /router off disable router",
|
|
47
|
+
" /router mode observe recommendations only",
|
|
48
|
+
" /router mode auto_model apply model switches only",
|
|
49
|
+
" /router profile <name> choose active profile",
|
|
50
|
+
" /router print mismatch_only notify only mismatches",
|
|
51
|
+
" /router print all notify every router decision",
|
|
52
|
+
" /router print off suppress observe notifications",
|
|
53
|
+
" /router models show active role → model mapping",
|
|
54
|
+
" /router profiles list configured profiles",
|
|
55
|
+
" /router cycle cycle to next profile",
|
|
56
|
+
" /router configure create/show config",
|
|
57
|
+
"",
|
|
58
|
+
"safety: observe is recommendations only; auto_model applies model switches only, never agent/subagent/tool routing.",
|
|
59
|
+
"",
|
|
60
|
+
statusText(ctx, config),
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
function setEnabled(ctx: any, enabled: boolean): void {
|
|
39
65
|
const config = ensureRouterConfig(ctx);
|
|
40
66
|
const next = { ...config, enabled };
|
|
41
67
|
saveRouterConfig(ctx, next);
|
|
42
|
-
ctx.ui.notify(enabled ?
|
|
68
|
+
ctx.ui.notify(enabled ? `router enabled: ${next.mode === "auto_model" ? "auto_model applies model switches only" : "observe recommendations only"}` : "router disabled", "info");
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
export function registerRouter(pi: ExtensionAPI): void {
|
|
@@ -48,7 +74,7 @@ export function registerRouter(pi: ExtensionAPI): void {
|
|
|
48
74
|
p.__piRogueRouterRegistered = true;
|
|
49
75
|
|
|
50
76
|
pi.registerCommand("router", {
|
|
51
|
-
description: "
|
|
77
|
+
description: "Trajectory router. Usage: /router status|help|on|off|mode|profile|print|profiles|models|configure|cycle. Default observe-only; auto_model applies model switches only.",
|
|
52
78
|
getArgumentCompletions: (prefix: string, ctx?: any) => routerArgumentCompletions(prefix, ctx),
|
|
53
79
|
handler: async (args, ctx) => {
|
|
54
80
|
const input = String(args ?? "").trim();
|
|
@@ -65,11 +91,15 @@ export function registerRouter(pi: ExtensionAPI): void {
|
|
|
65
91
|
}
|
|
66
92
|
if (cmd === "configure" || cmd === "config") {
|
|
67
93
|
const config = ensureRouterConfig(ctx);
|
|
68
|
-
ctx.ui.notify(["router config ready", "", statusText(ctx, config)].join("\n"), "info");
|
|
94
|
+
ctx.ui.notify(["router config ready", "", "next: /router mode …, /router profile …, /router print …", "", statusText(ctx, config)].join("\n"), "info");
|
|
69
95
|
return;
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
const config = ensureRouterConfig(ctx);
|
|
99
|
+
if (cmd === "help") {
|
|
100
|
+
ctx.ui.notify(helpText(ctx, config), "info");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
73
103
|
if (cmd === "status" || cmd === "show") {
|
|
74
104
|
ctx.ui.notify(statusText(ctx, config), "info");
|
|
75
105
|
return;
|
|
@@ -78,6 +108,36 @@ export function registerRouter(pi: ExtensionAPI): void {
|
|
|
78
108
|
notifyProfile(ctx, config, "router models");
|
|
79
109
|
return;
|
|
80
110
|
}
|
|
111
|
+
if (cmd === "mode") {
|
|
112
|
+
const mode = rest[0];
|
|
113
|
+
if (!mode) {
|
|
114
|
+
ctx.ui.notify(statusText(ctx, config), "info");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const next = setRouterMode(config, mode);
|
|
118
|
+
if (!next) {
|
|
119
|
+
ctx.ui.notify("unknown router mode: use observe or auto_model", "error");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
saveRouterConfig(ctx, next);
|
|
123
|
+
ctx.ui.notify(`router model routing mode set: ${next.mode === "auto_model" ? "auto_model (model switches only)" : "observe (recommendations only)"}`, "info");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (cmd === "print") {
|
|
127
|
+
const print = rest[0];
|
|
128
|
+
if (!print) {
|
|
129
|
+
ctx.ui.notify(statusText(ctx, config), "info");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const next = setRouterPrint(config, print);
|
|
133
|
+
if (!next) {
|
|
134
|
+
ctx.ui.notify("unknown router print mode: use mismatch_only, all, or off", "error");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
saveRouterConfig(ctx, next);
|
|
138
|
+
ctx.ui.notify(`router print mode set: ${next.print}`, "info");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
81
141
|
if (cmd === "profiles") {
|
|
82
142
|
ctx.ui.notify(config.profileOrder.map((name) => {
|
|
83
143
|
const marker = name === config.activeProfile ? "*" : " ";
|
|
@@ -108,7 +168,7 @@ export function registerRouter(pi: ExtensionAPI): void {
|
|
|
108
168
|
return;
|
|
109
169
|
}
|
|
110
170
|
|
|
111
|
-
ctx.ui.notify("Usage: /router on|off|
|
|
171
|
+
ctx.ui.notify("Usage: /router status|help|on|off|mode [observe|auto_model]|profile [name]|print [mismatch_only|all|off]|profiles|models|configure|cycle", "error");
|
|
112
172
|
},
|
|
113
173
|
});
|
|
114
174
|
|
|
@@ -127,7 +187,7 @@ export function registerRouter(pi: ExtensionAPI): void {
|
|
|
127
187
|
|
|
128
188
|
pi.on("turn_end", async (_event: any, ctx: any) => {
|
|
129
189
|
try {
|
|
130
|
-
await observeRouterTurn(ctx);
|
|
190
|
+
await observeRouterTurn(ctx, pi);
|
|
131
191
|
} catch (error) {
|
|
132
192
|
ctx.ui?.notify?.(`router observe failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
133
193
|
}
|
|
@@ -16,7 +16,16 @@ function git(cwd: string, args: string[]): string {
|
|
|
16
16
|
return execFileSync("git", args, { cwd: resolve(cwd), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
interface GitExcludes {
|
|
20
|
+
files: Set<string>;
|
|
21
|
+
prefixes: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isExcluded(file: string, excludes: GitExcludes): boolean {
|
|
25
|
+
return excludes.files.has(file) || excludes.prefixes.some((prefix) => file.startsWith(prefix));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseNumstat(output: string, excludes: GitExcludes = { files: new Set(), prefixes: [] }): Pick<DiffStats, "filesChanged" | "linesAdded" | "linesDeleted" | "totalLines" | "fileHashes"> {
|
|
20
29
|
let rows = 0;
|
|
21
30
|
let linesAdded = 0;
|
|
22
31
|
let linesDeleted = 0;
|
|
@@ -26,7 +35,7 @@ function parseNumstat(output: string, excludeFiles = new Set<string>()): Pick<Di
|
|
|
26
35
|
if (!line.trim()) continue;
|
|
27
36
|
const [added, deleted, ...fileParts] = line.split("\t");
|
|
28
37
|
const file = fileParts.join("\t").trim();
|
|
29
|
-
if (file &&
|
|
38
|
+
if (file && isExcluded(file, excludes)) continue;
|
|
30
39
|
rows++;
|
|
31
40
|
if (file) fileHashes.add(hashText(file));
|
|
32
41
|
const add = Number(added);
|
|
@@ -38,13 +47,13 @@ function parseNumstat(output: string, excludeFiles = new Set<string>()): Pick<Di
|
|
|
38
47
|
return { filesChanged: fileHashes.size || rows, linesAdded, linesDeleted, totalLines: linesAdded + linesDeleted, fileHashes: [...fileHashes].sort() };
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
function untrackedFiles(cwd: string,
|
|
50
|
+
function untrackedFiles(cwd: string, excludes: GitExcludes = { files: new Set(), prefixes: [] }): { hashes: string[]; linesAdded: number } {
|
|
42
51
|
try {
|
|
43
52
|
let linesAdded = 0;
|
|
44
53
|
const hashes: string[] = [];
|
|
45
54
|
for (const raw of git(cwd, ["ls-files", "--others", "--exclude-standard"]).split("\n")) {
|
|
46
55
|
const file = raw.trim();
|
|
47
|
-
if (!file ||
|
|
56
|
+
if (!file || isExcluded(file, excludes)) continue;
|
|
48
57
|
hashes.push(hashText(file));
|
|
49
58
|
try {
|
|
50
59
|
const path = resolve(cwd, file);
|
|
@@ -63,39 +72,45 @@ function untrackedFiles(cwd: string, excludeFiles = new Set<string>()): { hashes
|
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
function excludeFilesFromPaths(root: string, paths: string[] | undefined):
|
|
75
|
+
function excludeFilesFromPaths(root: string, paths: string[] | undefined): GitExcludes {
|
|
67
76
|
const files = new Set<string>();
|
|
77
|
+
const prefixes: string[] = [];
|
|
68
78
|
const realRoot = realpathSync(root);
|
|
69
79
|
for (const path of paths ?? []) {
|
|
70
80
|
const absolute = isAbsolute(path) ? path : resolve(root, path);
|
|
71
81
|
let rel = relative(root, absolute);
|
|
82
|
+
let isDirectory = false;
|
|
72
83
|
try {
|
|
73
|
-
|
|
84
|
+
const realAbsolute = realpathSync(absolute);
|
|
85
|
+
rel = relative(realRoot, realAbsolute);
|
|
86
|
+
isDirectory = statSync(realAbsolute).isDirectory();
|
|
74
87
|
} catch {
|
|
75
88
|
// Output paths may not exist yet; fall back to lexical repo-relative path.
|
|
76
89
|
}
|
|
77
|
-
if (rel
|
|
90
|
+
if (!rel || rel.startsWith("..")) continue;
|
|
91
|
+
if (isDirectory) prefixes.push(rel.endsWith("/") ? rel : `${rel}/`);
|
|
92
|
+
else files.add(rel);
|
|
78
93
|
}
|
|
79
|
-
return files;
|
|
94
|
+
return { files, prefixes };
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
export function readGitDiffStats(cwd?: string, options: { excludePaths?: string[] } = {}): DiffStats {
|
|
83
98
|
if (!cwd) return EMPTY_DIFF_STATS;
|
|
84
99
|
try {
|
|
85
100
|
const root = git(cwd, ["rev-parse", "--show-toplevel"]).trim() || cwd;
|
|
86
|
-
const
|
|
87
|
-
const untracked = untrackedFiles(root,
|
|
101
|
+
const excludes = excludeFilesFromPaths(root, options.excludePaths);
|
|
102
|
+
const untracked = untrackedFiles(root, excludes);
|
|
88
103
|
let parsed: Pick<DiffStats, "filesChanged" | "linesAdded" | "linesDeleted" | "totalLines" | "fileHashes"> = EMPTY_DIFF_STATS;
|
|
89
104
|
let shortStat = "";
|
|
90
105
|
try {
|
|
91
|
-
parsed = parseNumstat(git(root, ["diff", "--numstat", "HEAD"]),
|
|
106
|
+
parsed = parseNumstat(git(root, ["diff", "--numstat", "HEAD"]), excludes);
|
|
92
107
|
shortStat = git(root, ["diff", "--shortstat", "HEAD"]).trim();
|
|
93
108
|
} catch {
|
|
94
109
|
// Repositories without an initial commit have no HEAD; include staged files plus untracked counts.
|
|
95
110
|
try {
|
|
96
111
|
const cachedNumstat = git(root, ["diff", "--cached", "--numstat"]);
|
|
97
112
|
const worktreeNumstat = git(root, ["diff", "--numstat"]);
|
|
98
|
-
parsed = parseNumstat(`${cachedNumstat}\n${worktreeNumstat}`,
|
|
113
|
+
parsed = parseNumstat(`${cachedNumstat}\n${worktreeNumstat}`, excludes);
|
|
99
114
|
shortStat = `${git(root, ["diff", "--cached", "--shortstat"]).trim()} ${git(root, ["diff", "--shortstat"]).trim()}`.trim();
|
|
100
115
|
} catch {
|
|
101
116
|
// Still report untracked-file counts/hashes below.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./binary-gate.js";
|
|
1
2
|
export * from "./checkpoints.js";
|
|
2
3
|
export * from "./completions.js";
|
|
3
4
|
export * from "./config.js";
|
|
@@ -10,6 +11,9 @@ export * from "./ledger.js";
|
|
|
10
11
|
export * from "./observe.js";
|
|
11
12
|
export * from "./outcomes.js";
|
|
12
13
|
export * from "./progress.js";
|
|
14
|
+
export * from "./reports.js";
|
|
15
|
+
export * from "./sharpening.js";
|
|
13
16
|
export * from "./session-reader.js";
|
|
14
17
|
export * from "./subagents.js";
|
|
18
|
+
export * from "./teacher-runner.js";
|
|
15
19
|
export * from "./types.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import { appendRouteEvent, buildRouteEvent } from "./ledger.js";
|
|
2
3
|
import { decideRoute } from "./decision.js";
|
|
3
4
|
import { checkpointWithDiffStats, streamCheckpointsFromSessionPath } from "./checkpoints.js";
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
loadRouterConfig,
|
|
7
8
|
loadRouterState,
|
|
8
9
|
routerConfigPath,
|
|
10
|
+
routerDir,
|
|
9
11
|
routerEventsPath,
|
|
10
12
|
routerStatePath,
|
|
11
13
|
saveRouterState,
|
|
@@ -20,12 +22,20 @@ export interface RouterObserveSummary {
|
|
|
20
22
|
role: keyof RouterProfile | "none" | "current";
|
|
21
23
|
targetModel?: string;
|
|
22
24
|
currentModel?: string;
|
|
25
|
+
currentProvider?: string;
|
|
23
26
|
match: boolean | null;
|
|
24
27
|
confidence: number;
|
|
25
28
|
reason: string;
|
|
26
29
|
text: string;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
export interface RouterModelApplySummary {
|
|
33
|
+
applied: boolean;
|
|
34
|
+
reason: string;
|
|
35
|
+
fromModel?: string;
|
|
36
|
+
toModel?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
function squish(text: unknown, max = 140): string {
|
|
30
40
|
const value = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
31
41
|
return value.length <= max ? value : `${value.slice(0, max - 1).trimEnd()}…`;
|
|
@@ -52,10 +62,22 @@ function modelLeaf(model: string): string {
|
|
|
52
62
|
return model.split("/").at(-1)?.toLowerCase() ?? model.toLowerCase();
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
export function modelsMatch(current: string | undefined, target: string | undefined): boolean | null {
|
|
65
|
+
export function modelsMatch(current: string | undefined, target: string | undefined, currentProvider?: string): boolean | null {
|
|
56
66
|
if (!current || !target) return null;
|
|
57
67
|
const c = current.toLowerCase();
|
|
58
68
|
const t = target.toLowerCase();
|
|
69
|
+
const provider = currentProvider?.toLowerCase();
|
|
70
|
+
const [targetProvider, ...targetModelParts] = t.split("/");
|
|
71
|
+
if (targetModelParts.length > 0) {
|
|
72
|
+
const targetModel = targetModelParts.join("/");
|
|
73
|
+
if (provider) {
|
|
74
|
+
const currentModel = c.startsWith(`${provider}/`) ? c.slice(provider.length + 1) : c;
|
|
75
|
+
if (provider === targetProvider) return currentModel === targetModel;
|
|
76
|
+
return currentModel === t;
|
|
77
|
+
}
|
|
78
|
+
if (c.includes("/")) return c === t;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
59
81
|
return c === t || modelLeaf(c) === modelLeaf(t) || c.endsWith(`/${modelLeaf(t)}`) || t.endsWith(`/${modelLeaf(c)}`);
|
|
60
82
|
}
|
|
61
83
|
|
|
@@ -69,7 +91,7 @@ export function summarizeRouterDecision(checkpoint: RouterCheckpoint, decision:
|
|
|
69
91
|
const profile = activeProfile(config);
|
|
70
92
|
const role = actionRole(decision.action);
|
|
71
93
|
const targetModel = targetForRole(role, profile, checkpoint.activeModel);
|
|
72
|
-
const match = role === "none" ? null : modelsMatch(checkpoint.activeModel, targetModel);
|
|
94
|
+
const match = role === "none" ? null : modelsMatch(checkpoint.activeModel, targetModel, checkpoint.provider);
|
|
73
95
|
const verdict = match === null ? "INFO" : match ? "MATCH" : "MISMATCH";
|
|
74
96
|
const roleText = role === "none" ? "no-model" : role;
|
|
75
97
|
const targetText = targetModel ? `${roleText}(${targetModel})` : roleText;
|
|
@@ -80,6 +102,7 @@ export function summarizeRouterDecision(checkpoint: RouterCheckpoint, decision:
|
|
|
80
102
|
role,
|
|
81
103
|
targetModel,
|
|
82
104
|
currentModel: checkpoint.activeModel,
|
|
105
|
+
currentProvider: checkpoint.provider,
|
|
83
106
|
match,
|
|
84
107
|
confidence: decision.confidence,
|
|
85
108
|
reason: decision.reason,
|
|
@@ -93,27 +116,82 @@ export async function latestCheckpointFromSession(sessionPath: string): Promise<
|
|
|
93
116
|
return latest;
|
|
94
117
|
}
|
|
95
118
|
|
|
96
|
-
|
|
119
|
+
function findConfiguredModel(ctx: any, target: string, currentProvider?: string): { model: any; matchedBy: "qualified" | "id" } | undefined {
|
|
120
|
+
const all = ctx?.modelRegistry?.getAll?.() ?? [];
|
|
121
|
+
const observedProvider = currentProvider?.toLowerCase();
|
|
122
|
+
const byCurrentProviderId = observedProvider ? all.find((model: any) => model.id === target && String(model.provider).toLowerCase() === observedProvider) : undefined;
|
|
123
|
+
if (byCurrentProviderId) return { model: byCurrentProviderId, matchedBy: "id" };
|
|
124
|
+
const [provider, ...modelParts] = target.split("/");
|
|
125
|
+
if (modelParts.length > 0) {
|
|
126
|
+
const found = ctx?.modelRegistry?.find?.(provider, modelParts.join("/"));
|
|
127
|
+
if (found) return { model: found, matchedBy: "qualified" };
|
|
128
|
+
const byQualified = all.find((model: any) => `${model.provider}/${model.id}` === target);
|
|
129
|
+
if (byQualified) return { model: byQualified, matchedBy: "qualified" };
|
|
130
|
+
}
|
|
131
|
+
const byId = all.filter((model: any) => model.id === target);
|
|
132
|
+
return byId.length === 1 ? { model: byId[0], matchedBy: "id" } : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function configuredModelMatches(current: string | undefined, currentProvider: string | undefined, resolved: { model: any; matchedBy: "qualified" | "id" }): boolean {
|
|
136
|
+
const model = resolved.model;
|
|
137
|
+
if (!current || !model?.provider || !model?.id) return false;
|
|
138
|
+
const c = current.toLowerCase();
|
|
139
|
+
const provider = String(model.provider).toLowerCase();
|
|
140
|
+
const id = String(model.id).toLowerCase();
|
|
141
|
+
const observedProvider = currentProvider?.toLowerCase();
|
|
142
|
+
if (observedProvider) {
|
|
143
|
+
const currentModel = c.startsWith(`${observedProvider}/`) ? c.slice(observedProvider.length + 1) : c;
|
|
144
|
+
return observedProvider === provider && currentModel === id;
|
|
145
|
+
}
|
|
146
|
+
return c === `${provider}/${id}` || (resolved.matchedBy === "id" && c === id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function applyModelRouting(pi: Pick<ExtensionAPI, "setModel"> | undefined, ctx: any, summary: RouterObserveSummary): Promise<RouterModelApplySummary> {
|
|
150
|
+
if (!summary.targetModel || summary.role === "none" || summary.role === "current") return { applied: false, reason: "no model switch for route action" };
|
|
151
|
+
const resolved = findConfiguredModel(ctx, summary.targetModel, summary.currentProvider);
|
|
152
|
+
if (resolved?.matchedBy === "id" && modelsMatch(summary.currentModel, summary.targetModel, summary.currentProvider)) return { applied: false, reason: "current model already matches target", fromModel: summary.currentModel, toModel: summary.targetModel };
|
|
153
|
+
if (resolved && configuredModelMatches(summary.currentModel, summary.currentProvider, resolved)) return { applied: false, reason: "current model already matches target", fromModel: summary.currentModel, toModel: summary.targetModel };
|
|
154
|
+
if (!resolved && modelsMatch(summary.currentModel, summary.targetModel, summary.currentProvider)) return { applied: false, reason: "current model already matches target", fromModel: summary.currentModel, toModel: summary.targetModel };
|
|
155
|
+
if (!resolved) return { applied: false, reason: `target model not configured: ${summary.targetModel}`, fromModel: summary.currentModel, toModel: summary.targetModel };
|
|
156
|
+
const success = await pi?.setModel?.(resolved.model);
|
|
157
|
+
if (!success) return { applied: false, reason: `target model unavailable or missing auth: ${summary.targetModel}`, fromModel: summary.currentModel, toModel: summary.targetModel };
|
|
158
|
+
return { applied: true, reason: summary.reason, fromModel: summary.currentModel, toModel: summary.targetModel };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function observeRouterTurn(ctx: any, pi?: Pick<ExtensionAPI, "setModel">): Promise<RouterObserveSummary | null> {
|
|
97
162
|
const config = loadRouterConfig(ctx);
|
|
98
|
-
if (!config.enabled || config.print === "off") return null;
|
|
163
|
+
if (!config.enabled || (config.print === "off" && config.mode === "observe")) return null;
|
|
99
164
|
const sessionPath = ctx?.sessionManager?.getSessionFile?.();
|
|
100
165
|
if (!sessionPath) return null;
|
|
101
166
|
const checkpoint = await latestCheckpointFromSession(String(sessionPath));
|
|
102
167
|
if (!checkpoint) return null;
|
|
103
|
-
const state = loadRouterState(ctx);
|
|
168
|
+
const state = loadRouterState(ctx, String(sessionPath));
|
|
104
169
|
if (state.lastObservedCheckpointId === checkpoint.checkpointId) return null;
|
|
105
170
|
|
|
106
|
-
const liveCheckpoint = checkpointWithDiffStats(checkpoint, ctx?.cwd, [
|
|
171
|
+
const liveCheckpoint = checkpointWithDiffStats(checkpoint, ctx?.cwd, [
|
|
172
|
+
String(sessionPath),
|
|
173
|
+
routerConfigPath(ctx),
|
|
174
|
+
routerDir(ctx),
|
|
175
|
+
routerStatePath(ctx, String(sessionPath)),
|
|
176
|
+
routerEventsPath(ctx, String(sessionPath)),
|
|
177
|
+
]);
|
|
107
178
|
const decision = decideRoute(liveCheckpoint);
|
|
108
179
|
const summary = summarizeRouterDecision(liveCheckpoint, decision, config);
|
|
109
|
-
appendRouteEvent(routerEventsPath(ctx), buildRouteEvent(liveCheckpoint, decision));
|
|
180
|
+
appendRouteEvent(routerEventsPath(ctx, String(sessionPath)), buildRouteEvent(liveCheckpoint, decision));
|
|
110
181
|
saveRouterState(ctx, {
|
|
111
182
|
lastObservedCheckpointId: checkpoint.checkpointId,
|
|
112
183
|
lastDecisionAction: decision.action,
|
|
113
184
|
lastSummary: summary.text,
|
|
114
|
-
});
|
|
185
|
+
}, String(sessionPath));
|
|
186
|
+
|
|
187
|
+
if (config.mode === "auto_model") {
|
|
188
|
+
const applied = await applyModelRouting(pi, ctx, summary);
|
|
189
|
+
if (applied.applied || summary.match === false) {
|
|
190
|
+
ctx.ui?.notify?.(`router auto-model: ${applied.applied ? "APPLIED" : "SKIPPED"} ${applied.fromModel ?? "unknown"} → ${applied.toModel ?? "none"} · ${applied.reason}`, applied.applied ? "info" : "warning");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
115
193
|
|
|
116
194
|
if (config.print === "mismatch_only" && summary.match !== false) return summary;
|
|
117
|
-
ctx.ui?.notify?.(summary.text, summary.match === false ? "warning" : "info");
|
|
195
|
+
if (config.print !== "off") ctx.ui?.notify?.(summary.text, summary.match === false ? "warning" : "info");
|
|
118
196
|
return summary;
|
|
119
197
|
}
|
|
@@ -6,6 +6,7 @@ import { readRouteEvents, type RouteEvent } from "./ledger.js";
|
|
|
6
6
|
import type { RouterCheckpoint, TaskStatus, TaskType } from "./types.js";
|
|
7
7
|
|
|
8
8
|
export const ROUTER_OUTCOME_SCHEMA = "pi-router.outcome.v1" as const;
|
|
9
|
+
export const ROUTER_OUTCOME_ENRICH_SUMMARY_SCHEMA = "pi-router.outcome-enrich-summary.v1" as const;
|
|
9
10
|
|
|
10
11
|
export interface RouterOutcome {
|
|
11
12
|
schema: typeof ROUTER_OUTCOME_SCHEMA;
|
|
@@ -43,9 +44,15 @@ export interface OutcomeWriteSummary {
|
|
|
43
44
|
inferred: number;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
export interface OutcomeEnrichSummary {
|
|
48
|
+
schema: typeof ROUTER_OUTCOME_ENRICH_SUMMARY_SCHEMA;
|
|
49
|
+
output: string;
|
|
50
|
+
inputOutcomes: number;
|
|
51
|
+
outputOutcomes: number;
|
|
52
|
+
enriched: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function roundStatus(_event: RouteEvent, _checkpoint?: RouterCheckpoint): TaskStatus {
|
|
49
56
|
return "unknown";
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -78,7 +85,7 @@ export function buildUnknownOutcome(event: RouteEvent, checkpoint?: RouterCheckp
|
|
|
78
85
|
testsPassedAfter: null,
|
|
79
86
|
verifierImproved: null,
|
|
80
87
|
acceptedDiff: null,
|
|
81
|
-
userInterrupted:
|
|
88
|
+
userInterrupted: false,
|
|
82
89
|
userOverrodeDecision: Boolean(event.observed.overriddenBy),
|
|
83
90
|
finalFilesTouched: checkpoint ? ((checkpoint.features.diffFilesChanged ?? 0) > 0 ? (checkpoint.features.diffFilesChanged ?? 0) : checkpoint.features.filesTouched) : 0,
|
|
84
91
|
finalDiffLines: checkpoint?.features.diffLines ?? 0,
|
|
@@ -107,8 +114,14 @@ export function readOutcomes(path?: string): RouterOutcome[] {
|
|
|
107
114
|
return readFileSync(resolved, "utf8")
|
|
108
115
|
.split("\n")
|
|
109
116
|
.filter((line) => line.trim())
|
|
110
|
-
.
|
|
111
|
-
try {
|
|
117
|
+
.map((line, index) => {
|
|
118
|
+
try {
|
|
119
|
+
const outcome = JSON.parse(line) as RouterOutcome;
|
|
120
|
+
if (outcome.schema !== ROUTER_OUTCOME_SCHEMA) throw new Error("invalid schema");
|
|
121
|
+
return outcome;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error(`invalid outcome JSONL at ${path}:${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
112
125
|
});
|
|
113
126
|
}
|
|
114
127
|
|
|
@@ -118,6 +131,94 @@ export function writeOutcomesJsonl(outcomes: RouterOutcome[], path: string): voi
|
|
|
118
131
|
writeFileSync(resolved, outcomes.map((outcome) => JSON.stringify(outcome)).join("\n") + (outcomes.length ? "\n" : ""));
|
|
119
132
|
}
|
|
120
133
|
|
|
134
|
+
function routeEventForOutcome(outcome: RouterOutcome, byId: Map<string, RouteEvent>, byCheckpoint: Map<string, RouteEvent>): RouteEvent | undefined {
|
|
135
|
+
return (outcome.routeEventId ? byId.get(outcome.routeEventId) : undefined) ?? (outcome.checkpointId ? byCheckpoint.get(outcome.checkpointId) : undefined);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function checkpointForOutcome(outcome: RouterOutcome, event: RouteEvent | undefined, byCheckpoint: Map<string, RouterCheckpoint>): RouterCheckpoint | undefined {
|
|
139
|
+
return (outcome.checkpointId ? byCheckpoint.get(outcome.checkpointId) : undefined) ?? (event ? byCheckpoint.get(event.checkpointId) : undefined);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function inferredStatus(outcome: RouterOutcome, checkpoint?: RouterCheckpoint, event?: RouteEvent, testsPassed: boolean | null = outcome.testsPassedAfter): TaskStatus {
|
|
143
|
+
const stopWasFollowed = event?.decision.action === "stop_and_ask_user" && event.observed.followed === true && !event.observed.overriddenBy;
|
|
144
|
+
if (stopWasFollowed || outcome.userInterrupted) return outcome.taskStatus === "unknown" ? "abandoned" : outcome.taskStatus;
|
|
145
|
+
if (testsPassed === true && Math.max(outcome.finalDiffLines, checkpoint?.features.diffLines ?? 0, event?.metrics.diffLines ?? 0) > 0) return "success";
|
|
146
|
+
if (testsPassed === true && outcome.taskStatus === "unknown") return "partial";
|
|
147
|
+
if (testsPassed === false && outcome.taskStatus === "unknown") return "failed";
|
|
148
|
+
if (outcome.taskStatus === "partial" && testsPassed === true && Math.max(outcome.finalDiffLines, checkpoint?.features.diffLines ?? 0, event?.metrics.diffLines ?? 0) > 0) return "success";
|
|
149
|
+
return outcome.taskStatus;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function enrichOutcome(outcome: RouterOutcome, options: { checkpoint?: RouterCheckpoint; event?: RouteEvent; recordedAt?: string } = {}): RouterOutcome {
|
|
153
|
+
const checkpoint = options.checkpoint;
|
|
154
|
+
const event = options.event;
|
|
155
|
+
const testsPassedAfter = outcome.testsPassedAfter;
|
|
156
|
+
const verifierImproved = outcome.verifierImproved
|
|
157
|
+
?? (checkpoint?.features.testsImproved !== null && checkpoint?.features.testsImproved !== undefined ? checkpoint.features.testsImproved : null);
|
|
158
|
+
const taskStatus = inferredStatus(outcome, checkpoint, event, testsPassedAfter);
|
|
159
|
+
const evidenceDiffLines = checkpoint?.features.diffLines ?? event?.metrics.diffLines ?? 0;
|
|
160
|
+
const evidenceFilesTouched = checkpoint
|
|
161
|
+
? ((checkpoint.features.diffFilesChanged ?? 0) > 0 ? checkpoint.features.diffFilesChanged : checkpoint.features.filesTouched)
|
|
162
|
+
: event?.metrics.diffFilesChanged ?? 0;
|
|
163
|
+
const evidenceErrorRepeats = checkpoint?.features.sameErrorRepeatedCount ?? event?.metrics.sameErrorRepeatedCount ?? 0;
|
|
164
|
+
const finalDiffLines = Math.max(outcome.finalDiffLines, evidenceDiffLines);
|
|
165
|
+
const finalFilesTouched = Math.max(outcome.finalFilesTouched, evidenceFilesTouched);
|
|
166
|
+
const reworkTurns = Math.max(outcome.reworkTurns, evidenceErrorRepeats > 1 ? evidenceErrorRepeats - 1 : 0);
|
|
167
|
+
const acceptedDiff = outcome.acceptedDiff
|
|
168
|
+
?? (finalDiffLines > 0 && testsPassedAfter === true ? true : testsPassedAfter === false || taskStatus === "abandoned" ? false : null);
|
|
169
|
+
const notes = JSON.stringify({ enrichedFromCheckpoint: checkpoint?.checkpointId, routeEventId: event?.eventId, taskStatus, testsPassedAfter, verifierImproved, acceptedDiff });
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
...outcome,
|
|
173
|
+
recordedAt: options.recordedAt ?? outcome.recordedAt,
|
|
174
|
+
checkpointId: outcome.checkpointId ?? event?.checkpointId,
|
|
175
|
+
routeEventId: outcome.routeEventId ?? event?.eventId,
|
|
176
|
+
taskType: outcome.taskType === "unknown" ? taskTypeFromCheckpoint(checkpoint) : outcome.taskType,
|
|
177
|
+
taskStatus,
|
|
178
|
+
testsPassedAfter,
|
|
179
|
+
verifierImproved,
|
|
180
|
+
acceptedDiff,
|
|
181
|
+
userInterrupted: outcome.userInterrupted || Boolean(event?.decision.action === "stop_and_ask_user" && event.observed.followed === true && !event.observed.overriddenBy),
|
|
182
|
+
userOverrodeDecision: outcome.userOverrodeDecision || Boolean(event?.observed.overriddenBy),
|
|
183
|
+
finalFilesTouched,
|
|
184
|
+
finalDiffLines,
|
|
185
|
+
reworkTurns,
|
|
186
|
+
evidence: {
|
|
187
|
+
...outcome.evidence,
|
|
188
|
+
rawSessionRef: outcome.evidence.rawSessionRef ?? checkpoint?.rawSessionRef ?? event?.rawSessionRef,
|
|
189
|
+
routeEventId: outcome.evidence.routeEventId ?? event?.eventId,
|
|
190
|
+
notesHash: outcome.evidence.notesHash ?? hashText(notes),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validateOutcomeLinks(outcomes: RouterOutcome[], checkpoints: RouterCheckpoint[], events: RouteEvent[]): void {
|
|
196
|
+
const checkpointIds = new Set(checkpoints.map((checkpoint) => checkpoint.checkpointId));
|
|
197
|
+
const eventIds = new Set(events.map((event) => event.eventId));
|
|
198
|
+
const eventById = new Map(events.map((event) => [event.eventId, event]));
|
|
199
|
+
const eventCheckpointIds = new Set(events.map((event) => event.checkpointId));
|
|
200
|
+
for (const outcome of outcomes) {
|
|
201
|
+
if (events.length > 0 && outcome.routeEventId && !eventIds.has(outcome.routeEventId)) throw new Error(`outcome routeEventId not found: ${outcome.routeEventId}`);
|
|
202
|
+
if (outcome.routeEventId && outcome.checkpointId) {
|
|
203
|
+
const event = eventById.get(outcome.routeEventId);
|
|
204
|
+
if (event && event.checkpointId !== outcome.checkpointId) throw new Error(`outcome routeEventId/checkpointId mismatch: ${outcome.routeEventId}`);
|
|
205
|
+
}
|
|
206
|
+
if ((checkpoints.length > 0 || events.length > 0) && outcome.checkpointId && !checkpointIds.has(outcome.checkpointId) && !eventCheckpointIds.has(outcome.checkpointId)) throw new Error(`outcome checkpointId not found: ${outcome.checkpointId}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function enrichOutcomes(outcomes: RouterOutcome[], checkpoints: RouterCheckpoint[] = [], events: RouteEvent[] = [], recordedAt?: string): RouterOutcome[] {
|
|
211
|
+
validateOutcomeLinks(outcomes, checkpoints, events);
|
|
212
|
+
const checkpointById = new Map(checkpoints.map((checkpoint) => [checkpoint.checkpointId, checkpoint]));
|
|
213
|
+
const eventById = new Map(events.map((event) => [event.eventId, event]));
|
|
214
|
+
const eventByCheckpoint = new Map(events.map((event) => [event.checkpointId, event]));
|
|
215
|
+
return outcomes.map((outcome) => {
|
|
216
|
+
const event = routeEventForOutcome(outcome, eventById, eventByCheckpoint);
|
|
217
|
+
const checkpoint = checkpointForOutcome(outcome, event, checkpointById);
|
|
218
|
+
return enrichOutcome(outcome, { checkpoint, event, recordedAt });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
121
222
|
export function writeInferredOutcomes(options: { checkpointPath: string; eventsPath: string; outputPath: string }): OutcomeWriteSummary {
|
|
122
223
|
if (!existsSync(resolve(options.eventsPath))) throw new Error(`required route events file not found: ${options.eventsPath}`);
|
|
123
224
|
const checkpoints = readCheckpointJsonl(options.checkpointPath);
|
|
@@ -126,3 +227,26 @@ export function writeInferredOutcomes(options: { checkpointPath: string; eventsP
|
|
|
126
227
|
writeOutcomesJsonl(outcomes, options.outputPath);
|
|
127
228
|
return { schema: "pi-router.outcomes-summary.v1", output: resolve(options.outputPath), outcomes: outcomes.length, inferred: outcomes.length };
|
|
128
229
|
}
|
|
230
|
+
|
|
231
|
+
export function writeEnrichedOutcomes(options: { outcomesPath: string; outputPath: string; checkpointPath?: string; eventsPath?: string }): OutcomeEnrichSummary {
|
|
232
|
+
if (!options.checkpointPath && !options.eventsPath) throw new Error("outcome enrichment requires --checkpoint-file or --events evidence");
|
|
233
|
+
if (options.eventsPath && !existsSync(resolve(options.eventsPath))) throw new Error(`route events file not found: ${options.eventsPath}`);
|
|
234
|
+
if (options.checkpointPath && !existsSync(resolve(options.checkpointPath))) throw new Error(`checkpoint file not found: ${options.checkpointPath}`);
|
|
235
|
+
const input = readOutcomes(options.outcomesPath);
|
|
236
|
+
const checkpoints = options.checkpointPath ? readCheckpointJsonl(options.checkpointPath) : [];
|
|
237
|
+
const events = options.eventsPath ? readRouteEvents(options.eventsPath) : [];
|
|
238
|
+
if (checkpoints.length === 0 && events.length === 0) {
|
|
239
|
+
if (options.checkpointPath && !options.eventsPath) throw new Error(`checkpoint file contains no checkpoints: ${options.checkpointPath}`);
|
|
240
|
+
if (options.eventsPath && !options.checkpointPath) throw new Error(`route events file contains no events: ${options.eventsPath}`);
|
|
241
|
+
throw new Error("outcome enrichment evidence files contain no usable checkpoint or route events");
|
|
242
|
+
}
|
|
243
|
+
const enriched = enrichOutcomes(input, checkpoints, events);
|
|
244
|
+
writeOutcomesJsonl(enriched, options.outputPath);
|
|
245
|
+
return {
|
|
246
|
+
schema: ROUTER_OUTCOME_ENRICH_SUMMARY_SCHEMA,
|
|
247
|
+
output: resolve(options.outputPath),
|
|
248
|
+
inputOutcomes: input.length,
|
|
249
|
+
outputOutcomes: enriched.length,
|
|
250
|
+
enriched: enriched.filter((outcome, index) => JSON.stringify(outcome) !== JSON.stringify(input[index])).length,
|
|
251
|
+
};
|
|
252
|
+
}
|