openmerit 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -58
- package/data/catalogue.json +6778 -0
- package/dist/catalogue/catalogue.d.ts +41 -0
- package/dist/catalogue/catalogue.js +33 -0
- package/dist/cli/prompt.d.ts +26 -0
- package/dist/cli/prompt.js +114 -0
- package/dist/cli/render.d.ts +12 -0
- package/dist/cli/render.js +104 -0
- package/dist/cli.js +245 -158
- package/dist/connect.d.ts +34 -0
- package/dist/connect.js +158 -0
- package/dist/connectors/langfuse/index.d.ts +37 -110
- package/dist/connectors/langfuse/index.js +95 -246
- package/dist/daemon/daemon.d.ts +81 -0
- package/dist/daemon/daemon.js +408 -0
- package/dist/daemon/registry.d.ts +111 -0
- package/dist/daemon/registry.js +109 -0
- package/dist/eval/proxy.d.ts +54 -0
- package/dist/eval/proxy.js +174 -0
- package/dist/eval/runner.d.ts +29 -0
- package/dist/eval/runner.js +80 -0
- package/dist/harness/claude-bridge.d.ts +21 -0
- package/dist/harness/claude-bridge.js +65 -0
- package/dist/harness/harness.d.ts +60 -0
- package/dist/harness/harness.js +221 -0
- package/dist/index.d.ts +9 -10
- package/dist/index.js +9 -10
- package/dist/local.d.ts +12 -0
- package/dist/local.js +40 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +155 -0
- package/dist/scout/pool.d.ts +17 -0
- package/dist/scout/pool.js +56 -0
- package/dist/scout/scan.d.ts +42 -0
- package/dist/scout/scan.js +169 -0
- package/dist/scout/search.d.ts +25 -98
- package/dist/scout/search.js +56 -156
- package/dist/scout/workflow.d.ts +221 -0
- package/dist/scout/workflow.js +941 -0
- package/dist/setup.d.ts +44 -0
- package/dist/setup.js +211 -0
- package/dist/state/config.d.ts +116 -0
- package/dist/state/config.js +60 -0
- package/dist/state/db.d.ts +159 -0
- package/dist/state/db.js +260 -0
- package/dist/state/paths.d.ts +12 -0
- package/dist/state/paths.js +23 -0
- package/dist/update.d.ts +22 -0
- package/dist/update.js +58 -0
- package/package.json +12 -17
- package/dist/connectors/langfuse/otlp.d.ts +0 -60
- package/dist/connectors/langfuse/otlp.js +0 -40
- package/dist/pi/extension.d.ts +0 -13
- package/dist/pi/extension.js +0 -148
- package/dist/pi/harness.d.ts +0 -20
- package/dist/pi/harness.js +0 -49
- package/dist/pi/instructions.d.ts +0 -2
- package/dist/pi/instructions.js +0 -12
- package/dist/scout/evaluators/module.d.ts +0 -24
- package/dist/scout/evaluators/module.js +0 -19
- package/dist/scout/evaluators/pi.d.ts +0 -53
- package/dist/scout/evaluators/pi.js +0 -117
- package/dist/scout/pipeline.d.ts +0 -111
- package/dist/scout/pipeline.js +0 -157
- package/dist/scout/report.d.ts +0 -3
- package/dist/scout/report.js +0 -42
- package/dist/scout/service.d.ts +0 -148
- package/dist/scout/service.js +0 -143
- package/examples/health-declaration-codes/dataset.jsonl +0 -14
- package/examples/sahra-enrollment/evaluator.mts +0 -152
package/dist/state/db.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Local run history in SQLite (~/.openmerit/state.db), shared by the CLI and the MCP server and
|
|
2
|
+
// scoped by repository. It records runs, progress, eval units, paid usage and budget approvals,
|
|
3
|
+
// rankings and configuration decisions; never keys or raw dataset contents.
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { openmeritHome } from "./paths.js";
|
|
7
|
+
// node:sqlite prints an ExperimentalWarning on Node 22; keep it off the terminal (and MCP clients).
|
|
8
|
+
const sqlite = await (async () => {
|
|
9
|
+
const listeners = process.listeners("warning");
|
|
10
|
+
process.removeAllListeners("warning");
|
|
11
|
+
process.on("warning", warning => { if (warning.name !== "ExperimentalWarning")
|
|
12
|
+
for (const listener of listeners)
|
|
13
|
+
listener(warning); });
|
|
14
|
+
return import("node:sqlite");
|
|
15
|
+
})();
|
|
16
|
+
const MIGRATIONS = [
|
|
17
|
+
`CREATE TABLE repos (id TEXT PRIMARY KEY, root TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
18
|
+
CREATE TABLE runs (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id), revision TEXT, status TEXT NOT NULL, phase TEXT NOT NULL,
|
|
19
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, package_version TEXT NOT NULL, catalogue_version TEXT NOT NULL, harness TEXT NOT NULL,
|
|
20
|
+
budget_usd REAL NOT NULL, state_json TEXT NOT NULL, error TEXT, lock_pid INTEGER);
|
|
21
|
+
CREATE INDEX runs_repo ON runs(repo_id, created_at);
|
|
22
|
+
CREATE TABLE events (id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), at TEXT NOT NULL, kind TEXT NOT NULL, message TEXT NOT NULL);
|
|
23
|
+
CREATE TABLE eval_units (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), task TEXT NOT NULL, model TEXT NOT NULL, endpoint TEXT NOT NULL,
|
|
24
|
+
role TEXT NOT NULL, repeat INTEGER NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, result_json TEXT, error TEXT, updated_at TEXT NOT NULL);
|
|
25
|
+
CREATE TABLE usage (id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), unit_id TEXT NOT NULL, model TEXT NOT NULL, endpoint TEXT NOT NULL,
|
|
26
|
+
status TEXT NOT NULL, reserved_usd REAL NOT NULL, estimated_usd REAL, actual_usd REAL, latency_ms INTEGER, created_at TEXT NOT NULL, settled_at TEXT);
|
|
27
|
+
CREATE INDEX usage_run ON usage(run_id);
|
|
28
|
+
CREATE TABLE budget_events (id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), at TEXT NOT NULL, kind TEXT NOT NULL,
|
|
29
|
+
from_usd REAL, to_usd REAL, scope TEXT, spent_usd REAL);
|
|
30
|
+
CREATE TABLE decisions (id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), at TEXT NOT NULL, kind TEXT NOT NULL, decision TEXT NOT NULL, detail_json TEXT NOT NULL);`,
|
|
31
|
+
];
|
|
32
|
+
const now = () => new Date().toISOString();
|
|
33
|
+
function alive(pid) {
|
|
34
|
+
if (!pid)
|
|
35
|
+
return false;
|
|
36
|
+
if (pid === process.pid)
|
|
37
|
+
return true;
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return error.code === "EPERM";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class State {
|
|
47
|
+
db;
|
|
48
|
+
path;
|
|
49
|
+
constructor(env = process.env) {
|
|
50
|
+
const home = openmeritHome(env);
|
|
51
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
52
|
+
this.path = join(home, "state.db");
|
|
53
|
+
this.db = new sqlite.DatabaseSync(this.path);
|
|
54
|
+
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 10000; PRAGMA foreign_keys = ON;");
|
|
55
|
+
this.migrate();
|
|
56
|
+
}
|
|
57
|
+
/** Applies schema upgrades in order; older history stays readable. */
|
|
58
|
+
migrate() {
|
|
59
|
+
this.db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
60
|
+
this.transaction(() => {
|
|
61
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
62
|
+
const version = row ? Number(row.value) : 0;
|
|
63
|
+
for (let index = version; index < MIGRATIONS.length; index++)
|
|
64
|
+
this.db.exec(MIGRATIONS[index]);
|
|
65
|
+
this.db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(Math.max(version, MIGRATIONS.length)));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
get schemaVersion() { return Number(this.db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get().value); }
|
|
69
|
+
/** BEGIN IMMEDIATE: one writer at a time across CLI and MCP processes. */
|
|
70
|
+
transaction(work) {
|
|
71
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
72
|
+
try {
|
|
73
|
+
const result = work();
|
|
74
|
+
this.db.exec("COMMIT");
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
this.db.exec("ROLLBACK");
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
close() { this.db.close(); }
|
|
83
|
+
createRun(input) {
|
|
84
|
+
const at = now();
|
|
85
|
+
this.transaction(() => {
|
|
86
|
+
this.db.prepare("INSERT OR IGNORE INTO repos (id, root, created_at) VALUES (?, ?, ?)").run(input.repo.id, input.repo.root, at);
|
|
87
|
+
this.db.prepare(`INSERT INTO runs (id, repo_id, revision, status, phase, created_at, updated_at, package_version, catalogue_version, harness, budget_usd, state_json, lock_pid)
|
|
88
|
+
VALUES (?, ?, ?, 'running', 'scan', ?, ?, ?, ?, ?, ?, ?, NULL)`)
|
|
89
|
+
.run(input.id, input.repo.id, input.repo.revision ? `${input.repo.revision}${input.repo.dirty ? "+dirty" : ""}` : null, at, at, input.packageVersion, input.catalogueVersion, input.harness, input.budgetUsd, JSON.stringify(input.state));
|
|
90
|
+
});
|
|
91
|
+
this.event(input.id, "start", `Run started (openmerit ${input.packageVersion}, catalogue ${input.catalogueVersion}, harness ${input.harness}, budget $${input.budgetUsd.toFixed(2)})`);
|
|
92
|
+
return this.getRun(input.id);
|
|
93
|
+
}
|
|
94
|
+
getRun(id) {
|
|
95
|
+
const row = this.db.prepare("SELECT runs.*, repos.root FROM runs JOIN repos ON repos.id = runs.repo_id WHERE runs.id = ?").get(id);
|
|
96
|
+
return row ? this.toRun(row) : null;
|
|
97
|
+
}
|
|
98
|
+
listRuns(repoId, limit = 50) {
|
|
99
|
+
return this.db.prepare("SELECT runs.*, repos.root FROM runs JOIN repos ON repos.id = runs.repo_id WHERE repo_id = ? ORDER BY created_at DESC LIMIT ?").all(repoId, limit).map(row => this.toRun(row));
|
|
100
|
+
}
|
|
101
|
+
toRun(row) {
|
|
102
|
+
return {
|
|
103
|
+
id: row.id, repoId: row.repo_id, repoRoot: row.root, revision: row.revision, status: row.status, phase: row.phase,
|
|
104
|
+
createdAt: row.created_at, updatedAt: row.updated_at, packageVersion: row.package_version, catalogueVersion: row.catalogue_version,
|
|
105
|
+
harness: row.harness, budgetUsd: row.budget_usd, state: JSON.parse(row.state_json), error: row.error, lockPid: row.lock_pid,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
updateRun(id, patch) {
|
|
109
|
+
const sets = ["updated_at = ?"];
|
|
110
|
+
const values = [now()];
|
|
111
|
+
if (patch.status) {
|
|
112
|
+
sets.push("status = ?");
|
|
113
|
+
values.push(patch.status);
|
|
114
|
+
}
|
|
115
|
+
if (patch.phase) {
|
|
116
|
+
sets.push("phase = ?");
|
|
117
|
+
values.push(patch.phase);
|
|
118
|
+
}
|
|
119
|
+
if (patch.state) {
|
|
120
|
+
sets.push("state_json = ?");
|
|
121
|
+
values.push(JSON.stringify(patch.state));
|
|
122
|
+
}
|
|
123
|
+
if (patch.error !== undefined) {
|
|
124
|
+
sets.push("error = ?");
|
|
125
|
+
values.push(patch.error);
|
|
126
|
+
}
|
|
127
|
+
if (patch.budgetUsd !== undefined) {
|
|
128
|
+
sets.push("budget_usd = ?");
|
|
129
|
+
values.push(patch.budgetUsd);
|
|
130
|
+
}
|
|
131
|
+
if (patch.harness) {
|
|
132
|
+
sets.push("harness = ?");
|
|
133
|
+
values.push(patch.harness);
|
|
134
|
+
}
|
|
135
|
+
this.db.prepare(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`).run(...values, id);
|
|
136
|
+
}
|
|
137
|
+
/** Takes the run for this process. Fails while another live process works on it. */
|
|
138
|
+
lock(id) {
|
|
139
|
+
return this.transaction(() => {
|
|
140
|
+
const row = this.db.prepare("SELECT lock_pid FROM runs WHERE id = ?").get(id);
|
|
141
|
+
if (!row)
|
|
142
|
+
throw new Error(`No run ${id}`);
|
|
143
|
+
if (row.lock_pid && row.lock_pid !== process.pid && alive(row.lock_pid))
|
|
144
|
+
return false;
|
|
145
|
+
this.db.prepare("UPDATE runs SET lock_pid = ? WHERE id = ?").run(process.pid, id);
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
unlock(id) { this.db.prepare("UPDATE runs SET lock_pid = NULL WHERE id = ? AND lock_pid = ?").run(id, process.pid); }
|
|
150
|
+
/**
|
|
151
|
+
* Runs whose process died mid-work become `interrupted`: running units are interrupted, and
|
|
152
|
+
* reservations of requests that never settled stay counted as in-flight spend of unknown cost.
|
|
153
|
+
*/
|
|
154
|
+
reapInterrupted(repoId) {
|
|
155
|
+
return this.transaction(() => {
|
|
156
|
+
const rows = this.db.prepare("SELECT id, lock_pid FROM runs WHERE repo_id = ? AND status = 'running' AND lock_pid IS NOT NULL").all(repoId);
|
|
157
|
+
const reaped = rows.filter(row => !alive(row.lock_pid)).map(row => row.id);
|
|
158
|
+
for (const id of reaped)
|
|
159
|
+
this.interrupted(id, "The process stopped before the run finished; completed work and spend are kept.");
|
|
160
|
+
return reaped;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** Marks a run interrupted (Ctrl-C, or a dead process). Unsettled reservations stay counted as in-flight spend. */
|
|
164
|
+
markInterrupted(runId, message = "Interrupted; completed work and spend are kept.") { this.transaction(() => this.interrupted(runId, message)); }
|
|
165
|
+
interrupted(id, message) {
|
|
166
|
+
this.db.prepare("UPDATE runs SET status = 'interrupted', lock_pid = NULL, updated_at = ? WHERE id = ?").run(now(), id);
|
|
167
|
+
this.db.prepare("UPDATE eval_units SET status = 'interrupted', updated_at = ? WHERE run_id = ? AND status = 'running'").run(now(), id);
|
|
168
|
+
this.db.prepare("UPDATE usage SET status = 'interrupted' WHERE run_id = ? AND status = 'reserved'").run(id);
|
|
169
|
+
this.db.prepare("INSERT INTO events (run_id, at, kind, message) VALUES (?, ?, 'interrupted', ?)").run(id, now(), message);
|
|
170
|
+
}
|
|
171
|
+
event(runId, kind, message) {
|
|
172
|
+
this.db.prepare("INSERT INTO events (run_id, at, kind, message) VALUES (?, ?, ?, ?)").run(runId, now(), kind, message);
|
|
173
|
+
}
|
|
174
|
+
events(runId) {
|
|
175
|
+
return this.db.prepare("SELECT at, kind, message FROM events WHERE run_id = ? ORDER BY id").all(runId);
|
|
176
|
+
}
|
|
177
|
+
ensureUnit(unit) {
|
|
178
|
+
this.db.prepare("INSERT OR IGNORE INTO eval_units (id, run_id, task, model, endpoint, role, repeat, status, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)")
|
|
179
|
+
.run(unit.id, unit.runId, unit.task, unit.model, unit.endpoint, unit.role, unit.repeat, now());
|
|
180
|
+
return this.units(unit.runId).find(item => item.id === unit.id);
|
|
181
|
+
}
|
|
182
|
+
units(runId) {
|
|
183
|
+
return this.db.prepare("SELECT * FROM eval_units WHERE run_id = ? ORDER BY rowid").all(runId).map(row => ({
|
|
184
|
+
id: row.id, runId: row.run_id, task: row.task, model: row.model, endpoint: row.endpoint, role: row.role,
|
|
185
|
+
repeat: row.repeat, status: row.status, attempts: row.attempts,
|
|
186
|
+
result: row.result_json ? JSON.parse(row.result_json) : null, error: row.error,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
setUnit(id, patch) {
|
|
190
|
+
this.db.prepare(`UPDATE eval_units SET status = ?, result_json = COALESCE(?, result_json), error = ?, attempts = attempts + ?, updated_at = ? WHERE id = ?`)
|
|
191
|
+
.run(patch.status, patch.result ? JSON.stringify(patch.result) : null, patch.error ?? null, patch.attempt ? 1 : 0, now(), id);
|
|
192
|
+
}
|
|
193
|
+
spend(runId, unitId) {
|
|
194
|
+
const rows = this.db.prepare(`SELECT status, reserved_usd, estimated_usd, actual_usd FROM usage WHERE run_id = ?${unitId ? " AND unit_id = ?" : ""}`).all(...(unitId ? [runId, unitId] : [runId]));
|
|
195
|
+
const spend = { settledUsd: 0, estimatedUsd: 0, inFlightUsd: 0, inFlight: 0, unknown: 0, requests: rows.length, totalUsd: 0 };
|
|
196
|
+
for (const row of rows) {
|
|
197
|
+
if (row.status === "reserved" || row.status === "interrupted") {
|
|
198
|
+
spend.inFlightUsd += row.reserved_usd;
|
|
199
|
+
spend.inFlight++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (row.actual_usd !== null)
|
|
203
|
+
spend.settledUsd += row.actual_usd;
|
|
204
|
+
else if (row.estimated_usd !== null) {
|
|
205
|
+
spend.settledUsd += row.estimated_usd;
|
|
206
|
+
spend.estimatedUsd += row.estimated_usd;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
spend.settledUsd += row.reserved_usd;
|
|
210
|
+
spend.unknown++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
spend.totalUsd = spend.settledUsd + spend.inFlightUsd;
|
|
214
|
+
return spend;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Reserves a paid request's expected cost against the run budget, atomically across processes.
|
|
218
|
+
* Refused when settled plus reserved spend plus this request would pass the run's budget.
|
|
219
|
+
*/
|
|
220
|
+
reserve(input) {
|
|
221
|
+
return this.transaction(() => {
|
|
222
|
+
const budgetUsd = this.db.prepare("SELECT budget_usd FROM runs WHERE id = ?").get(input.runId).budget_usd;
|
|
223
|
+
const spend = this.spend(input.runId);
|
|
224
|
+
if (spend.totalUsd + input.expectedUsd > budgetUsd + 1e-9)
|
|
225
|
+
return { ok: false, spend, budgetUsd };
|
|
226
|
+
const result = this.db.prepare("INSERT INTO usage (run_id, unit_id, model, endpoint, status, reserved_usd, created_at) VALUES (?, ?, ?, ?, 'reserved', ?, ?)")
|
|
227
|
+
.run(input.runId, input.unitId, input.model, input.endpoint, input.expectedUsd, now());
|
|
228
|
+
return { ok: true, id: Number(result.lastInsertRowid) };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Records what a reserved request cost: provider-reported (`actualUsd`), list-price estimate, or unknown (both null). */
|
|
232
|
+
settle(id, cost) {
|
|
233
|
+
this.db.prepare("UPDATE usage SET status = ?, actual_usd = ?, estimated_usd = ?, latency_ms = ?, settled_at = ? WHERE id = ?")
|
|
234
|
+
.run(cost.failed ? "failed" : "settled", cost.actualUsd, cost.estimatedUsd, cost.latencyMs, now(), id);
|
|
235
|
+
}
|
|
236
|
+
usage(runId, unitId) {
|
|
237
|
+
const rows = this.db.prepare("SELECT status, actual_usd, estimated_usd, latency_ms FROM usage WHERE run_id = ? AND unit_id = ?").all(runId, unitId);
|
|
238
|
+
const settled = rows.filter(row => row.status === "settled");
|
|
239
|
+
return {
|
|
240
|
+
requests: settled.length, latencyMs: settled.reduce((sum, row) => sum + (row.latency_ms ?? 0), 0),
|
|
241
|
+
actualUsd: settled.reduce((sum, row) => sum + (row.actual_usd ?? 0), 0),
|
|
242
|
+
estimatedUsd: settled.reduce((sum, row) => sum + (row.actual_usd === null ? row.estimated_usd ?? 0 : 0), 0),
|
|
243
|
+
unknown: settled.filter(row => row.actual_usd === null && row.estimated_usd === null).length,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
budgetEvent(runId, event) {
|
|
247
|
+
this.db.prepare("INSERT INTO budget_events (run_id, at, kind, from_usd, to_usd, scope, spent_usd) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
248
|
+
.run(runId, now(), event.kind, event.fromUsd, event.toUsd ?? null, event.scope ?? null, event.spentUsd);
|
|
249
|
+
}
|
|
250
|
+
budgetEvents(runId) {
|
|
251
|
+
return this.db.prepare("SELECT at, kind, from_usd AS fromUsd, to_usd AS toUsd, scope, spent_usd AS spentUsd FROM budget_events WHERE run_id = ? ORDER BY id").all(runId);
|
|
252
|
+
}
|
|
253
|
+
decision(runId, kind, decision, detail) {
|
|
254
|
+
this.db.prepare("INSERT INTO decisions (run_id, at, kind, decision, detail_json) VALUES (?, ?, ?, ?, ?)").run(runId, now(), kind, decision, JSON.stringify(detail));
|
|
255
|
+
}
|
|
256
|
+
decisions(runId) {
|
|
257
|
+
return this.db.prepare("SELECT at, kind, decision, detail_json FROM decisions WHERE run_id = ? ORDER BY id").all(runId)
|
|
258
|
+
.map(row => ({ at: row.at, kind: row.kind, decision: row.decision, detail: JSON.parse(row.detail_json) }));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** OpenMerit's global directory: settings, secrets and the SQLite history. Never inside a repository. */
|
|
2
|
+
export declare const openmeritHome: (env?: Record<string, string | undefined>) => string;
|
|
3
|
+
export interface Repo {
|
|
4
|
+
/** Stable ID of the checkout: a hash of its real root path. */
|
|
5
|
+
id: string;
|
|
6
|
+
root: string;
|
|
7
|
+
/** Current commit, or null outside Git or before the first commit. */
|
|
8
|
+
revision: string | null;
|
|
9
|
+
dirty: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** The repository containing `cwd`: its Git root, or `cwd` itself outside Git. */
|
|
12
|
+
export declare function findRepo(cwd: string): Repo;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
/** OpenMerit's global directory: settings, secrets and the SQLite history. Never inside a repository. */
|
|
7
|
+
export const openmeritHome = (env = process.env) => env.OPENMERIT_HOME ?? join(homedir(), ".openmerit");
|
|
8
|
+
const git = (cwd, args) => {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/** The repository containing `cwd`: its Git root, or `cwd` itself outside Git. */
|
|
17
|
+
export function findRepo(cwd) {
|
|
18
|
+
const root = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]) ?? cwd);
|
|
19
|
+
const revision = git(root, ["rev-parse", "HEAD"]);
|
|
20
|
+
// OpenMerit's own files (config, saved evals) do not make the code under test differ from the commit.
|
|
21
|
+
const status = git(root, ["status", "--porcelain", "--", ".", ":(exclude).openmerit"]);
|
|
22
|
+
return { id: createHash("sha256").update(root).digest("hex").slice(0, 16), root, revision, dirty: !!status };
|
|
23
|
+
}
|
package/dist/update.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const packageVersion: () => string;
|
|
2
|
+
export declare function newer(latest: string, current: string): boolean;
|
|
3
|
+
export interface UpdateInfo {
|
|
4
|
+
current: string;
|
|
5
|
+
latest: string | null;
|
|
6
|
+
available: boolean;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Asks the npm registry for the latest version. Offline or slow registries are not errors. */
|
|
10
|
+
export declare function checkForUpdate(env?: Record<string, string | undefined>, fetcher?: typeof fetch): Promise<UpdateInfo>;
|
|
11
|
+
export declare const updateNotice: (info: UpdateInfo) => string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Interactive CLI flow. Returns the exit code of the updated process when it took over, or null to
|
|
14
|
+
* continue in this process on the installed version.
|
|
15
|
+
*/
|
|
16
|
+
export declare function offerUpdate(info: UpdateInfo, options: {
|
|
17
|
+
interactive: boolean;
|
|
18
|
+
confirm: (question: string) => Promise<boolean>;
|
|
19
|
+
log: (line: string) => void;
|
|
20
|
+
argv: string[];
|
|
21
|
+
env?: Record<string, string | undefined>;
|
|
22
|
+
}): Promise<number | null>;
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Update check on every invocation: a newer `openmerit` on npm is offered, never installed silently.
|
|
2
|
+
// Interactive: ask, install with approval, then continue on the new version (or the installed one).
|
|
3
|
+
// Non-interactive or MCP: report it without prompting; MCP reports it through the client.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
export const packageVersion = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
7
|
+
export function newer(latest, current) {
|
|
8
|
+
const parse = (value) => value.replace(/^v/, "").split("-")[0].split(".").map(part => Number(part) || 0);
|
|
9
|
+
const [a, b] = [parse(latest), parse(current)];
|
|
10
|
+
for (let index = 0; index < 3; index++)
|
|
11
|
+
if ((a[index] ?? 0) !== (b[index] ?? 0))
|
|
12
|
+
return (a[index] ?? 0) > (b[index] ?? 0);
|
|
13
|
+
return !latest.includes("-") && current.includes("-") && parse(latest).join(".") === parse(current).join(".");
|
|
14
|
+
}
|
|
15
|
+
/** Asks the npm registry for the latest version. Offline or slow registries are not errors. */
|
|
16
|
+
export async function checkForUpdate(env = process.env, fetcher = fetch) {
|
|
17
|
+
const current = packageVersion();
|
|
18
|
+
if (env.OPENMERIT_NO_UPDATE_CHECK === "1")
|
|
19
|
+
return { current, latest: null, available: false, error: "disabled" };
|
|
20
|
+
try {
|
|
21
|
+
const registry = (env.OPENMERIT_REGISTRY ?? "https://registry.npmjs.org").replace(/\/$/, "");
|
|
22
|
+
const response = await fetcher(`${registry}/openmerit/latest`, { signal: AbortSignal.timeout(Number(env.OPENMERIT_UPDATE_TIMEOUT_MS ?? 2500)), headers: { accept: "application/json" } });
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
return { current, latest: null, available: false, error: `registry HTTP ${response.status}` };
|
|
25
|
+
const latest = (await response.json()).version ?? null;
|
|
26
|
+
return { current, latest, available: !!latest && newer(latest, current) };
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return { current, latest: null, available: false, error: error instanceof Error ? error.message : String(error) };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const updateNotice = (info) => (info.available ? `openmerit ${info.latest} is available (installed ${info.current}). Update with: npm install -g openmerit@${info.latest}` : null);
|
|
33
|
+
/**
|
|
34
|
+
* Interactive CLI flow. Returns the exit code of the updated process when it took over, or null to
|
|
35
|
+
* continue in this process on the installed version.
|
|
36
|
+
*/
|
|
37
|
+
export async function offerUpdate(info, options) {
|
|
38
|
+
if (!info.available)
|
|
39
|
+
return null;
|
|
40
|
+
if (!options.interactive) {
|
|
41
|
+
options.log(updateNotice(info));
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!(await options.confirm(`openmerit ${info.latest} is available (installed ${info.current}). Update now?`))) {
|
|
45
|
+
options.log(`Continuing with openmerit ${info.current}.`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const env = options.env ?? process.env;
|
|
49
|
+
const npm = env.OPENMERIT_NPM ?? (process.platform === "win32" ? "npm.cmd" : "npm");
|
|
50
|
+
const install = spawnSync(npm, ["install", "-g", `openmerit@${info.latest}`], { stdio: ["ignore", "inherit", "inherit"], env });
|
|
51
|
+
if (install.status !== 0) {
|
|
52
|
+
options.log(`The update did not install (exit ${install.status ?? install.error?.message}); continuing with openmerit ${info.current}. Your history and settings are unchanged.`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
options.log(`Updated to openmerit ${info.latest}; continuing on it.`);
|
|
56
|
+
const rerun = spawnSync(env.OPENMERIT_BIN ?? "openmerit", options.argv, { stdio: "inherit", env: { ...env, OPENMERIT_NO_UPDATE_CHECK: "1" } });
|
|
57
|
+
return rerun.status ?? 1;
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openmerit",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Find
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "Find cheaper models that perform at least as well as the ones your code uses, measured with your own evals: a local CLI and MCP server built on Pi, powered by Claude Code, Codex or an API key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -12,22 +12,12 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"default": "./dist/index.js"
|
|
14
14
|
},
|
|
15
|
-
"./pi": {
|
|
16
|
-
"types": "./dist/pi/extension.d.ts",
|
|
17
|
-
"default": "./dist/pi/extension.js"
|
|
18
|
-
},
|
|
19
15
|
"./package.json": "./package.json"
|
|
20
16
|
},
|
|
21
|
-
"pi": {
|
|
22
|
-
"extensions": [
|
|
23
|
-
"./dist/pi/extension.js"
|
|
24
|
-
]
|
|
25
|
-
},
|
|
26
17
|
"files": [
|
|
27
18
|
"dist/",
|
|
28
19
|
"!dist/**/*.test.*",
|
|
29
20
|
"data/",
|
|
30
|
-
"examples/",
|
|
31
21
|
"README.md",
|
|
32
22
|
"LICENSE"
|
|
33
23
|
],
|
|
@@ -42,15 +32,20 @@
|
|
|
42
32
|
"scripts": {
|
|
43
33
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/rewrite-declaration-imports.ts && chmod +x dist/cli.js",
|
|
44
34
|
"typecheck": "tsc -p tsconfig.json",
|
|
45
|
-
"test": "node --test \"src/**/*.test.ts\" \"test/**/*.test.ts\"",
|
|
35
|
+
"test": "node --test --test-concurrency=3 \"src/**/*.test.ts\" \"test/**/*.test.ts\"",
|
|
36
|
+
"test:fast": "node --test \"src/**/*.test.ts\" \"test/**/!(daemon).test.ts\"",
|
|
46
37
|
"check": "npm run typecheck && npm test",
|
|
47
38
|
"prepack": "npm run build",
|
|
48
|
-
"openmerit": "node src/cli.ts"
|
|
39
|
+
"openmerit": "node src/cli.ts",
|
|
40
|
+
"build:catalogue": "node scripts/build-catalogue.ts"
|
|
49
41
|
},
|
|
50
42
|
"dependencies": {
|
|
51
|
-
"@earendil-works/pi-ai": "1.5.
|
|
52
|
-
"@earendil-works/pi-coding-agent": "1.5.
|
|
53
|
-
"
|
|
43
|
+
"@earendil-works/pi-ai": "1.5.2",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "1.5.2",
|
|
45
|
+
"@rivet-dev/workflows": "1.1.1",
|
|
46
|
+
"jiti": "2.7.0",
|
|
47
|
+
"pi-claude-bridge": "0.12.0",
|
|
48
|
+
"rivetkit": "3.1.1"
|
|
54
49
|
},
|
|
55
50
|
"devDependencies": {
|
|
56
51
|
"@types/node": "22.19.1",
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/** Deterministic hex IDs, so a retried recording updates the same trace, spans and scores. */
|
|
2
|
-
export declare const hexId: (value: string, length: 16 | 32) => string;
|
|
3
|
-
type AnyValue = {
|
|
4
|
-
stringValue: string;
|
|
5
|
-
} | {
|
|
6
|
-
boolValue: boolean;
|
|
7
|
-
} | {
|
|
8
|
-
intValue: string;
|
|
9
|
-
} | {
|
|
10
|
-
doubleValue: number;
|
|
11
|
-
} | {
|
|
12
|
-
arrayValue: {
|
|
13
|
-
values: AnyValue[];
|
|
14
|
-
};
|
|
15
|
-
};
|
|
16
|
-
export interface Attribute {
|
|
17
|
-
key: string;
|
|
18
|
-
value: AnyValue;
|
|
19
|
-
}
|
|
20
|
-
export declare function attribute(key: string, value: string | number | boolean | string[]): Attribute;
|
|
21
|
-
export declare const asText: (value: unknown) => string;
|
|
22
|
-
export interface OtlpSpan {
|
|
23
|
-
traceId: string;
|
|
24
|
-
spanId: string;
|
|
25
|
-
parentSpanId?: string;
|
|
26
|
-
name: string;
|
|
27
|
-
kind: number;
|
|
28
|
-
startTimeUnixNano: string;
|
|
29
|
-
endTimeUnixNano: string;
|
|
30
|
-
attributes: Attribute[];
|
|
31
|
-
status: {
|
|
32
|
-
code: number;
|
|
33
|
-
message?: string;
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
export declare function span(input: {
|
|
37
|
-
traceId: string;
|
|
38
|
-
spanId: string;
|
|
39
|
-
parentSpanId?: string;
|
|
40
|
-
name: string;
|
|
41
|
-
startMs: number;
|
|
42
|
-
endMs: number;
|
|
43
|
-
attributes: Attribute[];
|
|
44
|
-
error?: string;
|
|
45
|
-
}): OtlpSpan;
|
|
46
|
-
export declare function exportRequest(spans: OtlpSpan[], version: string): {
|
|
47
|
-
resourceSpans: {
|
|
48
|
-
resource: {
|
|
49
|
-
attributes: Attribute[];
|
|
50
|
-
};
|
|
51
|
-
scopeSpans: {
|
|
52
|
-
scope: {
|
|
53
|
-
name: string;
|
|
54
|
-
version: string;
|
|
55
|
-
};
|
|
56
|
-
spans: OtlpSpan[];
|
|
57
|
-
}[];
|
|
58
|
-
}[];
|
|
59
|
-
};
|
|
60
|
-
export {};
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// Builds OTLP/HTTP JSON spans with Langfuse's attribute names (ingestion format v4), so a
|
|
2
|
-
// recorded evaluation is a Langfuse experiment item without the deprecated dataset-run-items API.
|
|
3
|
-
// https://langfuse.com/integrations/native/opentelemetry
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
/** Deterministic hex IDs, so a retried recording updates the same trace, spans and scores. */
|
|
6
|
-
export const hexId = (value, length) => createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
7
|
-
export function attribute(key, value) {
|
|
8
|
-
if (Array.isArray(value))
|
|
9
|
-
return { key, value: { arrayValue: { values: value.map((item) => ({ stringValue: item })) } } };
|
|
10
|
-
if (typeof value === "boolean")
|
|
11
|
-
return { key, value: { boolValue: value } };
|
|
12
|
-
if (typeof value === "number")
|
|
13
|
-
return { key, value: Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value } };
|
|
14
|
-
return { key, value: { stringValue: value } };
|
|
15
|
-
}
|
|
16
|
-
export const asText = (value) => (typeof value === "string" ? value : JSON.stringify(value) ?? "null");
|
|
17
|
-
const nanos = (ms) => `${BigInt(Math.round(ms)) * 1000000n}`;
|
|
18
|
-
export function span(input) {
|
|
19
|
-
return {
|
|
20
|
-
traceId: input.traceId,
|
|
21
|
-
spanId: input.spanId,
|
|
22
|
-
...(input.parentSpanId ? { parentSpanId: input.parentSpanId } : {}),
|
|
23
|
-
name: input.name,
|
|
24
|
-
kind: 1,
|
|
25
|
-
startTimeUnixNano: nanos(input.startMs),
|
|
26
|
-
endTimeUnixNano: nanos(Math.max(input.endMs, input.startMs)),
|
|
27
|
-
attributes: input.attributes,
|
|
28
|
-
status: input.error ? { code: 2, message: input.error.slice(0, 500) } : { code: 1 },
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
export function exportRequest(spans, version) {
|
|
32
|
-
return {
|
|
33
|
-
resourceSpans: [
|
|
34
|
-
{
|
|
35
|
-
resource: { attributes: [attribute("service.name", "openmerit")] },
|
|
36
|
-
scopeSpans: [{ scope: { name: "openmerit-langfuse-connector", version }, spans }],
|
|
37
|
-
},
|
|
38
|
-
],
|
|
39
|
-
};
|
|
40
|
-
}
|
package/dist/pi/extension.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
export interface ScoutExtensionOptions {
|
|
3
|
-
/** Whether `scout_run` may spend money: true (headless `--execute`), false (plan only) or "ask" (confirm in the UI). */
|
|
4
|
-
allowSpend?: boolean | "ask";
|
|
5
|
-
/** Progress lines, for example to stderr in headless runs. */
|
|
6
|
-
log?: (line: string) => void;
|
|
7
|
-
/** The `provider/id` Scout runs on, for the judge grader. Defaults to the session's model. */
|
|
8
|
-
scoutModel?: string;
|
|
9
|
-
}
|
|
10
|
-
/** OpenMerit Scout inside Pi: model-recommendation tools, a `/scout` command and Scout's instructions. */
|
|
11
|
-
export declare function createScoutExtension(options?: ScoutExtensionOptions): ExtensionFactory;
|
|
12
|
-
declare const _default: ExtensionFactory;
|
|
13
|
-
export default _default;
|