openmerit 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -9,15 +9,68 @@ import { loadKey } from "./llm.js";
9
9
  import { providerAllowed } from "./policy.js";
10
10
  import { buildRecommendation } from "./recommend.js";
11
11
  import { appendJsonl, paths, readJson, readJsonl, writeJson, } from "./store.js";
12
- import { ingestNewTraces } from "./traces.js";
13
- import { budgetOk, ensureRubric, recordTrialSpend, runTrial } from "./trials.js";
12
+ import { budgetOk, ensureRubric, recordMeritSpend, recordTrialSpend, runTrial, trialBudgetOk } from "./trials.js";
14
13
  import { JUDGE_PREFS } from "./judge.js";
15
- import { availablePiModels, runPiTrial, scorePiRun, settledActiveTask } from "./pi-trials.js";
14
+ import { runPiTrial, scorePiRun, settledActiveTask } from "./pi-trials.js";
16
15
  import { taskInputKey } from "./task-input.js";
17
16
  import { pickNext, STRAT_PREFS } from "./strategist.js";
17
+ import { enrichRouteEntry, routeCatalog, routeKey, routeLabel } from "./routes.js";
18
+ import { PiCliChatClient } from "./llm.js";
19
+ import { LocalJsonlEventSink, PiTraceObservationSource, meritEvent } from "./integrations.js";
20
+ function resolveRoutePref(cat, prefs, label, requireImages = false) {
21
+ const available = [...cat.values()].filter((entry) => !!entry.route && entry.priceKnown !== false &&
22
+ (!requireImages || entry.inputModalities?.includes("image")));
23
+ for (const pref of prefs) {
24
+ if (!pref)
25
+ continue;
26
+ const routed = cat.get(pref);
27
+ if (routed && available.includes(routed))
28
+ return routed;
29
+ const exact = available.find((entry) => entry.id === pref);
30
+ if (exact)
31
+ return exact;
32
+ }
33
+ for (const pref of prefs) {
34
+ if (!pref)
35
+ continue;
36
+ const fuzzy = available.find((entry) => entry.id.includes(pref));
37
+ if (fuzzy)
38
+ return fuzzy;
39
+ }
40
+ const fallback = available.sort((a, b) => (a.priceKnown === false ? 1 : 0) - (b.priceKnown === false ? 1 : 0) || a.price - b.price)[0];
41
+ if (fallback)
42
+ return fallback;
43
+ throw new Error(`could not resolve ${label} route from Pi's eligible models`);
44
+ }
45
+ function processIsAlive(pid) {
46
+ if (!Number.isInteger(pid) || pid <= 0)
47
+ return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ }
52
+ catch (error) {
53
+ return error.code === "EPERM";
54
+ }
55
+ }
56
+ /** Completed jobs stay final; live owners retain their claim; abandoned claims can retry. */
57
+ export function taskJobIsClaimed(prior, marker, now = Date.now()) {
58
+ if (!prior || prior.marker !== marker || prior.status === "failed")
59
+ return false;
60
+ if (prior.status !== "running")
61
+ return true;
62
+ if (prior.ownerPid !== undefined)
63
+ return processIsAlive(prior.ownerPid);
64
+ const updated = Date.parse(prior.updatedAt);
65
+ return Number.isFinite(updated) && now - updated < 15 * 60_000;
66
+ }
18
67
  function emitTrialProgress(progress) {
19
68
  console.log(`[openmerit/progress] ${JSON.stringify(progress)}`);
20
69
  }
70
+ function persistTrial(point, sink) {
71
+ appendJsonl(paths.trials(), point);
72
+ sink.emit(meritEvent("trial.completed", point));
73
+ }
21
74
  function resolvePref(cat, prefs, label) {
22
75
  for (const p of prefs) {
23
76
  if (p && cat.has(p))
@@ -34,10 +87,12 @@ function resolvePref(cat, prefs, label) {
34
87
  throw new Error(`could not resolve ${label} model from preferences`);
35
88
  }
36
89
  /** Ingest new session-trace bytes into the observation log. */
37
- export function tracesTick() {
38
- const fresh = ingestNewTraces();
39
- for (const o of fresh)
90
+ export function tracesTick(source = new PiTraceObservationSource(), sink = new LocalJsonlEventSink()) {
91
+ const fresh = source.read();
92
+ for (const o of fresh) {
40
93
  appendJsonl(paths.observations(), o);
94
+ sink.emit(meritEvent("observation.recorded", o, source.id));
95
+ }
41
96
  if (fresh.length > 0)
42
97
  console.log(`[openmerit] traces: ${fresh.length} new observation(s)`);
43
98
  return fresh;
@@ -68,6 +123,7 @@ export async function catalogTick(key, policy) {
68
123
  * thin). Budget-capped per policy.
69
124
  */
70
125
  export async function trialTick(key, policy) {
126
+ const sink = new LocalJsonlEventSink();
71
127
  const budget = budgetOk(policy);
72
128
  if (!budget.ok) {
73
129
  console.log(`[openmerit] trials paused: ${budget.reason}`);
@@ -106,7 +162,7 @@ export async function trialTick(key, policy) {
106
162
  const rubric = await ensureRubric(key, judge, obs.taskLabel, rubricCache);
107
163
  const { point, costUsd, error } = await runTrial(key, judge, obs.taskLabel, rubric, candidate.id, cat.get(candidate.id));
108
164
  recordTrialSpend(costUsd);
109
- appendJsonl(paths.trials(), { ...point, taskKey: tKey });
165
+ persistTrial({ ...point, taskKey: tKey }, sink);
110
166
  if (error)
111
167
  console.log(`[openmerit] trial failed: ${error.slice(0, 120)}`);
112
168
  else
@@ -115,6 +171,7 @@ export async function trialTick(key, policy) {
115
171
  }
116
172
  /** Compare the one completed active pi task, sequentially, then target its session. */
117
173
  export async function autoTaskTick(key, policy, settledTask) {
174
+ const sink = new LocalJsonlEventSink();
118
175
  const task = settledTask === undefined ? settledActiveTask() : settledTask;
119
176
  if (!task)
120
177
  return null;
@@ -123,99 +180,122 @@ export async function autoTaskTick(key, policy, settledTask) {
123
180
  : `${task.sessionFile}:${task.sessionBytes ?? task.settledAt}`;
124
181
  const processedPath = settledTask === undefined ? paths.watchProcessed() : paths.sessionJob(marker);
125
182
  const prior = readJson(processedPath, null);
126
- if (prior?.marker === marker && prior.status !== "failed")
183
+ if (taskJobIsClaimed(prior, marker))
127
184
  return null;
128
185
  if (!budgetOk(policy).ok)
129
186
  return null;
130
187
  // Claim the settled turn before provider calls so the next poll cannot duplicate it.
131
- writeJson(processedPath, { marker, status: "running", updatedAt: new Date().toISOString() });
188
+ writeJson(processedPath, { marker, status: "running", ownerPid: process.pid,
189
+ updatedAt: new Date().toISOString() });
132
190
  try {
133
- const cat = await fetchCatalog(key);
134
- const piModels = availablePiModels();
135
- for (const id of [...cat.keys()]) {
136
- const e = cat.get(id);
137
- if (!piModels.has(id) || e.price > policy.max_usd_per_m || !providerAllowed(policy, id) ||
138
- (task.images.length > 0 && !e.inputModalities?.includes("image")))
191
+ let cat = routeCatalog(task.routes);
192
+ if (!cat.has(routeKey(task.route)))
193
+ cat.set(routeKey(task.route), routeCatalog([task.route]).values().next().value);
194
+ if (key) {
195
+ try {
196
+ const live = await fetchCatalog(key);
197
+ saveSnapshot(live);
198
+ cat = new Map([...cat].map(([id, entry]) => [id,
199
+ entry.route?.provider === "openrouter" ? enrichRouteEntry(entry, live.get(entry.id)) : entry]));
200
+ }
201
+ catch (error) {
202
+ console.log(`[openmerit] OpenRouter enrichment unavailable: ${error.message}`);
203
+ }
204
+ }
205
+ for (const [id, entry] of [...cat]) {
206
+ if ((entry.priceKnown !== false && entry.price > policy.max_usd_per_m) ||
207
+ !providerAllowed(policy, entry.id, entry.route?.provider) ||
208
+ (task.images.length > 0 && !entry.inputModalities?.includes("image")) ||
209
+ (id !== routeKey(task.route) && !trialBudgetOk(policy, entry, task.task.length).ok))
139
210
  cat.delete(id);
140
211
  }
141
- if (!cat.has(task.model))
142
- throw new Error(`active model ${task.model} is not trialable in pi/OpenRouter`);
143
- const judge = resolvePref(cat, [policy.judge_model, ...JUDGE_PREFS], "judge");
144
- const strategist = resolvePref(cat, [policy.strategist_model, ...STRAT_PREFS], "strategist");
212
+ const activeEntry = cat.get(routeKey(task.route));
213
+ if (!activeEntry)
214
+ throw new Error(`active route ${task.route.provider}/${task.route.modelId} is excluded by policy or task capabilities`);
215
+ if (cat.size < 2)
216
+ throw new Error("Pi exposes no eligible alternate model route for this task");
217
+ const judge = resolveRoutePref(cat, [policy.judge_model, ...JUDGE_PREFS, task.model], "judge", task.images.length > 0);
218
+ const strategist = resolveRoutePref(cat, [policy.strategist_model, ...STRAT_PREFS, task.model], "strategist");
219
+ const client = new PiCliChatClient(undefined, recordMeritSpend);
145
220
  const { category, benchmarks } = relevantBenchmarks(task.task);
146
221
  let benchmarkCandidates = [];
147
222
  try {
148
- benchmarkCandidates = await openRouterBenchmarkCandidates(key, category, cat);
223
+ if (key)
224
+ benchmarkCandidates = await openRouterBenchmarkCandidates(key, category, new Map([...cat.values()].map((entry) => [entry.id, entry])));
149
225
  }
150
226
  catch (e) {
151
227
  console.log(`[openmerit] benchmark shortlist unavailable: ${e.message}`);
152
228
  }
153
- const rubric = await ensureRubric(key, judge, task.task, new Map());
154
- const tKey = taskInputKey(task.task, task.images);
229
+ const rubric = await ensureRubric(key ?? "", judge.route, task.task, new Map(), client);
230
+ const tKey = taskInputKey(task.task, task.images, task.files);
155
231
  const points = [];
156
232
  const tried = new Set();
157
233
  const failedVendors = new Set();
158
234
  const total = Math.max(1, Math.floor(policy.watch.models_per_task));
159
235
  if (settledTask !== undefined)
160
236
  emitTrialProgress({ phase: "start", taskKey: tKey,
161
- model: task.model, index: 1, total });
162
- const baseline = await scorePiRun(key, judge, task.task, rubric, task.model, cat.get(task.model), task.run, "trace", task.images);
237
+ model: routeLabel(task.route), index: 1, total });
238
+ const baseline = await scorePiRun(key ?? "", judge.route, task.task, rubric, task.model, activeEntry, task.run, "trace", task.images, client);
163
239
  points.push(baseline.point);
164
- tried.add(task.model);
165
- appendJsonl(paths.trials(), { ...baseline.point, taskKey: tKey, sessionFile: task.sessionFile });
240
+ tried.add(routeKey(task.route));
241
+ persistTrial({ ...baseline.point, taskKey: tKey, sessionFile: task.sessionFile }, sink);
166
242
  console.log(`[openmerit] task ${tKey}: A=${task.model} score=${baseline.point.score.toFixed(2)} from pi trace`);
167
243
  if (settledTask !== undefined)
168
244
  emitTrialProgress({ phase: "complete", taskKey: tKey,
169
- model: task.model, index: 1, total, score: baseline.point.score,
245
+ model: routeLabel(task.route), index: 1, total, score: baseline.point.score,
170
246
  price: baseline.point.price, latencyMs: baseline.point.latencyMs,
171
247
  costUsd: baseline.costUsd, toolCalls: baseline.point.toolCalls,
172
248
  toolErrors: baseline.point.toolErrors, changedFiles: baseline.point.changedFiles });
173
249
  for (let i = 1; i < total; i++) {
174
250
  if (!budgetOk(policy).ok)
175
251
  break;
176
- const pick = await pickNext(key, strategist, task.task, rubric, benchmarks, points, cat, tried, policy.max_usd_per_m, failedVendors, benchmarkCandidates);
252
+ const pick = await pickNext(key ?? "", strategist.route, task.task, rubric, benchmarks, points, cat, tried, policy.max_usd_per_m, failedVendors, benchmarkCandidates, client);
177
253
  if (!pick)
178
254
  break;
179
255
  if (settledTask !== undefined)
180
256
  emitTrialProgress({ phase: "start", taskKey: tKey,
181
- model: pick.model, index: i + 1, total });
182
- const { point, costUsd, error } = await runPiTrial(key, judge, task.task, rubric, pick.model, cat.get(pick.model), task.cwd, task.images, task.files);
183
- tried.add(pick.model);
257
+ model: pick.route ? routeLabel(pick.route) : pick.model, index: i + 1, total });
258
+ const entry = pick.route ? cat.get(routeKey(pick.route)) : cat.get(pick.model);
259
+ const { point, costUsd, error } = await runPiTrial(key ?? "", judge.route, task.task, rubric, pick.model, entry, task.cwd, task.images, task.files, undefined, client);
260
+ tried.add(pick.route ? routeKey(pick.route) : pick.model);
184
261
  points.push(point);
185
262
  recordTrialSpend(costUsd);
186
- appendJsonl(paths.trials(), { ...point, taskKey: tKey, sessionFile: task.sessionFile });
263
+ persistTrial({ ...point, taskKey: tKey, sessionFile: task.sessionFile }, sink);
187
264
  if (error)
188
- failedVendors.add(pick.model.split("/")[0]);
265
+ failedVendors.add(pick.route?.provider ?? pick.model.split("/")[0]);
189
266
  console.log(`[openmerit] task ${tKey}: ${pick.model} score=${point.score.toFixed(2)} cost=$${costUsd.toFixed(4)} ` +
190
267
  `tools=${point.toolCalls ?? 0} toolErrors=${point.toolErrors ?? 0} changedFiles=${point.changedFiles ?? 0}` +
191
268
  `${error ? ` error=${error.slice(0, 80)}` : ""}`);
192
269
  if (settledTask !== undefined)
193
270
  emitTrialProgress({ phase: "complete", taskKey: tKey,
194
- model: pick.model, index: i + 1, total, score: point.score, price: point.price,
271
+ model: pick.route ? routeLabel(pick.route) : pick.model, index: i + 1, total, score: point.score, price: point.price,
195
272
  latencyMs: point.latencyMs, costUsd, error: error?.slice(0, 120),
196
273
  toolCalls: point.toolCalls, toolErrors: point.toolErrors, changedFiles: point.changedFiles });
197
274
  }
198
- const rec = buildRecommendation(tKey, task.task.slice(0, 120), task.model, points, policy, task.sessionFile);
275
+ const rec = buildRecommendation(tKey, task.task.slice(0, 120), task.model, points, policy, task.sessionFile, task.route);
199
276
  if (rec) {
200
277
  appendJsonl(paths.recommendations(), rec);
278
+ sink.emit(meritEvent("recommendation.created", rec));
201
279
  console.log(`[openmerit] task ${tKey}: selected ${rec.recommended.model}; auto=${rec.policy.autoApply}`);
202
280
  }
203
281
  else
204
282
  console.log(`[openmerit] task ${tKey}: active model remains best`);
205
283
  writeJson(processedPath, { marker, status: rec ? "complete" : "no_swap",
206
- updatedAt: new Date().toISOString() });
284
+ ownerPid: process.pid, updatedAt: new Date().toISOString() });
207
285
  return rec;
208
286
  }
209
287
  catch (error) {
210
288
  writeJson(processedPath, { marker, status: "failed",
211
- updatedAt: new Date().toISOString() });
289
+ ownerPid: process.pid, updatedAt: new Date().toISOString() });
212
290
  throw error;
213
291
  }
214
292
  }
215
293
  /** Rebuild frontiers from trials and emit recommendations for the harness's current model. */
216
294
  export function recommendTick(policy) {
295
+ const sink = new LocalJsonlEventSink();
217
296
  const harness = readJson(paths.harnessState(), {});
218
297
  const currentModel = harness.currentModel ?? null;
298
+ const currentRoute = harness.currentRoute ?? null;
219
299
  const trials = readJsonl(paths.trials());
220
300
  const observations = readJsonl(paths.observations());
221
301
  const labelByTask = new Map();
@@ -237,16 +317,17 @@ export function recommendTick(policy) {
237
317
  latestById.set(r.id, r);
238
318
  const existing = new Set([...latestById.values()]
239
319
  .filter((r) => r.status === "pending" || r.status === "dismissed")
240
- .map((r) => `${r.taskKey}:${r.recommended.model}`));
320
+ .map((r) => `${r.taskKey}:${r.recommended.route ? routeKey(r.recommended.route) : `openrouter:${r.recommended.model}`}`));
241
321
  const emitted = [];
242
322
  for (const [tKey, points] of byTask) {
243
- const rec = buildRecommendation(tKey, labelByTask.get(tKey) ?? tKey, currentModel, points, policy);
323
+ const rec = buildRecommendation(tKey, labelByTask.get(tKey) ?? tKey, currentModel, points, policy, null, currentRoute);
244
324
  if (!rec)
245
325
  continue;
246
- const dedupeKey = `${rec.taskKey}:${rec.recommended.model}`;
326
+ const dedupeKey = `${rec.taskKey}:${rec.recommended.route ? routeKey(rec.recommended.route) : `openrouter:${rec.recommended.model}`}`;
247
327
  if (existing.has(dedupeKey))
248
328
  continue;
249
329
  appendJsonl(paths.recommendations(), rec);
330
+ sink.emit(meritEvent("recommendation.created", rec));
250
331
  emitted.push(rec);
251
332
  console.log(`[openmerit] recommendation: ${rec.recommended.model} <- ${currentModel ?? "unknown"} ` +
252
333
  `(gain ${rec.evidence.scoreGain}, auto=${rec.policy.autoApply})`);
@@ -261,12 +342,12 @@ export async function tickOnce(policy) {
261
342
  key = loadKey();
262
343
  }
263
344
  catch (e) {
264
- console.log(`[openmerit] ${e.message}; skipping provider ticks`);
345
+ console.log(`[openmerit] ${e.message}; OpenRouter enrichment disabled`);
265
346
  }
266
347
  if (key) {
267
348
  await catalogTick(key, policy);
268
- await autoTaskTick(key, policy);
269
349
  }
350
+ await autoTaskTick(key, policy);
270
351
  }
271
352
  /** Run forever, honoring policy watch intervals. */
272
353
  export async function runDaemon(policy) {
@@ -291,17 +372,15 @@ export async function runDaemon(policy) {
291
372
  catch {
292
373
  /* no key; skip provider ticks */
293
374
  }
294
- if (key) {
295
- try {
296
- if (wantCatalog) {
297
- await catalogTick(key, policy);
298
- lastCatalog = now;
299
- }
300
- await autoTaskTick(key, policy);
301
- }
302
- catch (e) {
303
- console.log(`[openmerit] provider tick failed: ${e.message}`);
375
+ try {
376
+ if (key && wantCatalog) {
377
+ await catalogTick(key, policy);
378
+ lastCatalog = now;
304
379
  }
380
+ await autoTaskTick(key, policy);
381
+ }
382
+ catch (e) {
383
+ console.log(`[openmerit] provider tick failed: ${e.message}`);
305
384
  }
306
385
  }
307
386
  await new Promise((r) => setTimeout(r, tracesMs));
@@ -0,0 +1,194 @@
1
+ /** Read-only diagnostics plus a provider-free self-test for first-run support. */
2
+ import { spawnSync } from "node:child_process";
3
+ import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ import { LocalJsonlEventSink, meritEvent } from "./integrations.js";
7
+ import { piExecutable, availablePiRoutes } from "./pi-trials.js";
8
+ import { DEFAULT_POLICY, loadPolicy, providerAllowed } from "./policy.js";
9
+ import { buildRecommendation } from "./recommend.js";
10
+ import { modelRoute, routeCatalog, routeKey, routeLabel } from "./routes.js";
11
+ import { appendJsonl, paths, readJsonReport, readJsonlReport, writeJson, } from "./store.js";
12
+ function check(id, status, message, details) {
13
+ return { id, status, message, ...(details?.length ? { details } : {}) };
14
+ }
15
+ function packageSources(output) {
16
+ return [...new Set(output.split("\n").filter((line) => /^ {2}\S/.test(line)).map((line) => line.trim())
17
+ .filter((line) => /openmerit/i.test(line) && /^(npm:|git:|https?:|\.?\.?\/|\/)/.test(line)))];
18
+ }
19
+ function safePackageSource(source) {
20
+ if (source.startsWith("npm:/"))
21
+ return `npm:local/${basename(source)}`;
22
+ if (source.startsWith("/"))
23
+ return `local:${basename(source)}`;
24
+ return source;
25
+ }
26
+ /** No credentials, prompts, or trace contents are included in this report. */
27
+ export function collectDoctorReport() {
28
+ const checks = [];
29
+ const executable = piExecutable();
30
+ const versionResult = spawnSync(executable, ["--version"], { encoding: "utf8" });
31
+ const piVersion = versionResult.status === 0 ? versionResult.stdout.trim() : null;
32
+ checks.push(piVersion
33
+ ? check("pi", "pass", `Pi detected (${piVersion})`)
34
+ : check("pi", "fail", "Pi is not executable"));
35
+ try {
36
+ const policy = loadPolicy(paths.policy());
37
+ checks.push(check("policy", "pass", `Policy is valid (v${policy.version}, ${policy.mode})`));
38
+ }
39
+ catch (error) {
40
+ checks.push(check("policy", "fail", `Policy is invalid: ${error.message}`));
41
+ }
42
+ const stateRead = readJsonReport(paths.harnessState(), {});
43
+ checks.push(!stateRead.valid
44
+ ? check("harness-state", "fail", "harness-state.json is malformed; start Pi to replace it atomically")
45
+ : stateRead.exists
46
+ ? check("harness-state", "pass", "Pi extension state snapshot is readable")
47
+ : check("harness-state", "warn", "No Pi extension state yet; start Pi once with OpenMerit installed"));
48
+ let routes = stateRead.value.routes ?? [];
49
+ const liveSnapshot = routes.length > 0;
50
+ if (!routes.length && piVersion) {
51
+ try {
52
+ routes = availablePiRoutes();
53
+ }
54
+ catch { /* the check below explains it */ }
55
+ }
56
+ let policy = DEFAULT_POLICY;
57
+ try {
58
+ policy = loadPolicy(paths.policy());
59
+ }
60
+ catch { /* already reported */ }
61
+ routes = routes.filter((route) => providerAllowed(policy, route.meritId, route.provider));
62
+ const priced = routes.filter((route) => !!route.cost);
63
+ checks.push(routes.length >= 2
64
+ ? check("routes", "pass", `${routes.length} Pi model routes are visible`, routes.slice(0, 8).map((route) => `${routeLabel(route)}${route.cost ? "" : " (price unknown)"}`))
65
+ : check("routes", "fail", `Only ${routes.length} policy-eligible Pi route${routes.length === 1 ? " is" : "s are"} visible; at least two are required`));
66
+ checks.push(liveSnapshot
67
+ ? priced.length >= 2
68
+ ? check("pricing", "pass", `${priced.length} eligible routes expose pricing`)
69
+ : check("pricing", "warn", "Fewer than two eligible routes expose pricing; unknown-price routes cannot auto-apply")
70
+ : check("pricing", "warn", "Route prices are unavailable until the extension reports a live Pi snapshot"));
71
+ const session = stateRead.value.sessionFile;
72
+ checks.push(!session
73
+ ? check("session", "warn", "No active saved Pi session is reported yet")
74
+ : existsSync(session)
75
+ ? check("session", "pass", "The reported Pi session file is readable")
76
+ : check("session", "warn", "The reported Pi session file no longer exists"));
77
+ if (piVersion) {
78
+ const listed = spawnSync(executable, ["list"], { encoding: "utf8" });
79
+ const sources = listed.status === 0 ? packageSources(listed.stdout) : [];
80
+ const safeSources = sources.map(safePackageSource);
81
+ checks.push(sources.length > 1
82
+ ? check("installation", "fail", "OpenMerit is installed from multiple Pi package sources", safeSources)
83
+ : sources.length === 1
84
+ ? check("installation", "pass", `One Pi package source is installed`, safeSources)
85
+ : check("installation", "warn", "OpenMerit is not listed as an installed Pi package; use `pi install npm:openmerit`"));
86
+ }
87
+ const jsonlFiles = [
88
+ ["recommendations.jsonl", paths.recommendations()],
89
+ ["trials.jsonl", paths.trials()],
90
+ ["traces/observations.jsonl", paths.observations()],
91
+ ["events.jsonl", paths.events()],
92
+ ];
93
+ const malformed = jsonlFiles.flatMap(([label, file]) => {
94
+ const invalid = readJsonlReport(file).invalidLines;
95
+ return invalid.length ? [`${label}: lines ${invalid.join(", ")}`] : [];
96
+ });
97
+ checks.push(malformed.length
98
+ ? check("jsonl", "warn", "Malformed JSONL lines were skipped; valid later records remain readable", malformed)
99
+ : check("jsonl", "pass", "Append-only JSONL state is readable"));
100
+ const ledger = readJsonReport(paths.ledger(), { date: "", trials: 0, usd: 0 });
101
+ checks.push(ledger.valid
102
+ ? check("ledger", "pass", "Trial-spend ledger is readable")
103
+ : check("ledger", "fail", "Trial-spend ledger is malformed; spending remains disabled until repaired"));
104
+ let openRouter = false;
105
+ try {
106
+ const env = process.env.OPENROUTER_API_KEY;
107
+ if (env?.trim())
108
+ openRouter = true;
109
+ else if (existsSync(paths.envFile()))
110
+ openRouter = /^\s*OPENROUTER_API_KEY\s*=\s*\S+/m
111
+ .test(readFileSync(paths.envFile(), "utf8"));
112
+ }
113
+ catch { /* optional */ }
114
+ checks.push(check("openrouter-enrichment", "pass", openRouter ? "Optional OpenRouter enrichment is configured" : "Optional OpenRouter enrichment is not configured"));
115
+ const activeRoute = stateRead.value.currentRoute ? routeLabel(stateRead.value.currentRoute) : null;
116
+ return {
117
+ schemaVersion: 1,
118
+ ok: !checks.some((item) => item.status === "fail"),
119
+ checks,
120
+ summary: { piVersion, routeCount: routes.length, pricedRouteCount: priced.length, activeRoute },
121
+ };
122
+ }
123
+ export function renderDiagnosticReport(report) {
124
+ const icon = { pass: "PASS", warn: "WARN", fail: "FAIL" };
125
+ return report.checks.flatMap((item) => [
126
+ `${icon[item.status].padEnd(4)} ${item.id.padEnd(22)} ${item.message}`,
127
+ ...(item.details ?? []).map((detail) => ` - ${detail}`),
128
+ ]).join("\n");
129
+ }
130
+ /** Exercise core state, routing, recommendation, and event paths without provider calls. */
131
+ export function runOfflineVerify() {
132
+ const previousHome = process.env.OPENMERIT_HOME;
133
+ const root = mkdtempSync(join(tmpdir(), "openmerit-verify-"));
134
+ const checks = [];
135
+ try {
136
+ process.env.OPENMERIT_HOME = root;
137
+ const active = modelRoute("native", "model-a", {
138
+ input: ["text"], cost: { input: 1, output: 2 }, contextWindow: 8192, maxTokens: 4096,
139
+ });
140
+ const candidate = modelRoute("local", "model-b", {
141
+ input: ["text"], cost: { input: 0.2, output: 0.4 }, contextWindow: 8192, maxTokens: 4096,
142
+ });
143
+ writeJson(paths.harnessState(), { schemaVersion: 1, currentRoute: active, routes: [active, candidate] });
144
+ const state = readJsonReport(paths.harnessState(), {});
145
+ checks.push(state.valid && state.value.routes?.length === 2
146
+ ? check("atomic-json", "pass", "Atomic JSON state round-trip passed")
147
+ : check("atomic-json", "fail", "Atomic JSON state round-trip failed"));
148
+ const entries = routeCatalog([active, candidate]);
149
+ const point = (route, score) => {
150
+ const entry = entries.get(routeKey(route));
151
+ return { schemaVersion: 1, model: route.meritId, route, score, price: entry.price,
152
+ priceKnown: entry.priceKnown, ts: new Date().toISOString(), source: "pi_trial" };
153
+ };
154
+ const recommendation = buildRecommendation("verify-task", "offline verification", active.meritId, [point(active, 0.6), point(candidate, 0.9)], DEFAULT_POLICY, "/verify/session.jsonl", active);
155
+ checks.push(recommendation?.recommended.route?.provider === "local" &&
156
+ recommendation.policy.reasons.length > 0
157
+ ? check("provider-neutral", "pass", "Exact provider routes and policy reasons survive recommendation")
158
+ : check("provider-neutral", "fail", "Provider route or policy evidence was lost"));
159
+ if (recommendation)
160
+ appendJsonl(paths.recommendations(), recommendation);
161
+ appendJsonl(paths.recommendations(), { schemaVersion: 0, id: "legacy", status: "dismissed" });
162
+ appendFileSync(paths.recommendations(), "{interrupted");
163
+ const history = readJsonlReport(paths.recommendations());
164
+ checks.push(history.records.length === 2 && history.invalidLines.length === 1
165
+ ? check("jsonl-recovery", "pass", "Valid records survive legacy and interrupted JSONL lines")
166
+ : check("jsonl-recovery", "fail", "JSONL recovery did not preserve valid history"));
167
+ const sink = new LocalJsonlEventSink();
168
+ if (recommendation)
169
+ sink.emit(meritEvent("recommendation.created", recommendation));
170
+ const events = readJsonlReport(paths.events());
171
+ checks.push(events.records.length === 1
172
+ ? check("events", "pass", "Versioned neutral event sink round-trip passed")
173
+ : check("events", "fail", "Neutral event sink round-trip failed"));
174
+ checks.push(loadPolicy(paths.policy()).mode === "recommend"
175
+ ? check("compatibility", "pass", "Missing 0.1.x policy fields normalize to safe defaults")
176
+ : check("compatibility", "fail", "Policy defaults are unsafe"));
177
+ }
178
+ catch (error) {
179
+ checks.push(check("verify", "fail", error.message));
180
+ }
181
+ finally {
182
+ if (previousHome === undefined)
183
+ delete process.env.OPENMERIT_HOME;
184
+ else
185
+ process.env.OPENMERIT_HOME = previousHome;
186
+ rmSync(root, { recursive: true, force: true });
187
+ }
188
+ return {
189
+ schemaVersion: 1,
190
+ ok: !checks.some((item) => item.status === "fail"),
191
+ checks,
192
+ summary: { piVersion: null, routeCount: 2, pricedRouteCount: 2, activeRoute: "native/model-a via native" },
193
+ };
194
+ }
package/dist/frontier.js CHANGED
@@ -1,10 +1,16 @@
1
1
  /** Pareto frontier over (score up, price down, latency down) + best-fit/fallback picks. */
2
+ import { pointKey } from "./routes.js";
3
+ function effectivePrice(point) {
4
+ return point.priceKnown === false ? Number.POSITIVE_INFINITY : point.price;
5
+ }
2
6
  /** a dominates b if a is no worse on every objective and strictly better on one. */
3
7
  export function dominates(a, b) {
4
8
  const aLat = a.latencyMs ?? Number.POSITIVE_INFINITY;
5
9
  const bLat = b.latencyMs ?? Number.POSITIVE_INFINITY;
6
- const noWorse = a.score >= b.score && a.price <= b.price && aLat <= bLat;
7
- const better = a.score > b.score || a.price < b.price || aLat < bLat;
10
+ const aPrice = effectivePrice(a);
11
+ const bPrice = effectivePrice(b);
12
+ const noWorse = a.score >= b.score && aPrice <= bPrice && aLat <= bLat;
13
+ const better = a.score > b.score || aPrice < bPrice || aLat < bLat;
8
14
  return noWorse && better;
9
15
  }
10
16
  /**
@@ -14,11 +20,11 @@ export function dominates(a, b) {
14
20
  */
15
21
  export function paretoFrontier(points) {
16
22
  const fr = points.filter((p) => !points.some((q) => q !== p && dominates(q, p)));
17
- return fr.sort((a, b) => a.price - b.price || b.score - a.score);
23
+ return fr.sort((a, b) => effectivePrice(a) - effectivePrice(b) || b.score - a.score);
18
24
  }
19
25
  /** Simple 2-objective chain (score vs price only), cheapest first. */
20
26
  export function scorePriceChain(points) {
21
- const pts = [...points].sort((a, b) => a.price - b.price || b.score - a.score);
27
+ const pts = [...points].sort((a, b) => effectivePrice(a) - effectivePrice(b) || b.score - a.score);
22
28
  const chain = [];
23
29
  let best = -1;
24
30
  for (const p of pts) {
@@ -31,7 +37,8 @@ export function scorePriceChain(points) {
31
37
  }
32
38
  /** Highest score; ties broken by lower price then lower latency. */
33
39
  export function pickBest(points) {
34
- return [...points].sort((a, b) => b.score - a.score || a.price - b.price || (a.latencyMs ?? 1e18) - (b.latencyMs ?? 1e18))[0];
40
+ return [...points].sort((a, b) => b.score - a.score || effectivePrice(a) - effectivePrice(b) ||
41
+ (a.latencyMs ?? 1e18) - (b.latencyMs ?? 1e18))[0];
35
42
  }
36
43
  /**
37
44
  * Fallback for `best`: prefer the cheapest frontier point that still clears
@@ -39,7 +46,7 @@ export function pickBest(points) {
39
46
  * `best` itself.
40
47
  */
41
48
  export function pickFallback(points, best, minScore) {
42
- const others = points.filter((p) => p.model !== best.model);
49
+ const others = points.filter((p) => pointKey(p) !== pointKey(best));
43
50
  if (others.length === 0)
44
51
  return undefined;
45
52
  const fr = paretoFrontier(others).filter((p) => p.score >= minScore);
@@ -0,0 +1,19 @@
1
+ /** Stable boundaries for trace inputs and telemetry outputs. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { appendJsonl, paths } from "./store.js";
4
+ import { ingestNewTraces } from "./traces.js";
5
+ export class PiTraceObservationSource {
6
+ sessionsDir;
7
+ id = "pi-jsonl";
8
+ constructor(sessionsDir) {
9
+ this.sessionsDir = sessionsDir;
10
+ }
11
+ read() { return ingestNewTraces(this.sessionsDir); }
12
+ }
13
+ export class LocalJsonlEventSink {
14
+ id = "local-jsonl";
15
+ emit(event) { appendJsonl(paths.events(), event); }
16
+ }
17
+ export function meritEvent(type, payload, source = "openmerit") {
18
+ return { schemaVersion: 1, id: randomUUID(), ts: new Date().toISOString(), source, type, payload };
19
+ }
package/dist/judge.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Judge: score a model's answer against a rubric. */
2
- import { chat, chatWithImages } from "./llm.js";
2
+ import { directChatClient } from "./llm.js";
3
3
  export const JUDGE_PREFS = ["openai/gpt-4o-mini", "openai/gpt-4o"];
4
4
  const JUDGE_PROMPT = `You are grading a model's answer.
5
5
  TASK: {task}
@@ -13,8 +13,8 @@ export function parseObj(txt) {
13
13
  throw new Error("no json object found");
14
14
  return JSON.parse(m[0]);
15
15
  }
16
- export async function judge(key, judgeModel, task, rubric, answer) {
17
- const { content } = await chat(key, judgeModel, JUDGE_PROMPT.replace("{task}", task)
16
+ export async function judge(key, judgeModel, task, rubric, answer, client = directChatClient(key)) {
17
+ const { content } = await client.chat(judgeModel, JUDGE_PROMPT.replace("{task}", task)
18
18
  .replace("{rubric}", rubric)
19
19
  .replace("{answer}", answer.slice(0, 16000)), 1024, 0);
20
20
  try {
@@ -26,13 +26,13 @@ export async function judge(key, judgeModel, task, rubric, answer) {
26
26
  }
27
27
  }
28
28
  /** Grade OCR answers against the actual uploaded pixels, not answer text alone. */
29
- export async function judgeWithImages(key, judgeModel, task, rubric, answer, images) {
29
+ export async function judgeWithImages(key, judgeModel, task, rubric, answer, images, client = directChatClient(key)) {
30
30
  const prompt = `You are grading an answer to a visual task. Inspect the attached image carefully.\n` +
31
31
  `TASK: ${task}\nRUBRIC: ${rubric}\nANSWER: ${answer.slice(0, 16000)}\n` +
32
32
  `For invoice extraction, check every visible field and line item against the image. ` +
33
33
  `Penalize missing, invented, or mistyped values and invalid JSON. ` +
34
34
  `Return ONLY json: {"score": <0..1>, "why": "<one line>"}`;
35
- const { content } = await chatWithImages(key, judgeModel, prompt, images, 1024);
35
+ const { content } = await client.chatWithImages(judgeModel, prompt, images, 1024);
36
36
  try {
37
37
  const j = parseObj(content);
38
38
  return { score: Number(j.score ?? 0), why: String(j.why ?? "") };