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,44 @@
1
+ import type { Prompter } from "./cli/prompt.js";
2
+ import { type Endpoint, type HarnessKind, type HarnessSettings } from "./state/config.js";
3
+ import type { State } from "./state/db.js";
4
+ import type { Repo } from "./state/paths.js";
5
+ export interface SetupFlags {
6
+ harness?: HarnessKind;
7
+ harnessModel?: string;
8
+ apiProvider?: string;
9
+ apiModel?: string;
10
+ apiKeyEnv?: string;
11
+ /** Sign in to ChatGPT with a device code (for --harness codex on a server). */
12
+ deviceCode?: boolean;
13
+ /** `openrouter:ENV_VAR`, `openai:ENV_VAR` or `<id>=<baseUrl>:ENV_VAR` (OpenAI-compatible). */
14
+ endpoints?: string[];
15
+ allowedModels?: string[];
16
+ budget?: number;
17
+ registerMcp?: boolean;
18
+ langfuseUrl?: string;
19
+ langfusePublicKeyEnv?: string;
20
+ langfuseSecretKeyEnv?: string;
21
+ /** Accept defaults and never prompt. */
22
+ nonInteractive?: boolean;
23
+ }
24
+ interface SetupContext {
25
+ repo: Repo;
26
+ state: State;
27
+ prompter: Prompter | null;
28
+ log: (line: string) => void;
29
+ env: Record<string, string | undefined>;
30
+ fetch?: typeof fetch;
31
+ flags: SetupFlags;
32
+ mcpCommand: string[];
33
+ }
34
+ export declare function verifyEndpoint(endpoint: Endpoint, env: Record<string, string | undefined>, fetcher?: typeof fetch): Promise<{
35
+ ok: boolean;
36
+ detail: string;
37
+ }>;
38
+ export declare function runSetup(ctx: SetupContext): Promise<{
39
+ harness: HarnessSettings | null;
40
+ endpoints: Endpoint[];
41
+ langfuse: boolean;
42
+ budgetUsd: number;
43
+ }>;
44
+ export {};
package/dist/setup.js ADDED
@@ -0,0 +1,211 @@
1
+ // `openmerit setup`: detect → explicit choice → verify → persist, rerunnable. Global: how OpenMerit
2
+ // Harness is powered, and the MCP registration with Claude Code/Codex. Per repository: read-only
3
+ // Langfuse, candidate endpoints with allowed models, and the default eval budget. Re-running keeps
4
+ // working settings unless the user replaces them, and a failed connection is offered for retry or
5
+ // reconfiguration; nothing switches provider on its own.
6
+ import { spawnSync } from "node:child_process";
7
+ import { confirm, connect, credential, HARNESS_LABEL } from "./connect.js";
8
+ import { createLangfuseConnector } from "./connectors/langfuse/index.js";
9
+ import { DEFAULT_BUDGET_USD, describeCredential, OPENAI_ENDPOINT, OPENROUTER_ENDPOINT, readRepoConfig, resolveCredential, updateRepoConfig, } from "./state/config.js";
10
+ export async function verifyEndpoint(endpoint, env, fetcher = fetch) {
11
+ const key = resolveCredential(endpoint.apiKey, env);
12
+ if (!key)
13
+ return { ok: false, detail: `no key (${describeCredential(endpoint.apiKey)} is empty)` };
14
+ const url = endpoint.kind === "openrouter" ? `${endpoint.baseUrl.replace(/\/$/, "")}/key` : `${endpoint.baseUrl.replace(/\/$/, "")}/models`;
15
+ try {
16
+ const response = await fetcher(url, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(20_000), redirect: "manual" });
17
+ await response.body?.cancel();
18
+ return response.ok ? { ok: true, detail: "key accepted" } : { ok: false, detail: `HTTP ${response.status}${response.status === 401 ? " (key rejected or expired)" : ""}` };
19
+ }
20
+ catch (error) {
21
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
22
+ }
23
+ }
24
+ /** Claude Code and Codex CLIs on PATH, which can register OpenMerit's MCP server. */
25
+ function mcpClients(env) {
26
+ return ["claude-code", "codex"].filter(kind => !spawnSync(kind === "claude-code" ? env.OPENMERIT_CLAUDE_BIN ?? "claude" : env.OPENMERIT_CODEX_BIN ?? "codex", ["--version"], { env, stdio: "ignore" }).error);
27
+ }
28
+ /** Adds OpenMerit's stdio MCP server to Claude Code and Codex through their own `mcp add` commands; existing entries are left alone. */
29
+ function registerMcp(ctx, kinds) {
30
+ for (const kind of kinds) {
31
+ const bin = kind === "claude-code" ? ctx.env.OPENMERIT_CLAUDE_BIN ?? "claude" : ctx.env.OPENMERIT_CODEX_BIN ?? "codex";
32
+ const get = spawnSync(bin, ["mcp", "get", "openmerit"], { encoding: "utf8", env: ctx.env });
33
+ if (get.error)
34
+ continue;
35
+ if (get.status === 0) {
36
+ ctx.log(` ${kind === "claude-code" ? "Claude Code" : "Codex"} already has an "openmerit" MCP server; left unchanged.`);
37
+ continue;
38
+ }
39
+ const args = kind === "claude-code" ? ["mcp", "add", "--scope", "user", "openmerit", "--", ...ctx.mcpCommand] : ["mcp", "add", "openmerit", "--", ...ctx.mcpCommand];
40
+ const add = spawnSync(bin, args, { encoding: "utf8", env: ctx.env });
41
+ ctx.log(add.status === 0 ? ` ✓ Registered OpenMerit's MCP server with ${kind === "claude-code" ? "Claude Code" : "Codex"}.` : ` ✗ Could not register with ${kind}: ${(add.stderr || add.stdout).trim().slice(0, 200)}`);
42
+ }
43
+ }
44
+ async function setupLangfuse(ctx) {
45
+ const existing = readRepoConfig(ctx.repo.root).langfuse;
46
+ const connector = (settings) => {
47
+ const publicKey = resolveCredential(settings.publicKey, ctx.env), secretKey = resolveCredential(settings.secretKey, ctx.env);
48
+ if (!publicKey || !secretKey)
49
+ throw new Error("the Viewer key pair is not available");
50
+ return createLangfuseConnector({ baseUrl: settings.baseUrl, publicKey, secretKey }, { ...(ctx.fetch ? { fetch: ctx.fetch } : {}), retries: 1 });
51
+ };
52
+ const verify = async (settings) => {
53
+ try {
54
+ const lf = connector(settings);
55
+ const project = await lf.check();
56
+ await lf.verifyViewer();
57
+ return { ok: true, detail: `project ${project.projectName}, read-only key`, lf };
58
+ }
59
+ catch (error) {
60
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
61
+ }
62
+ };
63
+ if (existing && !ctx.flags.langfuseUrl) {
64
+ const check = await verify(existing);
65
+ ctx.log(check.ok ? ` ✓ Langfuse at ${existing.baseUrl}: ${check.detail}` : ` ✗ Saved Langfuse connection failed: ${check.detail}`);
66
+ if (check.ok && await confirm(ctx, "Keep this Langfuse connection?", true))
67
+ return { ...existing, verifiedAt: new Date().toISOString() };
68
+ if (!check.ok && !(await confirm(ctx, "Reconfigure Langfuse now?", false)))
69
+ return existing;
70
+ }
71
+ else if (!ctx.flags.langfuseUrl && !(await confirm(ctx, "Connect this repository's Langfuse (read-only aggregates)?", false)))
72
+ return existing;
73
+ for (;;) {
74
+ const baseUrl = ctx.flags.langfuseUrl ?? (ctx.prompter ? await ctx.prompter.ask("Langfuse URL (Cloud or self-hosted)", existing?.baseUrl ?? "https://cloud.langfuse.com") : "https://cloud.langfuse.com");
75
+ ctx.log(" OpenMerit needs a separate Viewer key pair (Project Settings → API Keys → Viewer); it never writes to Langfuse.");
76
+ const publicKey = await credential(ctx, "Langfuse Viewer public key", ["OPENMERIT_LANGFUSE_PUBLIC_KEY"], `langfuse-public-${ctx.repo.id}`, ctx.flags.langfusePublicKeyEnv);
77
+ const secretKey = await credential(ctx, "Langfuse Viewer secret key", ["OPENMERIT_LANGFUSE_SECRET_KEY"], `langfuse-secret-${ctx.repo.id}`, ctx.flags.langfuseSecretKeyEnv);
78
+ if (!publicKey || !secretKey)
79
+ return existing;
80
+ const check = await verify({ baseUrl, publicKey, secretKey });
81
+ if (check.ok) {
82
+ ctx.log(` ✓ Langfuse: ${check.detail}`);
83
+ const mappings = existing?.mappings ?? [];
84
+ if (ctx.prompter && !ctx.flags.nonInteractive) {
85
+ const traces = await check.lf.traceNames().catch(() => []);
86
+ const latest = ctx.state.listRuns(ctx.repo.id, 1)[0];
87
+ const taskNames = (latest?.state.tasks ?? []).map(task => task.name);
88
+ if (traces.length)
89
+ ctx.log(` Trace names with generations in the last 30 days: ${traces.slice(0, 15).map(trace => `${trace.traceName} (${trace.generations})`).join(", ")}`);
90
+ if (taskNames.length)
91
+ ctx.log(` Tasks from the last Scout run: ${taskNames.join(", ")}`);
92
+ for (;;) {
93
+ const line = await ctx.prompter.ask("Map a task to Langfuse evidence as <task>=<traceName>:<score,score> (empty to finish)", "");
94
+ if (!line)
95
+ break;
96
+ const match = /^(.+?)=([^:]+)(?::(.*))?$/.exec(line);
97
+ if (!match) {
98
+ ctx.log(" Write <task>=<traceName>:<score names>.");
99
+ continue;
100
+ }
101
+ const mapping = { task: match[1].trim(), traceName: match[2].trim(), scores: (match[3] ?? "").split(",").map(item => item.trim()).filter(Boolean) };
102
+ mappings.splice(0, mappings.length, ...mappings.filter(item => item.task !== mapping.task), mapping);
103
+ }
104
+ }
105
+ return { baseUrl, publicKey, secretKey, mappings, verifiedAt: new Date().toISOString() };
106
+ }
107
+ ctx.log(` ✗ Langfuse: ${check.detail}`);
108
+ if (!ctx.prompter || ctx.flags.nonInteractive || !(await ctx.prompter.confirm("Try again?", true)))
109
+ return existing;
110
+ }
111
+ }
112
+ function parseEndpointFlag(value, allowed) {
113
+ const known = /^(openrouter|openai):([A-Z_][A-Z0-9_]*)$/.exec(value);
114
+ if (known)
115
+ return { ...(known[1] === "openrouter" ? OPENROUTER_ENDPOINT : OPENAI_ENDPOINT), allowedModels: allowed, apiKey: { env: known[2] } };
116
+ const custom = /^([a-z0-9-]+)=(https?:\/\/[^\s]+):([A-Z_][A-Z0-9_]*)$/.exec(value);
117
+ if (!custom)
118
+ throw new Error(`--endpoint ${value}: use openrouter:ENV_VAR, openai:ENV_VAR or <id>=<baseUrl>:ENV_VAR`);
119
+ if (allowed === "all")
120
+ throw new Error("An OpenAI-compatible endpoint needs --allowed-models: the catalogue cannot tell which models it serves.");
121
+ return { id: custom[1], kind: "openai-compatible", baseUrl: custom[2], apiKey: { env: custom[3] }, allowedModels: allowed };
122
+ }
123
+ async function setupEndpoints(ctx) {
124
+ const existing = readRepoConfig(ctx.repo.root).endpoints ?? [];
125
+ const allowed = ctx.flags.allowedModels?.length ? ctx.flags.allowedModels : "all";
126
+ if (ctx.flags.endpoints?.length) {
127
+ const next = [...existing];
128
+ for (const flag of ctx.flags.endpoints) {
129
+ const endpoint = parseEndpointFlag(flag, allowed);
130
+ const check = await verifyEndpoint(endpoint, ctx.env, ctx.fetch);
131
+ if (!check.ok)
132
+ throw new Error(`Endpoint ${endpoint.id} failed verification: ${check.detail}. Existing endpoints are unchanged.`);
133
+ ctx.log(` ✓ ${endpoint.id}: ${check.detail}; allowed models: ${endpoint.allowedModels === "all" ? "all" : endpoint.allowedModels.join(", ")}`);
134
+ next.splice(0, next.length, ...next.filter(item => item.id !== endpoint.id), { ...endpoint, verifiedAt: new Date().toISOString() });
135
+ }
136
+ return next;
137
+ }
138
+ const kept = [];
139
+ for (const endpoint of existing) {
140
+ const check = await verifyEndpoint(endpoint, ctx.env, ctx.fetch);
141
+ ctx.log(` ${check.ok ? "✓" : "✗"} Endpoint ${endpoint.id} (${endpoint.baseUrl}): ${check.detail}; allowed models: ${endpoint.allowedModels === "all" ? "all" : endpoint.allowedModels.join(", ")}`);
142
+ if (check.ok) {
143
+ kept.push(await confirm(ctx, `Keep ${endpoint.id}?`, true) ? endpoint : await reconfigure(endpoint) ?? endpoint);
144
+ continue;
145
+ }
146
+ const fixed = await reconfigure(endpoint);
147
+ kept.push(fixed ?? endpoint);
148
+ }
149
+ async function reconfigure(endpoint) {
150
+ if (!ctx.prompter || ctx.flags.nonInteractive || !(await ctx.prompter.confirm(`Enter a new key for ${endpoint.id}?`, true)))
151
+ return null;
152
+ for (;;) {
153
+ const apiKey = await credential(ctx, `${endpoint.id} API key`, [], `endpoint-${endpoint.id}-${ctx.repo.id}`);
154
+ if (!apiKey)
155
+ return null;
156
+ const next = { ...endpoint, apiKey };
157
+ const check = await verifyEndpoint(next, ctx.env, ctx.fetch);
158
+ ctx.log(` ${check.ok ? "✓" : "✗"} ${endpoint.id}: ${check.detail}`);
159
+ if (check.ok)
160
+ return { ...next, verifiedAt: new Date().toISOString() };
161
+ if (!(await ctx.prompter.confirm("Try again?", true)))
162
+ return null;
163
+ }
164
+ }
165
+ if (!ctx.prompter || ctx.flags.nonInteractive)
166
+ return kept;
167
+ if (!kept.some(item => item.kind === "openrouter") && await ctx.prompter.confirm("Add OpenRouter for candidates (all its models eligible)?", !kept.length)) {
168
+ const apiKey = await credential(ctx, "OpenRouter key", ["OPENROUTER_API_KEY"], `endpoint-openrouter-${ctx.repo.id}`);
169
+ if (apiKey)
170
+ kept.push(...await added({ ...OPENROUTER_ENDPOINT, apiKey }, "all"));
171
+ }
172
+ if (!kept.some(item => item.kind === "openai") && await ctx.prompter.confirm("Add OpenAI for candidates?", false)) {
173
+ const apiKey = await credential(ctx, "OpenAI key", ["OPENAI_API_KEY"], `endpoint-openai-${ctx.repo.id}`);
174
+ if (apiKey)
175
+ kept.push(...await added({ ...OPENAI_ENDPOINT, apiKey }));
176
+ }
177
+ // OpenRouter's question already says every model is eligible; other endpoints ask which.
178
+ async function added(endpoint, allowed) {
179
+ const models = allowed ?? await ctx.prompter.ask(`Allowed models on ${endpoint.id}: "all", or IDs comma-separated`, "all");
180
+ const next = { ...endpoint, allowedModels: /^all$/i.test(models) ? "all" : models.split(",").map(item => item.trim()).filter(Boolean) };
181
+ const check = await verifyEndpoint(next, ctx.env, ctx.fetch);
182
+ ctx.log(` ${check.ok ? "✓" : "✗"} ${endpoint.id}: ${check.detail}`);
183
+ return check.ok ? [{ ...next, verifiedAt: new Date().toISOString() }] : (await ctx.prompter.confirm("Save it anyway, to fix later?", false)) ? [next] : [];
184
+ }
185
+ return kept;
186
+ }
187
+ export async function runSetup(ctx) {
188
+ ctx.log("OpenMerit setup\n\n1. OpenMerit Harness (global)");
189
+ const { harness: kind, harnessModel, apiProvider, apiModel, apiKeyEnv, deviceCode, nonInteractive } = ctx.flags;
190
+ const harness = await connect({ ...ctx, root: ctx.repo.root, flags: { ...(harnessModel ? { model: harnessModel } : {}), ...(apiProvider ? { apiProvider } : {}), ...(apiModel ? { apiModel } : {}), ...(apiKeyEnv ? { apiKeyEnv } : {}), ...(deviceCode ? { deviceCode } : {}), ...(nonInteractive ? { nonInteractive } : {}) } }, kind);
191
+ if (!harness)
192
+ return { harness: null, endpoints: [], langfuse: false, budgetUsd: DEFAULT_BUDGET_USD };
193
+ const agents = mcpClients(ctx.env);
194
+ if (agents.length && (ctx.flags.registerMcp ?? await confirm(ctx, `Register OpenMerit's MCP server with ${agents.map(kind => (kind === "claude-code" ? "Claude Code" : "Codex")).join(" and ")}?`, true)))
195
+ registerMcp(ctx, agents);
196
+ ctx.log(`\n2. This repository: ${ctx.repo.root}`);
197
+ const langfuse = await setupLangfuse(ctx);
198
+ const endpoints = await setupEndpoints(ctx);
199
+ if (!endpoints.length)
200
+ ctx.log(" No candidate endpoints: Scout needs at least one to evaluate models. Run setup again to add one.");
201
+ const current = readRepoConfig(ctx.repo.root).budget?.defaultUsd ?? DEFAULT_BUDGET_USD;
202
+ let budgetUsd = ctx.flags.budget ?? current;
203
+ if (ctx.flags.budget === undefined && ctx.prompter && !ctx.flags.nonInteractive) {
204
+ const answer = Number(await ctx.prompter.ask("Default eval budget per Scout run, USD", String(current)));
205
+ if (Number.isFinite(answer) && answer > 0)
206
+ budgetUsd = answer;
207
+ }
208
+ updateRepoConfig(ctx.repo.root, { endpoints, budget: { defaultUsd: budgetUsd }, ...(langfuse ? { langfuse } : {}) });
209
+ ctx.log(`\nSaved. Harness: ${HARNESS_LABEL[harness.kind]}. Endpoints: ${endpoints.map(item => item.id).join(", ") || "none"}. Langfuse: ${langfuse ? "read-only" : "not connected"}. Eval budget: $${budgetUsd.toFixed(2)} per run.\nSettings: ~/.openmerit/config.json (global) and .openmerit/config.json (this repository; credential references only). Next: openmerit scout`);
210
+ return { harness, endpoints, langfuse: !!langfuse, budgetUsd };
211
+ }
@@ -0,0 +1,116 @@
1
+ /** Where a credential comes from: an environment variable, or a value OpenMerit stored in secrets.json. */
2
+ export type CredentialRef = {
3
+ env: string;
4
+ } | {
5
+ secret: string;
6
+ };
7
+ export type HarnessKind = "claude-code" | "codex" | "api";
8
+ export interface HarnessSettings {
9
+ kind: HarnessKind;
10
+ /** Model the coding agent uses for harness inference; its own default when absent. */
11
+ model?: string;
12
+ /** For kind "api": a Pi provider (anthropic, openai, openrouter, ...) and model. */
13
+ api?: {
14
+ provider: string;
15
+ model: string;
16
+ apiKey: CredentialRef;
17
+ };
18
+ verifiedAt?: string;
19
+ }
20
+ /** How history names a harness, such as `api:openrouter/anthropic/claude-sonnet-5.5` or `codex`. */
21
+ export declare const harnessLabel: (settings: HarnessSettings) => string;
22
+ export interface GlobalConfig {
23
+ harness?: HarnessSettings;
24
+ [key: string]: unknown;
25
+ }
26
+ export type EndpointKind = "openrouter" | "openai" | "openai-compatible";
27
+ export interface Endpoint {
28
+ id: string;
29
+ kind: EndpointKind;
30
+ /** OpenAI-compatible API root, such as https://openrouter.ai/api/v1. */
31
+ baseUrl: string;
32
+ apiKey: CredentialRef;
33
+ /** "all" for every catalogue model the endpoint serves, or explicit model IDs (catalogue or endpoint IDs). */
34
+ allowedModels: "all" | string[];
35
+ verifiedAt?: string;
36
+ }
37
+ /** Which Langfuse scores and traces measure a task. Read-only aggregates. */
38
+ export interface LangfuseMapping {
39
+ task: string;
40
+ traceName: string;
41
+ scores: string[];
42
+ }
43
+ export interface LangfuseSettings {
44
+ baseUrl: string;
45
+ publicKey: CredentialRef;
46
+ secretKey: CredentialRef;
47
+ mappings: LangfuseMapping[];
48
+ verifiedAt?: string;
49
+ }
50
+ /** How OpenMerit runs a task's existing eval for one model. See README "Eval contract". */
51
+ export interface EvalSpec {
52
+ command: string;
53
+ cwd?: string;
54
+ /** Extra variables for the eval; `{{model}}`, `{{items}}` and `{{experiment}}` are filled in. */
55
+ env?: Record<string, string>;
56
+ /** Helper files the harness wrote (and the user approved) to run the existing eval. */
57
+ helpers?: string[];
58
+ /** The eval's own acceptance threshold: the minimum pass rate, 0 to 1. */
59
+ threshold?: number;
60
+ /**
61
+ * The eval records each run as a Langfuse experiment named $OPENMERIT_EXPERIMENT on this dataset,
62
+ * with a per-item score. OpenMerit reads pass/fail back with the Viewer key. Without it, the eval
63
+ * reports its items in $OPENMERIT_RESULT.
64
+ */
65
+ langfuse?: {
66
+ dataset: string;
67
+ score: string;
68
+ min?: number;
69
+ };
70
+ description?: string;
71
+ }
72
+ export interface RepoConfig {
73
+ langfuse?: LangfuseSettings;
74
+ endpoints?: Endpoint[];
75
+ budget?: {
76
+ defaultUsd: number;
77
+ };
78
+ evals?: Record<string, EvalSpec>;
79
+ /** Glob patterns, relative to the repository root, of files or directories Scout's scan skips. */
80
+ scan?: {
81
+ exclude?: string[];
82
+ };
83
+ /** Runs of every item for the current model and each finalist; default 3. */
84
+ repeats?: number;
85
+ [key: string]: unknown;
86
+ }
87
+ export declare const DEFAULT_BUDGET_USD = 10;
88
+ export declare const DEFAULT_REPEATS = 3;
89
+ export declare const globalConfigPath: (env?: Record<string, string | undefined>) => string;
90
+ export declare const repoConfigPath: (root: string) => string;
91
+ export declare const readGlobalConfig: (env?: Record<string, string | undefined>) => GlobalConfig;
92
+ export declare const readRepoConfig: (root: string) => RepoConfig;
93
+ /** Merges `patch` into the stored object, keeping every other key. */
94
+ export declare function updateGlobalConfig(patch: Partial<GlobalConfig>, env?: Record<string, string | undefined>): {
95
+ [x: string]: unknown;
96
+ harness?: HarnessSettings;
97
+ };
98
+ export declare function updateRepoConfig(root: string, patch: Partial<RepoConfig>): {
99
+ [x: string]: unknown;
100
+ langfuse?: LangfuseSettings;
101
+ endpoints?: Endpoint[];
102
+ budget?: {
103
+ defaultUsd: number;
104
+ };
105
+ evals?: Record<string, EvalSpec>;
106
+ scan?: {
107
+ exclude?: string[];
108
+ };
109
+ repeats?: number;
110
+ };
111
+ export declare function storeSecret(name: string, value: string, env?: Record<string, string | undefined>): CredentialRef;
112
+ export declare function resolveCredential(ref: CredentialRef | undefined, env?: Record<string, string | undefined>): string | null;
113
+ export declare const describeCredential: (ref: CredentialRef) => string;
114
+ /** Default candidate pool: OpenRouter with every model eligible. */
115
+ export declare const OPENROUTER_ENDPOINT: Omit<Endpoint, "apiKey">;
116
+ export declare const OPENAI_ENDPOINT: Omit<Endpoint, "apiKey">;
@@ -0,0 +1,60 @@
1
+ // Settings. Global (~/.openmerit/config.json): how OpenMerit Harness is powered. Per repository
2
+ // (<repo>/.openmerit/config.json): Langfuse, candidate endpoints, allowed models, evals and the eval
3
+ // budget, with credential references only. Secret values live in ~/.openmerit/secrets.json (0600),
4
+ // outside every repository. Writes keep keys OpenMerit does not know about.
5
+ import { randomUUID } from "node:crypto";
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { openmeritHome } from "./paths.js";
9
+ /** How history names a harness, such as `api:openrouter/anthropic/claude-sonnet-5.5` or `codex`. */
10
+ export const harnessLabel = (settings) => `${settings.kind}${settings.kind === "api" ? `:${settings.api?.provider}/${settings.api?.model}` : settings.model ? `:${settings.model}` : ""}`;
11
+ export const DEFAULT_BUDGET_USD = 10;
12
+ export const DEFAULT_REPEATS = 3;
13
+ function readJson(path) {
14
+ if (!existsSync(path))
15
+ return null;
16
+ const value = JSON.parse(readFileSync(path, "utf8"));
17
+ if (!value || typeof value !== "object" || Array.isArray(value))
18
+ throw new Error(`${path} must hold a JSON object`);
19
+ return value;
20
+ }
21
+ function writeJson(path, value, mode = 0o644) {
22
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
23
+ const temp = `${path}.${randomUUID()}.tmp`;
24
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode });
25
+ renameSync(temp, path);
26
+ chmodSync(path, mode);
27
+ }
28
+ export const globalConfigPath = (env) => join(openmeritHome(env), "config.json");
29
+ export const repoConfigPath = (root) => join(root, ".openmerit", "config.json");
30
+ const secretsPath = (env) => join(openmeritHome(env), "secrets.json");
31
+ export const readGlobalConfig = (env) => readJson(globalConfigPath(env)) ?? {};
32
+ export const readRepoConfig = (root) => readJson(repoConfigPath(root)) ?? {};
33
+ /** Merges `patch` into the stored object, keeping every other key. */
34
+ export function updateGlobalConfig(patch, env) {
35
+ const next = { ...readGlobalConfig(env), ...patch };
36
+ writeJson(globalConfigPath(env), next);
37
+ return next;
38
+ }
39
+ export function updateRepoConfig(root, patch) {
40
+ const next = { ...readRepoConfig(root), ...patch };
41
+ writeJson(repoConfigPath(root), next);
42
+ return next;
43
+ }
44
+ export function storeSecret(name, value, env) {
45
+ const secrets = readJson(secretsPath(env)) ?? {};
46
+ secrets[name] = value;
47
+ writeJson(secretsPath(env), secrets, 0o600);
48
+ return { secret: name };
49
+ }
50
+ export function resolveCredential(ref, env = process.env) {
51
+ if (!ref)
52
+ return null;
53
+ if ("env" in ref)
54
+ return env[ref.env]?.trim() || null;
55
+ return readJson(secretsPath(env))?.[ref.secret] ?? null;
56
+ }
57
+ export const describeCredential = (ref) => ("env" in ref ? `$${ref.env}` : "stored in ~/.openmerit/secrets.json");
58
+ /** Default candidate pool: OpenRouter with every model eligible. */
59
+ export const OPENROUTER_ENDPOINT = { id: "openrouter", kind: "openrouter", baseUrl: "https://openrouter.ai/api/v1", allowedModels: "all" };
60
+ export const OPENAI_ENDPOINT = { id: "openai", kind: "openai", baseUrl: "https://api.openai.com/v1", allowedModels: "all" };
@@ -0,0 +1,159 @@
1
+ import type { DatabaseSync as Database } from "node:sqlite";
2
+ import { type Repo } from "./paths.js";
3
+ export type RunStatus = "running" | "needs_input" | "budget_paused" | "interrupted" | "failed" | "done" | "cancelled";
4
+ export type UnitStatus = "pending" | "running" | "done" | "failed" | "unavailable" | "budget_paused" | "interrupted";
5
+ export interface RunRow {
6
+ id: string;
7
+ repoId: string;
8
+ repoRoot: string;
9
+ revision: string | null;
10
+ status: RunStatus;
11
+ phase: string;
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ packageVersion: string;
15
+ catalogueVersion: string;
16
+ harness: string;
17
+ budgetUsd: number;
18
+ state: Record<string, unknown>;
19
+ error: string | null;
20
+ lockPid: number | null;
21
+ }
22
+ export interface UnitRow {
23
+ id: string;
24
+ runId: string;
25
+ task: string;
26
+ model: string;
27
+ endpoint: string;
28
+ /** baseline: the current model on every item; screen: a candidate on the sampled items; final: a candidate on every item. */
29
+ role: "baseline" | "screen" | "final";
30
+ repeat: number;
31
+ status: UnitStatus;
32
+ attempts: number;
33
+ result: Record<string, unknown> | null;
34
+ error: string | null;
35
+ }
36
+ export interface Spend {
37
+ /** Settled requests: provider-reported cost, or list-price estimates where the provider reports none. */
38
+ settledUsd: number;
39
+ /** Part of settledUsd that is a list-price estimate. */
40
+ estimatedUsd: number;
41
+ /** Reserved for requests still in flight (or cut off by an interruption): cost unknown, counted at the reservation. */
42
+ inFlightUsd: number;
43
+ inFlight: number;
44
+ /** Requests with no price at all, counted at their reservation. */
45
+ unknown: number;
46
+ requests: number;
47
+ totalUsd: number;
48
+ }
49
+ export declare class State {
50
+ readonly db: Database;
51
+ readonly path: string;
52
+ constructor(env?: Record<string, string | undefined>);
53
+ /** Applies schema upgrades in order; older history stays readable. */
54
+ private migrate;
55
+ get schemaVersion(): number;
56
+ /** BEGIN IMMEDIATE: one writer at a time across CLI and MCP processes. */
57
+ transaction<T>(work: () => T): T;
58
+ close(): void;
59
+ createRun(input: {
60
+ id: string;
61
+ repo: Repo;
62
+ packageVersion: string;
63
+ catalogueVersion: string;
64
+ harness: string;
65
+ budgetUsd: number;
66
+ state: Record<string, unknown>;
67
+ }): RunRow;
68
+ getRun(id: string): RunRow | null;
69
+ listRuns(repoId: string, limit?: number): RunRow[];
70
+ private toRun;
71
+ updateRun(id: string, patch: {
72
+ status?: RunStatus;
73
+ phase?: string;
74
+ state?: Record<string, unknown>;
75
+ error?: string | null;
76
+ budgetUsd?: number;
77
+ harness?: string;
78
+ }): void;
79
+ /** Takes the run for this process. Fails while another live process works on it. */
80
+ lock(id: string): boolean;
81
+ unlock(id: string): void;
82
+ /**
83
+ * Runs whose process died mid-work become `interrupted`: running units are interrupted, and
84
+ * reservations of requests that never settled stay counted as in-flight spend of unknown cost.
85
+ */
86
+ reapInterrupted(repoId: string): string[];
87
+ /** Marks a run interrupted (Ctrl-C, or a dead process). Unsettled reservations stay counted as in-flight spend. */
88
+ markInterrupted(runId: string, message?: string): void;
89
+ private interrupted;
90
+ event(runId: string, kind: string, message: string): void;
91
+ events(runId: string): {
92
+ at: string;
93
+ kind: string;
94
+ message: string;
95
+ }[];
96
+ ensureUnit(unit: Omit<UnitRow, "status" | "attempts" | "result" | "error">): UnitRow;
97
+ units(runId: string): UnitRow[];
98
+ setUnit(id: string, patch: {
99
+ status: UnitStatus;
100
+ result?: Record<string, unknown> | null;
101
+ error?: string | null;
102
+ attempt?: boolean;
103
+ }): void;
104
+ spend(runId: string, unitId?: string): Spend;
105
+ /**
106
+ * Reserves a paid request's expected cost against the run budget, atomically across processes.
107
+ * Refused when settled plus reserved spend plus this request would pass the run's budget.
108
+ */
109
+ reserve(input: {
110
+ runId: string;
111
+ unitId: string;
112
+ model: string;
113
+ endpoint: string;
114
+ expectedUsd: number;
115
+ }): {
116
+ ok: true;
117
+ id: number;
118
+ } | {
119
+ ok: false;
120
+ spend: Spend;
121
+ budgetUsd: number;
122
+ };
123
+ /** Records what a reserved request cost: provider-reported (`actualUsd`), list-price estimate, or unknown (both null). */
124
+ settle(id: number, cost: {
125
+ actualUsd: number | null;
126
+ estimatedUsd: number | null;
127
+ latencyMs: number;
128
+ failed?: boolean;
129
+ }): void;
130
+ usage(runId: string, unitId: string): {
131
+ requests: number;
132
+ latencyMs: number;
133
+ actualUsd: number;
134
+ estimatedUsd: number;
135
+ unknown: number;
136
+ };
137
+ budgetEvent(runId: string, event: {
138
+ kind: "alert" | "raise" | "decline";
139
+ fromUsd: number;
140
+ toUsd?: number;
141
+ scope?: "run" | "default";
142
+ spentUsd: number;
143
+ }): void;
144
+ budgetEvents(runId: string): {
145
+ at: string;
146
+ kind: string;
147
+ fromUsd: number;
148
+ toUsd: number | null;
149
+ scope: string | null;
150
+ spentUsd: number;
151
+ }[];
152
+ decision(runId: string, kind: string, decision: string, detail: unknown): void;
153
+ decisions(runId: string): {
154
+ at: string;
155
+ kind: string;
156
+ decision: string;
157
+ detail: unknown;
158
+ }[];
159
+ }