openmerit 0.1.0
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/LICENSE +21 -0
- package/README.md +270 -0
- package/benchmark/invoice_ocr/data/invoice_01_ground_truth.json +38 -0
- package/benchmark/invoice_ocr/data/invoice_01_row_2.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_02_ground_truth.json +32 -0
- package/benchmark/invoice_ocr/data/invoice_02_row_5.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_03_ground_truth.json +26 -0
- package/benchmark/invoice_ocr/data/invoice_03_row_6.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_04_ground_truth.json +26 -0
- package/benchmark/invoice_ocr/data/invoice_04_row_7.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_05_ground_truth.json +38 -0
- package/benchmark/invoice_ocr/data/invoice_05_row_947.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_06_ground_truth.json +38 -0
- package/benchmark/invoice_ocr/data/invoice_06_row_948.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_07_ground_truth.json +20 -0
- package/benchmark/invoice_ocr/data/invoice_07_row_949.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_08_ground_truth.json +38 -0
- package/benchmark/invoice_ocr/data/invoice_08_row_1888.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_09_ground_truth.json +26 -0
- package/benchmark/invoice_ocr/data/invoice_09_row_1890.jpg +0 -0
- package/benchmark/invoice_ocr/data/invoice_10_ground_truth.json +20 -0
- package/benchmark/invoice_ocr/data/invoice_10_row_1892.jpg +0 -0
- package/benchmark/invoice_ocr/data/manifest.json +97 -0
- package/dist/benchmarks.js +98 -0
- package/dist/catalog.js +51 -0
- package/dist/cli.js +261 -0
- package/dist/daemon.js +305 -0
- package/dist/frontier.js +49 -0
- package/dist/invoice-eval.js +33 -0
- package/dist/invoice-score.js +124 -0
- package/dist/judge.js +43 -0
- package/dist/llm.js +66 -0
- package/dist/pi-trials.js +224 -0
- package/dist/policy.js +110 -0
- package/dist/recommend.js +63 -0
- package/dist/store.js +55 -0
- package/dist/strategist.js +64 -0
- package/dist/task-input.js +30 -0
- package/dist/traces.js +123 -0
- package/dist/trials.js +101 -0
- package/dist/types.js +2 -0
- package/examples/invoice-prompt.txt +19 -0
- package/examples/task.example.json +6 -0
- package/extension/openmerit.ts +705 -0
- package/instructions/OPENMERIT.md +51 -0
- package/instructions/openmerit.policy.json +33 -0
- package/package.json +77 -0
|
@@ -0,0 +1,705 @@
|
|
|
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 { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
25
|
+
import type { Readable } from "node:stream";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const HOME = process.env.OPENMERIT_HOME ?? join(homedir(), ".openmerit");
|
|
29
|
+
const RECS_FILE = join(HOME, "recommendations.jsonl");
|
|
30
|
+
const TRIALS_FILE = join(HOME, "trials.jsonl");
|
|
31
|
+
const STATE_FILE = join(HOME, "harness-state.json");
|
|
32
|
+
const POLICY_FILE = join(HOME, "policy.json");
|
|
33
|
+
const LEDGER_FILE = join(HOME, "ledger.json");
|
|
34
|
+
const JOB_DIR = join(HOME, "watch", "jobs");
|
|
35
|
+
const ENGINE_FILE = join(fileURLToPath(new URL("..", import.meta.url)), "dist", "cli.js");
|
|
36
|
+
const PROGRESS_PREFIX = "[openmerit/progress] ";
|
|
37
|
+
|
|
38
|
+
interface TrialProgress {
|
|
39
|
+
phase: "start" | "complete";
|
|
40
|
+
taskKey: string;
|
|
41
|
+
model: string;
|
|
42
|
+
index: number;
|
|
43
|
+
total: number;
|
|
44
|
+
score?: number;
|
|
45
|
+
price?: number;
|
|
46
|
+
latencyMs?: number;
|
|
47
|
+
costUsd?: number;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseTrialProgress(line: string): TrialProgress | null {
|
|
52
|
+
if (!line.startsWith(PROGRESS_PREFIX)) return null;
|
|
53
|
+
try {
|
|
54
|
+
const value = JSON.parse(line.slice(PROGRESS_PREFIX.length)) as TrialProgress;
|
|
55
|
+
if ((value.phase !== "start" && value.phase !== "complete") ||
|
|
56
|
+
typeof value.taskKey !== "string" || typeof value.model !== "string" ||
|
|
57
|
+
!Number.isInteger(value.index) || !Number.isInteger(value.total) ||
|
|
58
|
+
value.index < 1 || value.total < value.index) return null;
|
|
59
|
+
return value;
|
|
60
|
+
} catch { return null; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function completedTrialText(trial: TrialProgress): string {
|
|
64
|
+
return `${trial.model}: quality ${(trial.score ?? 0).toFixed(2)}, ` +
|
|
65
|
+
`$${(trial.price ?? 0).toFixed(2)}/M, ${Math.round(trial.latencyMs ?? 0)}ms, ` +
|
|
66
|
+
`run $${(trial.costUsd ?? 0).toFixed(4)}` + (trial.error ? ` (failed: ${trial.error})` : "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ExtensionPolicy {
|
|
70
|
+
mode?: string;
|
|
71
|
+
fallback?: { apply_on_error?: boolean };
|
|
72
|
+
budgets?: { max_trials_per_day?: number; max_usd_per_day?: number };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function trialBudget(): { summary: string; reason: string | null } {
|
|
76
|
+
const policy = loadPolicy();
|
|
77
|
+
const limit = policy.budgets?.max_trials_per_day ?? 20;
|
|
78
|
+
const usdLimit = policy.budgets?.max_usd_per_day ?? 5;
|
|
79
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
80
|
+
let ledger: { date?: string; trials?: number; usd?: number } = {};
|
|
81
|
+
try { ledger = JSON.parse(readFileSync(LEDGER_FILE, "utf8")) as typeof ledger; }
|
|
82
|
+
catch { /* no trial spend yet */ }
|
|
83
|
+
const trials = ledger.date === today ? ledger.trials ?? 0 : 0;
|
|
84
|
+
const usd = ledger.date === today ? ledger.usd ?? 0 : 0;
|
|
85
|
+
const reason = trials >= limit ? `daily trial cap reached (${limit})`
|
|
86
|
+
: usd >= usdLimit ? `daily budget exhausted ($${usdLimit})` : null;
|
|
87
|
+
return { summary: `${trials}/${limit} trials, $${usd.toFixed(4)}/$${usdLimit.toFixed(2)} today (UTC)`, reason };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function latestSessionJob(sessionFile: string | undefined): string | null {
|
|
91
|
+
if (!sessionFile || !existsSync(JOB_DIR)) return null;
|
|
92
|
+
const jobs: { marker?: string; status?: string; updatedAt?: string }[] = [];
|
|
93
|
+
for (const file of readdirSync(JOB_DIR)) {
|
|
94
|
+
if (!file.endsWith(".json")) continue;
|
|
95
|
+
try {
|
|
96
|
+
const job = JSON.parse(readFileSync(join(JOB_DIR, file), "utf8")) as typeof jobs[number];
|
|
97
|
+
if (job.marker?.startsWith(`${sessionFile}:`)) jobs.push(job);
|
|
98
|
+
} catch { /* partial or unrelated job */ }
|
|
99
|
+
}
|
|
100
|
+
const latest = jobs.sort((a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? "")).at(-1);
|
|
101
|
+
return latest?.status ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function loadPolicy(): ExtensionPolicy {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(readFileSync(POLICY_FILE, "utf8")) as ExtensionPolicy;
|
|
107
|
+
} catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Recommendation {
|
|
113
|
+
id: string;
|
|
114
|
+
ts: string;
|
|
115
|
+
taskKey: string;
|
|
116
|
+
taskLabel: string;
|
|
117
|
+
sessionFile?: string | null;
|
|
118
|
+
currentModel: string | null;
|
|
119
|
+
recommended: { model: string; reason: string };
|
|
120
|
+
fallback: { model: string; reason: string } | null;
|
|
121
|
+
evidence: { scoreGain: number; priceRatio: number; onFrontier: boolean; trials: number };
|
|
122
|
+
policy: { autoApply: boolean; reasons: string[] };
|
|
123
|
+
status: "pending" | "applied" | "dismissed" | "expired";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface RecordedTrial {
|
|
127
|
+
taskKey?: string;
|
|
128
|
+
sessionFile?: string;
|
|
129
|
+
model: string;
|
|
130
|
+
score: number;
|
|
131
|
+
price: number;
|
|
132
|
+
latencyMs?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readJsonl<T>(file: string): T[] {
|
|
136
|
+
if (!existsSync(file)) return [];
|
|
137
|
+
return readFileSync(file, "utf8")
|
|
138
|
+
.split("\n")
|
|
139
|
+
.filter((l) => l.trim())
|
|
140
|
+
.map((l) => {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(l) as T;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
.filter((x): x is T => x !== null);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function appendJsonl(file: string, obj: unknown): void {
|
|
151
|
+
mkdirSync(HOME, { recursive: true });
|
|
152
|
+
appendFileSync(file, JSON.stringify(obj) + "\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Latest status per recommendation id. */
|
|
156
|
+
function latestRecommendations(): Recommendation[] {
|
|
157
|
+
const byId = new Map<string, Recommendation>();
|
|
158
|
+
for (const r of readJsonl<Recommendation>(RECS_FILE)) byId.set(r.id, r);
|
|
159
|
+
return [...byId.values()];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function taskKey(text: string): string {
|
|
163
|
+
return createHash("sha1").update(text.toLowerCase().replace(/\s+/g, " ").trim()).digest("hex").slice(0, 12);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function userTaskKey(content: unknown): string | null {
|
|
167
|
+
const parts = typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
168
|
+
if (!Array.isArray(parts)) return null;
|
|
169
|
+
const text = parts.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join(" ");
|
|
170
|
+
if (!text.trim()) return null;
|
|
171
|
+
const images = parts.filter((b) => b?.type === "image");
|
|
172
|
+
if (parts.length !== parts.filter((b) => b?.type === "text" || b?.type === "image").length ||
|
|
173
|
+
images.some((b) => typeof b.data !== "string" || typeof b.mimeType !== "string")) return null;
|
|
174
|
+
if (images.length === 0) return taskKey(text);
|
|
175
|
+
const hashes = images.map((b) => `${b.mimeType}:` +
|
|
176
|
+
createHash("sha256").update(Buffer.from(b.data, "base64")).digest("hex"));
|
|
177
|
+
return taskKey(`${text.toLowerCase().replace(/\s+/g, " ").trim()}\nimages:${hashes.join(",")}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function latestSessionTaskKey(ctx: ExtensionContext): string | null {
|
|
181
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
182
|
+
if (!file || !existsSync(file)) return null;
|
|
183
|
+
let latest: string | null = null;
|
|
184
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
185
|
+
if (!line.trim()) continue;
|
|
186
|
+
try {
|
|
187
|
+
const e = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
|
|
188
|
+
if (e.type !== "message" || e.message?.role !== "user") continue;
|
|
189
|
+
latest = userTaskKey(e.message.content);
|
|
190
|
+
} catch { /* partial/corrupt session line */ }
|
|
191
|
+
}
|
|
192
|
+
return latest;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let settledTaskKey: string | null = null;
|
|
196
|
+
|
|
197
|
+
function pendingRecommendations(ctx?: ExtensionContext): Recommendation[] {
|
|
198
|
+
const pending = latestRecommendations().filter((r) => r.status === "pending");
|
|
199
|
+
if (!ctx) return pending;
|
|
200
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
201
|
+
const liveTaskKey = latestSessionTaskKey(ctx);
|
|
202
|
+
return pending.filter((r) => r.sessionFile
|
|
203
|
+
? r.sessionFile === file && r.taskKey === settledTaskKey
|
|
204
|
+
: !!liveTaskKey && r.taskKey === liveTaskKey);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Earlier measured comparisons are advice in another session, never pending swaps. */
|
|
208
|
+
function priorExactTaskSuggestion(ctx: ExtensionContext): {
|
|
209
|
+
recommendation: Recommendation; trials: RecordedTrial[];
|
|
210
|
+
} | null {
|
|
211
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
212
|
+
const key = latestSessionTaskKey(ctx);
|
|
213
|
+
if (!file || !key || key !== settledTaskKey) return null;
|
|
214
|
+
const prior = latestRecommendations().filter((r) =>
|
|
215
|
+
r.sessionFile && r.sessionFile !== file && r.taskKey === key &&
|
|
216
|
+
(r.status === "pending" || r.status === "applied") && r.evidence.trials >= 2);
|
|
217
|
+
const recommendation = prior.at(-1);
|
|
218
|
+
if (!recommendation) return null;
|
|
219
|
+
const byModel = new Map<string, RecordedTrial>();
|
|
220
|
+
for (const trial of readJsonl<RecordedTrial>(TRIALS_FILE)) {
|
|
221
|
+
if (trial.taskKey === key && trial.sessionFile === recommendation.sessionFile) {
|
|
222
|
+
byModel.set(trial.model, trial);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { recommendation, trials: [...byModel.values()] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function suggestionText(rec: Recommendation, trials: RecordedTrial[]): string {
|
|
229
|
+
const measured = trials.map((p) =>
|
|
230
|
+
`${p.model}: quality ${p.score.toFixed(2)}, $${p.price.toFixed(2)}/M` +
|
|
231
|
+
(p.latencyMs ? `, ${Math.round(p.latencyMs)}ms` : ""));
|
|
232
|
+
return `openmerit: exact task was tested in another pi session (${rec.evidence.trials} models). ` +
|
|
233
|
+
`Earlier choice: ${rec.recommended.model}. This session will run its own comparison before any swap.` +
|
|
234
|
+
(measured.length ? `\n${measured.join("\n")}` : `\n${rec.recommended.reason}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function canonicalModel(provider: string, id: string): string {
|
|
238
|
+
return provider === "openrouter" ? id : `${provider}/${id}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function setStatus(rec: Recommendation, status: Recommendation["status"]): void {
|
|
242
|
+
appendJsonl(RECS_FILE, { ...rec, status, ts: new Date().toISOString() });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let fallbackModel: string | null = null;
|
|
246
|
+
|
|
247
|
+
function reportHarnessState(ctx: ExtensionContext, settled = false): void {
|
|
248
|
+
try {
|
|
249
|
+
mkdirSync(HOME, { recursive: true });
|
|
250
|
+
const model = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : null;
|
|
251
|
+
if (settled) settledTaskKey = latestSessionTaskKey(ctx);
|
|
252
|
+
const prior = existsSync(STATE_FILE)
|
|
253
|
+
? JSON.parse(readFileSync(STATE_FILE, "utf8")) as { sessionFile?: string | null; settledAt?: string }
|
|
254
|
+
: {};
|
|
255
|
+
const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
256
|
+
const settledAt = settled ? new Date().toISOString()
|
|
257
|
+
: prior.sessionFile === sessionFile ? prior.settledAt ?? null : null;
|
|
258
|
+
writeFileSync(
|
|
259
|
+
STATE_FILE,
|
|
260
|
+
JSON.stringify({
|
|
261
|
+
currentModel: model,
|
|
262
|
+
fallbackModel,
|
|
263
|
+
sessionFile,
|
|
264
|
+
settledTaskKey,
|
|
265
|
+
settledAt,
|
|
266
|
+
cwd: ctx.cwd,
|
|
267
|
+
updatedAt: new Date().toISOString(),
|
|
268
|
+
}) + "\n",
|
|
269
|
+
);
|
|
270
|
+
} catch {
|
|
271
|
+
/* never break the host session over reporting */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Restore the persisted fallback so a fresh session keeps it even with no pending recs. */
|
|
276
|
+
function restoreFallback(): void {
|
|
277
|
+
try {
|
|
278
|
+
const st = JSON.parse(readFileSync(STATE_FILE, "utf8")) as { fallbackModel?: string | null };
|
|
279
|
+
if (st.fallbackModel) fallbackModel = st.fallbackModel;
|
|
280
|
+
} catch {
|
|
281
|
+
/* no state yet */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Resolve an OpenRouter-style "vendor/model" id against pi's model registry. */
|
|
286
|
+
function resolveModel(ctx: ExtensionContext, openrouterId: string) {
|
|
287
|
+
const [vendor, ...rest] = openrouterId.split("/");
|
|
288
|
+
const modelId = rest.join("/");
|
|
289
|
+
const reg = ctx.modelRegistry as unknown as {
|
|
290
|
+
find?: (provider: string, id: string) => unknown | undefined;
|
|
291
|
+
getAvailable?: () => { provider: string; id: string }[];
|
|
292
|
+
};
|
|
293
|
+
// Prefer the OpenRouter provider entry itself.
|
|
294
|
+
const viaOpenRouter = reg.find?.("openrouter", openrouterId) ?? reg.find?.("openrouter", modelId);
|
|
295
|
+
if (viaOpenRouter) return viaOpenRouter;
|
|
296
|
+
// Then a native provider ("anthropic/claude-..." -> provider "anthropic").
|
|
297
|
+
const native = reg.find?.(vendor, modelId);
|
|
298
|
+
if (native) return native;
|
|
299
|
+
// Last resort: scan the catalogue for an id match.
|
|
300
|
+
for (const m of reg.getAvailable?.() ?? []) {
|
|
301
|
+
if (m.id === openrouterId || m.id === modelId || m.id.endsWith(`/${modelId}`)) return m;
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
interface CatalogEntryLite {
|
|
307
|
+
id: string;
|
|
308
|
+
name?: string;
|
|
309
|
+
ctx?: number;
|
|
310
|
+
pp?: number;
|
|
311
|
+
pc?: number;
|
|
312
|
+
inputModalities?: string[];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const INJECT_PROVIDER = "openmerit-openrouter";
|
|
316
|
+
const injectedModels: CatalogEntryLite[] = [];
|
|
317
|
+
|
|
318
|
+
function catalogEntry(openrouterId: string): CatalogEntryLite | undefined {
|
|
319
|
+
try {
|
|
320
|
+
const snap = JSON.parse(readFileSync(join(HOME, "catalog", "snapshot.json"), "utf8")) as {
|
|
321
|
+
entries?: CatalogEntryLite[];
|
|
322
|
+
};
|
|
323
|
+
return (snap.entries ?? []).find((e) => e.id === openrouterId);
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Ensure pi can use the model. pi's built-in registry lags the live
|
|
331
|
+
* OpenRouter catalog, so inject anything missing via registerProvider
|
|
332
|
+
* (takes effect immediately, no /reload needed).
|
|
333
|
+
*/
|
|
334
|
+
function ensureModel(pi: ExtensionAPI, ctx: ExtensionContext, openrouterId: string) {
|
|
335
|
+
const existing = resolveModel(ctx, openrouterId);
|
|
336
|
+
if (existing) return existing;
|
|
337
|
+
|
|
338
|
+
const entry = catalogEntry(openrouterId);
|
|
339
|
+
if (!entry) return undefined;
|
|
340
|
+
if (!injectedModels.some((m) => m.id === entry.id)) injectedModels.push(entry);
|
|
341
|
+
|
|
342
|
+
pi.registerProvider(INJECT_PROVIDER, {
|
|
343
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
344
|
+
apiKey: "$OPENROUTER_API_KEY",
|
|
345
|
+
api: "openai-completions",
|
|
346
|
+
models: injectedModels.map((m) => ({
|
|
347
|
+
id: m.id,
|
|
348
|
+
name: m.name ?? m.id,
|
|
349
|
+
reasoning: false,
|
|
350
|
+
input: entry.inputModalities?.includes("image") ? ["text", "image"] : ["text"],
|
|
351
|
+
cost: {
|
|
352
|
+
input: (m.pp ?? 0) * 1e6,
|
|
353
|
+
output: (m.pc ?? 0) * 1e6,
|
|
354
|
+
cacheRead: 0,
|
|
355
|
+
cacheWrite: 0,
|
|
356
|
+
},
|
|
357
|
+
contextWindow: m.ctx && m.ctx > 0 ? m.ctx : 128000,
|
|
358
|
+
maxTokens: 16384,
|
|
359
|
+
})),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const reg = ctx.modelRegistry as unknown as {
|
|
363
|
+
find?: (provider: string, id: string) => unknown | undefined;
|
|
364
|
+
};
|
|
365
|
+
return reg.find?.(INJECT_PROVIDER, openrouterId);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export default function openmerit(pi: ExtensionAPI) {
|
|
369
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
370
|
+
let trialJob: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
|
371
|
+
const queuedJobs: { sessionFile: string; settledAt: string; model: string; key: string; cwd: string; bytes: number }[] = [];
|
|
372
|
+
const startedJobs = new Set<string>();
|
|
373
|
+
let activeSession: string | null = null;
|
|
374
|
+
let progress: { sessionFile: string; taskKey: string; current: TrialProgress | null;
|
|
375
|
+
completed: TrialProgress[] } | null = null;
|
|
376
|
+
|
|
377
|
+
function stopTrialJob(): void {
|
|
378
|
+
if (!trialJob) return;
|
|
379
|
+
const child = trialJob;
|
|
380
|
+
trialJob = null;
|
|
381
|
+
try {
|
|
382
|
+
if (child.pid && process.platform !== "win32") process.kill(-child.pid, "SIGTERM");
|
|
383
|
+
else child.kill("SIGTERM");
|
|
384
|
+
} catch { child.kill("SIGTERM"); }
|
|
385
|
+
}
|
|
386
|
+
const notified = new Set<string>();
|
|
387
|
+
const suggested = new Set<string>();
|
|
388
|
+
|
|
389
|
+
function startNextJob(ctx: ExtensionContext): void {
|
|
390
|
+
if (trialJob || queuedJobs.length === 0) return;
|
|
391
|
+
const job = queuedJobs.shift()!;
|
|
392
|
+
if (job.sessionFile !== activeSession) return startNextJob(ctx);
|
|
393
|
+
if (!existsSync(ENGINE_FILE)) {
|
|
394
|
+
ctx.ui.notify("openmerit: trial engine missing; run npm ci in the OpenMerit checkout", "error");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const child = spawn(process.execPath, [ENGINE_FILE, "session-trial", job.sessionFile,
|
|
398
|
+
job.settledAt, job.model, job.key, job.cwd, String(job.bytes)], {
|
|
399
|
+
cwd: job.cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"],
|
|
400
|
+
detached: process.platform !== "win32",
|
|
401
|
+
});
|
|
402
|
+
trialJob = child;
|
|
403
|
+
progress = { sessionFile: job.sessionFile, taskKey: job.key, current: null, completed: [] };
|
|
404
|
+
if (ctx.hasUI) ctx.ui.notify(`openmerit: comparing models for task ${job.key} (A=${job.model})`, "info");
|
|
405
|
+
let stdout = "";
|
|
406
|
+
let stderr = "";
|
|
407
|
+
function onLine(line: string): void {
|
|
408
|
+
const trial = parseTrialProgress(line);
|
|
409
|
+
if (trial && job.sessionFile === activeSession && progress?.taskKey === trial.taskKey) {
|
|
410
|
+
if (trial.phase === "start") {
|
|
411
|
+
progress.current = trial;
|
|
412
|
+
if (ctx.hasUI) ctx.ui.notify(
|
|
413
|
+
`openmerit: trial ${trial.index}/${trial.total} comparing ${trial.model}` +
|
|
414
|
+
(trial.index === 1 ? " (scoring Pi trace)" : ""), "info",
|
|
415
|
+
);
|
|
416
|
+
} else {
|
|
417
|
+
progress.current = null;
|
|
418
|
+
progress.completed.push(trial);
|
|
419
|
+
if (ctx.hasUI) ctx.ui.notify(`openmerit: trial ${trial.index}/${trial.total} finished — ` +
|
|
420
|
+
completedTrialText(trial), trial.error ? "warning" : "info");
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (ctx.hasUI && job.sessionFile === activeSession &&
|
|
425
|
+
(line.includes(": selected ") || line.includes(": active model remains best")))
|
|
426
|
+
ctx.ui.notify(line.trim(), "info");
|
|
427
|
+
}
|
|
428
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
429
|
+
stdout += chunk.toString();
|
|
430
|
+
let end: number;
|
|
431
|
+
while ((end = stdout.indexOf("\n")) >= 0) {
|
|
432
|
+
onLine(stdout.slice(0, end));
|
|
433
|
+
stdout = stdout.slice(end + 1);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
437
|
+
child.on("error", (error) => {
|
|
438
|
+
if (ctx.hasUI && job.sessionFile === activeSession)
|
|
439
|
+
ctx.ui.notify(`openmerit: trial engine failed: ${error.message}`, "error");
|
|
440
|
+
});
|
|
441
|
+
child.on("close", (code) => {
|
|
442
|
+
if (trialJob !== child) return;
|
|
443
|
+
trialJob = null;
|
|
444
|
+
if (job.sessionFile === activeSession) {
|
|
445
|
+
if (code !== 0 && ctx.hasUI)
|
|
446
|
+
ctx.ui.notify(`openmerit: comparison failed: ${stderr.trim().slice(0, 240) || `exit ${code}`}`, "error");
|
|
447
|
+
else surface(ctx).catch(() => undefined);
|
|
448
|
+
startNextJob(ctx);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function queueSettledTask(ctx: ExtensionContext): void {
|
|
454
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
455
|
+
const model = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : null;
|
|
456
|
+
if (!sessionFile || !model || !settledTaskKey || !existsSync(sessionFile)) return;
|
|
457
|
+
// The alpha compares completed response tasks. The engine validates images,
|
|
458
|
+
// tool use and the baseline answer before any provider calls.
|
|
459
|
+
let completed = false;
|
|
460
|
+
for (const line of readFileSync(sessionFile, "utf8").split("\n")) {
|
|
461
|
+
try {
|
|
462
|
+
const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown; stopReason?: string } };
|
|
463
|
+
if (entry.type !== "message") continue;
|
|
464
|
+
if (entry.message?.role === "user") completed = false;
|
|
465
|
+
if (entry.message?.role === "assistant" && entry.message.stopReason !== "error" &&
|
|
466
|
+
userTaskKeyFromAnswer(entry.message.content)) completed = true;
|
|
467
|
+
} catch { /* partial session line */ }
|
|
468
|
+
}
|
|
469
|
+
if (!completed) return;
|
|
470
|
+
const settledAt = new Date().toISOString();
|
|
471
|
+
const bytes = readFileSync(sessionFile).length;
|
|
472
|
+
const marker = `${sessionFile}:${settledTaskKey}:${model}:${bytes}`;
|
|
473
|
+
if (startedJobs.has(marker)) return;
|
|
474
|
+
startedJobs.add(marker);
|
|
475
|
+
const budget = trialBudget();
|
|
476
|
+
if (budget.reason) {
|
|
477
|
+
if (ctx.hasUI) ctx.ui.notify(`openmerit: comparison skipped: ${budget.reason}. ${budget.summary}`, "warning");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
queuedJobs.push({ sessionFile, settledAt, model, key: settledTaskKey, cwd: ctx.cwd, bytes });
|
|
481
|
+
startNextJob(ctx);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function userTaskKeyFromAnswer(content: unknown): boolean {
|
|
485
|
+
if (typeof content === "string") return !!content.trim();
|
|
486
|
+
return Array.isArray(content) && content.some((part) => part?.type === "text" &&
|
|
487
|
+
typeof part.text === "string" && part.text.trim());
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function applyRecommendation(ctx: ExtensionContext, rec: Recommendation): Promise<boolean> {
|
|
491
|
+
const current = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : null;
|
|
492
|
+
if (rec.currentModel && current !== rec.currentModel) {
|
|
493
|
+
ctx.ui.notify(`openmerit: recommendation was measured against ${rec.currentModel}; current model is ${current ?? "unknown"}`, "warning");
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
const model = ensureModel(pi, ctx, rec.recommended.model);
|
|
497
|
+
if (!model) {
|
|
498
|
+
ctx.ui.notify(
|
|
499
|
+
`openmerit: ${rec.recommended.model} not found in pi's model registry (add the provider/model first)`,
|
|
500
|
+
"warning",
|
|
501
|
+
);
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
const ok = await pi.setModel(model as Parameters<typeof pi.setModel>[0]);
|
|
505
|
+
if (ok) {
|
|
506
|
+
setStatus(rec, "applied");
|
|
507
|
+
if (rec.fallback) fallbackModel = rec.fallback.model;
|
|
508
|
+
ctx.ui.notify(`openmerit: switched to ${rec.recommended.model}\n${rec.recommended.reason}`, "info");
|
|
509
|
+
reportHarnessState(ctx);
|
|
510
|
+
} else {
|
|
511
|
+
ctx.ui.notify(`openmerit: no auth for ${rec.recommended.model}`, "error");
|
|
512
|
+
}
|
|
513
|
+
return ok;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function surface(ctx: ExtensionContext): Promise<void> {
|
|
517
|
+
const prior = priorExactTaskSuggestion(ctx);
|
|
518
|
+
if (prior && ctx.hasUI) {
|
|
519
|
+
const key = `${ctx.sessionManager.getSessionFile()}:${prior.recommendation.id}:${settledTaskKey}`;
|
|
520
|
+
if (!suggested.has(key)) {
|
|
521
|
+
suggested.add(key);
|
|
522
|
+
ctx.ui.notify(suggestionText(prior.recommendation, prior.trials), "info");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
for (const rec of pendingRecommendations(ctx)) {
|
|
526
|
+
if (notified.has(rec.id)) continue;
|
|
527
|
+
if (rec.policy.autoApply) {
|
|
528
|
+
if (!rec.sessionFile || rec.sessionFile !== ctx.sessionManager.getSessionFile()) continue;
|
|
529
|
+
if (!ctx.isIdle()) continue;
|
|
530
|
+
notified.add(rec.id);
|
|
531
|
+
await applyRecommendation(ctx, rec);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
notified.add(rec.id);
|
|
535
|
+
// Never block here: surface() runs during session_start and on a poll
|
|
536
|
+
// timer, where a modal dialog would deadlock RPC mode and stall TUI
|
|
537
|
+
// startup. Notify (fire-and-forget) and let the user act via /openmerit.
|
|
538
|
+
if (ctx.hasUI) {
|
|
539
|
+
ctx.ui.notify(
|
|
540
|
+
`openmerit recommends: ${rec.recommended.model}\n${rec.recommended.reason}\n` +
|
|
541
|
+
`gate: ${rec.policy.reasons.join("; ")}\n(/openmerit apply to switch, /openmerit dismiss to ignore)`,
|
|
542
|
+
"info",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
549
|
+
activeSession = ctx.sessionManager.getSessionFile() ?? null;
|
|
550
|
+
stopTrialJob();
|
|
551
|
+
queuedJobs.length = 0;
|
|
552
|
+
progress = null;
|
|
553
|
+
suggested.clear();
|
|
554
|
+
restoreFallback();
|
|
555
|
+
try {
|
|
556
|
+
const st = JSON.parse(readFileSync(STATE_FILE, "utf8")) as {
|
|
557
|
+
sessionFile?: string | null; settledTaskKey?: string | null;
|
|
558
|
+
};
|
|
559
|
+
settledTaskKey = st.sessionFile === ctx.sessionManager.getSessionFile()
|
|
560
|
+
? st.settledTaskKey ?? null : null;
|
|
561
|
+
} catch { settledTaskKey = null; }
|
|
562
|
+
reportHarnessState(ctx);
|
|
563
|
+
await surface(ctx);
|
|
564
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
565
|
+
pollTimer = setInterval(() => {
|
|
566
|
+
surface(ctx).catch(() => undefined);
|
|
567
|
+
}, 5_000);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
571
|
+
reportHarnessState(ctx, true);
|
|
572
|
+
queueSettledTask(ctx);
|
|
573
|
+
await surface(ctx);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
pi.on("input", async (_event, ctx) => {
|
|
577
|
+
// A ready comparison can switch the route before the next user task starts.
|
|
578
|
+
await surface(ctx);
|
|
579
|
+
return { action: "continue" };
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
583
|
+
reportHarnessState(ctx);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
pi.on("session_shutdown", async () => {
|
|
587
|
+
activeSession = null;
|
|
588
|
+
queuedJobs.length = 0;
|
|
589
|
+
stopTrialJob();
|
|
590
|
+
progress = null;
|
|
591
|
+
if (pollTimer) {
|
|
592
|
+
clearInterval(pollTimer);
|
|
593
|
+
pollTimer = null;
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Fallback swap when the active model finished with an error.
|
|
598
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
599
|
+
if (!fallbackModel) return;
|
|
600
|
+
const e = event as unknown as {
|
|
601
|
+
willRetry?: boolean;
|
|
602
|
+
messages?: { role?: string; stopReason?: string }[];
|
|
603
|
+
};
|
|
604
|
+
if (e.willRetry) return; // pi is still retrying; wait for the final outcome
|
|
605
|
+
const lastAssistant = [...(e.messages ?? [])].reverse().find((m) => m.role === "assistant");
|
|
606
|
+
if (lastAssistant?.stopReason !== "error") return;
|
|
607
|
+
|
|
608
|
+
const policy = loadPolicy();
|
|
609
|
+
if (policy.fallback?.apply_on_error === false) return;
|
|
610
|
+
const current = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : "current model";
|
|
611
|
+
const model = ensureModel(pi, ctx, fallbackModel);
|
|
612
|
+
if (!model) return;
|
|
613
|
+
|
|
614
|
+
const auto = policy.mode === "auto";
|
|
615
|
+
let ok = auto;
|
|
616
|
+
if (!ok && ctx.mode === "tui") {
|
|
617
|
+
// Modal confirm only in TUI; in RPC mode an unanswered dialog blocks
|
|
618
|
+
// the command stream.
|
|
619
|
+
ok = await ctx.ui.confirm(
|
|
620
|
+
"openmerit fallback",
|
|
621
|
+
`${current} is failing. Switch to frontier fallback ${fallbackModel}?`,
|
|
622
|
+
);
|
|
623
|
+
} else if (!ok && ctx.hasUI) {
|
|
624
|
+
ctx.ui.notify(
|
|
625
|
+
`openmerit: ${current} is failing; frontier fallback is ${fallbackModel} (/openmerit fallback to switch)`,
|
|
626
|
+
"warning",
|
|
627
|
+
);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (!ok) return;
|
|
631
|
+
if (await pi.setModel(model as Parameters<typeof pi.setModel>[0])) {
|
|
632
|
+
ctx.ui.notify(`openmerit: fallback engaged -> ${fallbackModel}`, "warning");
|
|
633
|
+
reportHarnessState(ctx);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
pi.registerCommand("openmerit", {
|
|
638
|
+
description: "OpenMerit: status, pending recommendations, apply/dismiss",
|
|
639
|
+
handler: async (args, ctx) => {
|
|
640
|
+
const pending = pendingRecommendations(ctx);
|
|
641
|
+
const prior = priorExactTaskSuggestion(ctx);
|
|
642
|
+
const sub = args.trim().split(/\s+/)[0] ?? "";
|
|
643
|
+
|
|
644
|
+
if (sub === "apply") {
|
|
645
|
+
const target = pending[pending.length - 1];
|
|
646
|
+
if (!target) {
|
|
647
|
+
ctx.ui.notify("openmerit: no pending recommendations", "info");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
await applyRecommendation(ctx, target);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (sub === "dismiss") {
|
|
654
|
+
for (const r of pending) setStatus(r, "dismissed");
|
|
655
|
+
ctx.ui.notify(`openmerit: dismissed ${pending.length} recommendation(s)`, "info");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (sub === "fallback") {
|
|
659
|
+
if (!fallbackModel) {
|
|
660
|
+
ctx.ui.notify("openmerit: no fallback set yet", "info");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const model = ensureModel(pi, ctx, fallbackModel);
|
|
664
|
+
if (model && (await pi.setModel(model as Parameters<typeof pi.setModel>[0]))) {
|
|
665
|
+
ctx.ui.notify(`openmerit: switched to fallback ${fallbackModel}`, "info");
|
|
666
|
+
reportHarnessState(ctx);
|
|
667
|
+
} else {
|
|
668
|
+
ctx.ui.notify(`openmerit: could not switch to ${fallbackModel} (not in registry or no auth)`, "error");
|
|
669
|
+
}
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const current = ctx.model ? canonicalModel(ctx.model.provider, ctx.model.id) : "unknown";
|
|
674
|
+
const budget = trialBudget();
|
|
675
|
+
const liveProgress = progress?.sessionFile === ctx.sessionManager.getSessionFile() ? progress : null;
|
|
676
|
+
const comparison = trialJob ? liveProgress?.current
|
|
677
|
+
? `running — ${liveProgress.current.model} (trial ${liveProgress.current.index}/${liveProgress.current.total})`
|
|
678
|
+
: "running — selecting next model"
|
|
679
|
+
: queuedJobs.length ? `queued (${queuedJobs.length})`
|
|
680
|
+
: latestSessionJob(ctx.sessionManager.getSessionFile() ?? undefined) ?? "not started";
|
|
681
|
+
const lines = [
|
|
682
|
+
`current model : ${current}`,
|
|
683
|
+
`fallback : ${fallbackModel ?? "n/a"}`,
|
|
684
|
+
`pending recs : ${pending.length}`,
|
|
685
|
+
`comparison : ${comparison}${budget.reason ? ` (${budget.reason})` : ""}`,
|
|
686
|
+
`trial budget : ${budget.summary}`,
|
|
687
|
+
];
|
|
688
|
+
if (liveProgress?.completed.length) {
|
|
689
|
+
lines.push("", "models tested:");
|
|
690
|
+
for (const trial of liveProgress.completed) lines.push(` ${completedTrialText(trial)}`);
|
|
691
|
+
}
|
|
692
|
+
if (prior) lines.push("", suggestionText(prior.recommendation, prior.trials));
|
|
693
|
+
for (const r of pending.slice(-5)) {
|
|
694
|
+
lines.push(
|
|
695
|
+
"",
|
|
696
|
+
`-> ${r.recommended.model} (gain ${r.evidence.scoreGain}, ${r.evidence.priceRatio}x price, frontier=${r.evidence.onFrontier})`,
|
|
697
|
+
` ${r.recommended.reason}`,
|
|
698
|
+
` gate: ${r.policy.reasons.join("; ") || "n/a"}`,
|
|
699
|
+
);
|
|
700
|
+
if (r.fallback) lines.push(` fallback: ${r.fallback.model} — ${r.fallback.reason}`);
|
|
701
|
+
}
|
|
702
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
}
|