openmerit 0.1.4 → 0.1.5
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/CHANGELOG.md +26 -0
- package/README.md +83 -372
- package/dist/core/src/index.d.ts +90 -0
- package/dist/core/src/index.js +1137 -0
- package/dist/core/src/store.d.ts +35 -0
- package/dist/core/src/store.js +102 -0
- package/dist/pi/src/index.d.ts +15 -0
- package/dist/pi/src/index.js +423 -0
- package/dist/protocol/src/index.d.ts +402 -0
- package/dist/protocol/src/index.js +47 -0
- package/dist/protocol/src/schemas.d.ts +450 -0
- package/dist/protocol/src/schemas.js +224 -0
- package/docs/adapter-guide.md +172 -0
- package/docs/architecture.md +55 -0
- package/docs/automation.md +66 -0
- package/docs/getting-started.md +55 -0
- package/docs/lifecycle.md +30 -0
- package/docs/metrics-and-evidence.md +40 -0
- package/docs/operations.md +31 -0
- package/docs/pareto-spec.md +76 -0
- package/docs/pi-extension.md +44 -0
- package/docs/roadmap.md +26 -0
- package/docs/security.md +23 -0
- package/docs/testing.md +36 -0
- package/docs/ux-reference.md +32 -0
- package/package.json +45 -54
- package/benchmark/invoice_ocr/data/invoice_01_ground_truth.json +0 -38
- package/benchmark/invoice_ocr/data/invoice_01_row_2.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_02_ground_truth.json +0 -32
- package/benchmark/invoice_ocr/data/invoice_02_row_5.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_03_ground_truth.json +0 -26
- package/benchmark/invoice_ocr/data/invoice_03_row_6.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_04_ground_truth.json +0 -26
- package/benchmark/invoice_ocr/data/invoice_04_row_7.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_05_ground_truth.json +0 -38
- package/benchmark/invoice_ocr/data/invoice_05_row_947.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_06_ground_truth.json +0 -38
- package/benchmark/invoice_ocr/data/invoice_06_row_948.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_07_ground_truth.json +0 -20
- package/benchmark/invoice_ocr/data/invoice_07_row_949.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_08_ground_truth.json +0 -38
- package/benchmark/invoice_ocr/data/invoice_08_row_1888.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_09_ground_truth.json +0 -26
- package/benchmark/invoice_ocr/data/invoice_09_row_1890.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_10_ground_truth.json +0 -20
- package/benchmark/invoice_ocr/data/invoice_10_row_1892.jpg +0 -0
- package/benchmark/invoice_ocr/data/manifest.json +0 -97
- package/dist/benchmarks.js +0 -98
- package/dist/catalog.js +0 -61
- package/dist/cli.js +0 -188
- package/dist/daemon.js +0 -407
- package/dist/diagnostics.js +0 -227
- package/dist/frontier.js +0 -56
- package/dist/harness.js +0 -1
- package/dist/integrations.js +0 -19
- package/dist/invoice-eval.js +0 -33
- package/dist/invoice-score.js +0 -124
- package/dist/judge.js +0 -43
- package/dist/llm.js +0 -207
- package/dist/pi-config.js +0 -46
- package/dist/pi-trials.js +0 -373
- package/dist/policy.js +0 -185
- package/dist/providers.js +0 -1
- package/dist/recommend.js +0 -76
- package/dist/routes.js +0 -74
- package/dist/standalone.js +0 -224
- package/dist/store.js +0 -89
- package/dist/strategist.js +0 -68
- package/dist/task-input.js +0 -54
- package/dist/traces.js +0 -127
- package/dist/trials.js +0 -140
- package/dist/types.js +0 -2
- package/examples/invoice-prompt.txt +0 -19
- package/examples/task.example.json +0 -7
- package/extension/openmerit.ts +0 -947
- package/instructions/OPENMERIT.md +0 -63
- package/instructions/openmerit.policy.json +0 -37
- package/rules.md +0 -43
package/extension/openmerit.ts
DELETED
|
@@ -1,947 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenMerit pi extension — runs INSIDE the main harness.
|
|
3
|
-
*
|
|
4
|
-
* Install: `pi install /path/to/openmerit` after `npm ci`.
|
|
5
|
-
* The extension launches session-bound comparisons after pi settles.
|
|
6
|
-
*
|
|
7
|
-
* What it does:
|
|
8
|
-
* - Reports this harness's current model to openmerit (~/.openmerit/harness-state.json).
|
|
9
|
-
* - Polls ~/.openmerit/recommendations.jsonl for model-swap recommendations
|
|
10
|
-
* produced by the background track.
|
|
11
|
-
* - Applies swaps with pi.setModel() when the policy gate says "auto",
|
|
12
|
-
* otherwise asks (Apply / Dismiss) or waits for /openmerit apply.
|
|
13
|
-
* - Keeps the fallback model: on provider errors/rate limits, offers to
|
|
14
|
-
* switch to the frontier fallback.
|
|
15
|
-
*
|
|
16
|
-
* Self-contained on purpose: pi loads extensions standalone via jiti.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import {
|
|
21
|
-
appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
|
|
22
|
-
} from "node:fs";
|
|
23
|
-
import { homedir } from "node:os";
|
|
24
|
-
import { basename, join } from "node:path";
|
|
25
|
-
import { createHash } from "node:crypto";
|
|
26
|
-
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
27
|
-
import type { Readable } from "node:stream";
|
|
28
|
-
import { fileURLToPath } from "node:url";
|
|
29
|
-
|
|
30
|
-
const HOME = process.env.OPENMERIT_HOME ?? join(homedir(), ".openmerit");
|
|
31
|
-
const RECS_FILE = join(HOME, "recommendations.jsonl");
|
|
32
|
-
const TRIALS_FILE = join(HOME, "trials.jsonl");
|
|
33
|
-
const STATE_FILE = join(HOME, "harness-state.json");
|
|
34
|
-
const POLICY_FILE = join(HOME, "policy.json");
|
|
35
|
-
const LEDGER_FILE = join(HOME, "ledger.json");
|
|
36
|
-
const JOB_DIR = join(HOME, "watch", "jobs");
|
|
37
|
-
const ENGINE_FILE = join(fileURLToPath(new URL("..", import.meta.url)), "dist", "cli.js");
|
|
38
|
-
const PROGRESS_PREFIX = "[openmerit/progress] ";
|
|
39
|
-
|
|
40
|
-
interface TrialRunProgress {
|
|
41
|
-
phase: "start" | "complete";
|
|
42
|
-
taskKey: string;
|
|
43
|
-
model: string;
|
|
44
|
-
index: number;
|
|
45
|
-
total: number;
|
|
46
|
-
score?: number;
|
|
47
|
-
price?: number;
|
|
48
|
-
latencyMs?: number;
|
|
49
|
-
costUsd?: number;
|
|
50
|
-
error?: string;
|
|
51
|
-
toolCalls?: number;
|
|
52
|
-
toolErrors?: number;
|
|
53
|
-
changedFiles?: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface TrialSelectionProgress {
|
|
57
|
-
phase: "selection";
|
|
58
|
-
taskKey: string;
|
|
59
|
-
eligible: string[];
|
|
60
|
-
excluded: { model: string; reasons: string[] }[];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
type TrialProgress = TrialRunProgress | TrialSelectionProgress;
|
|
64
|
-
|
|
65
|
-
function parseTrialProgress(line: string): TrialProgress | null {
|
|
66
|
-
if (!line.startsWith(PROGRESS_PREFIX)) return null;
|
|
67
|
-
try {
|
|
68
|
-
const value = JSON.parse(line.slice(PROGRESS_PREFIX.length)) as TrialProgress;
|
|
69
|
-
if (typeof value.taskKey !== "string") return null;
|
|
70
|
-
if (value.phase === "selection") {
|
|
71
|
-
if (!Array.isArray(value.eligible) || !Array.isArray(value.excluded) ||
|
|
72
|
-
value.eligible.some((item) => typeof item !== "string") ||
|
|
73
|
-
value.excluded.some((item) => typeof item?.model !== "string" || !Array.isArray(item.reasons) ||
|
|
74
|
-
item.reasons.some((reason) => typeof reason !== "string"))) return null;
|
|
75
|
-
return value;
|
|
76
|
-
}
|
|
77
|
-
if ((value.phase !== "start" && value.phase !== "complete") || typeof value.model !== "string" ||
|
|
78
|
-
!Number.isInteger(value.index) || !Number.isInteger(value.total) ||
|
|
79
|
-
value.index < 1 || value.total < value.index) return null;
|
|
80
|
-
return value;
|
|
81
|
-
} catch { return null; }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function completedTrialText(trial: TrialRunProgress): string {
|
|
85
|
-
return `${trial.model}: quality ${(trial.score ?? 0).toFixed(2)}, ` +
|
|
86
|
-
`$${(trial.price ?? 0).toFixed(2)}/M, ${Math.round(trial.latencyMs ?? 0)}ms, ` +
|
|
87
|
-
`run $${(trial.costUsd ?? 0).toFixed(4)}, tools ${trial.toolCalls ?? 0}` +
|
|
88
|
-
(trial.toolErrors || trial.changedFiles ? `, tool errors ${trial.toolErrors ?? 0}, changed files ${trial.changedFiles ?? 0}` : "") +
|
|
89
|
-
(trial.error ? ` (failed: ${trial.error})` : "");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface ExtensionPolicy {
|
|
93
|
-
mode?: string;
|
|
94
|
-
fallback?: { apply_on_error?: boolean };
|
|
95
|
-
budgets?: { max_trials_per_day?: number; max_usd_per_day?: number };
|
|
96
|
-
route_overrides?: Record<string, {
|
|
97
|
-
cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
|
|
98
|
-
context_window?: number;
|
|
99
|
-
max_tokens?: number;
|
|
100
|
-
input?: ("text" | "image")[];
|
|
101
|
-
}>;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function trialBudget(): { summary: string; reason: string | null } {
|
|
105
|
-
const policy = loadPolicy();
|
|
106
|
-
const limit = policy.budgets?.max_trials_per_day ?? 20;
|
|
107
|
-
const usdLimit = policy.budgets?.max_usd_per_day ?? 5;
|
|
108
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
109
|
-
let ledger: { date?: string; trials?: number; usd?: number } = {};
|
|
110
|
-
try { ledger = JSON.parse(readFileSync(LEDGER_FILE, "utf8")) as typeof ledger; }
|
|
111
|
-
catch {
|
|
112
|
-
if (existsSync(LEDGER_FILE)) return {
|
|
113
|
-
summary: "ledger unreadable",
|
|
114
|
-
reason: "trial ledger is malformed; run `openmerit doctor` before spending",
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
const trials = ledger.date === today ? ledger.trials ?? 0 : 0;
|
|
118
|
-
const usd = ledger.date === today ? ledger.usd ?? 0 : 0;
|
|
119
|
-
const reason = trials >= limit ? `daily trial cap reached (${limit})`
|
|
120
|
-
: usd >= usdLimit ? `daily budget exhausted ($${usdLimit})` : null;
|
|
121
|
-
return { summary: `${trials}/${limit} trials, $${usd.toFixed(4)}/$${usdLimit.toFixed(2)} today (UTC)`, reason };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function latestSessionJob(sessionFile: string | undefined): string | null {
|
|
125
|
-
if (!sessionFile || !existsSync(JOB_DIR)) return null;
|
|
126
|
-
const jobs: { marker?: string; status?: string; updatedAt?: string }[] = [];
|
|
127
|
-
for (const file of readdirSync(JOB_DIR)) {
|
|
128
|
-
if (!file.endsWith(".json")) continue;
|
|
129
|
-
try {
|
|
130
|
-
const job = JSON.parse(readFileSync(join(JOB_DIR, file), "utf8")) as typeof jobs[number];
|
|
131
|
-
if (job.marker?.startsWith(`${sessionFile}:`)) jobs.push(job);
|
|
132
|
-
} catch { /* partial or unrelated job */ }
|
|
133
|
-
}
|
|
134
|
-
const latest = jobs.sort((a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? "")).at(-1);
|
|
135
|
-
return latest?.status ?? null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function loadPolicy(): ExtensionPolicy {
|
|
139
|
-
if (!existsSync(POLICY_FILE)) return {};
|
|
140
|
-
try {
|
|
141
|
-
return JSON.parse(readFileSync(POLICY_FILE, "utf8")) as ExtensionPolicy;
|
|
142
|
-
} catch {
|
|
143
|
-
return { mode: "recommend", fallback: { apply_on_error: false },
|
|
144
|
-
budgets: { max_trials_per_day: 0, max_usd_per_day: 0 } };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
interface Recommendation {
|
|
149
|
-
schemaVersion?: 1;
|
|
150
|
-
id: string;
|
|
151
|
-
ts: string;
|
|
152
|
-
taskKey: string;
|
|
153
|
-
taskLabel: string;
|
|
154
|
-
sessionFile?: string | null;
|
|
155
|
-
currentModel: string | null;
|
|
156
|
-
currentRoute?: ModelRoute | null;
|
|
157
|
-
recommended: { model: string; route?: ModelRoute; reason: string };
|
|
158
|
-
fallback: { model: string; route?: ModelRoute; reason: string } | null;
|
|
159
|
-
evidence: { scoreGain: number; priceRatio: number; priceKnown?: boolean; onFrontier: boolean; trials: number };
|
|
160
|
-
policy: { autoApply: boolean; reasons: string[] };
|
|
161
|
-
status: "pending" | "applied" | "dismissed" | "expired";
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
interface ModelRoute {
|
|
165
|
-
provider: string;
|
|
166
|
-
modelId: string;
|
|
167
|
-
meritId: string;
|
|
168
|
-
input: ("text" | "image")[];
|
|
169
|
-
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
|
170
|
-
contextWindow?: number;
|
|
171
|
-
maxTokens?: number;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
interface RecordedTrial {
|
|
175
|
-
taskKey?: string;
|
|
176
|
-
sessionFile?: string;
|
|
177
|
-
model: string;
|
|
178
|
-
score: number;
|
|
179
|
-
price: number;
|
|
180
|
-
latencyMs?: number;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function readJsonl<T>(file: string): T[] {
|
|
184
|
-
if (!existsSync(file)) return [];
|
|
185
|
-
return readFileSync(file, "utf8")
|
|
186
|
-
.split("\n")
|
|
187
|
-
.filter((l) => l.trim())
|
|
188
|
-
.map((l) => {
|
|
189
|
-
try {
|
|
190
|
-
return JSON.parse(l) as T;
|
|
191
|
-
} catch {
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
})
|
|
195
|
-
.filter((x): x is T => x !== null);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function appendJsonl(file: string, obj: unknown): void {
|
|
199
|
-
mkdirSync(HOME, { recursive: true });
|
|
200
|
-
appendFileSync(file, JSON.stringify(obj) + "\n");
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Latest status per recommendation id. */
|
|
204
|
-
function latestRecommendations(): Recommendation[] {
|
|
205
|
-
const byId = new Map<string, Recommendation>();
|
|
206
|
-
for (const r of readJsonl<Recommendation>(RECS_FILE)) byId.set(r.id, r);
|
|
207
|
-
let malformed = false;
|
|
208
|
-
if (existsSync(RECS_FILE)) {
|
|
209
|
-
for (const line of readFileSync(RECS_FILE, "utf8").split("\n")) {
|
|
210
|
-
if (!line.trim()) continue;
|
|
211
|
-
try { JSON.parse(line); } catch { malformed = true; break; }
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return [...byId.values()].map((rec) => malformed && rec.status === "pending" && rec.policy.autoApply
|
|
215
|
-
? { ...rec, policy: { autoApply: false,
|
|
216
|
-
reasons: [...rec.policy.reasons, "recommendation history contains a malformed line; manual review required"] } }
|
|
217
|
-
: rec);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function taskKey(text: string): string {
|
|
221
|
-
return createHash("sha1").update(text.toLowerCase().replace(/\s+/g, " ").trim()).digest("hex").slice(0, 12);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function userTaskKey(content: unknown): string | null {
|
|
225
|
-
const parts = typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
226
|
-
if (!Array.isArray(parts)) return null;
|
|
227
|
-
const text = parts.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join(" ");
|
|
228
|
-
if (!text.trim()) return null;
|
|
229
|
-
const images = parts.filter((b) => b?.type === "image");
|
|
230
|
-
if (parts.length !== parts.filter((b) => b?.type === "text" || b?.type === "image").length ||
|
|
231
|
-
images.some((b) => typeof b.data !== "string" || typeof b.mimeType !== "string")) return null;
|
|
232
|
-
const files: string[] = [];
|
|
233
|
-
const fileRe = /<file\s+name=["']([^"']+)["'][^>]*>/g;
|
|
234
|
-
for (const match of text.matchAll(fileRe)) {
|
|
235
|
-
const file = match[1];
|
|
236
|
-
if (!file || !existsSync(file) || files.includes(file)) continue;
|
|
237
|
-
try { files.push(`${basename(file)}:` + createHash("sha256").update(readFileSync(file)).digest("hex")); }
|
|
238
|
-
catch { /* inaccessible attachment */ }
|
|
239
|
-
}
|
|
240
|
-
if (images.length === 0 && files.length === 0) return taskKey(text);
|
|
241
|
-
const hashes = images.map((b) => `${b.mimeType}:` +
|
|
242
|
-
createHash("sha256").update(Buffer.from(b.data, "base64")).digest("hex"));
|
|
243
|
-
return taskKey(`${text.toLowerCase().replace(/\s+/g, " ").trim()}\nimages:${hashes.join(",")}\nfiles:${files.join(",")}`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function latestSessionTaskKey(ctx: ExtensionContext): string | null {
|
|
247
|
-
const file = ctx.sessionManager.getSessionFile();
|
|
248
|
-
if (!file || !existsSync(file)) return null;
|
|
249
|
-
let latest: string | null = null;
|
|
250
|
-
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
251
|
-
if (!line.trim()) continue;
|
|
252
|
-
try {
|
|
253
|
-
const e = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
|
|
254
|
-
if (e.type !== "message" || e.message?.role !== "user") continue;
|
|
255
|
-
latest = userTaskKey(e.message.content);
|
|
256
|
-
} catch { /* partial/corrupt session line */ }
|
|
257
|
-
}
|
|
258
|
-
return latest;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
let settledTaskKey: string | null = null;
|
|
262
|
-
let comparisonsPaused = false;
|
|
263
|
-
|
|
264
|
-
function pendingRecommendations(ctx?: ExtensionContext): Recommendation[] {
|
|
265
|
-
const pending = latestRecommendations().filter((r) => r.status === "pending");
|
|
266
|
-
if (!ctx) return pending;
|
|
267
|
-
const file = ctx.sessionManager.getSessionFile();
|
|
268
|
-
const liveTaskKey = latestSessionTaskKey(ctx);
|
|
269
|
-
return pending.filter((r) => r.sessionFile
|
|
270
|
-
? r.sessionFile === file && r.taskKey === settledTaskKey
|
|
271
|
-
: !!liveTaskKey && r.taskKey === liveTaskKey);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Earlier measured comparisons are advice in another session, never pending swaps. */
|
|
275
|
-
function priorExactTaskSuggestion(ctx: ExtensionContext): {
|
|
276
|
-
recommendation: Recommendation; trials: RecordedTrial[];
|
|
277
|
-
} | null {
|
|
278
|
-
const file = ctx.sessionManager.getSessionFile();
|
|
279
|
-
const key = latestSessionTaskKey(ctx);
|
|
280
|
-
if (!file || !key || key !== settledTaskKey) return null;
|
|
281
|
-
const prior = latestRecommendations().filter((r) =>
|
|
282
|
-
r.sessionFile && r.sessionFile !== file && r.taskKey === key &&
|
|
283
|
-
(r.status === "pending" || r.status === "applied") && r.evidence.trials >= 2);
|
|
284
|
-
const recommendation = prior.at(-1);
|
|
285
|
-
if (!recommendation) return null;
|
|
286
|
-
const byModel = new Map<string, RecordedTrial>();
|
|
287
|
-
for (const trial of readJsonl<RecordedTrial>(TRIALS_FILE)) {
|
|
288
|
-
if (trial.taskKey === key && trial.sessionFile === recommendation.sessionFile) {
|
|
289
|
-
byModel.set(trial.model, trial);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return { recommendation, trials: [...byModel.values()] };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function suggestionText(rec: Recommendation, trials: RecordedTrial[]): string {
|
|
296
|
-
const measured = trials.map((p) =>
|
|
297
|
-
`${p.model}: quality ${p.score.toFixed(2)}, $${p.price.toFixed(2)}/M` +
|
|
298
|
-
(p.latencyMs ? `, ${Math.round(p.latencyMs)}ms` : ""));
|
|
299
|
-
return `openmerit: exact task was tested in another pi session (${rec.evidence.trials} models). ` +
|
|
300
|
-
`Earlier choice: ${rec.recommended.model}. This session will run its own comparison before any swap.` +
|
|
301
|
-
(measured.length ? `\n${measured.join("\n")}` : `\n${rec.recommended.reason}`);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function canonicalModel(provider: string, id: string): string {
|
|
305
|
-
return provider === "openrouter" || id.startsWith(`${provider}/`) ? id : `${provider}/${id}`;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function routeDisplay(route: ModelRoute | undefined, fallback: string): string {
|
|
309
|
-
return route && route.provider !== "openrouter" ? `${route.meritId} via ${route.provider}` : fallback;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function setStatus(rec: Recommendation, status: Recommendation["status"]): void {
|
|
313
|
-
appendJsonl(RECS_FILE, { ...rec, status, ts: new Date().toISOString() });
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
let fallbackModel: string | null = null;
|
|
317
|
-
let fallbackRoute: ModelRoute | null = null;
|
|
318
|
-
|
|
319
|
-
function routeFor(model: { provider: string; id: string; input?: ("text" | "image")[];
|
|
320
|
-
cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
|
|
321
|
-
contextWindow?: number; maxTokens?: number }, overrides = loadPolicy().route_overrides ?? {}): ModelRoute {
|
|
322
|
-
const route: ModelRoute = {
|
|
323
|
-
provider: model.provider,
|
|
324
|
-
modelId: model.id,
|
|
325
|
-
meritId: canonicalModel(model.provider, model.id),
|
|
326
|
-
input: model.input ?? ["text"],
|
|
327
|
-
cost: model.cost && typeof model.cost.input === "number" && typeof model.cost.output === "number"
|
|
328
|
-
? { input: model.cost.input, output: model.cost.output,
|
|
329
|
-
cacheRead: model.cost.cacheRead, cacheWrite: model.cost.cacheWrite }
|
|
330
|
-
: undefined,
|
|
331
|
-
contextWindow: model.contextWindow,
|
|
332
|
-
maxTokens: model.maxTokens,
|
|
333
|
-
};
|
|
334
|
-
const override = overrides[`${route.provider}:${route.modelId}`];
|
|
335
|
-
if (!override) return route;
|
|
336
|
-
const finite = (value: unknown, min: number) =>
|
|
337
|
-
typeof value === "number" && Number.isFinite(value) && value >= min ? value : undefined;
|
|
338
|
-
const integer = (value: unknown) => {
|
|
339
|
-
const normalized = finite(value, 1);
|
|
340
|
-
return normalized !== undefined && Number.isInteger(normalized) ? normalized : undefined;
|
|
341
|
-
};
|
|
342
|
-
const input = Array.isArray(override.input) && override.input.length > 0 &&
|
|
343
|
-
override.input.every((item) => item === "text" || item === "image")
|
|
344
|
-
? [...new Set(override.input)] : undefined;
|
|
345
|
-
const costInput = finite(override.cost?.input, 0);
|
|
346
|
-
const costOutput = finite(override.cost?.output, 0);
|
|
347
|
-
return {
|
|
348
|
-
...route,
|
|
349
|
-
...(costInput === undefined || costOutput === undefined ? {} : { cost: {
|
|
350
|
-
input: costInput,
|
|
351
|
-
output: costOutput,
|
|
352
|
-
cacheRead: finite(override.cost?.cacheRead, 0),
|
|
353
|
-
cacheWrite: finite(override.cost?.cacheWrite, 0),
|
|
354
|
-
}}),
|
|
355
|
-
...(integer(override.context_window) === undefined ? {} : {
|
|
356
|
-
contextWindow: integer(override.context_window),
|
|
357
|
-
}),
|
|
358
|
-
...(integer(override.max_tokens) === undefined ? {} : { maxTokens: integer(override.max_tokens) }),
|
|
359
|
-
...(input ? { input } : {}),
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function eligibleRoutes(ctx: ExtensionContext): ModelRoute[] {
|
|
364
|
-
const registry = ctx.modelRegistry as unknown as { getAvailable?: () => Array<Parameters<typeof routeFor>[0]> };
|
|
365
|
-
const scoped = (ctx.scopedModels ?? []) as readonly { model: Parameters<typeof routeFor>[0] }[];
|
|
366
|
-
const models = scoped.length ? scoped.map((item) => item.model) : registry.getAvailable?.() ?? [];
|
|
367
|
-
if (ctx.model && !models.some((model) => model.provider === ctx.model!.provider && model.id === ctx.model!.id))
|
|
368
|
-
models.unshift(ctx.model);
|
|
369
|
-
const byRoute = new Map<string, ModelRoute>();
|
|
370
|
-
const overrides = loadPolicy().route_overrides ?? {};
|
|
371
|
-
for (const model of models) {
|
|
372
|
-
const route = routeFor(model, overrides);
|
|
373
|
-
byRoute.set(`${route.provider}:${route.modelId}`, route);
|
|
374
|
-
}
|
|
375
|
-
return [...byRoute.values()];
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function writeState(value: unknown): void {
|
|
379
|
-
const temp = `${STATE_FILE}.tmp-${process.pid}-${Date.now()}`;
|
|
380
|
-
try {
|
|
381
|
-
writeFileSync(temp, JSON.stringify(value) + "\n");
|
|
382
|
-
renameSync(temp, STATE_FILE);
|
|
383
|
-
} finally {
|
|
384
|
-
if (existsSync(temp)) rmSync(temp, { force: true });
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function reportHarnessState(ctx: ExtensionContext, settled = false): void {
|
|
389
|
-
try {
|
|
390
|
-
mkdirSync(HOME, { recursive: true });
|
|
391
|
-
const model = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : null;
|
|
392
|
-
const currentRoute = ctx.model ? routeFor(ctx.model) : null;
|
|
393
|
-
if (settled) settledTaskKey = latestSessionTaskKey(ctx);
|
|
394
|
-
const prior = existsSync(STATE_FILE)
|
|
395
|
-
? JSON.parse(readFileSync(STATE_FILE, "utf8")) as { sessionFile?: string | null; settledAt?: string }
|
|
396
|
-
: {};
|
|
397
|
-
const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
398
|
-
const settledAt = settled ? new Date().toISOString()
|
|
399
|
-
: prior.sessionFile === sessionFile ? prior.settledAt ?? null : null;
|
|
400
|
-
writeState({
|
|
401
|
-
schemaVersion: 2,
|
|
402
|
-
currentModel: model,
|
|
403
|
-
currentRoute,
|
|
404
|
-
routes: eligibleRoutes(ctx),
|
|
405
|
-
fallbackModel,
|
|
406
|
-
fallbackRoute,
|
|
407
|
-
sessionFile,
|
|
408
|
-
settledTaskKey,
|
|
409
|
-
settledAt,
|
|
410
|
-
comparisonsPaused,
|
|
411
|
-
cwd: ctx.cwd,
|
|
412
|
-
updatedAt: new Date().toISOString(),
|
|
413
|
-
});
|
|
414
|
-
} catch {
|
|
415
|
-
/* never break the host session over reporting */
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** Restore the persisted fallback so a fresh session keeps it even with no pending recs. */
|
|
420
|
-
function restoreFallback(): void {
|
|
421
|
-
try {
|
|
422
|
-
const st = JSON.parse(readFileSync(STATE_FILE, "utf8")) as {
|
|
423
|
-
fallbackModel?: string | null; fallbackRoute?: ModelRoute | null;
|
|
424
|
-
};
|
|
425
|
-
if (st.fallbackModel) fallbackModel = st.fallbackModel;
|
|
426
|
-
if (st.fallbackRoute) fallbackRoute = st.fallbackRoute;
|
|
427
|
-
} catch {
|
|
428
|
-
/* no state yet */
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** Resolve an OpenRouter-style "vendor/model" id against pi's model registry. */
|
|
433
|
-
function resolveModel(ctx: ExtensionContext, openrouterId: string) {
|
|
434
|
-
const [vendor, ...rest] = openrouterId.split("/");
|
|
435
|
-
const modelId = rest.join("/");
|
|
436
|
-
const reg = ctx.modelRegistry as unknown as {
|
|
437
|
-
find?: (provider: string, id: string) => unknown | undefined;
|
|
438
|
-
getAvailable?: () => { provider: string; id: string }[];
|
|
439
|
-
};
|
|
440
|
-
// Prefer the OpenRouter provider entry itself.
|
|
441
|
-
const viaOpenRouter = reg.find?.("openrouter", openrouterId) ?? reg.find?.("openrouter", modelId);
|
|
442
|
-
if (viaOpenRouter) return viaOpenRouter;
|
|
443
|
-
// Then a native provider ("anthropic/claude-..." -> provider "anthropic").
|
|
444
|
-
const native = reg.find?.(vendor, modelId);
|
|
445
|
-
if (native) return native;
|
|
446
|
-
// Last resort: scan the catalogue for an id match.
|
|
447
|
-
for (const m of reg.getAvailable?.() ?? []) {
|
|
448
|
-
if (m.id === openrouterId || m.id === modelId || m.id.endsWith(`/${modelId}`)) return m;
|
|
449
|
-
}
|
|
450
|
-
return undefined;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
interface CatalogEntryLite {
|
|
454
|
-
id: string;
|
|
455
|
-
name?: string;
|
|
456
|
-
ctx?: number;
|
|
457
|
-
pp?: number;
|
|
458
|
-
pc?: number;
|
|
459
|
-
inputModalities?: string[];
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const INJECT_PROVIDER = "openmerit-openrouter";
|
|
463
|
-
const injectedModels: CatalogEntryLite[] = [];
|
|
464
|
-
|
|
465
|
-
function catalogEntry(openrouterId: string): CatalogEntryLite | undefined {
|
|
466
|
-
try {
|
|
467
|
-
const snap = JSON.parse(readFileSync(join(HOME, "catalog", "snapshot.json"), "utf8")) as {
|
|
468
|
-
entries?: CatalogEntryLite[];
|
|
469
|
-
};
|
|
470
|
-
return (snap.entries ?? []).find((e) => e.id === openrouterId);
|
|
471
|
-
} catch {
|
|
472
|
-
return undefined;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Ensure pi can use the model. pi's built-in registry lags the live
|
|
478
|
-
* OpenRouter catalog, so inject anything missing via registerProvider
|
|
479
|
-
* (takes effect immediately, no /reload needed).
|
|
480
|
-
*/
|
|
481
|
-
function ensureModel(pi: ExtensionAPI, ctx: ExtensionContext, openrouterId: string, route?: ModelRoute) {
|
|
482
|
-
const registry = ctx.modelRegistry as unknown as {
|
|
483
|
-
find?: (provider: string, id: string) => unknown | undefined;
|
|
484
|
-
};
|
|
485
|
-
if (route) {
|
|
486
|
-
const exact = registry.find?.(route.provider, route.modelId);
|
|
487
|
-
if (exact) return exact;
|
|
488
|
-
if (route.provider !== "openrouter") return undefined;
|
|
489
|
-
}
|
|
490
|
-
const existing = resolveModel(ctx, openrouterId);
|
|
491
|
-
if (existing) return existing;
|
|
492
|
-
|
|
493
|
-
const entry = catalogEntry(openrouterId);
|
|
494
|
-
if (!entry) return undefined;
|
|
495
|
-
if (!injectedModels.some((m) => m.id === entry.id)) injectedModels.push(entry);
|
|
496
|
-
|
|
497
|
-
pi.registerProvider(INJECT_PROVIDER, {
|
|
498
|
-
baseUrl: "https://openrouter.ai/api/v1",
|
|
499
|
-
apiKey: "$OPENROUTER_API_KEY",
|
|
500
|
-
api: "openai-completions",
|
|
501
|
-
models: injectedModels.map((m) => ({
|
|
502
|
-
id: m.id,
|
|
503
|
-
name: m.name ?? m.id,
|
|
504
|
-
reasoning: false,
|
|
505
|
-
input: entry.inputModalities?.includes("image") ? ["text", "image"] : ["text"],
|
|
506
|
-
cost: {
|
|
507
|
-
input: (m.pp ?? 0) * 1e6,
|
|
508
|
-
output: (m.pc ?? 0) * 1e6,
|
|
509
|
-
cacheRead: 0,
|
|
510
|
-
cacheWrite: 0,
|
|
511
|
-
},
|
|
512
|
-
contextWindow: m.ctx && m.ctx > 0 ? m.ctx : 128000,
|
|
513
|
-
maxTokens: 16384,
|
|
514
|
-
})),
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
return registry.find?.(INJECT_PROVIDER, openrouterId);
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async function doctorSummary(): Promise<{ text: string; ok: boolean }> {
|
|
521
|
-
if (!existsSync(ENGINE_FILE)) return {
|
|
522
|
-
text: "trial engine missing; reinstall OpenMerit or run npm ci in the checkout",
|
|
523
|
-
ok: false,
|
|
524
|
-
};
|
|
525
|
-
const child = spawn(process.execPath, [ENGINE_FILE, "doctor", "--json"], {
|
|
526
|
-
env: process.env, stdio: ["ignore", "pipe", "pipe"],
|
|
527
|
-
});
|
|
528
|
-
let stdout = "";
|
|
529
|
-
let stderr = "";
|
|
530
|
-
child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
531
|
-
child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
532
|
-
const code = await new Promise<number>((resolve, reject) => {
|
|
533
|
-
child.on("error", reject);
|
|
534
|
-
child.on("close", (value) => resolve(value ?? 1));
|
|
535
|
-
});
|
|
536
|
-
try {
|
|
537
|
-
const report = JSON.parse(stdout) as { ok?: boolean; checks?: {
|
|
538
|
-
id?: string; status?: string; message?: string;
|
|
539
|
-
}[] };
|
|
540
|
-
const lines = (report.checks ?? []).map((item) =>
|
|
541
|
-
`${(item.status ?? "?").toUpperCase().padEnd(4)} ${item.id ?? "check"}: ${item.message ?? ""}`);
|
|
542
|
-
return { text: lines.join("\n") || "doctor returned no checks", ok: report.ok === true };
|
|
543
|
-
} catch {
|
|
544
|
-
return { text: stderr.trim().slice(0, 500) || stdout.trim().slice(0, 500) || `doctor exited ${code}`, ok: false };
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
export default function openmerit(pi: ExtensionAPI) {
|
|
549
|
-
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
550
|
-
let trialJob: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
|
551
|
-
const queuedJobs: { sessionFile: string; settledAt: string; model: string; route: ModelRoute;
|
|
552
|
-
key: string; cwd: string; bytes: number }[] = [];
|
|
553
|
-
const startedJobs = new Set<string>();
|
|
554
|
-
let activeSession: string | null = null;
|
|
555
|
-
let progress: { sessionFile: string; taskKey: string; current: TrialRunProgress | null;
|
|
556
|
-
completed: TrialRunProgress[]; eligible: string[]; excluded: TrialSelectionProgress["excluded"] } | null = null;
|
|
557
|
-
|
|
558
|
-
function stopTrialJob(): void {
|
|
559
|
-
if (!trialJob) return;
|
|
560
|
-
const child = trialJob;
|
|
561
|
-
trialJob = null;
|
|
562
|
-
try {
|
|
563
|
-
if (child.pid && process.platform !== "win32") process.kill(-child.pid, "SIGTERM");
|
|
564
|
-
else child.kill("SIGTERM");
|
|
565
|
-
} catch { child.kill("SIGTERM"); }
|
|
566
|
-
}
|
|
567
|
-
const notified = new Set<string>();
|
|
568
|
-
const suggested = new Set<string>();
|
|
569
|
-
|
|
570
|
-
function startNextJob(ctx: ExtensionContext): void {
|
|
571
|
-
if (trialJob || queuedJobs.length === 0) return;
|
|
572
|
-
const job = queuedJobs.shift()!;
|
|
573
|
-
if (job.sessionFile !== activeSession) return startNextJob(ctx);
|
|
574
|
-
if (!existsSync(ENGINE_FILE)) {
|
|
575
|
-
ctx.ui.notify("openmerit: trial engine missing; run npm ci in the OpenMerit checkout", "error");
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
const child = spawn(process.execPath, [ENGINE_FILE, "session-trial", job.sessionFile,
|
|
579
|
-
job.settledAt, job.model, job.key, job.cwd, String(job.bytes), job.route.provider, job.route.modelId], {
|
|
580
|
-
cwd: job.cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"],
|
|
581
|
-
detached: process.platform !== "win32",
|
|
582
|
-
});
|
|
583
|
-
trialJob = child;
|
|
584
|
-
progress = { sessionFile: job.sessionFile, taskKey: job.key, current: null, completed: [],
|
|
585
|
-
eligible: [], excluded: [] };
|
|
586
|
-
if (ctx.hasUI) ctx.ui.notify(`openmerit: comparing models for task ${job.key} (A=${job.model})`, "info");
|
|
587
|
-
let stdout = "";
|
|
588
|
-
let stderr = "";
|
|
589
|
-
function onLine(line: string): void {
|
|
590
|
-
const trial = parseTrialProgress(line);
|
|
591
|
-
if (trial && job.sessionFile === activeSession && progress?.taskKey === trial.taskKey) {
|
|
592
|
-
if (trial.phase === "selection") {
|
|
593
|
-
progress.eligible = trial.eligible;
|
|
594
|
-
progress.excluded = trial.excluded;
|
|
595
|
-
if (ctx.hasUI) ctx.ui.notify(`openmerit: ${trial.eligible.length} routes eligible; ` +
|
|
596
|
-
`${trial.excluded.length} skipped (/openmerit for reasons)`, "info");
|
|
597
|
-
} else if (trial.phase === "start") {
|
|
598
|
-
progress.current = trial;
|
|
599
|
-
if (ctx.hasUI) ctx.ui.notify(
|
|
600
|
-
`openmerit: trial ${trial.index}/${trial.total} comparing ${trial.model}` +
|
|
601
|
-
(trial.index === 1 ? " (scoring Pi trace)" : ""), "info",
|
|
602
|
-
);
|
|
603
|
-
} else {
|
|
604
|
-
progress.current = null;
|
|
605
|
-
progress.completed.push(trial);
|
|
606
|
-
if (ctx.hasUI) ctx.ui.notify(`openmerit: trial ${trial.index}/${trial.total} finished — ` +
|
|
607
|
-
completedTrialText(trial), trial.error ? "warning" : "info");
|
|
608
|
-
}
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
if (ctx.hasUI && job.sessionFile === activeSession &&
|
|
612
|
-
(line.includes(": selected ") || line.includes(": active model remains best")))
|
|
613
|
-
ctx.ui.notify(line.trim(), "info");
|
|
614
|
-
}
|
|
615
|
-
child.stdout.on("data", (chunk: Buffer) => {
|
|
616
|
-
stdout += chunk.toString();
|
|
617
|
-
let end: number;
|
|
618
|
-
while ((end = stdout.indexOf("\n")) >= 0) {
|
|
619
|
-
onLine(stdout.slice(0, end));
|
|
620
|
-
stdout = stdout.slice(end + 1);
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
624
|
-
child.on("error", (error) => {
|
|
625
|
-
if (ctx.hasUI && job.sessionFile === activeSession)
|
|
626
|
-
ctx.ui.notify(`openmerit: trial engine failed: ${error.message}`, "error");
|
|
627
|
-
});
|
|
628
|
-
child.on("close", (code) => {
|
|
629
|
-
if (trialJob !== child) return;
|
|
630
|
-
trialJob = null;
|
|
631
|
-
if (job.sessionFile === activeSession) {
|
|
632
|
-
if (code !== 0 && ctx.hasUI)
|
|
633
|
-
ctx.ui.notify(`openmerit: comparison failed: ${stderr.trim().slice(0, 240) || `exit ${code}`}`, "error");
|
|
634
|
-
else surface(ctx).catch(() => undefined);
|
|
635
|
-
startNextJob(ctx);
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function queueSettledTask(ctx: ExtensionContext, force = false): string {
|
|
641
|
-
if (comparisonsPaused && !force) return "comparisons are paused";
|
|
642
|
-
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
643
|
-
const model = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : null;
|
|
644
|
-
const route = ctx.model ? routeFor(ctx.model) : null;
|
|
645
|
-
if (!sessionFile || !model || !route || !settledTaskKey || !existsSync(sessionFile))
|
|
646
|
-
return "no saved completed task is available";
|
|
647
|
-
// The alpha compares completed response tasks. The engine validates images,
|
|
648
|
-
// tool use and the baseline answer before any provider calls.
|
|
649
|
-
let completed = false;
|
|
650
|
-
for (const line of readFileSync(sessionFile, "utf8").split("\n")) {
|
|
651
|
-
try {
|
|
652
|
-
const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown; stopReason?: string } };
|
|
653
|
-
if (entry.type !== "message") continue;
|
|
654
|
-
if (entry.message?.role === "user") completed = false;
|
|
655
|
-
if (entry.message?.role === "assistant" && entry.message.stopReason !== "error" &&
|
|
656
|
-
userTaskKeyFromAnswer(entry.message.content)) completed = true;
|
|
657
|
-
} catch { /* partial session line */ }
|
|
658
|
-
}
|
|
659
|
-
if (!completed) return "the latest task has not completed successfully";
|
|
660
|
-
const settledAt = new Date().toISOString();
|
|
661
|
-
const bytes = readFileSync(sessionFile).length;
|
|
662
|
-
const marker = `${sessionFile}:${settledTaskKey}:${model}:${bytes}`;
|
|
663
|
-
if (startedJobs.has(marker)) return "this task is already queued or was compared in this Pi session";
|
|
664
|
-
startedJobs.add(marker);
|
|
665
|
-
const budget = trialBudget();
|
|
666
|
-
if (budget.reason) {
|
|
667
|
-
if (ctx.hasUI) ctx.ui.notify(`openmerit: comparison skipped: ${budget.reason}. ${budget.summary}`, "warning");
|
|
668
|
-
return budget.reason;
|
|
669
|
-
}
|
|
670
|
-
queuedJobs.push({ sessionFile, settledAt, model, route, key: settledTaskKey, cwd: ctx.cwd, bytes });
|
|
671
|
-
startNextJob(ctx);
|
|
672
|
-
return "comparison queued";
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function userTaskKeyFromAnswer(content: unknown): boolean {
|
|
676
|
-
if (typeof content === "string") return !!content.trim();
|
|
677
|
-
return Array.isArray(content) && content.some((part) => part?.type === "text" &&
|
|
678
|
-
typeof part.text === "string" && part.text.trim());
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
async function applyRecommendation(ctx: ExtensionContext, rec: Recommendation): Promise<boolean> {
|
|
682
|
-
const current = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : null;
|
|
683
|
-
const currentRoute = ctx.model ? routeFor(ctx.model) : null;
|
|
684
|
-
if (rec.currentRoute && currentRoute &&
|
|
685
|
-
(rec.currentRoute.provider !== currentRoute.provider || rec.currentRoute.modelId !== currentRoute.modelId)) {
|
|
686
|
-
ctx.ui.notify(`openmerit: recommendation targets ${rec.currentRoute.provider}/${rec.currentRoute.modelId}; ` +
|
|
687
|
-
`current route is ${currentRoute.provider}/${currentRoute.modelId}`, "warning");
|
|
688
|
-
return false;
|
|
689
|
-
}
|
|
690
|
-
if (!rec.currentRoute && rec.currentModel && current !== rec.currentModel) {
|
|
691
|
-
ctx.ui.notify(`openmerit: recommendation was measured against ${rec.currentModel}; current model is ${current ?? "unknown"}`, "warning");
|
|
692
|
-
return false;
|
|
693
|
-
}
|
|
694
|
-
const model = ensureModel(pi, ctx, rec.recommended.model, rec.recommended.route);
|
|
695
|
-
if (!model) {
|
|
696
|
-
ctx.ui.notify(
|
|
697
|
-
`openmerit: ${rec.recommended.model} not found in pi's model registry (add the provider/model first)`,
|
|
698
|
-
"warning",
|
|
699
|
-
);
|
|
700
|
-
return false;
|
|
701
|
-
}
|
|
702
|
-
const ok = await pi.setModel(model as Parameters<typeof pi.setModel>[0]);
|
|
703
|
-
if (ok) {
|
|
704
|
-
setStatus(rec, "applied");
|
|
705
|
-
if (rec.fallback) {
|
|
706
|
-
fallbackModel = rec.fallback.model;
|
|
707
|
-
fallbackRoute = rec.fallback.route ?? null;
|
|
708
|
-
}
|
|
709
|
-
ctx.ui.notify(`openmerit: switched to ${routeDisplay(rec.recommended.route, rec.recommended.model)}\n` +
|
|
710
|
-
rec.recommended.reason, "info");
|
|
711
|
-
reportHarnessState(ctx);
|
|
712
|
-
} else {
|
|
713
|
-
ctx.ui.notify(`openmerit: no auth for ${rec.recommended.model}`, "error");
|
|
714
|
-
}
|
|
715
|
-
return ok;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
async function surface(ctx: ExtensionContext): Promise<void> {
|
|
719
|
-
const prior = priorExactTaskSuggestion(ctx);
|
|
720
|
-
if (prior && ctx.hasUI) {
|
|
721
|
-
const key = `${ctx.sessionManager.getSessionFile()}:${prior.recommendation.id}:${settledTaskKey}`;
|
|
722
|
-
if (!suggested.has(key)) {
|
|
723
|
-
suggested.add(key);
|
|
724
|
-
ctx.ui.notify(suggestionText(prior.recommendation, prior.trials), "info");
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
for (const rec of pendingRecommendations(ctx)) {
|
|
728
|
-
if (notified.has(rec.id)) continue;
|
|
729
|
-
if (rec.policy.autoApply) {
|
|
730
|
-
if (!rec.sessionFile || rec.sessionFile !== ctx.sessionManager.getSessionFile()) continue;
|
|
731
|
-
if (!ctx.isIdle()) continue;
|
|
732
|
-
notified.add(rec.id);
|
|
733
|
-
await applyRecommendation(ctx, rec);
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
notified.add(rec.id);
|
|
737
|
-
// Never block here: surface() runs during session_start and on a poll
|
|
738
|
-
// timer, where a modal dialog would deadlock RPC mode and stall TUI
|
|
739
|
-
// startup. Notify (fire-and-forget) and let the user act via /openmerit.
|
|
740
|
-
if (ctx.hasUI) {
|
|
741
|
-
ctx.ui.notify(
|
|
742
|
-
`openmerit recommends: ${routeDisplay(rec.recommended.route, rec.recommended.model)}\n${rec.recommended.reason}\n` +
|
|
743
|
-
`gate: ${rec.policy.reasons.join("; ")}\n(/openmerit apply to switch, /openmerit dismiss to ignore)`,
|
|
744
|
-
"info",
|
|
745
|
-
);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
751
|
-
activeSession = ctx.sessionManager.getSessionFile() ?? null;
|
|
752
|
-
stopTrialJob();
|
|
753
|
-
queuedJobs.length = 0;
|
|
754
|
-
progress = null;
|
|
755
|
-
suggested.clear();
|
|
756
|
-
restoreFallback();
|
|
757
|
-
try {
|
|
758
|
-
const st = JSON.parse(readFileSync(STATE_FILE, "utf8")) as {
|
|
759
|
-
sessionFile?: string | null; settledTaskKey?: string | null; comparisonsPaused?: boolean;
|
|
760
|
-
};
|
|
761
|
-
comparisonsPaused = st.comparisonsPaused === true;
|
|
762
|
-
settledTaskKey = st.sessionFile === ctx.sessionManager.getSessionFile()
|
|
763
|
-
? st.settledTaskKey ?? null : null;
|
|
764
|
-
} catch { settledTaskKey = null; comparisonsPaused = false; }
|
|
765
|
-
reportHarnessState(ctx);
|
|
766
|
-
await surface(ctx);
|
|
767
|
-
if (pollTimer) clearInterval(pollTimer);
|
|
768
|
-
pollTimer = setInterval(() => {
|
|
769
|
-
surface(ctx).catch(() => undefined);
|
|
770
|
-
}, 5_000);
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
pi.on("agent_settled", async (_event, ctx) => {
|
|
774
|
-
reportHarnessState(ctx, true);
|
|
775
|
-
queueSettledTask(ctx);
|
|
776
|
-
await surface(ctx);
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
pi.on("input", async (_event, ctx) => {
|
|
780
|
-
// A ready comparison can switch the route before the next user task starts.
|
|
781
|
-
await surface(ctx);
|
|
782
|
-
return { action: "continue" };
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
pi.on("model_select", async (_event, ctx) => {
|
|
786
|
-
reportHarnessState(ctx);
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
pi.on("session_shutdown", async () => {
|
|
790
|
-
activeSession = null;
|
|
791
|
-
queuedJobs.length = 0;
|
|
792
|
-
stopTrialJob();
|
|
793
|
-
progress = null;
|
|
794
|
-
if (pollTimer) {
|
|
795
|
-
clearInterval(pollTimer);
|
|
796
|
-
pollTimer = null;
|
|
797
|
-
}
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
// Fallback swap when the active model finished with an error.
|
|
801
|
-
pi.on("agent_end", async (event, ctx) => {
|
|
802
|
-
if (!fallbackModel) return;
|
|
803
|
-
const e = event as unknown as {
|
|
804
|
-
willRetry?: boolean;
|
|
805
|
-
messages?: { role?: string; stopReason?: string }[];
|
|
806
|
-
};
|
|
807
|
-
if (e.willRetry) return; // pi is still retrying; wait for the final outcome
|
|
808
|
-
const lastAssistant = [...(e.messages ?? [])].reverse().find((m) => m.role === "assistant");
|
|
809
|
-
if (lastAssistant?.stopReason !== "error") return;
|
|
810
|
-
|
|
811
|
-
const policy = loadPolicy();
|
|
812
|
-
if (policy.fallback?.apply_on_error === false) return;
|
|
813
|
-
const current = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : "current model";
|
|
814
|
-
const model = ensureModel(pi, ctx, fallbackModel, fallbackRoute ?? undefined);
|
|
815
|
-
if (!model) return;
|
|
816
|
-
|
|
817
|
-
const auto = policy.mode === "auto";
|
|
818
|
-
let ok = auto;
|
|
819
|
-
if (!ok && ctx.mode === "tui") {
|
|
820
|
-
// Modal confirm only in TUI; in RPC mode an unanswered dialog blocks
|
|
821
|
-
// the command stream.
|
|
822
|
-
ok = await ctx.ui.confirm(
|
|
823
|
-
"openmerit fallback",
|
|
824
|
-
`${current} is failing. Switch to frontier fallback ${fallbackModel}?`,
|
|
825
|
-
);
|
|
826
|
-
} else if (!ok && ctx.hasUI) {
|
|
827
|
-
ctx.ui.notify(
|
|
828
|
-
`openmerit: ${current} is failing; frontier fallback is ${fallbackModel} (/openmerit fallback to switch)`,
|
|
829
|
-
"warning",
|
|
830
|
-
);
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
if (!ok) return;
|
|
834
|
-
if (await pi.setModel(model as Parameters<typeof pi.setModel>[0])) {
|
|
835
|
-
ctx.ui.notify(`openmerit: fallback engaged -> ${fallbackModel}`, "warning");
|
|
836
|
-
reportHarnessState(ctx);
|
|
837
|
-
}
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
pi.registerCommand("openmerit", {
|
|
841
|
-
description: "OpenMerit: status, compare, pause/resume, doctor, apply/dismiss",
|
|
842
|
-
handler: async (args, ctx) => {
|
|
843
|
-
const pending = pendingRecommendations(ctx);
|
|
844
|
-
const prior = priorExactTaskSuggestion(ctx);
|
|
845
|
-
const sub = args.trim().split(/\s+/)[0] ?? "";
|
|
846
|
-
|
|
847
|
-
if (sub === "apply") {
|
|
848
|
-
const target = pending[pending.length - 1];
|
|
849
|
-
if (!target) {
|
|
850
|
-
ctx.ui.notify("openmerit: no pending recommendations", "info");
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
await applyRecommendation(ctx, target);
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
if (sub === "dismiss") {
|
|
857
|
-
for (const r of pending) setStatus(r, "dismissed");
|
|
858
|
-
ctx.ui.notify(`openmerit: dismissed ${pending.length} recommendation(s)`, "info");
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
if (sub === "fallback") {
|
|
862
|
-
if (!fallbackModel) {
|
|
863
|
-
ctx.ui.notify("openmerit: no fallback set yet", "info");
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
const model = ensureModel(pi, ctx, fallbackModel, fallbackRoute ?? undefined);
|
|
867
|
-
if (model && (await pi.setModel(model as Parameters<typeof pi.setModel>[0]))) {
|
|
868
|
-
ctx.ui.notify(`openmerit: switched to fallback ${fallbackModel}`, "info");
|
|
869
|
-
reportHarnessState(ctx);
|
|
870
|
-
} else {
|
|
871
|
-
ctx.ui.notify(`openmerit: could not switch to ${fallbackModel} (not in registry or no auth)`, "error");
|
|
872
|
-
}
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
if (sub === "pause") {
|
|
876
|
-
comparisonsPaused = true;
|
|
877
|
-
queuedJobs.length = 0;
|
|
878
|
-
stopTrialJob();
|
|
879
|
-
startedJobs.clear();
|
|
880
|
-
progress = null;
|
|
881
|
-
reportHarnessState(ctx);
|
|
882
|
-
ctx.ui.notify("openmerit: automatic comparisons paused; `/openmerit compare` remains available", "info");
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
if (sub === "resume") {
|
|
886
|
-
comparisonsPaused = false;
|
|
887
|
-
reportHarnessState(ctx);
|
|
888
|
-
ctx.ui.notify("openmerit: automatic comparisons resumed for future completed tasks", "info");
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
if (sub === "compare") {
|
|
892
|
-
settledTaskKey = latestSessionTaskKey(ctx);
|
|
893
|
-
const result = queueSettledTask(ctx, true);
|
|
894
|
-
if (result !== "comparison queued") ctx.ui.notify(`openmerit: ${result}`, "warning");
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
if (sub === "doctor") {
|
|
898
|
-
try {
|
|
899
|
-
const result = await doctorSummary();
|
|
900
|
-
ctx.ui.notify(`openmerit doctor:\n${result.text}`, result.ok ? "info" : "warning");
|
|
901
|
-
} catch (error) {
|
|
902
|
-
ctx.ui.notify(`openmerit doctor failed: ${(error as Error).message}`, "error");
|
|
903
|
-
}
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
const currentRoute = ctx.model ? routeFor(ctx.model) : undefined;
|
|
908
|
-
const current = currentRoute ? routeDisplay(currentRoute, currentRoute.meritId) : "unknown";
|
|
909
|
-
const budget = trialBudget();
|
|
910
|
-
const liveProgress = progress?.sessionFile === ctx.sessionManager.getSessionFile() ? progress : null;
|
|
911
|
-
const comparison = trialJob ? liveProgress?.current
|
|
912
|
-
? `running — ${liveProgress.current.model} (trial ${liveProgress.current.index}/${liveProgress.current.total})`
|
|
913
|
-
: "running — selecting next model"
|
|
914
|
-
: queuedJobs.length ? `queued (${queuedJobs.length})`
|
|
915
|
-
: comparisonsPaused ? "paused"
|
|
916
|
-
: latestSessionJob(ctx.sessionManager.getSessionFile() ?? undefined) ?? "not started";
|
|
917
|
-
const lines = [
|
|
918
|
-
`current model : ${current}`,
|
|
919
|
-
`fallback : ${fallbackModel ?? "n/a"}`,
|
|
920
|
-
`pending recs : ${pending.length}`,
|
|
921
|
-
`comparison : ${comparison}${budget.reason ? ` (${budget.reason})` : ""}`,
|
|
922
|
-
`trial budget : ${budget.summary}`,
|
|
923
|
-
];
|
|
924
|
-
if (liveProgress?.completed.length) {
|
|
925
|
-
lines.push("", "models tested:");
|
|
926
|
-
for (const trial of liveProgress.completed) lines.push(` ${completedTrialText(trial)}`);
|
|
927
|
-
}
|
|
928
|
-
if (liveProgress?.excluded.length) {
|
|
929
|
-
lines.push("", "routes skipped:");
|
|
930
|
-
for (const route of liveProgress.excluded)
|
|
931
|
-
lines.push(` ${route.model}: ${route.reasons.join("; ")}`);
|
|
932
|
-
}
|
|
933
|
-
if (prior) lines.push("", suggestionText(prior.recommendation, prior.trials));
|
|
934
|
-
for (const r of pending.slice(-5)) {
|
|
935
|
-
lines.push(
|
|
936
|
-
"",
|
|
937
|
-
`-> ${routeDisplay(r.recommended.route, r.recommended.model)} (gain ${r.evidence.scoreGain}, ` +
|
|
938
|
-
`${r.evidence.priceKnown === false ? "price unknown" : `${r.evidence.priceRatio}x price`}, frontier=${r.evidence.onFrontier})`,
|
|
939
|
-
` ${r.recommended.reason}`,
|
|
940
|
-
` gate: ${r.policy.reasons.join("; ") || "n/a"}`,
|
|
941
|
-
);
|
|
942
|
-
if (r.fallback) lines.push(` fallback: ${r.fallback.model} — ${r.fallback.reason}`);
|
|
943
|
-
}
|
|
944
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
945
|
-
},
|
|
946
|
-
});
|
|
947
|
-
}
|