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,158 @@
1
+ import { DEFAULT_CLAUDE_MODEL } from "./harness/claude-bridge.js";
2
+ import { loadCatalogue } from "./catalogue/catalogue.js";
3
+ import { CODEX_PROVIDER, createHarness, detectAgents, harnessRuntime, runClaude, verifyHarness } from "./harness/harness.js";
4
+ import { readGlobalConfig, storeSecret, updateGlobalConfig } from "./state/config.js";
5
+ export const HARNESS_LABEL = { "claude-code": "Claude Code", codex: "Codex (ChatGPT sign-in)", api: "an API key" };
6
+ const interactive = (ctx) => !!ctx.prompter && !ctx.flags.nonInteractive;
7
+ export async function confirm(ctx, question, fallback) { return interactive(ctx) ? ctx.prompter.confirm(question, fallback) : fallback; }
8
+ /** A key from an environment variable (kept as a reference) or typed in (stored in ~/.openmerit/secrets.json). */
9
+ export async function credential(ctx, label, envNames, secretName, flagEnv) {
10
+ if (flagEnv)
11
+ return ctx.env[flagEnv] ? { env: flagEnv } : (() => { throw new Error(`$${flagEnv} is not set.`); })();
12
+ const present = envNames.find(name => ctx.env[name]?.trim());
13
+ if (present && await confirm(ctx, `Use $${present} for the ${label}?`, true))
14
+ return { env: present };
15
+ if (!interactive(ctx))
16
+ return null;
17
+ const value = await ctx.prompter.secret(`${label} (input hidden; stored in ~/.openmerit/secrets.json, outside the repository):`);
18
+ return value ? storeSecret(secretName, value, ctx.env) : null;
19
+ }
20
+ /** Pi's login prompts and notices in the terminal, or on stderr only without one (device code). */
21
+ function authInteraction(ctx) {
22
+ return {
23
+ async prompt(prompt) {
24
+ if (prompt.type === "select") {
25
+ const ids = prompt.options.map(option => option.id);
26
+ const device = ids.find(id => id === "device_code");
27
+ if (device && (ctx.flags.deviceCode || !interactive(ctx)))
28
+ return device;
29
+ if (!interactive(ctx))
30
+ return ids[0];
31
+ return ctx.prompter.choose(prompt.message, prompt.options.map(option => ({ value: option.id, label: option.label })), ids[0]);
32
+ }
33
+ if (!interactive(ctx))
34
+ throw new Error(`${prompt.message} needs a terminal. Rerun with --device-code.`);
35
+ return prompt.type === "secret" ? ctx.prompter.secret(prompt.message) : ctx.prompter.ask(prompt.message, "", prompt.signal);
36
+ },
37
+ notify(event) {
38
+ if (event.type === "auth_url")
39
+ ctx.log(` Open this URL to sign in to ChatGPT:\n ${event.url}${event.instructions ? `\n ${event.instructions}` : ""}`);
40
+ else if (event.type === "device_code")
41
+ ctx.log(` On any device, open ${event.verificationUri} and enter the code ${event.userCode}${event.expiresInSeconds ? ` within ${Math.round(event.expiresInSeconds / 60)} minutes` : ""}.`);
42
+ else
43
+ ctx.log(` ${event.message}`);
44
+ },
45
+ };
46
+ }
47
+ /** OpenAI's reply when a ChatGPT plan does not include a Codex model. */
48
+ const NOT_ON_PLAN = /not supported when using Codex with a ChatGPT account/i;
49
+ /** Pi's Codex models, most capable (by the bundled catalogue's ECI) first. */
50
+ async function codexModelsByEci(env) {
51
+ const eci = new Map(loadCatalogue().models.map(model => [model.id, model.eci ?? 0]));
52
+ const score = (id) => eci.get(`openai/${id}`) ?? 0;
53
+ return (await harnessRuntime(env)).getModels(CODEX_PROVIDER).map(model => model.id).sort((a, b) => score(b) - score(a));
54
+ }
55
+ /** Makes sure the chosen connection is signed in. Returns false when the user declined. */
56
+ async function signIn(ctx, kind) {
57
+ if (kind === "api")
58
+ return true;
59
+ const agent = (await detectAgents(ctx.env)).find(item => item.kind === kind);
60
+ if (agent.loggedIn)
61
+ return true;
62
+ if (kind === "claude-code") {
63
+ if (!interactive(ctx))
64
+ throw new Error("Claude Code is not logged in. Run `openmerit connect claude-code` in a terminal to log in, or on a server set CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`.");
65
+ if (!(await confirm(ctx, "Claude Code is not logged in. Log in with your Claude subscription now?", true)))
66
+ return false;
67
+ await ctx.prompter.handOver(() => runClaude(["auth", "login"], { env: ctx.env, interactive: true }));
68
+ return true;
69
+ }
70
+ ctx.log(" Signing in to ChatGPT (Plus or Pro) for Codex models. OpenMerit keeps the sign-in in ~/.openmerit/agent/auth.json.");
71
+ const runtime = await harnessRuntime(ctx.env);
72
+ await runtime.login(CODEX_PROVIDER, "oauth", authInteraction(ctx));
73
+ await runtime.settleCredentialWrites();
74
+ ctx.log(" ✓ Signed in to ChatGPT.");
75
+ return true;
76
+ }
77
+ /**
78
+ * Connects OpenMerit Harness. With `kind`, connects that; without it, keeps a working saved
79
+ * connection when the user agrees, and otherwise asks which to use.
80
+ */
81
+ export async function connect(ctx, kind) {
82
+ const existing = readGlobalConfig(ctx.env).harness;
83
+ if (existing && !kind) {
84
+ ctx.log(`Checking the current harness connection (${HARNESS_LABEL[existing.kind]}${existing.model ? `, ${existing.model}` : ""})…`);
85
+ const check = await createHarness(existing, ctx.env).then(harness => verifyHarness(harness, ctx.root), (error) => ({ ok: false, detail: error instanceof Error ? error.message : String(error) }));
86
+ if (check.ok) {
87
+ ctx.log(` ✓ OpenMerit Harness runs on ${HARNESS_LABEL[existing.kind]} (${check.detail}).`);
88
+ if (await confirm(ctx, "Keep this harness connection?", true)) {
89
+ const saved = { ...existing, verifiedAt: new Date().toISOString() };
90
+ updateGlobalConfig({ harness: saved }, ctx.env);
91
+ return saved;
92
+ }
93
+ }
94
+ else
95
+ ctx.log(` ✗ The saved harness connection failed: ${check.detail}`);
96
+ }
97
+ for (;;) {
98
+ if (!kind) {
99
+ if (!interactive(ctx))
100
+ throw new Error("Choose how to power OpenMerit Harness: `openmerit connect claude-code`, `codex` or `api`.");
101
+ const agents = await detectAgents(ctx.env);
102
+ kind = await ctx.prompter.choose("Which should power OpenMerit Harness?", [
103
+ ...agents.map(agent => ({ value: agent.kind, label: `${HARNESS_LABEL[agent.kind]} (${agent.detail})` })),
104
+ { value: "api", label: "An API provider, key and model" },
105
+ ], agents.find(agent => agent.loggedIn)?.kind ?? "claude-code");
106
+ }
107
+ const settings = { kind };
108
+ if (kind === "api") {
109
+ const provider = ctx.flags.apiProvider ?? (interactive(ctx) ? await ctx.prompter.ask("Pi provider for the harness (anthropic, openai, openrouter, google, ...)", existing?.api?.provider ?? "anthropic") : "anthropic");
110
+ const suggested = (existing?.api?.provider === provider ? existing.api.model : undefined) ?? (provider === "openai" ? "gpt-6.1-sol" : provider === "openrouter" ? "anthropic/claude-sonnet-5.5" : "claude-sonnet-5-5");
111
+ const model = ctx.flags.apiModel ?? (interactive(ctx) ? await ctx.prompter.ask("Model", suggested) : suggested);
112
+ const envNames = { anthropic: ["ANTHROPIC_API_KEY"], openai: ["OPENAI_API_KEY"], openrouter: ["OPENROUTER_API_KEY"], google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"] }[provider] ?? [];
113
+ const apiKey = await credential(ctx, `${provider} API key for the harness`, envNames, `harness-${provider}`, ctx.flags.apiKeyEnv);
114
+ if (!apiKey)
115
+ throw new Error("The API harness needs a key: pass --api-key-env <VAR>.");
116
+ settings.api = { provider, model, apiKey };
117
+ }
118
+ else {
119
+ const chosen = ctx.flags.model ?? (existing?.kind === kind ? existing.model : undefined);
120
+ if (chosen || kind === "claude-code")
121
+ settings.model = chosen ?? DEFAULT_CLAUDE_MODEL;
122
+ ctx.log(settings.model ? ` Model: ${settings.model} (change it with --model).` : " Model: the most capable Codex model your ChatGPT plan allows (choose one with --model).");
123
+ }
124
+ let check = { ok: false, detail: "no model to try" };
125
+ try {
126
+ if (!(await signIn(ctx, kind)))
127
+ return null;
128
+ // Which Codex models a ChatGPT account may use depends on its plan, and OpenAI does not list them: try them by ECI.
129
+ const models = kind === "codex" && !settings.model ? await codexModelsByEci(ctx.env) : [settings.model];
130
+ for (const model of models) {
131
+ if (model)
132
+ settings.model = model;
133
+ ctx.log(`Verifying that ${HARNESS_LABEL[kind]}${model ? ` (${model})` : ""} powers OpenMerit Harness…`);
134
+ check = await verifyHarness(await createHarness(settings, ctx.env), ctx.root);
135
+ if (check.ok || !NOT_ON_PLAN.test(check.detail))
136
+ break;
137
+ ctx.log(` ${model} is not available on this ChatGPT plan.`);
138
+ }
139
+ }
140
+ catch (error) {
141
+ check = { ok: false, detail: error instanceof Error ? error.message : String(error) };
142
+ }
143
+ if (check.ok) {
144
+ ctx.log(` ✓ ${HARNESS_LABEL[kind]} answered through OpenMerit Harness (${check.detail}).`);
145
+ const saved = { ...settings, verifiedAt: new Date().toISOString() };
146
+ updateGlobalConfig({ harness: saved }, ctx.env);
147
+ return saved;
148
+ }
149
+ ctx.log(` ✗ ${HARNESS_LABEL[kind]} did not answer: ${check.detail}`);
150
+ if (!interactive(ctx))
151
+ throw new Error(`${HARNESS_LABEL[kind]} could not power the harness: ${check.detail}${existing ? ". Your previous harness settings are unchanged." : ""}`);
152
+ const next = await ctx.prompter.choose("What now?", [{ value: "retry", label: `Retry ${HARNESS_LABEL[kind]}` }, { value: "other", label: "Choose another connection" }, { value: "quit", label: existing ? "Stop and keep the previous settings" : "Stop" }], "retry");
153
+ if (next === "quit")
154
+ return null;
155
+ if (next === "other")
156
+ kind = undefined;
157
+ }
158
+ }
@@ -1,27 +1,6 @@
1
1
  import { type LangfuseConnection, type TransportOptions } from "./client.js";
2
2
  export { connectionFromEnv, type LangfuseConnection, LangfuseError, normalizeBaseUrl, type TransportOptions } from "./client.js";
3
3
  export type ScoreValue = number | boolean | string;
4
- export type Metadata = Record<string, string | number | boolean>;
5
- export interface EvalItem {
6
- id: string;
7
- input: unknown;
8
- expectedOutput: unknown;
9
- metadata: Record<string, unknown>;
10
- }
11
- export interface DatasetSummary {
12
- id: string;
13
- name: string;
14
- }
15
- export interface ExperimentSummary {
16
- id: string;
17
- name: string;
18
- datasetId: string | null;
19
- itemCount: number | null;
20
- startTime: string | null;
21
- metadata: Record<string, unknown>;
22
- /** Experiment metadata `model_<role>` entries, e.g. { enrollment: "openrouter/anthropic/claude-sonnet-5.5" }. */
23
- models: Record<string, string>;
24
- }
25
4
  /**
26
5
  * Decides whether one item passed from one score. With no condition, a BOOLEAN score must be true
27
6
  * and a NUMERIC score must be 1. `equals` compares exactly; `min`/`max` are inclusive numeric bounds.
@@ -38,12 +17,10 @@ export interface ItemResult {
38
17
  passed: boolean;
39
18
  /** The pass score was absent: the item failed to run or was not scored. It never counts as a pass. */
40
19
  missingScore: boolean;
41
- scores: Record<string, ScoreValue>;
42
- costUsd: number | null;
43
20
  }
44
21
  export interface ExperimentResult {
45
- experimentId: string;
46
22
  experimentName: string;
23
+ experimentId: string;
47
24
  datasetId: string | null;
48
25
  items: ItemResult[];
49
26
  passes: number;
@@ -52,74 +29,34 @@ export interface ExperimentResult {
52
29
  missing: string[];
53
30
  /** Item IDs present without the pass score. */
54
31
  missingScores: string[];
55
- /** Sum of generation costs Langfuse recorded for the items; null when not requested or none recorded. */
56
- costUsd: number | null;
57
32
  }
58
33
  export interface ExperimentQuery {
59
- experimentId?: string;
60
- /** Names are not unique (the playground gives its claims and enrollments runs one name); an ambiguous name throws. */
61
- experimentName?: string;
34
+ experimentName: string;
62
35
  pass: PassRule;
63
- /** Only these items count, e.g. the five screening samples; absent ones are listed in `missing`. */
64
- itemIds?: string[];
65
- includeCost?: boolean;
66
- /** Lower bound for Langfuse's required start-time filter. Default: 30 days ago. */
67
- since?: Date | string;
36
+ /** Only these items count; absent ones are listed in `missing`. */
37
+ itemIds?: readonly string[];
38
+ /** Lower bound for Langfuse's required start-time filter. */
39
+ since: string;
68
40
  }
69
- export interface RecordedScore {
70
- name: string;
71
- value: ScoreValue;
72
- /** Inferred from the value when absent: boolean → BOOLEAN, number → NUMERIC, string → CATEGORICAL. */
73
- dataType?: "NUMERIC" | "BOOLEAN" | "CATEGORICAL";
74
- comment?: string;
75
- }
76
- export interface RecordedItem {
77
- /** Dataset item ID; it must exist in the experiment's dataset. */
78
- itemId: string;
79
- /** Model that produced the output, e.g. "openrouter/deepseek/deepseek-v4-pro". */
41
+ export interface ModelUsage {
42
+ /** Model name as the traces report it (Langfuse `providedModelName`). */
80
43
  model: string;
81
- input: unknown;
82
- output: unknown;
83
- startMs: number;
84
- endMs: number;
85
- usage?: {
86
- input?: number;
87
- output?: number;
88
- total?: number;
89
- };
90
- costUsd?: number;
91
- /** Set when the run failed; the root observation gets level ERROR. */
92
- error?: string;
93
- /** Per-item trace metadata, e.g. { stage: "screen", eci: 142.1 }. */
94
- metadata?: Metadata;
95
- scores: RecordedScore[];
96
- }
97
- export interface ExperimentRecording {
98
- datasetId: string;
99
- experimentId: string;
100
- experimentName: string;
101
- /** Experiment metadata. Put the candidate model under `model_<role>` so listExperiments reports it. */
102
- metadata?: Metadata;
103
- /** Trace and root observation name. Default "openmerit-eval". */
104
- traceName?: string;
105
- items: RecordedItem[];
106
- }
107
- export interface RecordedExperiment {
108
- experimentId: string;
109
- traceIds: Record<string, string>;
110
- url: string;
44
+ generations: number;
45
+ costUsd: number | null;
46
+ p50LatencyMs: number | null;
111
47
  }
112
- export interface DatasetInput {
48
+ export interface ScoreAggregate {
113
49
  name: string;
114
- description?: string;
115
- metadata?: Metadata;
116
- /** Without an ID, an item gets one derived from the dataset name and its input, so re-pushing a file upserts. */
117
- items: {
118
- id?: string;
119
- input: unknown;
120
- expectedOutput: unknown;
121
- metadata?: Record<string, unknown>;
122
- }[];
50
+ average: number;
51
+ count: number;
52
+ }
53
+ export interface TaskEvidence {
54
+ traceName: string;
55
+ from: string;
56
+ to: string;
57
+ models: ModelUsage[];
58
+ /** Score averages over every trace of this name in the window, whichever model produced it. */
59
+ scores: ScoreAggregate[];
123
60
  }
124
61
  export interface LangfuseConnector {
125
62
  readonly baseUrl: string;
@@ -128,36 +65,26 @@ export interface LangfuseConnector {
128
65
  projectId: string;
129
66
  projectName: string;
130
67
  }>;
131
- listDatasets(): Promise<DatasetSummary[]>;
132
- getDataset(name: string): Promise<DatasetSummary>;
133
- getDatasetItems(datasetName: string): Promise<EvalItem[]>;
134
- ensureDataset(input: DatasetInput): Promise<{
135
- datasetId: string;
136
- itemIds: string[];
137
- }>;
138
- listExperiments(query?: {
139
- datasetName?: string;
140
- datasetId?: string;
141
- names?: string[];
142
- ids?: string[];
143
- since?: Date | string;
144
- }): Promise<ExperimentSummary[]>;
68
+ /** Throws unless the key pair is read-only (Viewer). */
69
+ verifyViewer(): Promise<void>;
70
+ /** IDs of the dataset's active items. */
71
+ datasetItemIds(name: string): Promise<string[]>;
145
72
  experimentResults(query: ExperimentQuery): Promise<ExperimentResult>;
73
+ /** Polls until every expected item is present and scored, allowing for ingestion lag; throws on timeout. */
146
74
  waitForExperiment(query: ExperimentQuery, options: {
147
75
  expectItems: number;
148
76
  timeoutMs?: number;
149
77
  intervalMs?: number;
150
78
  }): Promise<ExperimentResult>;
151
- recordExperimentItems(recording: ExperimentRecording): Promise<RecordedExperiment>;
152
- experimentUrl(datasetId: string, experimentId: string): Promise<string>;
153
- }
154
- export interface ConnectorOptions extends TransportOptions {
155
- /** Langfuse environment for recorded traces and scores. Default "openmerit". */
156
- environment?: string;
79
+ /** Trace names with generation counts, to suggest task mappings in setup. */
80
+ traceNames(days?: number): Promise<{
81
+ traceName: string;
82
+ generations: number;
83
+ }[]>;
84
+ taskEvidence(mapping: {
85
+ traceName: string;
86
+ scores: string[];
87
+ }, days?: number): Promise<TaskEvidence>;
157
88
  }
158
- /** Version written into recorded traces' scope. */
159
- export declare const CONNECTOR_VERSION = "0.1.0";
160
89
  export declare function evaluatePass(rule: PassRule, value: ScoreValue | undefined): boolean;
161
- /** Trace ID of one recorded item: the first 32 hex characters of SHA-256 over its experiment and item. */
162
- export declare const recordedTraceId: (experimentId: string, itemId: string) => string;
163
- export declare function createLangfuseConnector(connection: LangfuseConnection, options?: ConnectorOptions): LangfuseConnector;
90
+ export declare function createLangfuseConnector(connection: LangfuseConnection, options?: TransportOptions): LangfuseConnector;