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.
Files changed (70) hide show
  1. package/README.md +239 -58
  2. package/data/catalogue.json +6778 -0
  3. package/dist/catalogue/catalogue.d.ts +41 -0
  4. package/dist/catalogue/catalogue.js +33 -0
  5. package/dist/cli/prompt.d.ts +26 -0
  6. package/dist/cli/prompt.js +114 -0
  7. package/dist/cli/render.d.ts +12 -0
  8. package/dist/cli/render.js +104 -0
  9. package/dist/cli.js +245 -158
  10. package/dist/connect.d.ts +34 -0
  11. package/dist/connect.js +158 -0
  12. package/dist/connectors/langfuse/index.d.ts +37 -110
  13. package/dist/connectors/langfuse/index.js +95 -246
  14. package/dist/daemon/daemon.d.ts +81 -0
  15. package/dist/daemon/daemon.js +408 -0
  16. package/dist/daemon/registry.d.ts +111 -0
  17. package/dist/daemon/registry.js +109 -0
  18. package/dist/eval/proxy.d.ts +54 -0
  19. package/dist/eval/proxy.js +174 -0
  20. package/dist/eval/runner.d.ts +29 -0
  21. package/dist/eval/runner.js +80 -0
  22. package/dist/harness/claude-bridge.d.ts +21 -0
  23. package/dist/harness/claude-bridge.js +65 -0
  24. package/dist/harness/harness.d.ts +60 -0
  25. package/dist/harness/harness.js +221 -0
  26. package/dist/index.d.ts +9 -10
  27. package/dist/index.js +9 -10
  28. package/dist/local.d.ts +12 -0
  29. package/dist/local.js +40 -0
  30. package/dist/mcp.d.ts +1 -0
  31. package/dist/mcp.js +155 -0
  32. package/dist/scout/pool.d.ts +17 -0
  33. package/dist/scout/pool.js +56 -0
  34. package/dist/scout/scan.d.ts +42 -0
  35. package/dist/scout/scan.js +169 -0
  36. package/dist/scout/search.d.ts +25 -98
  37. package/dist/scout/search.js +56 -156
  38. package/dist/scout/workflow.d.ts +221 -0
  39. package/dist/scout/workflow.js +941 -0
  40. package/dist/setup.d.ts +44 -0
  41. package/dist/setup.js +211 -0
  42. package/dist/state/config.d.ts +116 -0
  43. package/dist/state/config.js +60 -0
  44. package/dist/state/db.d.ts +159 -0
  45. package/dist/state/db.js +260 -0
  46. package/dist/state/paths.d.ts +12 -0
  47. package/dist/state/paths.js +23 -0
  48. package/dist/update.d.ts +22 -0
  49. package/dist/update.js +58 -0
  50. package/package.json +12 -17
  51. package/dist/connectors/langfuse/otlp.d.ts +0 -60
  52. package/dist/connectors/langfuse/otlp.js +0 -40
  53. package/dist/pi/extension.d.ts +0 -13
  54. package/dist/pi/extension.js +0 -148
  55. package/dist/pi/harness.d.ts +0 -20
  56. package/dist/pi/harness.js +0 -49
  57. package/dist/pi/instructions.d.ts +0 -2
  58. package/dist/pi/instructions.js +0 -12
  59. package/dist/scout/evaluators/module.d.ts +0 -24
  60. package/dist/scout/evaluators/module.js +0 -19
  61. package/dist/scout/evaluators/pi.d.ts +0 -53
  62. package/dist/scout/evaluators/pi.js +0 -117
  63. package/dist/scout/pipeline.d.ts +0 -111
  64. package/dist/scout/pipeline.js +0 -157
  65. package/dist/scout/report.d.ts +0 -3
  66. package/dist/scout/report.js +0 -42
  67. package/dist/scout/service.d.ts +0 -148
  68. package/dist/scout/service.js +0 -143
  69. package/examples/health-declaration-codes/dataset.jsonl +0 -14
  70. package/examples/sahra-enrollment/evaluator.mts +0 -152
@@ -0,0 +1,221 @@
1
+ // OpenMerit Harness: Scout's reasoning steps run on Pi's model runtime. Pi is powered by one of:
2
+ // - Claude Code through pi-claude-bridge, with the machine's Claude Code login;
3
+ // - Codex models through pi's ChatGPT sign-in (`openai-codex`, the login pi's own `/login` uses),
4
+ // whose tokens pi keeps in ~/.openmerit/agent/auth.json;
5
+ // - a Pi API provider (anthropic, openai, openrouter, ...) with a configured key.
6
+ // Every connection reads the repository only through pi's read-only tools (read, grep, find, ls),
7
+ // which refuse paths outside it. The harness never reads coding-agent credential files, and never
8
+ // switches provider silently.
9
+ import { spawn } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
11
+ import { existsSync, realpathSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve, sep } from "node:path";
14
+ import { validateToolCall } from "@earendil-works/pi-ai";
15
+ import { createReadOnlyTools, ModelRuntime } from "@earendil-works/pi-coding-agent";
16
+ import { harnessLabel, resolveCredential } from "../state/config.js";
17
+ import { openmeritHome } from "../state/paths.js";
18
+ import { CLAUDE_BRIDGE_PROVIDER, claudeBinary, DEFAULT_CLAUDE_MODEL, inBridgeSession, loadClaudeBridge } from "./claude-bridge.js";
19
+ /** Pi's provider for ChatGPT Plus/Pro sign-ins, which serves Codex models. */
20
+ export const CODEX_PROVIDER = "openai-codex";
21
+ /** Used only when saved settings name no model; `openmerit connect codex` saves the best one the plan accepts. */
22
+ export const DEFAULT_CODEX_MODEL = "gpt-5.6-terra";
23
+ /** Pi's model runtime on OpenMerit's agent directory, ~/.openmerit/agent: auth.json (the ChatGPT sign-in), models.json and the bridge's config. */
24
+ export function harnessRuntime(env = process.env) {
25
+ const agentDir = env.PI_CODING_AGENT_DIR ?? join(openmeritHome(env), "agent");
26
+ // The bridge and pi's tools read these from the process environment.
27
+ process.env.PI_CODING_AGENT_DIR ??= agentDir;
28
+ process.env.PI_SKIP_VERSION_CHECK ??= "1";
29
+ return ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json"), allowModelNetwork: false, refreshOnCreate: false });
30
+ }
31
+ /** True when `path` (a tool argument, relative to `root` or absolute) is inside `root` after resolving symlinks. */
32
+ function insideRoot(root, path) {
33
+ if (path !== undefined && typeof path !== "string")
34
+ return false;
35
+ const raw = (path ?? ".").replace(/^@/, "");
36
+ if (raw.startsWith("~"))
37
+ return false;
38
+ const real = (target) => (existsSync(target) ? realpathSync(target) : target);
39
+ const base = real(root), target = real(resolve(root, raw || "."));
40
+ return target === base || target.startsWith(base + sep);
41
+ }
42
+ /** Pi's read-only tools in `root`, refusing paths outside it. */
43
+ function repositoryTools(root) {
44
+ return createReadOnlyTools(root).map(tool => ({
45
+ ...tool,
46
+ execute: (id, params, signal, onUpdate) => {
47
+ if (!insideRoot(root, params.path))
48
+ return Promise.reject(new Error(`OpenMerit Harness reads only files inside ${root}.`));
49
+ return tool.execute(id, params, signal, onUpdate);
50
+ },
51
+ }));
52
+ }
53
+ async function runTool(tools, call, signal) {
54
+ const result = (content, isError) => ({ role: "toolResult", toolCallId: call.id, toolName: call.name, content, isError, timestamp: Date.now() });
55
+ try {
56
+ const tool = tools.find(item => item.name === call.name);
57
+ if (!tool)
58
+ throw new Error(`Unknown tool ${call.name}`);
59
+ const output = await tool.execute(call.id, validateToolCall(tools, call), signal);
60
+ return result(output.content, false);
61
+ }
62
+ catch (error) {
63
+ return result([{ type: "text", text: error instanceof Error ? error.message : String(error) }], true);
64
+ }
65
+ }
66
+ /** Tool rounds one harness request may take before it is stopped. */
67
+ const MAX_TOOL_ROUNDS = 40;
68
+ /** The first JSON object in a reply, with code fences removed. With `required`, the object must have those keys. */
69
+ export function parseJsonObject(reply, required = []) {
70
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(reply)?.[1] ?? reply;
71
+ const start = fenced.indexOf("{");
72
+ for (let end = fenced.length; start >= 0 && end > start; end--) {
73
+ let value;
74
+ try {
75
+ value = JSON.parse(fenced.slice(start, end));
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ if (!value || typeof value !== "object" || Array.isArray(value))
81
+ break;
82
+ const missing = required.filter(key => !(key in value));
83
+ if (missing.length)
84
+ throw new Error(`The harness reply is missing ${missing.join(", ")}: ${reply.slice(0, 200)}`);
85
+ return value;
86
+ }
87
+ throw new Error(`The harness did not reply with a JSON object: ${reply.slice(0, 200)}`);
88
+ }
89
+ /** Builds the harness from saved settings. Fails clearly, without falling back, when it cannot. */
90
+ export async function createHarness(settings, env = process.env) {
91
+ const runtime = await harnessRuntime(env);
92
+ let model;
93
+ let apiKey;
94
+ let bridge;
95
+ if (settings.kind === "api") {
96
+ if (!settings.api)
97
+ throw new Error("The API harness has no provider and model. Run `openmerit connect api`.");
98
+ model = runtime.getModel(settings.api.provider, settings.api.model);
99
+ if (!model)
100
+ throw new Error(`Pi does not know ${settings.api.provider}/${settings.api.model}. Run \`openmerit connect api\` to pick another model.`);
101
+ apiKey = resolveCredential(settings.api.apiKey, env) ?? undefined;
102
+ if (!apiKey)
103
+ throw new Error(`The harness API key (${JSON.stringify(settings.api.apiKey)}) is not set. Run \`openmerit connect api\`.`);
104
+ }
105
+ else if (settings.kind === "claude-code") {
106
+ bridge = await loadClaudeBridge();
107
+ runtime.registerProvider(CLAUDE_BRIDGE_PROVIDER, bridge.provider);
108
+ model = runtime.getModel(CLAUDE_BRIDGE_PROVIDER, settings.model ?? DEFAULT_CLAUDE_MODEL);
109
+ if (!model)
110
+ throw new Error(`Claude Code has no model ${settings.model}. Run \`openmerit connect claude-code --model <id>\` with one of: ${runtime.getModels(CLAUDE_BRIDGE_PROVIDER).map(item => item.id).join(", ")}.`);
111
+ }
112
+ else {
113
+ model = runtime.getModel(CODEX_PROVIDER, settings.model ?? DEFAULT_CODEX_MODEL);
114
+ if (!model)
115
+ throw new Error(`Codex has no model ${settings.model}. Run \`openmerit connect codex --model <id>\` with one of: ${runtime.getModels(CODEX_PROVIDER).map(item => item.id).join(", ")}.`);
116
+ // checkAuth reads the stored credential; hasConfiguredAuth waits for an availability refresh, which create() skips.
117
+ if (!(await runtime.checkAuth(CODEX_PROVIDER)))
118
+ throw new Error("OpenMerit is not signed in to ChatGPT. Run `openmerit connect codex`.");
119
+ }
120
+ const chosen = model;
121
+ const usage = { calls: 0, costUsd: 0, unknownCost: 0, inputTokens: 0, outputTokens: 0 };
122
+ const complete = async (system, messages, tools, input, sessionId) => {
123
+ const reply = await runtime.complete(chosen, { systemPrompt: system, messages, ...(tools.length ? { tools } : {}) }, { ...(input.signal ? { signal: input.signal } : {}), ...(apiKey ? { apiKey } : {}), sessionId, maxTokens: 16_000 });
124
+ usage.calls++;
125
+ // Pi reports cached prompt tokens apart from `input`, which is then only the uncached remainder.
126
+ usage.inputTokens += reply.usage.input + reply.usage.cacheRead + reply.usage.cacheWrite;
127
+ usage.outputTokens += reply.usage.output;
128
+ if (Number.isFinite(reply.usage.cost.total))
129
+ usage.costUsd += reply.usage.cost.total;
130
+ else
131
+ usage.unknownCost++;
132
+ if (reply.stopReason === "error" || reply.stopReason === "aborted")
133
+ throw new Error(reply.errorMessage ?? reply.stopReason);
134
+ return reply;
135
+ };
136
+ const replyText = (reply) => reply.content.filter(part => part.type === "text").map(part => part.text).join("");
137
+ return {
138
+ kind: settings.kind, label: harnessLabel(settings), usage,
139
+ async json(input) {
140
+ const system = `${input.system}\n\nReply with one JSON object that matches this JSON Schema, and nothing else. Give your answer's values, not the schema itself:\n${JSON.stringify(input.schema)}`;
141
+ const messages = [{ role: "user", content: input.prompt, timestamp: Date.now() }];
142
+ const tools = input.cwd ? repositoryTools(input.cwd) : [];
143
+ const sessionId = `openmerit-${randomUUID()}`;
144
+ const required = Array.isArray(input.schema.required) ? input.schema.required.map(String) : [];
145
+ const ask = async () => {
146
+ let reasked = false;
147
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
148
+ const reply = await complete(system, messages, tools, input, sessionId);
149
+ const calls = reply.content.filter((part) => part.type === "toolCall");
150
+ if (reply.stopReason === "toolUse" && calls.length) {
151
+ messages.push(reply);
152
+ for (const call of calls)
153
+ messages.push(await runTool(tools, call, input.signal));
154
+ continue;
155
+ }
156
+ try {
157
+ return parseJsonObject(replyText(reply), required);
158
+ }
159
+ catch (error) {
160
+ // Some models echo the schema's "type" and "properties" around their answer and drop a brace: ask once more.
161
+ if (reasked)
162
+ throw error;
163
+ reasked = true;
164
+ messages.push(reply, { role: "user", content: `${error instanceof Error ? error.message : String(error)}\nReply again with only the JSON object of your answer's values, without the schema's "type" or "properties".`, timestamp: Date.now() });
165
+ }
166
+ }
167
+ throw new Error(`OpenMerit Harness stopped after ${MAX_TOOL_ROUNDS} rounds of file reads without an answer.`);
168
+ };
169
+ try {
170
+ // Claude Code: one bridge session per request, so its query starts in the repository and closes afterwards.
171
+ return bridge ? await inBridgeSession(bridge, { sessionId, cwd: input.cwd ?? tmpdir(), systemPrompt: system, tools: tools.map(tool => tool.name) }, ask) : await ask();
172
+ }
173
+ finally {
174
+ // A refreshed ChatGPT token replaces the old one; save it before the process can exit.
175
+ await runtime.settleCredentialWrites();
176
+ }
177
+ },
178
+ };
179
+ }
180
+ /** One round trip that proves the configured connection powers the harness. */
181
+ export async function verifyHarness(harness, cwd) {
182
+ try {
183
+ const reply = await harness.json({
184
+ system: "You are OpenMerit Harness checking its connection.", cwd,
185
+ prompt: "Reply with status \"OPENMERIT_READY\" and, in harness, the name of the model you are.",
186
+ schema: { type: "object", properties: { status: { type: "string" }, harness: { type: "string" } }, required: ["status", "harness"], additionalProperties: false },
187
+ });
188
+ if (reply.status !== "OPENMERIT_READY")
189
+ return { ok: false, detail: `Unexpected reply ${JSON.stringify(reply).slice(0, 200)}` };
190
+ return { ok: true, detail: `answered as ${String(reply.harness).slice(0, 80)}` };
191
+ }
192
+ catch (error) {
193
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
194
+ }
195
+ }
196
+ /** Runs the Claude Code executable the bridge uses, such as `auth status` or `auth login`. */
197
+ export function runClaude(args, options = {}) {
198
+ return new Promise((done, reject) => {
199
+ const child = spawn(claudeBinary(options.env), args, { env: options.env ?? process.env, stdio: options.interactive ? "inherit" : ["ignore", "pipe", "ignore"], ...(options.signal ? { signal: options.signal } : {}) });
200
+ let stdout = "";
201
+ child.stdout?.on("data", chunk => { stdout += chunk; });
202
+ child.on("error", reject);
203
+ child.on("close", code => done({ code: code ?? 1, stdout }));
204
+ });
205
+ }
206
+ /** Whether Claude Code reports a login (its own status command), and whether OpenMerit has a ChatGPT sign-in. */
207
+ export async function detectAgents(env = process.env) {
208
+ let claudeLogin = false;
209
+ try {
210
+ claudeLogin = JSON.parse((await runClaude(["auth", "status"], { env, signal: AbortSignal.timeout(20_000) })).stdout).loggedIn === true;
211
+ }
212
+ catch { /* no status */ }
213
+ // Claude Code also runs with ANTHROPIC_API_KEY or a third-party provider; its status only covers logins.
214
+ if (env.ANTHROPIC_API_KEY || env.CLAUDE_CODE_OAUTH_TOKEN || env.CLAUDE_CODE_USE_BEDROCK || env.CLAUDE_CODE_USE_VERTEX)
215
+ claudeLogin = true;
216
+ const codexLogin = (await (await harnessRuntime(env)).checkAuth(CODEX_PROVIDER)) !== undefined;
217
+ return [
218
+ { kind: "claude-code", loggedIn: claudeLogin, detail: claudeLogin ? "logged in" : "not logged in" },
219
+ { kind: "codex", loggedIn: codexLogin, detail: codexLogin ? "signed in to ChatGPT" : "not signed in to ChatGPT" },
220
+ ];
221
+ }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,10 @@
1
- export { rankCandidates, rank, openRouterToolSupport, type Candidate, type CandidateFilter, type RunnableModel } from "./scout/candidates.js";
2
- export { loadEci, parseEciScores, ECI_ATTRIBUTION, type EciRow, type EciSource } from "./scout/eci.js";
3
- export { scoutSearch, CandidateUnavailable, outward, compareFinals, meanCost, SAMPLE_SIZE, FINALISTS, type EvalItem, type Evaluate, type Outcome, type SearchOptions, type SearchResult, type Stage, type Trial } from "./scout/search.js";
4
- export { runScout, type ScoutReport, type ScoutRunOptions, type FinalVerdict, type DatasetSource } from "./scout/pipeline.js";
5
- export { reportMarkdown } from "./scout/report.js";
6
- export { piEvaluator, jsonMatches, extractJson, type Grader, type ModelCaller, type PiTaskSpec } from "./scout/evaluators/pi.js";
7
- export { loadEvaluatorPlugin, type EvaluatorPlugin } from "./scout/evaluators/module.js";
8
- export * as scout from "./scout/service.js";
9
- export { createScoutExtension, type ScoutExtensionOptions } from "./pi/extension.js";
10
- export { configureHarness } from "./pi/harness.js";
1
+ export { loadCatalogue, catalogueIndex, requestPrice, type Catalogue, type CatalogueModel } from "./catalogue/catalogue.js";
2
+ export { rankCandidates, rank, type Candidate, type CandidateFilter, type RunnableModel } from "./scout/candidates.js";
3
+ export { parseEciScores, parseEciEstimates, ECI_ATTRIBUTION, type EciRow, type EciEstimate } from "./scout/eci.js";
4
+ export { scanRepo, redact, type Usage, type ScanResult } from "./scout/scan.js";
5
+ export { advance, eligibleCandidates, TASK_QUESTION, CONFIGURE_QUESTION, type Answers, type Question, type Step, type TaskComparison, type ModelResult } from "./scout/workflow.js";
6
+ export { localContext, missingSetup, startRun } from "./local.js";
7
+ export { createHarness, verifyHarness, detectAgents, type Harness } from "./harness/harness.js";
8
+ export { State, type RunRow, type Spend } from "./state/db.js";
9
+ export { serveMcp } from "./mcp.js";
11
10
  export * as langfuse from "./connectors/langfuse/index.js";
package/dist/index.js CHANGED
@@ -1,11 +1,10 @@
1
- export { rankCandidates, rank, openRouterToolSupport } from "./scout/candidates.js";
2
- export { loadEci, parseEciScores, ECI_ATTRIBUTION } from "./scout/eci.js";
3
- export { scoutSearch, CandidateUnavailable, outward, compareFinals, meanCost, SAMPLE_SIZE, FINALISTS } from "./scout/search.js";
4
- export { runScout } from "./scout/pipeline.js";
5
- export { reportMarkdown } from "./scout/report.js";
6
- export { piEvaluator, jsonMatches, extractJson } from "./scout/evaluators/pi.js";
7
- export { loadEvaluatorPlugin } from "./scout/evaluators/module.js";
8
- export * as scout from "./scout/service.js";
9
- export { createScoutExtension } from "./pi/extension.js";
10
- export { configureHarness } from "./pi/harness.js";
1
+ export { loadCatalogue, catalogueIndex, requestPrice } from "./catalogue/catalogue.js";
2
+ export { rankCandidates, rank } from "./scout/candidates.js";
3
+ export { parseEciScores, parseEciEstimates, ECI_ATTRIBUTION } from "./scout/eci.js";
4
+ export { scanRepo, redact } from "./scout/scan.js";
5
+ export { advance, eligibleCandidates, TASK_QUESTION, CONFIGURE_QUESTION } from "./scout/workflow.js";
6
+ export { localContext, missingSetup, startRun } from "./local.js";
7
+ export { createHarness, verifyHarness, detectAgents } from "./harness/harness.js";
8
+ export { State } from "./state/db.js";
9
+ export { serveMcp } from "./mcp.js";
11
10
  export * as langfuse from "./connectors/langfuse/index.js";
@@ -0,0 +1,12 @@
1
+ import { type Context } from "./scout/workflow.js";
2
+ export interface LocalOptions {
3
+ cwd: string;
4
+ env?: Record<string, string | undefined>;
5
+ log?: (line: string) => void;
6
+ signal?: AbortSignal;
7
+ fetch?: typeof fetch;
8
+ }
9
+ export declare function localContext(options: LocalOptions): Context;
10
+ /** Settings a run needs before it starts; each entry is something `openmerit setup` configures. */
11
+ export declare function missingSetup(ctx: Context): string[];
12
+ export declare function startRun(ctx: Context): string;
package/dist/local.js ADDED
@@ -0,0 +1,40 @@
1
+ // What the CLI and the MCP server share: one repository's local context, and starting a run.
2
+ import { loadCatalogue } from "./catalogue/catalogue.js";
3
+ import { createHarness } from "./harness/harness.js";
4
+ import { DEFAULT_BUDGET_USD, harnessLabel, readGlobalConfig, readRepoConfig } from "./state/config.js";
5
+ import { State } from "./state/db.js";
6
+ import { findRepo } from "./state/paths.js";
7
+ import { packageVersion } from "./update.js";
8
+ import { newRunId } from "./scout/workflow.js";
9
+ export function localContext(options) {
10
+ const env = options.env ?? process.env;
11
+ const repo = findRepo(options.cwd);
12
+ let harness = null;
13
+ return {
14
+ state: new State(env), repo, catalogue: loadCatalogue(), env, log: options.log ?? (() => { }),
15
+ ...(options.signal ? { signal: options.signal } : {}), ...(options.fetch ? { fetch: options.fetch } : {}),
16
+ harness: () => {
17
+ const settings = readGlobalConfig(env).harness;
18
+ if (!settings)
19
+ return Promise.reject(new Error("OpenMerit Harness is not set up. Run `openmerit setup`."));
20
+ return (harness ??= createHarness(settings, env).catch(error => { harness = null; throw error; }));
21
+ },
22
+ };
23
+ }
24
+ /** Settings a run needs before it starts; each entry is something `openmerit setup` configures. */
25
+ export function missingSetup(ctx) {
26
+ const missing = [];
27
+ if (!readGlobalConfig(ctx.env).harness)
28
+ missing.push("harness: choose Claude Code, Codex or an API key to power OpenMerit Harness");
29
+ if (!(readRepoConfig(ctx.repo.root).endpoints ?? []).length)
30
+ missing.push("endpoints: add at least one candidate endpoint (default: OpenRouter, every model eligible)");
31
+ return missing;
32
+ }
33
+ export function startRun(ctx) {
34
+ const id = newRunId();
35
+ ctx.state.createRun({
36
+ id, repo: ctx.repo, packageVersion: packageVersion(), catalogueVersion: ctx.catalogue.version, harness: harnessLabel(readGlobalConfig(ctx.env).harness),
37
+ budgetUsd: readRepoConfig(ctx.repo.root).budget?.defaultUsd ?? DEFAULT_BUDGET_USD, state: {},
38
+ });
39
+ return id;
40
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function serveMcp(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, env?: Record<string, string | undefined>): Promise<void>;
package/dist/mcp.js ADDED
@@ -0,0 +1,155 @@
1
+ // OpenMerit's local MCP server: newline-delimited JSON-RPC 2.0 over stdio, for Claude Code, Codex and
2
+ // other MCP clients. Stdout carries protocol messages only; progress goes to stderr. It runs the
3
+ // same workflow and SQLite state as the CLI. Questions and approvals come back as structured
4
+ // `questions` for the client to ask the user; the server never prompts on a terminal.
5
+ import { createInterface } from "node:readline";
6
+ import { renderRun, renderStep } from "./cli/render.js";
7
+ import { localContext, missingSetup, startRun } from "./local.js";
8
+ import { advanceInDaemon } from "./daemon/daemon.js";
9
+ import { step } from "./scout/workflow.js";
10
+ import { checkForUpdate, packageVersion, updateNotice } from "./update.js";
11
+ const PROTOCOL = "2025-06-18";
12
+ const TOOLS = [
13
+ {
14
+ name: "openmerit_status",
15
+ title: "OpenMerit status",
16
+ description: "Read-only. Reports whether OpenMerit is set up for a repository (harness, candidate endpoints, Langfuse, eval budget), the installed package and catalogue versions, and whether a newer openmerit is available.",
17
+ inputSchema: { type: "object", properties: { cwd: { type: "string", description: "Repository directory (default: the server's working directory)" } }, additionalProperties: false },
18
+ annotations: { readOnlyHint: true, openWorldHint: false },
19
+ },
20
+ {
21
+ name: "openmerit_scout",
22
+ title: "OpenMerit Scout",
23
+ description: "Runs OpenMerit Scout: scans the repository's model usages, lets the user pick a model and its agents/tasks, runs Scout Model Binary Search with the task's existing eval (the current model on every item, ECI-ranked candidates screened on five items it passes, five finalists on every item; paid model requests, within the run's eval budget; per-item results read back read-only from the Langfuse experiment the eval records, or from the eval's own report), ranks the cheapest finalists at least as good as the current model, and can edit the application's model configuration. Call without runId to start; each call returns `questions` when it needs the user. Show every question (with its detail) to the user and pass their answers back in `answers`, keyed by question id, with the same runId. Never answer approval, budget or configuration questions on the user's behalf. Not read-only: it sends code context to the harness, spends on evals and can change files, each only after the matching approval.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ cwd: { type: "string", description: "Repository directory (default: the server's working directory)" },
28
+ runId: { type: "string", description: "Run to continue; omit to start a new run" },
29
+ answers: { type: "object", description: "Answers keyed by question id: strings, string arrays, booleans or numbers", additionalProperties: true },
30
+ },
31
+ additionalProperties: false,
32
+ },
33
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
34
+ },
35
+ {
36
+ name: "openmerit_history",
37
+ title: "OpenMerit history",
38
+ description: "Read-only. Lists this repository's Scout runs, including failed, interrupted and budget-paused ones, or shows one run's results, eval runs, spend, budget approvals and configuration decisions.",
39
+ inputSchema: { type: "object", properties: { cwd: { type: "string" }, runId: { type: "string" } }, additionalProperties: false },
40
+ annotations: { readOnlyHint: true, openWorldHint: false },
41
+ },
42
+ ];
43
+ export async function serveMcp(input = process.stdin, output = process.stdout, env = process.env) {
44
+ const send = (message) => output.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
45
+ const log = (line) => process.stderr.write(`[openmerit] ${line}\n`);
46
+ let update = null;
47
+ const updateCheck = checkForUpdate(env).then(info => { update = info; return info; });
48
+ const running = new Set();
49
+ const call = async (name, args) => {
50
+ const cwd = typeof args.cwd === "string" && args.cwd ? args.cwd : process.cwd();
51
+ const progress = [];
52
+ const ctx = localContext({ cwd, env, log: line => { progress.push(line); log(line); } });
53
+ const notice = update ? updateNotice(update) : null;
54
+ try {
55
+ if (name === "openmerit_status") {
56
+ const missing = missingSetup(ctx);
57
+ const data = { repository: ctx.repo.root, ready: !missing.length, missing, packageVersion: packageVersion(), catalogueVersion: ctx.catalogue.version, update, history: ctx.state.listRuns(ctx.repo.id, 5).map(run => ({ id: run.id, status: run.status, phase: run.phase })) };
58
+ return { text: `${missing.length ? `Not ready. Run \`openmerit setup\` in ${ctx.repo.root} for: ${missing.join("; ")}.` : `Ready in ${ctx.repo.root}.`} openmerit ${data.packageVersion}, catalogue ${data.catalogueVersion}.${notice ? ` ${notice}` : ""}`, data };
59
+ }
60
+ if (name === "openmerit_history") {
61
+ ctx.state.reapInterrupted(ctx.repo.id);
62
+ if (typeof args.runId === "string") {
63
+ const run = ctx.state.getRun(args.runId);
64
+ if (!run || run.repoId !== ctx.repo.id)
65
+ return { text: `No run ${args.runId} in ${ctx.repo.root}.`, data: {}, isError: true };
66
+ const current = step(ctx, run.id, [], []);
67
+ return { text: renderRun(ctx.state, run, current), data: { run: { ...run, state: undefined }, step: current, units: ctx.state.units(run.id), budget: ctx.state.budgetEvents(run.id), decisions: ctx.state.decisions(run.id) } };
68
+ }
69
+ const runs = ctx.state.listRuns(ctx.repo.id);
70
+ return { text: runs.map(run => `${run.id} ${run.status} ${run.phase} ${run.createdAt}`).join("\n") || "No runs yet.", data: { runs: runs.map(run => ({ id: run.id, status: run.status, phase: run.phase, createdAt: run.createdAt, model: run.state.model ?? null, tasks: run.state.selected ?? [], spend: ctx.state.spend(run.id), budgetUsd: run.budgetUsd, packageVersion: run.packageVersion, catalogueVersion: run.catalogueVersion })) } };
71
+ }
72
+ if (name === "openmerit_scout") {
73
+ const missing = missingSetup(ctx);
74
+ if (missing.length)
75
+ return { text: `OpenMerit is not set up for ${ctx.repo.root}. Ask the user to run \`openmerit setup\` in a terminal there.`, data: { status: "needs_setup", missing } };
76
+ const runId = typeof args.runId === "string" && args.runId ? args.runId : startRun(ctx);
77
+ if (running.has(runId))
78
+ return { text: `Run ${runId} is already being advanced by another call.`, data: { runId, status: "busy" }, isError: true };
79
+ running.add(runId);
80
+ try {
81
+ const result = await advanceInDaemon({ runId, cwd, env, answers: (args.answers && typeof args.answers === "object" ? args.answers : {}), log: line => { progress.push(line); log(line); } });
82
+ const questions = result.questions.map(question => `- [${question.id}] (${question.kind}) ${question.prompt}${question.options ? `\n Options: ${question.options.map(option => `${option.value}${option.label !== option.value ? ` = ${option.label}` : ""}`).join(" | ")}` : ""}${question.detail ? `\n ${question.detail.split("\n").join("\n ")}` : ""}`).join("\n");
83
+ const text = [
84
+ ...(notice ? [notice] : []), ...result.notices,
85
+ questions ? `Run ${runId} needs the user (${result.status}). Ask them, then call openmerit_scout again with runId "${runId}" and answers keyed by question id:\n${questions}` : renderStep(result),
86
+ ].join("\n\n");
87
+ return { text, data: { ...result, progress: progress.slice(-40), ...(update ? { update } : {}) }, ...(result.status === "failed" ? { isError: true } : {}) };
88
+ }
89
+ finally {
90
+ running.delete(runId);
91
+ }
92
+ }
93
+ return { text: `Unknown tool ${name}`, data: {}, isError: true };
94
+ }
95
+ finally {
96
+ ctx.state.close();
97
+ }
98
+ };
99
+ const handle = async (message) => {
100
+ const id = message.id;
101
+ const method = message.method;
102
+ if (!method)
103
+ return;
104
+ try {
105
+ if (method === "initialize") {
106
+ const info = await Promise.race([updateCheck, new Promise(resolve => setTimeout(() => resolve(null), 3000))]);
107
+ const notice = info ? updateNotice(info) : null;
108
+ send({ id, result: { protocolVersion: message.params?.protocolVersion ?? PROTOCOL, capabilities: { tools: {}, logging: {} }, serverInfo: { name: "openmerit", version: packageVersion() },
109
+ instructions: `OpenMerit Scout finds cheaper models that perform at least as well as the current one on a repository's own evals. Relay every question it returns to the user.${notice ? ` ${notice}` : ""}` } });
110
+ return;
111
+ }
112
+ if (method === "notifications/initialized") {
113
+ const info = await updateCheck;
114
+ const notice = updateNotice(info);
115
+ if (notice)
116
+ send({ method: "notifications/message", params: { level: "warning", logger: "openmerit", data: notice } });
117
+ return;
118
+ }
119
+ if (method === "ping")
120
+ return void send({ id, result: {} });
121
+ if (method === "tools/list")
122
+ return void send({ id, result: { tools: TOOLS } });
123
+ if (method === "tools/call") {
124
+ const params = (message.params ?? {});
125
+ const result = await call(params.name ?? "", params.arguments ?? {});
126
+ return void send({ id, result: { content: [{ type: "text", text: result.text }], structuredContent: result.data, ...(result.isError ? { isError: true } : {}) } });
127
+ }
128
+ if (method === "logging/setLevel")
129
+ return void send({ id, result: {} });
130
+ if (id !== undefined)
131
+ send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
132
+ }
133
+ catch (error) {
134
+ if (id !== undefined)
135
+ send({ id, result: { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true } });
136
+ }
137
+ };
138
+ const lines = createInterface({ input });
139
+ const pending = new Set();
140
+ for await (const line of lines) {
141
+ if (!line.trim())
142
+ continue;
143
+ let message;
144
+ try {
145
+ message = JSON.parse(line);
146
+ }
147
+ catch {
148
+ send({ id: null, error: { code: -32700, message: "Parse error" } });
149
+ continue;
150
+ }
151
+ const work = handle(message).finally(() => pending.delete(work));
152
+ pending.add(work);
153
+ }
154
+ await Promise.all(pending);
155
+ }
@@ -0,0 +1,17 @@
1
+ import { type Catalogue, type CatalogueModel } from "../catalogue/catalogue.js";
2
+ import type { Endpoint } from "../state/config.js";
3
+ export interface PoolModel {
4
+ endpoint: Endpoint;
5
+ endpointModel: string;
6
+ model: CatalogueModel;
7
+ }
8
+ /** How an endpoint names a catalogue model: OpenRouter uses catalogue IDs; OpenAI drops the `openai/` vendor prefix. */
9
+ export declare function endpointModelId(endpoint: Endpoint, model: CatalogueModel): string | null;
10
+ /** Every model an endpoint may run under the repository's allowed list. */
11
+ export declare function endpointPool(endpoint: Endpoint, catalogue: Catalogue): PoolModel[];
12
+ export declare function pool(endpoints: readonly Endpoint[], catalogue: Catalogue): PoolModel[];
13
+ /** The proxy's pool check for one endpoint: catalogue model, null for an allowed model without a catalogue entry, undefined when not allowed. */
14
+ export declare function allowedOn(endpoint: Endpoint, catalogue: Catalogue): (endpointModel: string) => CatalogueModel | null | undefined;
15
+ /** The endpoint that would run the current (baseline) model: the first one that may run it. */
16
+ export declare function baselineEndpoint(endpoints: readonly Endpoint[], catalogue: Catalogue, modelId: string): PoolModel | null;
17
+ export declare const blendedPrice: (model: CatalogueModel) => number | null;
@@ -0,0 +1,56 @@
1
+ // The repository's candidate pool: which catalogue models each configured endpoint may run.
2
+ import { catalogueIndex } from "../catalogue/catalogue.js";
3
+ /** How an endpoint names a catalogue model: OpenRouter uses catalogue IDs; OpenAI drops the `openai/` vendor prefix. */
4
+ export function endpointModelId(endpoint, model) {
5
+ if (endpoint.kind === "openrouter")
6
+ return model.id;
7
+ if (endpoint.kind === "openai")
8
+ return model.id.startsWith("openai/") ? model.id.slice("openai/".length) : null;
9
+ return null;
10
+ }
11
+ /** Every model an endpoint may run under the repository's allowed list. */
12
+ export function endpointPool(endpoint, catalogue) {
13
+ const index = catalogueIndex(catalogue);
14
+ if (endpoint.allowedModels === "all") {
15
+ if (endpoint.kind === "openai-compatible")
16
+ return [];
17
+ return catalogue.models.flatMap(model => {
18
+ const endpointModel = endpointModelId(endpoint, model);
19
+ return endpointModel ? [{ endpoint, endpointModel, model }] : [];
20
+ });
21
+ }
22
+ return endpoint.allowedModels.flatMap(id => {
23
+ const model = index.find(id) ?? index.find(`${endpoint.kind === "openai" ? "openai/" : ""}${id}`);
24
+ if (!model)
25
+ return [];
26
+ const endpointModel = endpoint.kind === "openai-compatible" ? id : endpointModelId(endpoint, model);
27
+ return endpointModel ? [{ endpoint, endpointModel, model }] : [];
28
+ });
29
+ }
30
+ export function pool(endpoints, catalogue) {
31
+ const seen = new Set();
32
+ return endpoints.flatMap(endpoint => endpointPool(endpoint, catalogue)).filter(item => !seen.has(item.model.id) && seen.add(item.model.id));
33
+ }
34
+ /** The proxy's pool check for one endpoint: catalogue model, null for an allowed model without a catalogue entry, undefined when not allowed. */
35
+ export function allowedOn(endpoint, catalogue) {
36
+ const allowed = new Map(endpointPool(endpoint, catalogue).map(item => [item.endpointModel.toLowerCase(), item.model]));
37
+ const explicit = endpoint.allowedModels === "all" ? null : new Set(endpoint.allowedModels.map(item => item.toLowerCase()));
38
+ return model => allowed.get(model.toLowerCase()) ?? (explicit?.has(model.toLowerCase()) ? null : undefined);
39
+ }
40
+ /** The endpoint that would run the current (baseline) model: the first one that may run it. */
41
+ export function baselineEndpoint(endpoints, catalogue, modelId) {
42
+ for (const endpoint of endpoints) {
43
+ const match = endpointPool(endpoint, catalogue).find(item => item.model.id === modelId);
44
+ if (match)
45
+ return match;
46
+ }
47
+ // The baseline must run even when the allowed list leaves it out: it is the model already in production.
48
+ const model = catalogue.models.find(item => item.id === modelId);
49
+ for (const endpoint of endpoints) {
50
+ const endpointModel = model ? endpointModelId(endpoint, model) : null;
51
+ if (model && endpointModel)
52
+ return { endpoint, endpointModel, model };
53
+ }
54
+ return null;
55
+ }
56
+ export const blendedPrice = (model) => (model.inputPerM === null || model.outputPerM === null ? null : model.inputPerM + model.outputPerM);
@@ -0,0 +1,42 @@
1
+ import { type Catalogue } from "../catalogue/catalogue.js";
2
+ export interface Usage {
3
+ id: string;
4
+ kind: "model" | "endpoint";
5
+ file: string;
6
+ line: number;
7
+ /** The literal value (model ID or URL), or the expression for a dynamic use. Redacted. */
8
+ value: string;
9
+ /** Set when the value is computed at run time (an environment variable, a variable, a function call). */
10
+ dynamic: boolean;
11
+ /** Environment variable a dynamic use reads, when the code names one. */
12
+ envVar?: string;
13
+ /** Catalogue model this literal names, or null for an unknown model or an endpoint. */
14
+ catalogueId: string | null;
15
+ /** The line, redacted. */
16
+ snippet: string;
17
+ }
18
+ export interface ScanResult {
19
+ files: number;
20
+ usages: Usage[];
21
+ /** Catalogue models found, with their usage IDs. */
22
+ models: {
23
+ catalogueId: string | null;
24
+ value: string;
25
+ usageIds: string[];
26
+ }[];
27
+ /** JSON files skipped as data because they name more than MAX_DATA_FILE_MODELS models. */
28
+ skipped: string[];
29
+ }
30
+ export interface ScanOptions {
31
+ /** Glob patterns, relative to the repository root, of files or directories not to scan. */
32
+ exclude?: string[];
33
+ }
34
+ /** More model usages than this in one JSON file make it data rather than configuration. */
35
+ export declare const MAX_DATA_FILE_MODELS = 20;
36
+ /** Replaces key- and token-shaped text with [redacted]. */
37
+ export declare function redact(text: string): string;
38
+ export declare function listFiles(root: string): string[];
39
+ /** Scans the repository. Deterministic; no model is called. */
40
+ export declare function scanRepo(root: string, catalogue: Catalogue, options?: ScanOptions): ScanResult;
41
+ /** A few lines around a usage, redacted, for the harness. */
42
+ export declare function context(root: string, usage: Pick<Usage, "file" | "line">, radius?: number): string;