evrex-mcp 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,18 +9,42 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
+ // src/request-context.ts
13
+ import { AsyncLocalStorage } from "node:async_hooks";
14
+ function runInRequest(ctx, fn) {
15
+ return storage.run(ctx, fn);
16
+ }
17
+ function currentRequest() {
18
+ return storage.getStore();
19
+ }
20
+ var storage;
21
+ var init_request_context = __esm({
22
+ "src/request-context.ts"() {
23
+ "use strict";
24
+ storage = new AsyncLocalStorage();
25
+ }
26
+ });
27
+
12
28
  // src/client.ts
13
29
  var client_exports = {};
14
30
  __export(client_exports, {
31
+ EvrexApiError: () => EvrexApiError,
15
32
  evrexApi: () => evrexApi
16
33
  });
34
+ function token() {
35
+ return currentRequest()?.token ?? EVREX_TOKEN;
36
+ }
17
37
  function headers() {
18
38
  const base = { "Content-Type": "application/json" };
19
- if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
39
+ const credential = token();
40
+ if (credential) base.Authorization = `Bearer ${credential}`;
20
41
  return base;
21
42
  }
22
43
  function describeFailure(method, path, status, statusText) {
23
44
  if (status === 401 || status === 403) {
45
+ if (currentRequest()) {
46
+ return `${method} ${path} -> ${status}: the Evrex token this connection carries was rejected, or lacks the scope this needs. Mint a read token at https://app.evrex.ai/agents.`;
47
+ }
24
48
  return EVREX_TOKEN ? `${method} ${path} -> ${status}: the EVREX_TOKEN this server was started with was rejected. It may be revoked or expired.` : `${method} ${path} -> ${status}: this evrex backend requires a credential, but no EVREX_TOKEN is set for this MCP server.`;
25
49
  }
26
50
  return `${method} ${path} -> ${status} ${statusText}`;
@@ -32,22 +56,31 @@ async function request(method, path, { body, absentIsAnswer } = {}) {
32
56
  ...body === void 0 ? {} : { body: JSON.stringify(body) }
33
57
  });
34
58
  if (res.status === 404 && absentIsAnswer) return null;
35
- if (!res.ok) throw new Error(describeFailure(method, path, res.status, res.statusText));
59
+ if (!res.ok) throw new EvrexApiError(describeFailure(method, path, res.status, res.statusText), res.status);
36
60
  return await res.json();
37
61
  }
38
- var DEFAULT_API_BASE_URL, API_BASE_URL, EVREX_TOKEN, get, getOrNull, post, evrexApi;
62
+ var DEFAULT_API_BASE_URL, API_BASE_URL, EVREX_TOKEN, EvrexApiError, get, getOrNull, post, evrexApi;
39
63
  var init_client = __esm({
40
64
  "src/client.ts"() {
41
65
  "use strict";
66
+ init_request_context();
42
67
  DEFAULT_API_BASE_URL = "https://api.evrex.ai";
43
68
  API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
44
69
  EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
70
+ EvrexApiError = class extends Error {
71
+ constructor(message, status) {
72
+ super(message);
73
+ this.status = status;
74
+ this.name = "EvrexApiError";
75
+ }
76
+ };
45
77
  get = (path) => request("GET", path);
46
78
  getOrNull = (path) => request("GET", path, { absentIsAnswer: true });
47
79
  post = (path, body) => request("POST", path, { body });
48
80
  evrexApi = {
49
81
  baseUrl: API_BASE_URL,
50
82
  repos: () => get("/repos"),
83
+ me: () => get("/auth/me"),
51
84
  commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
52
85
  // Abbreviated shas resolve server-side, so a value pasted from `git log`
53
86
  // works here (apps/backend/src/reads/reads.service.ts#resolveSha).
@@ -75,425 +108,145 @@ var init_client = __esm({
75
108
  }
76
109
  });
77
110
 
78
- // src/credential-store.ts
79
- var credential_store_exports = {};
80
- __export(credential_store_exports, {
81
- CredentialStore: () => CredentialStore,
82
- NoKeychainError: () => NoKeychainError,
83
- systemRunner: () => systemRunner
111
+ // ../../packages/llm-core/src/types.ts
112
+ var init_types = __esm({
113
+ "../../packages/llm-core/src/types.ts"() {
114
+ "use strict";
115
+ }
84
116
  });
85
- import { spawn } from "node:child_process";
86
- var SERVICE, ACCOUNT, systemRunner, NoKeychainError, CredentialStore, WINDOWS_PATH, WINDOWS_STORE, WINDOWS_RETRIEVE, WINDOWS_REMOVE;
87
- var init_credential_store = __esm({
88
- "src/credential-store.ts"() {
117
+
118
+ // ../../packages/llm-core/src/client.ts
119
+ import Anthropic from "@anthropic-ai/sdk";
120
+ var DEFAULT_MAX_TOKENS;
121
+ var init_client2 = __esm({
122
+ "../../packages/llm-core/src/client.ts"() {
89
123
  "use strict";
90
- SERVICE = "evrex-capture";
91
- ACCOUNT = "evrex";
92
- systemRunner = {
93
- platform: process.platform,
94
- run(command, args, stdin) {
95
- return new Promise((resolve) => {
96
- const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
97
- let stdout = "";
98
- let stderr = "";
99
- child.stdout.on("data", (d) => stdout += d.toString());
100
- child.stderr.on("data", (d) => stderr += d.toString());
101
- child.on("error", () => resolve({ code: 127, stdout: "", stderr: "" }));
102
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
103
- if (stdin !== void 0) child.stdin.write(stdin);
104
- child.stdin.end();
105
- });
106
- }
107
- };
108
- NoKeychainError = class extends Error {
109
- constructor(platform) {
110
- super(
111
- `evrex could not find a credential store on this machine (${platform}).
112
- macOS needs \`security\`, which ships with the system.
113
- Linux needs \`secret-tool\` \u2014 install libsecret-tools (Debian/Ubuntu)
114
- or libsecret (Fedora/Arch), and make sure a keyring daemon is running.
115
- Windows needs PowerShell.
116
- evrex will not fall back to writing the credential in a plain file.`
117
- );
118
- this.name = "NoKeychainError";
119
- }
124
+ DEFAULT_MAX_TOKENS = 16e3;
125
+ }
126
+ });
127
+
128
+ // ../../packages/llm-core/src/complete.ts
129
+ import Anthropic2 from "@anthropic-ai/sdk";
130
+ var init_complete = __esm({
131
+ "../../packages/llm-core/src/complete.ts"() {
132
+ "use strict";
133
+ init_client2();
134
+ }
135
+ });
136
+
137
+ // ../../packages/llm-core/src/providers.ts
138
+ function providerForBaseUrl(baseUrl) {
139
+ const url = (baseUrl ?? "").trim();
140
+ if (!url) return null;
141
+ return /^https?:\/\//i.test(url) ? "openai-compatible" : null;
142
+ }
143
+ function detectProvider(key) {
144
+ const k = (key ?? "").trim();
145
+ if (!k) return null;
146
+ if (k.startsWith("sk-ant-")) return "anthropic";
147
+ if (k.startsWith("AIza") || k.startsWith("AQ.")) return "google";
148
+ if (k.startsWith("gsk_")) return "groq";
149
+ if (k.startsWith("csk-")) return "cerebras";
150
+ if (k.startsWith("sk-or-")) return "openrouter";
151
+ if (k.startsWith("sk-")) return "openai";
152
+ if (/^https?:\/\//i.test(k)) return "local";
153
+ if (/^(ollama|local)$/i.test(k)) return "local";
154
+ if (/^claude-?cli$/i.test(k)) return "claude-cli";
155
+ if (/^codex-?cli$/i.test(k)) return "codex-cli";
156
+ return null;
157
+ }
158
+ function localBaseUrl(key) {
159
+ const k = key.trim();
160
+ return /^https?:\/\//i.test(k) ? k.replace(/\/+$/, "") : DEFAULT_LOCAL_BASE_URL;
161
+ }
162
+ var OPENAI_COMPATIBLE_BASE_URLS, DEFAULT_MODELS, DEFAULT_LOCAL_BASE_URL;
163
+ var init_providers = __esm({
164
+ "../../packages/llm-core/src/providers.ts"() {
165
+ "use strict";
166
+ OPENAI_COMPATIBLE_BASE_URLS = {
167
+ openai: "https://api.openai.com/v1",
168
+ groq: "https://api.groq.com/openai/v1",
169
+ cerebras: "https://api.cerebras.ai/v1",
170
+ openrouter: "https://openrouter.ai/api/v1"
120
171
  };
121
- CredentialStore = class {
122
- constructor(runner = systemRunner) {
123
- this.runner = runner;
124
- }
125
- async available() {
126
- switch (this.runner.platform) {
127
- case "darwin":
128
- return (await this.runner.run("security", ["help"])).code !== 127;
129
- case "win32":
130
- return (await this.runner.run("powershell", ["-Command", "$PSVersionTable.PSVersion.Major"])).code !== 127;
131
- default:
132
- return (await this.runner.run("secret-tool", ["--version"])).code !== 127;
133
- }
134
- }
135
- /**
136
- * Replaces any existing credential rather than adding a second one. A
137
- * machine that re-enrols after expiry must end up with exactly one entry, or
138
- * the next read is a coin flip between the live credential and a dead one.
139
- */
140
- async store(secret) {
141
- if (!await this.available()) throw new NoKeychainError(this.runner.platform);
142
- switch (this.runner.platform) {
143
- case "darwin": {
144
- const result = await this.runner.run(
145
- "security",
146
- ["add-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-U", "-w"],
147
- `${secret}
148
- ${secret}
149
- `
150
- );
151
- if (result.code !== 0) throw new Error(`Keychain write failed: ${result.stderr.trim()}`);
152
- return;
153
- }
154
- case "win32": {
155
- const result = await this.runner.run(
156
- "powershell",
157
- ["-NoProfile", "-Command", WINDOWS_STORE],
158
- secret
159
- );
160
- if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
161
- return;
162
- }
163
- default: {
164
- const result = await this.runner.run(
165
- "secret-tool",
166
- ["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
167
- secret
168
- );
169
- if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
170
- return;
171
- }
172
- }
173
- }
174
- /** Null when there is nothing stored, which is the normal pre-enrolment state. */
175
- async retrieve() {
176
- if (!await this.available()) throw new NoKeychainError(this.runner.platform);
177
- switch (this.runner.platform) {
178
- case "darwin": {
179
- const r = await this.runner.run("security", [
180
- "find-generic-password",
181
- "-a",
182
- ACCOUNT,
183
- "-s",
184
- SERVICE,
185
- "-w"
186
- ]);
187
- return r.code === 0 ? r.stdout.trim() || null : null;
188
- }
189
- case "win32": {
190
- const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
191
- return r.code === 0 ? r.stdout.trim() || null : null;
192
- }
193
- default: {
194
- const r = await this.runner.run("secret-tool", [
195
- "lookup",
196
- "service",
197
- SERVICE,
198
- "account",
199
- ACCOUNT
200
- ]);
201
- return r.code === 0 ? r.stdout.trim() || null : null;
202
- }
203
- }
204
- }
205
- /** Idempotent: removing a credential that is not there is not an error. */
206
- async remove() {
207
- if (!await this.available()) return;
208
- switch (this.runner.platform) {
209
- case "darwin":
210
- await this.runner.run("security", [
211
- "delete-generic-password",
212
- "-a",
213
- ACCOUNT,
214
- "-s",
215
- SERVICE
216
- ]);
217
- return;
218
- case "win32":
219
- await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
220
- return;
221
- default:
222
- await this.runner.run("secret-tool", [
223
- "clear",
224
- "service",
225
- SERVICE,
226
- "account",
227
- ACCOUNT
228
- ]);
229
- return;
230
- }
231
- }
172
+ DEFAULT_MODELS = {
173
+ anthropic: "claude-haiku-4-5",
174
+ openai: "gpt-4o",
175
+ // gemini-2.0-flash is retired: Google answers a request for it with
176
+ // "no longer available to new users. Please update your code to use
177
+ // models/gemini-3.6-flash". Second time a stale default has done this —
178
+ // Groq's llama-3.3-70b-versatile went the same way — and the failure looks
179
+ // identical to having no key at all, so it is worth checking against the
180
+ // provider's own model list when insights stop appearing.
181
+ google: "gemini-3.6-flash",
182
+ // Free tiers, chosen for extraction: a capable model behind a rate limit
183
+ // beats a weak one behind none, because indexing is a background batch and
184
+ // can wait out a limit but cannot recover a missed rejected approach.
185
+ // The smaller of the two open models, and the better one here: measured on
186
+ // ten real sessions it beat gpt-oss-120b on every category and ran faster.
187
+ // Groq's catalogue also turns over — llama-3.3-70b-versatile was the obvious
188
+ // default and 404s now — and a wrong name is a 404 that degrades to no
189
+ // insights, so `GET /models` is the check when this breaks.
190
+ groq: "openai/gpt-oss-20b",
191
+ cerebras: "llama-3.3-70b",
192
+ openrouter: "meta-llama/llama-3.3-70b-instruct:free",
193
+ // Cheapest credible host of this model measured at the time of writing
194
+ // ($0.03/$0.14 per million against Cloudflare's $0.20/$0.30), and the model
195
+ // that scored best on decisions and constraints in
196
+ // apps/backend/scripts/eval-extraction.mjs.
197
+ "openai-compatible": "openai/gpt-oss-20b",
198
+ // Small on purpose. Every invocation re-sends the CLI's own system prompt
199
+ // and is billed to the operator, so the default is the cheapest model that
200
+ // can read a transcript rather than the CLI's own default.
201
+ "claude-cli": "haiku",
202
+ "codex-cli": "",
203
+ // Measured the best of the models that fit on a laptop — see
204
+ // apps/backend/scripts/eval-extraction.mjs. Overridden with
205
+ // EVREX_LOCAL_MODEL for whatever is already pulled.
206
+ local: "qwen3:8b"
232
207
  };
233
- WINDOWS_PATH = "$env:APPDATA\\evrex\\capture.cred";
234
- WINDOWS_STORE = `
235
- $ErrorActionPreference = 'Stop'
236
- $p = "${WINDOWS_PATH}"
237
- New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
238
- $secret = [Console]::In.ReadToEnd().Trim()
239
- ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
240
- `.trim();
241
- WINDOWS_RETRIEVE = `
242
- $ErrorActionPreference = 'Stop'
243
- $p = "${WINDOWS_PATH}"
244
- if (-not (Test-Path $p)) { exit 1 }
245
- $sec = Get-Content $p | ConvertTo-SecureString
246
- [Runtime.InteropServices.Marshal]::PtrToStringAuto(
247
- [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
248
- `.trim();
249
- WINDOWS_REMOVE = `
250
- $p = "${WINDOWS_PATH}"
251
- if (Test-Path $p) { Remove-Item $p -Force }
252
- `.trim();
208
+ DEFAULT_LOCAL_BASE_URL = "http://localhost:11434/v1";
253
209
  }
254
210
  });
255
211
 
256
- // src/enrol.ts
257
- var enrol_exports = {};
258
- __export(enrol_exports, {
259
- RENEW_WITHIN_MS: () => RENEW_WITHIN_MS,
260
- enrol: () => enrol,
261
- needsRenewal: () => needsRenewal
212
+ // ../../packages/llm-core/src/extraction.ts
213
+ var MAX_ITEMS_PER_CATEGORY, EXTRACTION_SYSTEM_PROMPT;
214
+ var init_extraction = __esm({
215
+ "../../packages/llm-core/src/extraction.ts"() {
216
+ "use strict";
217
+ init_providers();
218
+ MAX_ITEMS_PER_CATEGORY = 6;
219
+ EXTRACTION_SYSTEM_PROMPT = [
220
+ "You extract structured reasoning from a real coding-agent session transcript for Evrex, a tool that recovers WHY code changed, not just what changed.",
221
+ `Each line is labeled "user" (the human engineer actually typed this), "assistant" (the agent), or "tool_result" (raw output from a tool call, e.g. command stdout \u2014 NOT something either party said; never attribute a "tool_result" line's content to "engineer" as raisedBy/source).`,
222
+ 'Only tool calls that FAILED are included, truncated. A "tool_result" line is evidence that the approach the assistant was taking at that point was tried and did not work: read it together with the assistant lines around it, and where the failure led to a change of approach, record the abandoned one as a rejected approach with the failure as its reason. A trivial failure \u2014 a typo in a path, a command re-run successfully a line later \u2014 is not a rejected approach.',
223
+ "Only extract items explicitly present in the transcript below. Never invent, infer beyond the text, or pad categories with generic filler.",
224
+ "If a category has nothing genuinely present, return an empty array for it \u2014 an empty result is correct and expected, not a failure.",
225
+ `Cap each array at ${MAX_ITEMS_PER_CATEGORY} items \u2014 pick the most consequential ones.`,
226
+ '"atMessage" is the [N] index of the transcript line the item came from.',
227
+ 'For a rejected approach, "reason" is why it was turned down and "tradeoff" is what was given up by not taking it. Fill "tradeoff" from the transcript whenever the cost is stated or clearly implied; leave it empty only when the transcript genuinely says nothing about it.',
228
+ 'A "procedure" is a reusable, multi-step way of doing something in this repository that the transcript shows actually working \u2014 adding and registering a migration, cutting a release, installing a hook, running a particular check \u2014 written as the concrete steps somebody would follow next time, each step one line with the real command or file where the transcript has it. Only when the steps are visible in the transcript and were carried out, never a plan that was proposed and not done; a one-off fix is not a procedure.'
229
+ ].join(" ");
230
+ }
262
231
  });
263
- async function keychainReady(deps) {
264
- try {
265
- return await deps.store.available();
266
- } catch {
232
+
233
+ // ../../packages/llm-core/src/synthesis.ts
234
+ function buildSynthesisPrompt(question, evidence) {
235
+ const list = evidence.map(
236
+ (e, i) => `[${i}] (${e.kind}${e.at ? `, ${e.at.slice(0, 10)}` : ""}, confidence ${(e.confidence ?? 1).toFixed(2)}): ${e.excerpt}`
237
+ ).join("\n");
238
+ return `Question: ${question}
239
+
240
+ Evidence:
241
+ ${list}`;
242
+ }
243
+ function isValidSynthesis(result, evidenceCount) {
244
+ if (result.noRecordedReasoning === true) return true;
245
+ if (!Array.isArray(result.sentences) || !Array.isArray(result.sentenceEvidence)) {
267
246
  return false;
268
247
  }
269
- }
270
- async function enrol(deps) {
271
- if (!await keychainReady(deps)) {
272
- deps.log(new NoKeychainError(process.platform).message);
273
- return { ok: false, reason: "no_keychain" };
274
- }
275
- let started;
276
- try {
277
- const res = await deps.fetch(`${deps.baseUrl}/auth/device/start`, {
278
- method: "POST",
279
- headers: { "content-type": "application/json" },
280
- body: JSON.stringify({
281
- deviceName: deps.hostname(),
282
- agentKind: "claude-code",
283
- clientVersion: deps.clientVersion
284
- })
285
- });
286
- if (!res.ok) throw new Error(String(res.status));
287
- started = await res.json();
288
- } catch {
289
- deps.log(`Could not reach evrex at ${deps.baseUrl}.`);
290
- return { ok: false, reason: "unreachable" };
291
- }
292
- deps.log("");
293
- deps.log(` Enrolling ${deps.hostname()}.`);
294
- deps.log("");
295
- deps.log(` 1. Open ${deps.dashboardUrl}/devices`);
296
- deps.log(` 2. Enter this code: ${started.userCode}`);
297
- deps.log("");
298
- deps.log(" Waiting for approval. Ctrl-C to stop.");
299
- let interval = started.intervalMs;
300
- for (; ; ) {
301
- await deps.sleep(interval);
302
- let outcome;
303
- try {
304
- const res = await deps.fetch(`${deps.baseUrl}/auth/device/poll`, {
305
- method: "POST",
306
- headers: { "content-type": "application/json" },
307
- body: JSON.stringify({ deviceCode: started.deviceCode })
308
- });
309
- outcome = await res.json();
310
- } catch {
311
- continue;
312
- }
313
- switch (outcome.status) {
314
- case "pending":
315
- continue;
316
- case "slow_down":
317
- interval = Math.max(interval, outcome.retryAfterMs);
318
- continue;
319
- case "denied":
320
- deps.log(" Enrolment was declined.");
321
- return { ok: false, reason: "denied" };
322
- case "expired":
323
- deps.log(" That code expired. Run enrolment again for a new one.");
324
- return { ok: false, reason: "expired" };
325
- case "granted": {
326
- await deps.store.store(outcome.token);
327
- deps.log("");
328
- deps.log(` ${deps.hostname()} is enrolled.`);
329
- if (outcome.expiresAt) {
330
- deps.log(` The credential renews automatically before ${outcome.expiresAt.slice(0, 10)}.`);
331
- }
332
- return { ok: true, expiresAt: outcome.expiresAt };
333
- }
334
- }
335
- }
336
- }
337
- function needsRenewal(expiresAt, now) {
338
- if (!expiresAt) return false;
339
- const expiry = new Date(expiresAt).getTime();
340
- if (Number.isNaN(expiry)) return false;
341
- return expiry - now <= RENEW_WITHIN_MS;
342
- }
343
- var RENEW_WITHIN_MS;
344
- var init_enrol = __esm({
345
- "src/enrol.ts"() {
346
- "use strict";
347
- init_credential_store();
348
- RENEW_WITHIN_MS = 7 * 24 * 60 * 60 * 1e3;
349
- }
350
- });
351
-
352
- // src/index.ts
353
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
354
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
355
- import { z } from "zod";
356
-
357
- // src/subcommands.ts
358
- var SUBCOMMANDS = {
359
- "evrex-hook": "hook.js",
360
- "evrex-capture": "capture.js",
361
- "evrex-import": "import.js",
362
- "evrex-tickets": "tickets.js",
363
- "evrex-pretool": "pretool.js",
364
- "evrex-continuity": "continuity.js",
365
- "evrex-account": "account.js"
366
- };
367
-
368
- // src/tools.ts
369
- init_client();
370
- import { execFileSync as execFileSync2 } from "node:child_process";
371
- import { realpathSync } from "node:fs";
372
- import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
373
-
374
- // ../../packages/llm-core/src/client.ts
375
- import Anthropic from "@anthropic-ai/sdk";
376
- var DEFAULT_MAX_TOKENS = 16e3;
377
-
378
- // ../../packages/llm-core/src/complete.ts
379
- import Anthropic2 from "@anthropic-ai/sdk";
380
-
381
- // ../../packages/llm-core/src/providers.ts
382
- var OPENAI_COMPATIBLE_BASE_URLS = {
383
- openai: "https://api.openai.com/v1",
384
- groq: "https://api.groq.com/openai/v1",
385
- cerebras: "https://api.cerebras.ai/v1",
386
- openrouter: "https://openrouter.ai/api/v1"
387
- };
388
- var DEFAULT_MODELS = {
389
- anthropic: "claude-haiku-4-5",
390
- openai: "gpt-4o",
391
- // gemini-2.0-flash is retired: Google answers a request for it with
392
- // "no longer available to new users. Please update your code to use
393
- // models/gemini-3.6-flash". Second time a stale default has done this —
394
- // Groq's llama-3.3-70b-versatile went the same way — and the failure looks
395
- // identical to having no key at all, so it is worth checking against the
396
- // provider's own model list when insights stop appearing.
397
- google: "gemini-3.6-flash",
398
- // Free tiers, chosen for extraction: a capable model behind a rate limit
399
- // beats a weak one behind none, because indexing is a background batch and
400
- // can wait out a limit but cannot recover a missed rejected approach.
401
- // The smaller of the two open models, and the better one here: measured on
402
- // ten real sessions it beat gpt-oss-120b on every category and ran faster.
403
- // Groq's catalogue also turns over — llama-3.3-70b-versatile was the obvious
404
- // default and 404s now — and a wrong name is a 404 that degrades to no
405
- // insights, so `GET /models` is the check when this breaks.
406
- groq: "openai/gpt-oss-20b",
407
- cerebras: "llama-3.3-70b",
408
- openrouter: "meta-llama/llama-3.3-70b-instruct:free",
409
- // Cheapest credible host of this model measured at the time of writing
410
- // ($0.03/$0.14 per million against Cloudflare's $0.20/$0.30), and the model
411
- // that scored best on decisions and constraints in
412
- // apps/backend/scripts/eval-extraction.mjs.
413
- "openai-compatible": "openai/gpt-oss-20b",
414
- // Small on purpose. Every invocation re-sends the CLI's own system prompt
415
- // and is billed to the operator, so the default is the cheapest model that
416
- // can read a transcript rather than the CLI's own default.
417
- "claude-cli": "haiku",
418
- "codex-cli": "",
419
- // Measured the best of the models that fit on a laptop — see
420
- // apps/backend/scripts/eval-extraction.mjs. Overridden with
421
- // EVREX_LOCAL_MODEL for whatever is already pulled.
422
- local: "qwen3:8b"
423
- };
424
- var DEFAULT_LOCAL_BASE_URL = "http://localhost:11434/v1";
425
- function providerForBaseUrl(baseUrl) {
426
- const url = (baseUrl ?? "").trim();
427
- if (!url) return null;
428
- return /^https?:\/\//i.test(url) ? "openai-compatible" : null;
429
- }
430
- function detectProvider(key) {
431
- const k = (key ?? "").trim();
432
- if (!k) return null;
433
- if (k.startsWith("sk-ant-")) return "anthropic";
434
- if (k.startsWith("AIza") || k.startsWith("AQ.")) return "google";
435
- if (k.startsWith("gsk_")) return "groq";
436
- if (k.startsWith("csk-")) return "cerebras";
437
- if (k.startsWith("sk-or-")) return "openrouter";
438
- if (k.startsWith("sk-")) return "openai";
439
- if (/^https?:\/\//i.test(k)) return "local";
440
- if (/^(ollama|local)$/i.test(k)) return "local";
441
- if (/^claude-?cli$/i.test(k)) return "claude-cli";
442
- if (/^codex-?cli$/i.test(k)) return "codex-cli";
443
- return null;
444
- }
445
- function localBaseUrl(key) {
446
- const k = key.trim();
447
- return /^https?:\/\//i.test(k) ? k.replace(/\/+$/, "") : DEFAULT_LOCAL_BASE_URL;
448
- }
449
-
450
- // ../../packages/llm-core/src/extraction.ts
451
- var MAX_ITEMS_PER_CATEGORY = 6;
452
- var EXTRACTION_SYSTEM_PROMPT = [
453
- "You extract structured reasoning from a real coding-agent session transcript for Evrex, a tool that recovers WHY code changed, not just what changed.",
454
- `Each line is labeled "user" (the human engineer actually typed this), "assistant" (the agent), or "tool_result" (raw output from a tool call, e.g. command stdout \u2014 NOT something either party said; never attribute a "tool_result" line's content to "engineer" as raisedBy/source).`,
455
- 'Only tool calls that FAILED are included, truncated. A "tool_result" line is evidence that the approach the assistant was taking at that point was tried and did not work: read it together with the assistant lines around it, and where the failure led to a change of approach, record the abandoned one as a rejected approach with the failure as its reason. A trivial failure \u2014 a typo in a path, a command re-run successfully a line later \u2014 is not a rejected approach.',
456
- "Only extract items explicitly present in the transcript below. Never invent, infer beyond the text, or pad categories with generic filler.",
457
- "If a category has nothing genuinely present, return an empty array for it \u2014 an empty result is correct and expected, not a failure.",
458
- `Cap each array at ${MAX_ITEMS_PER_CATEGORY} items \u2014 pick the most consequential ones.`,
459
- '"atMessage" is the [N] index of the transcript line the item came from.',
460
- 'For a rejected approach, "reason" is why it was turned down and "tradeoff" is what was given up by not taking it. Fill "tradeoff" from the transcript whenever the cost is stated or clearly implied; leave it empty only when the transcript genuinely says nothing about it.',
461
- 'A "procedure" is a reusable, multi-step way of doing something in this repository that the transcript shows actually working \u2014 adding and registering a migration, cutting a release, installing a hook, running a particular check \u2014 written as the concrete steps somebody would follow next time, each step one line with the real command or file where the transcript has it. Only when the steps are visible in the transcript and were carried out, never a plan that was proposed and not done; a one-off fix is not a procedure.'
462
- ].join(" ");
463
-
464
- // ../../packages/llm-core/src/synthesis.ts
465
- var MIN_SENTENCES = 1;
466
- var MAX_SENTENCES = 4;
467
- var SYNTHESIS_SYSTEM_PROMPT = `You answer questions about why code changed, using ONLY the evidence provided below. The evidence spans the project's whole history and the system it describes keeps changing: when items disagree, the most recently dated evidence describes the CURRENT state \u2014 answer as of the newest evidence, frame older accounts as how it used to work, and never present a superseded design as current. Never state a claim that is not directly supported by the evidence text. Respond with a JSON object: {"sentences": string[], "sentenceEvidence": number[][], "noRecordedReasoning": boolean}. Write 2-4 sentences when the evidence supports an answer; each sentence's entry in "sentenceEvidence" lists only the evidence indices (0-based) that directly support it. If the evidence is too thin or unrelated to actually answer the question, set "noRecordedReasoning": true and return empty arrays for "sentences" and "sentenceEvidence" \u2014 do not speculate or hedge.`;
468
- var SYNTHESIS_SCHEMA = {
469
- type: "object",
470
- additionalProperties: false,
471
- required: ["sentences", "sentenceEvidence", "noRecordedReasoning"],
472
- properties: {
473
- sentences: { type: "array", items: { type: "string" } },
474
- sentenceEvidence: {
475
- type: "array",
476
- items: { type: "array", items: { type: "integer" } }
477
- },
478
- noRecordedReasoning: { type: "boolean" }
479
- }
480
- };
481
- function buildSynthesisPrompt(question, evidence) {
482
- const list = evidence.map(
483
- (e, i) => `[${i}] (${e.kind}${e.at ? `, ${e.at.slice(0, 10)}` : ""}, confidence ${(e.confidence ?? 1).toFixed(2)}): ${e.excerpt}`
484
- ).join("\n");
485
- return `Question: ${question}
486
-
487
- Evidence:
488
- ${list}`;
489
- }
490
- function isValidSynthesis(result, evidenceCount) {
491
- if (result.noRecordedReasoning === true) return true;
492
- if (!Array.isArray(result.sentences) || !Array.isArray(result.sentenceEvidence)) {
493
- return false;
494
- }
495
- if (result.sentences.length < MIN_SENTENCES || result.sentences.length > MAX_SENTENCES) {
496
- return false;
248
+ if (result.sentences.length < MIN_SENTENCES || result.sentences.length > MAX_SENTENCES) {
249
+ return false;
497
250
  }
498
251
  if (result.sentences.length !== result.sentenceEvidence.length) return false;
499
252
  return result.sentenceEvidence.every(
@@ -535,20 +288,46 @@ async function synthesizeAnswer(question, evidence, client2, options = {}) {
535
288
  noRecordedReasoning: Boolean(raw.noRecordedReasoning)
536
289
  };
537
290
  }
291
+ var MIN_SENTENCES, MAX_SENTENCES, SYNTHESIS_SYSTEM_PROMPT, SYNTHESIS_SCHEMA;
292
+ var init_synthesis = __esm({
293
+ "../../packages/llm-core/src/synthesis.ts"() {
294
+ "use strict";
295
+ MIN_SENTENCES = 1;
296
+ MAX_SENTENCES = 4;
297
+ SYNTHESIS_SYSTEM_PROMPT = `You answer questions about why code changed, using ONLY the evidence provided below. The evidence spans the project's whole history and the system it describes keeps changing: when items disagree, the most recently dated evidence describes the CURRENT state \u2014 answer as of the newest evidence, frame older accounts as how it used to work, and never present a superseded design as current. Never state a claim that is not directly supported by the evidence text. Respond with a JSON object: {"sentences": string[], "sentenceEvidence": number[][], "noRecordedReasoning": boolean}. Write 2-4 sentences when the evidence supports an answer; each sentence's entry in "sentenceEvidence" lists only the evidence indices (0-based) that directly support it. If the evidence is too thin or unrelated to actually answer the question, set "noRecordedReasoning": true and return empty arrays for "sentences" and "sentenceEvidence" \u2014 do not speculate or hedge.`;
298
+ SYNTHESIS_SCHEMA = {
299
+ type: "object",
300
+ additionalProperties: false,
301
+ required: ["sentences", "sentenceEvidence", "noRecordedReasoning"],
302
+ properties: {
303
+ sentences: { type: "array", items: { type: "string" } },
304
+ sentenceEvidence: {
305
+ type: "array",
306
+ items: { type: "array", items: { type: "integer" } }
307
+ },
308
+ noRecordedReasoning: { type: "boolean" }
309
+ }
310
+ };
311
+ }
312
+ });
538
313
 
539
314
  // ../../packages/llm-core/src/retrieval-boost.ts
540
- var CORPUS = `The corpus is an engineering-memory index built from a team's coding-agent conversations and git history, retrieved with Postgres full-text OR-matching plus BM25 (exact word stems match; meanings do not). Searchable documents:
315
+ var CORPUS, EXPAND_SYSTEM, RERANK_SYSTEM;
316
+ var init_retrieval_boost = __esm({
317
+ "../../packages/llm-core/src/retrieval-boost.ts"() {
318
+ "use strict";
319
+ CORPUS = `The corpus is an engineering-memory index built from a team's coding-agent conversations and git history, retrieved with Postgres full-text OR-matching plus BM25 (exact word stems match; meanings do not). Searchable documents:
541
320
  - Conversation turns: prose by the person and the agent, plus tool-call lines like "[tool_call: Edit] apps/backend/src/query/query.service.ts" \u2014 so file paths, function names, error strings and identifiers appear verbatim.
542
321
  - Extracted insights: short titled records of decisions made, approaches REJECTED (with the reason), constraints, and procedures.
543
322
  - Commit messages: conventional-commit subjects and explanatory bodies.`;
544
- var EXPAND_SYSTEM = `You expand a search query into terms that will match an engineering record.
323
+ EXPAND_SYSTEM = `You expand a search query into terms that will match an engineering record.
545
324
 
546
325
  ${CORPUS}
547
326
 
548
327
  Given a person's question, produce the search terms most likely to appear VERBATIM in that corpus but absent from the question itself: the identifiers an engineer would have typed (function, constant, env var, file names), the error text the failure would have printed, and the words the same idea takes in commit-message register ("fix the flaky test" \u2192 "flaky", "deflake", "retry", "timeout"). Prefer specific over general \u2014 "RLS" and "row level security" over "database permissions". Never invent identifiers you are not confident exist in this kind of codebase; a wrong specific term matches nothing and costs nothing, but fill the list with your best evidence-seeking guesses.
549
328
 
550
329
  Respond with JSON only: {"terms": ["...", ...]} \u2014 3 to 8 terms, each 1 to 4 words, no duplicates of the question's own words.`;
551
- var RERANK_SYSTEM = `You order search results for an engineer's question about their own project's history.
330
+ RERANK_SYSTEM = `You order search results for an engineer's question about their own project's history.
552
331
 
553
332
  ${CORPUS}
554
333
 
@@ -558,131 +337,135 @@ You are given the question and a numbered list of candidate results that lexical
558
337
  - Match recency to the question's intent: for "why does X exist / why was this decided", the origin of the idea is the answer; for "how does X work / what is the current pipeline", the system has usually changed since its founding \u2014 prefer the most recent account of the mechanism and treat older ones as history. Among near-duplicates prefer the more complete one.
559
338
 
560
339
  Respond with JSON only: {"ranked": ["id", ...]} \u2014 every listed id at most once, ids from the list only, best first. Include every id you consider relevant; omit only what would waste the reader's attention.`;
340
+ }
341
+ });
561
342
 
562
343
  // ../../packages/llm-core/src/cli-client.ts
563
344
  import { execFile } from "node:child_process";
564
- var TIMEOUT_MS = 12e4;
565
- var CLAUDE = {
566
- provider: "claude-cli",
567
- command: "claude",
568
- args: (model) => [
569
- "-p",
570
- // `--bare` never reads OAuth credentials or the system keychain, so this
571
- // cannot run on a Claude.ai subscription — it requires ANTHROPIC_API_KEY.
572
- //
573
- // That is deliberate and it is the whole compliance question. Anthropic's
574
- // consumer terms prohibit accessing the service "through automated or
575
- // non-human means, whether through a bot, script, or otherwise" except
576
- // via an API key, and evrex spawning the CLI is a script doing exactly
577
- // that. Anthropic's own headless documentation points the same way:
578
- // `--bare` is "the recommended mode for scripted and SDK calls" and "will
579
- // become the default for `-p` in a future release".
580
- //
581
- // It costs this provider its reason to exist — with an API key in the
582
- // environment there is little point going through the CLI at all — which
583
- // is the honest answer to "can we spend a subscription instead of a
584
- // budget": no, and evrex had already recorded that conclusion once.
585
- "--bare",
586
- "--output-format",
587
- "json",
588
- // Extraction is a reading task; the CLI's default is far larger than it
589
- // needs and is billed to the operator.
590
- ...model ? ["--model", model] : ["--model", "haiku"]
591
- ],
592
- parse: (stdout) => {
593
- try {
594
- const envelope = JSON.parse(stdout);
595
- if (envelope.is_error) return null;
596
- return typeof envelope.result === "string" ? envelope.result : null;
597
- } catch {
598
- return null;
599
- }
600
- }
601
- };
602
- var CODEX = {
603
- provider: "codex-cli",
604
- command: "codex",
605
- // `exec` is Codex's non-interactive mode. It prints the answer plainly, so
606
- // there is no envelope to unwrap.
607
- args: (model) => ["exec", ...model ? ["--model", model] : []],
608
- parse: (stdout) => stdout.trim() || null,
609
- requiresEnv: "OPENAI_API_KEY"
610
- };
611
- var CLI_SPECS = {
612
- "claude-cli": CLAUDE,
613
- "codex-cli": CODEX
614
- };
615
345
  function unfence(text) {
616
346
  const fenced = /```(?:json)?\s*([\s\S]*?)\s*```/.exec(text);
617
347
  return (fenced ? fenced[1] : text).trim();
618
348
  }
619
- var CliClient = class {
620
- constructor(spec, model, logger, run = execFile) {
621
- this.spec = spec;
622
- this.model = model;
623
- this.logger = logger;
624
- this.run = run;
625
- this.provider = spec.provider;
626
- }
627
- /**
628
- * Never set. A CLI bills the operator's existing subscription rather than a
629
- * metered key, so there is no quota to spend that handing over to evrex's
630
- * own would relieve — and a CLI that refuses is a configuration problem the
631
- * operator has to fix, not one another provider can absorb.
632
- */
633
- exhausted = false;
634
- provider;
635
- async completeJson(request2) {
636
- const required = this.spec.requiresEnv;
637
- if (required && !process.env[required]) {
638
- this.logger.warn(
639
- `${this.spec.command} needs ${required} set: evrex will not drive a CLI that is signed in with a consumer subscription, which the provider's terms do not allow.`
640
- );
641
- return null;
642
- }
643
- const shape = request2.schema ? `
349
+ var TIMEOUT_MS, CLAUDE, CODEX, CLI_SPECS, CliClient;
350
+ var init_cli_client = __esm({
351
+ "../../packages/llm-core/src/cli-client.ts"() {
352
+ "use strict";
353
+ TIMEOUT_MS = 12e4;
354
+ CLAUDE = {
355
+ provider: "claude-cli",
356
+ command: "claude",
357
+ args: (model) => [
358
+ "-p",
359
+ // `--bare` never reads OAuth credentials or the system keychain, so this
360
+ // cannot run on a Claude.ai subscription — it requires ANTHROPIC_API_KEY.
361
+ //
362
+ // That is deliberate and it is the whole compliance question. Anthropic's
363
+ // consumer terms prohibit accessing the service "through automated or
364
+ // non-human means, whether through a bot, script, or otherwise" except
365
+ // via an API key, and evrex spawning the CLI is a script doing exactly
366
+ // that. Anthropic's own headless documentation points the same way:
367
+ // `--bare` is "the recommended mode for scripted and SDK calls" and "will
368
+ // become the default for `-p` in a future release".
369
+ //
370
+ // It costs this provider its reason to exist — with an API key in the
371
+ // environment there is little point going through the CLI at all — which
372
+ // is the honest answer to "can we spend a subscription instead of a
373
+ // budget": no, and evrex had already recorded that conclusion once.
374
+ "--bare",
375
+ "--output-format",
376
+ "json",
377
+ // Extraction is a reading task; the CLI's default is far larger than it
378
+ // needs and is billed to the operator.
379
+ ...model ? ["--model", model] : ["--model", "haiku"]
380
+ ],
381
+ parse: (stdout) => {
382
+ try {
383
+ const envelope = JSON.parse(stdout);
384
+ if (envelope.is_error) return null;
385
+ return typeof envelope.result === "string" ? envelope.result : null;
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+ };
391
+ CODEX = {
392
+ provider: "codex-cli",
393
+ command: "codex",
394
+ // `exec` is Codex's non-interactive mode. It prints the answer plainly, so
395
+ // there is no envelope to unwrap.
396
+ args: (model) => ["exec", ...model ? ["--model", model] : []],
397
+ parse: (stdout) => stdout.trim() || null,
398
+ requiresEnv: "OPENAI_API_KEY"
399
+ };
400
+ CLI_SPECS = {
401
+ "claude-cli": CLAUDE,
402
+ "codex-cli": CODEX
403
+ };
404
+ CliClient = class {
405
+ constructor(spec, model, logger, run = execFile) {
406
+ this.spec = spec;
407
+ this.model = model;
408
+ this.logger = logger;
409
+ this.run = run;
410
+ this.provider = spec.provider;
411
+ }
412
+ /**
413
+ * Never set. A CLI bills the operator's existing subscription rather than a
414
+ * metered key, so there is no quota to spend that handing over to evrex's
415
+ * own would relieve — and a CLI that refuses is a configuration problem the
416
+ * operator has to fix, not one another provider can absorb.
417
+ */
418
+ exhausted = false;
419
+ provider;
420
+ async completeJson(request2) {
421
+ const required = this.spec.requiresEnv;
422
+ if (required && !process.env[required]) {
423
+ this.logger.warn(
424
+ `${this.spec.command} needs ${required} set: evrex will not drive a CLI that is signed in with a consumer subscription, which the provider's terms do not allow.`
425
+ );
426
+ return null;
427
+ }
428
+ const shape = request2.schema ? `
644
429
 
645
430
  Respond with JSON matching exactly this schema, and nothing else:
646
431
  ${JSON.stringify(request2.schema)}` : "\n\nRespond with JSON and nothing else.";
647
- const prompt = `${request2.system}${shape}
432
+ const prompt = `${request2.system}${shape}
648
433
 
649
434
  ${request2.user}`;
650
- const stdout = await new Promise((resolve) => {
651
- const child = this.run(
652
- this.spec.command,
653
- [...this.spec.args(request2.model ?? this.model)],
654
- { timeout: TIMEOUT_MS, maxBuffer: 1 << 26 },
655
- (error, out) => {
656
- if (error) {
657
- this.logger.warn(
658
- `${this.spec.command} call failed: ${error.message.slice(0, 200)}`
659
- );
660
- resolve(null);
661
- return;
662
- }
663
- resolve(String(out));
435
+ const stdout = await new Promise((resolve) => {
436
+ const child = this.run(
437
+ this.spec.command,
438
+ [...this.spec.args(request2.model ?? this.model)],
439
+ { timeout: TIMEOUT_MS, maxBuffer: 1 << 26 },
440
+ (error, out) => {
441
+ if (error) {
442
+ this.logger.warn(
443
+ `${this.spec.command} call failed: ${error.message.slice(0, 200)}`
444
+ );
445
+ resolve(null);
446
+ return;
447
+ }
448
+ resolve(String(out));
449
+ }
450
+ );
451
+ child.stdin?.end(prompt);
452
+ });
453
+ if (stdout === null) return null;
454
+ const answer = this.spec.parse(stdout);
455
+ if (!answer) return null;
456
+ try {
457
+ return JSON.parse(unfence(answer));
458
+ } catch {
459
+ this.logger.warn(`${this.spec.command} did not return usable JSON`);
460
+ return null;
664
461
  }
665
- );
666
- child.stdin?.end(prompt);
667
- });
668
- if (stdout === null) return null;
669
- const answer = this.spec.parse(stdout);
670
- if (!answer) return null;
671
- try {
672
- return JSON.parse(unfence(answer));
673
- } catch {
674
- this.logger.warn(`${this.spec.command} did not return usable JSON`);
675
- return null;
676
- }
462
+ }
463
+ };
677
464
  }
678
- };
465
+ });
679
466
 
680
467
  // ../../packages/llm-core/src/provider-clients.ts
681
468
  import Anthropic3 from "@anthropic-ai/sdk";
682
- var NOOP = { warn: () => void 0, error: () => void 0 };
683
- var BATCH_POLL_MS = 5e3;
684
- var BATCH_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
685
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
686
469
  function totalize(items, results) {
687
470
  for (const item of items) if (!results.has(item.id)) results.set(item.id, null);
688
471
  return results;
@@ -703,9 +486,6 @@ async function pollToCompletion(handle, resolve, sleepFn, onTimeout) {
703
486
  await sleepFn(BATCH_POLL_MS);
704
487
  }
705
488
  }
706
- var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
707
- var MAX_RETRIES = 4;
708
- var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
709
489
  function isExhaustedStatus(status, retriesSpent) {
710
490
  if (status === 401 || status === 403) return true;
711
491
  return status === 429 && retriesSpent;
@@ -717,7 +497,6 @@ async function waitBeforeRetry(response, attempt, sleep2) {
717
497
  const wait = Math.min(seconds ?? backoff, MAX_RETRY_WAIT_SECONDS);
718
498
  await sleep2(wait * 1e3);
719
499
  }
720
- var MAX_RETRY_WAIT_SECONDS = 90;
721
500
  function parseDuration(value) {
722
501
  const plain = Number(value);
723
502
  if (Number.isFinite(plain)) return plain;
@@ -735,523 +514,547 @@ function parseJsonBody(text) {
735
514
  return null;
736
515
  }
737
516
  }
738
- var AnthropicClient = class _AnthropicClient {
739
- constructor(key, model, logger) {
740
- this.model = model;
741
- this.logger = logger;
742
- this.sdk = new Anthropic3({ apiKey: key });
517
+ function describeError(err) {
518
+ return err instanceof Error ? err.message : String(err);
519
+ }
520
+ function createModelClient(key, options = {}) {
521
+ const provider = providerForBaseUrl(options.baseUrl) ?? detectProvider(key);
522
+ if (!provider || !key) return null;
523
+ const logger = options.logger ?? NOOP;
524
+ const model = options.model ?? DEFAULT_MODELS[provider];
525
+ const trimmed = key.trim();
526
+ if (provider === "anthropic") return new AnthropicClient(trimmed, model, logger);
527
+ const sleep2 = options.sleep ?? realSleep;
528
+ if (provider === "google")
529
+ return new GoogleClient(trimmed, model, logger, sleep2);
530
+ const cli = CLI_SPECS[provider];
531
+ if (cli) return new CliClient(cli, model, logger);
532
+ if (provider === "local") {
533
+ return new OpenAiCompatibleClient(
534
+ provider,
535
+ options.baseUrl ?? localBaseUrl(trimmed),
536
+ null,
537
+ model,
538
+ logger,
539
+ sleep2
540
+ );
743
541
  }
744
- provider = "anthropic";
745
- exhausted = false;
746
- sdk;
747
- // The one place a ModelRequest becomes Anthropic's message params, so the
748
- // single and batch paths cannot drift on the cache breakpoint or the
749
- // json_schema wiring.
750
- messageParams(request2) {
751
- return {
752
- model: request2.model ?? this.model,
753
- max_tokens: request2.maxTokens ?? DEFAULT_MAX_TOKENS,
754
- // The breakpoint sits at the end of the system prompt, which is the
755
- // only part of these requests that repeats. Rendering order is
756
- // tools -> system -> messages, so a marker here caches everything
757
- // ahead of the transcript.
758
- //
759
- // Deliberately NOT on the user block. That block is a different
760
- // transcript chunk on every call, so a breakpoint there would write a
761
- // fresh cache entry per request and read none of them back — paying
762
- // the write premium for nothing, which is worse than not caching.
763
- system: [
764
- {
765
- type: "text",
766
- text: request2.system,
767
- cache_control: { type: "ephemeral" }
542
+ const baseUrl = options.baseUrl ?? OPENAI_COMPATIBLE_BASE_URLS[provider];
543
+ if (!baseUrl) return null;
544
+ return new OpenAiCompatibleClient(
545
+ provider,
546
+ baseUrl,
547
+ trimmed,
548
+ model,
549
+ logger,
550
+ sleep2
551
+ );
552
+ }
553
+ var NOOP, BATCH_POLL_MS, BATCH_TIMEOUT_MS, sleep, realSleep, MAX_RETRIES, RETRYABLE_STATUS, MAX_RETRY_WAIT_SECONDS, AnthropicClient, GoogleClient, OpenAiCompatibleClient;
554
+ var init_provider_clients = __esm({
555
+ "../../packages/llm-core/src/provider-clients.ts"() {
556
+ "use strict";
557
+ init_client2();
558
+ init_providers();
559
+ init_cli_client();
560
+ NOOP = { warn: () => void 0, error: () => void 0 };
561
+ BATCH_POLL_MS = 5e3;
562
+ BATCH_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
563
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms));
564
+ realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
565
+ MAX_RETRIES = 4;
566
+ RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
567
+ MAX_RETRY_WAIT_SECONDS = 90;
568
+ AnthropicClient = class _AnthropicClient {
569
+ constructor(key, model, logger) {
570
+ this.model = model;
571
+ this.logger = logger;
572
+ this.sdk = new Anthropic3({ apiKey: key });
573
+ }
574
+ provider = "anthropic";
575
+ exhausted = false;
576
+ sdk;
577
+ // The one place a ModelRequest becomes Anthropic's message params, so the
578
+ // single and batch paths cannot drift on the cache breakpoint or the
579
+ // json_schema wiring.
580
+ messageParams(request2) {
581
+ return {
582
+ model: request2.model ?? this.model,
583
+ max_tokens: request2.maxTokens ?? DEFAULT_MAX_TOKENS,
584
+ // The breakpoint sits at the end of the system prompt, which is the
585
+ // only part of these requests that repeats. Rendering order is
586
+ // tools -> system -> messages, so a marker here caches everything
587
+ // ahead of the transcript.
588
+ //
589
+ // Deliberately NOT on the user block. That block is a different
590
+ // transcript chunk on every call, so a breakpoint there would write a
591
+ // fresh cache entry per request and read none of them back — paying
592
+ // the write premium for nothing, which is worse than not caching.
593
+ system: [
594
+ {
595
+ type: "text",
596
+ text: request2.system,
597
+ cache_control: { type: "ephemeral" }
598
+ }
599
+ ],
600
+ messages: [{ role: "user", content: request2.user }],
601
+ ...request2.schema ? {
602
+ output_config: {
603
+ format: { type: "json_schema", schema: request2.schema }
604
+ }
605
+ } : {}
606
+ };
607
+ }
608
+ static messageText(message) {
609
+ return message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
610
+ }
611
+ async completeJson(request2) {
612
+ try {
613
+ const message = await this.sdk.messages.create(
614
+ this.messageParams(request2)
615
+ );
616
+ this.reportCacheUsage(message.usage);
617
+ return parseJsonBody(_AnthropicClient.messageText(message));
618
+ } catch (err) {
619
+ const status = err?.status;
620
+ if (typeof status === "number" && isExhaustedStatus(status, true)) {
621
+ this.exhausted = true;
622
+ }
623
+ this.logger.warn(`anthropic call failed: ${describeError(err)}`);
624
+ return null;
768
625
  }
769
- ],
770
- messages: [{ role: "user", content: request2.user }],
771
- ...request2.schema ? {
772
- output_config: {
773
- format: { type: "json_schema", schema: request2.schema }
626
+ }
627
+ async submitBatch(items) {
628
+ if (items.length === 0) return null;
629
+ try {
630
+ const created = await this.sdk.messages.batches.create({
631
+ requests: items.map((item) => ({
632
+ custom_id: item.id,
633
+ params: this.messageParams(item.request)
634
+ }))
635
+ });
636
+ return { provider: "anthropic", id: created.id, itemIds: items.map((i) => i.id) };
637
+ } catch (err) {
638
+ const status = err?.status;
639
+ if (typeof status === "number" && isExhaustedStatus(status, true)) {
640
+ this.exhausted = true;
641
+ }
642
+ this.logger.warn(`anthropic batch submit failed: ${describeError(err)}`);
643
+ return null;
774
644
  }
775
- } : {}
776
- };
777
- }
778
- static messageText(message) {
779
- return message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
780
- }
781
- async completeJson(request2) {
782
- try {
783
- const message = await this.sdk.messages.create(
784
- this.messageParams(request2)
785
- );
786
- this.reportCacheUsage(message.usage);
787
- return parseJsonBody(_AnthropicClient.messageText(message));
788
- } catch (err) {
789
- const status = err?.status;
790
- if (typeof status === "number" && isExhaustedStatus(status, true)) {
791
- this.exhausted = true;
792
645
  }
793
- this.logger.warn(`anthropic call failed: ${describeError(err)}`);
794
- return null;
795
- }
796
- }
797
- async submitBatch(items) {
798
- if (items.length === 0) return null;
799
- try {
800
- const created = await this.sdk.messages.batches.create({
801
- requests: items.map((item) => ({
802
- custom_id: item.id,
803
- params: this.messageParams(item.request)
804
- }))
805
- });
806
- return { provider: "anthropic", id: created.id, itemIds: items.map((i) => i.id) };
807
- } catch (err) {
808
- const status = err?.status;
809
- if (typeof status === "number" && isExhaustedStatus(status, true)) {
810
- this.exhausted = true;
646
+ async resolveBatch(handle) {
647
+ try {
648
+ const batch = await this.sdk.messages.batches.retrieve(handle.id);
649
+ if (batch.processing_status !== "ended") return null;
650
+ const results = /* @__PURE__ */ new Map();
651
+ for await (const entry of await this.sdk.messages.batches.results(
652
+ handle.id
653
+ )) {
654
+ results.set(
655
+ entry.custom_id,
656
+ entry.result.type === "succeeded" ? parseJsonBody(_AnthropicClient.messageText(entry.result.message)) : null
657
+ );
658
+ }
659
+ return totalizeIds(handle.itemIds, results);
660
+ } catch (err) {
661
+ this.logger.warn(`anthropic batch resolve failed: ${describeError(err)}`);
662
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
663
+ }
811
664
  }
812
- this.logger.warn(`anthropic batch submit failed: ${describeError(err)}`);
813
- return null;
814
- }
815
- }
816
- async resolveBatch(handle) {
817
- try {
818
- const batch = await this.sdk.messages.batches.retrieve(handle.id);
819
- if (batch.processing_status !== "ended") return null;
820
- const results = /* @__PURE__ */ new Map();
821
- for await (const entry of await this.sdk.messages.batches.results(
822
- handle.id
823
- )) {
824
- results.set(
825
- entry.custom_id,
826
- entry.result.type === "succeeded" ? parseJsonBody(_AnthropicClient.messageText(entry.result.message)) : null
665
+ async completeJsonBatch(items) {
666
+ if (items.length === 0) return /* @__PURE__ */ new Map();
667
+ const handle = await this.submitBatch(items);
668
+ if (!handle) return totalize(items, /* @__PURE__ */ new Map());
669
+ const resolved = await pollToCompletion(
670
+ handle,
671
+ (h) => this.resolveBatch(h),
672
+ sleep,
673
+ (id) => this.logger.warn(
674
+ `anthropic batch ${id} did not finish within the cap; the sessions it covers stay unextracted until the next pass`
675
+ )
827
676
  );
677
+ return resolved ?? totalize(items, /* @__PURE__ */ new Map());
828
678
  }
829
- return totalizeIds(handle.itemIds, results);
830
- } catch (err) {
831
- this.logger.warn(`anthropic batch resolve failed: ${describeError(err)}`);
832
- return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
833
- }
834
- }
835
- async completeJsonBatch(items) {
836
- if (items.length === 0) return /* @__PURE__ */ new Map();
837
- const handle = await this.submitBatch(items);
838
- if (!handle) return totalize(items, /* @__PURE__ */ new Map());
839
- const resolved = await pollToCompletion(
840
- handle,
841
- (h) => this.resolveBatch(h),
842
- sleep,
843
- (id) => this.logger.warn(
844
- `anthropic batch ${id} did not finish within the cap; the sessions it covers stay unextracted until the next pass`
845
- )
846
- );
847
- return resolved ?? totalize(items, /* @__PURE__ */ new Map());
848
- }
849
- /**
850
- * Says out loud whether the cache was actually used.
851
- *
852
- * A prompt shorter than the model's minimum cacheable prefix is not an
853
- * error and produces no warning from the API — it simply never caches, and
854
- * `cache_control` sits in the request looking as though it works. The
855
- * minimum is per-model and not monotonic across generations (4,096 tokens
856
- * on Haiku 4.5, 512 on Opus 5), so the same prompt can cache on one model
857
- * and silently not on another.
858
- *
859
- * Reported once per process rather than per call: this is a fact about the
860
- * prompt and the model, identical for every session in a run, and a line
861
- * per extraction would be noise.
862
- */
863
- reportedCacheState = false;
864
- reportCacheUsage(usage) {
865
- if (this.reportedCacheState) return;
866
- this.reportedCacheState = true;
867
- const read = usage.cache_read_input_tokens ?? 0;
868
- const written = usage.cache_creation_input_tokens ?? 0;
869
- if (read > 0 || written > 0) return;
870
- this.logger.warn(
871
- `[evrex] prompt caching is enabled but this prompt is below ${this.model}'s minimum cacheable prefix, so nothing was cached (${usage.input_tokens} input tokens). It will start caching if the shared prefix grows past the model's floor.`
872
- );
873
- }
874
- };
875
- var GoogleClient = class _GoogleClient {
876
- constructor(key, model, logger, sleep2 = realSleep) {
877
- this.key = key;
878
- this.model = model;
879
- this.logger = logger;
880
- this.sleep = sleep2;
881
- }
882
- provider = "google";
883
- exhausted = false;
884
- async completeJson(request2, attempt = 0) {
885
- const model = request2.model ?? this.model;
886
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
887
- const shape = request2.schema ? `
679
+ /**
680
+ * Says out loud whether the cache was actually used.
681
+ *
682
+ * A prompt shorter than the model's minimum cacheable prefix is not an
683
+ * error and produces no warning from the API — it simply never caches, and
684
+ * `cache_control` sits in the request looking as though it works. The
685
+ * minimum is per-model and not monotonic across generations (4,096 tokens
686
+ * on Haiku 4.5, 512 on Opus 5), so the same prompt can cache on one model
687
+ * and silently not on another.
688
+ *
689
+ * Reported once per process rather than per call: this is a fact about the
690
+ * prompt and the model, identical for every session in a run, and a line
691
+ * per extraction would be noise.
692
+ */
693
+ reportedCacheState = false;
694
+ reportCacheUsage(usage) {
695
+ if (this.reportedCacheState) return;
696
+ this.reportedCacheState = true;
697
+ const read = usage.cache_read_input_tokens ?? 0;
698
+ const written = usage.cache_creation_input_tokens ?? 0;
699
+ if (read > 0 || written > 0) return;
700
+ this.logger.warn(
701
+ `[evrex] prompt caching is enabled but this prompt is below ${this.model}'s minimum cacheable prefix, so nothing was cached (${usage.input_tokens} input tokens). It will start caching if the shared prefix grows past the model's floor.`
702
+ );
703
+ }
704
+ };
705
+ GoogleClient = class _GoogleClient {
706
+ constructor(key, model, logger, sleep2 = realSleep) {
707
+ this.key = key;
708
+ this.model = model;
709
+ this.logger = logger;
710
+ this.sleep = sleep2;
711
+ }
712
+ provider = "google";
713
+ exhausted = false;
714
+ async completeJson(request2, attempt = 0) {
715
+ const model = request2.model ?? this.model;
716
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
717
+ const shape = request2.schema ? `
888
718
 
889
719
  Respond with JSON matching exactly this schema:
890
720
  ${JSON.stringify(request2.schema)}` : "";
891
- try {
892
- const res = await fetch(url, {
893
- method: "POST",
894
- headers: {
895
- "Content-Type": "application/json",
896
- "x-goog-api-key": this.key
897
- },
898
- body: JSON.stringify({
899
- systemInstruction: { parts: [{ text: request2.system + shape }] },
900
- contents: [{ role: "user", parts: [{ text: request2.user }] }],
901
- generationConfig: { responseMimeType: "application/json" }
902
- })
903
- });
904
- if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
905
- await waitBeforeRetry(res, attempt, this.sleep);
906
- return this.completeJson(request2, attempt + 1);
907
- }
908
- if (!res.ok) {
909
- if (isExhaustedStatus(res.status, true)) this.exhausted = true;
910
- this.logger.warn(
911
- `google call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
912
- );
913
- return null;
721
+ try {
722
+ const res = await fetch(url, {
723
+ method: "POST",
724
+ headers: {
725
+ "Content-Type": "application/json",
726
+ "x-goog-api-key": this.key
727
+ },
728
+ body: JSON.stringify({
729
+ systemInstruction: { parts: [{ text: request2.system + shape }] },
730
+ contents: [{ role: "user", parts: [{ text: request2.user }] }],
731
+ generationConfig: { responseMimeType: "application/json" }
732
+ })
733
+ });
734
+ if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
735
+ await waitBeforeRetry(res, attempt, this.sleep);
736
+ return this.completeJson(request2, attempt + 1);
737
+ }
738
+ if (!res.ok) {
739
+ if (isExhaustedStatus(res.status, true)) this.exhausted = true;
740
+ this.logger.warn(
741
+ `google call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
742
+ );
743
+ return null;
744
+ }
745
+ const body = await res.json();
746
+ const text = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
747
+ return text ? parseJsonBody(text) : null;
748
+ } catch (err) {
749
+ this.logger.warn(`google call failed: ${describeError(err)}`);
750
+ return null;
751
+ }
914
752
  }
915
- const body = await res.json();
916
- const text = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
917
- return text ? parseJsonBody(text) : null;
918
- } catch (err) {
919
- this.logger.warn(`google call failed: ${describeError(err)}`);
920
- return null;
921
- }
922
- }
923
- static GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta";
924
- async submitBatch(items) {
925
- if (items.length === 0) return null;
926
- const model = items[0].request.model ?? this.model;
927
- try {
928
- const create = await fetch(
929
- `${_GoogleClient.GEMINI_BASE}/models/${encodeURIComponent(model)}:batchGenerateContent`,
930
- {
931
- method: "POST",
932
- headers: {
933
- "Content-Type": "application/json",
934
- "x-goog-api-key": this.key
935
- },
936
- body: JSON.stringify({
937
- batch: {
938
- display_name: "evrex-extraction",
939
- input_config: {
940
- requests: {
941
- requests: items.map((item) => {
942
- const shape = item.request.schema ? `
753
+ static GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta";
754
+ async submitBatch(items) {
755
+ if (items.length === 0) return null;
756
+ const model = items[0].request.model ?? this.model;
757
+ try {
758
+ const create = await fetch(
759
+ `${_GoogleClient.GEMINI_BASE}/models/${encodeURIComponent(model)}:batchGenerateContent`,
760
+ {
761
+ method: "POST",
762
+ headers: {
763
+ "Content-Type": "application/json",
764
+ "x-goog-api-key": this.key
765
+ },
766
+ body: JSON.stringify({
767
+ batch: {
768
+ display_name: "evrex-extraction",
769
+ input_config: {
770
+ requests: {
771
+ requests: items.map((item) => {
772
+ const shape = item.request.schema ? `
943
773
 
944
774
  Respond with JSON matching exactly this schema:
945
775
  ${JSON.stringify(item.request.schema)}` : "";
946
- return {
947
- request: {
948
- systemInstruction: {
949
- parts: [{ text: item.request.system + shape }]
950
- },
951
- contents: [
952
- { role: "user", parts: [{ text: item.request.user }] }
953
- ],
954
- generationConfig: { responseMimeType: "application/json" }
955
- },
956
- metadata: { key: item.id }
957
- };
958
- })
776
+ return {
777
+ request: {
778
+ systemInstruction: {
779
+ parts: [{ text: item.request.system + shape }]
780
+ },
781
+ contents: [
782
+ { role: "user", parts: [{ text: item.request.user }] }
783
+ ],
784
+ generationConfig: { responseMimeType: "application/json" }
785
+ },
786
+ metadata: { key: item.id }
787
+ };
788
+ })
789
+ }
790
+ }
959
791
  }
960
- }
792
+ })
961
793
  }
962
- })
794
+ );
795
+ if (!create.ok) {
796
+ if (isExhaustedStatus(create.status, true)) this.exhausted = true;
797
+ this.logger.warn(
798
+ `google batch submit failed: ${create.status} ${(await create.text()).slice(0, 200)}`
799
+ );
800
+ return null;
801
+ }
802
+ const name = (await create.json()).name;
803
+ if (!name) {
804
+ this.logger.warn("google batch: no job name returned");
805
+ return null;
806
+ }
807
+ return { provider: "google", id: name, itemIds: items.map((i) => i.id) };
808
+ } catch (err) {
809
+ this.logger.warn(`google batch submit failed: ${describeError(err)}`);
810
+ return null;
963
811
  }
964
- );
965
- if (!create.ok) {
966
- if (isExhaustedStatus(create.status, true)) this.exhausted = true;
967
- this.logger.warn(
968
- `google batch submit failed: ${create.status} ${(await create.text()).slice(0, 200)}`
969
- );
970
- return null;
971
812
  }
972
- const name = (await create.json()).name;
973
- if (!name) {
974
- this.logger.warn("google batch: no job name returned");
975
- return null;
976
- }
977
- return { provider: "google", id: name, itemIds: items.map((i) => i.id) };
978
- } catch (err) {
979
- this.logger.warn(`google batch submit failed: ${describeError(err)}`);
980
- return null;
981
- }
982
- }
983
- async resolveBatch(handle) {
984
- try {
985
- const poll = await fetch(`${_GoogleClient.GEMINI_BASE}/${handle.id}`, {
986
- headers: { "x-goog-api-key": this.key }
987
- });
988
- if (!poll.ok) {
989
- this.logger.warn(`google batch poll failed: ${poll.status}`);
990
- return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
991
- }
992
- const job = await poll.json();
993
- const state = job.state ?? "";
994
- if (state === "JOB_STATE_FAILED" || state === "JOB_STATE_CANCELLED" || state === "JOB_STATE_EXPIRED") {
995
- this.logger.warn(`google batch ${handle.id} ended ${state}`);
996
- return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
997
- }
998
- if (state !== "JOB_STATE_SUCCEEDED") return null;
999
- const inlined = job.response?.inlinedResponses ?? [];
1000
- const results = /* @__PURE__ */ new Map();
1001
- handle.itemIds.forEach((id, i) => {
1002
- const text = inlined[i]?.response?.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
1003
- results.set(id, text ? parseJsonBody(text) : null);
1004
- });
1005
- return totalizeIds(handle.itemIds, results);
1006
- } catch (err) {
1007
- this.logger.warn(`google batch resolve failed: ${describeError(err)}`);
1008
- return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
1009
- }
1010
- }
1011
- async completeJsonBatch(items) {
1012
- if (items.length === 0) return /* @__PURE__ */ new Map();
1013
- const handle = await this.submitBatch(items);
1014
- if (!handle) return totalize(items, /* @__PURE__ */ new Map());
1015
- const resolved = await pollToCompletion(
1016
- handle,
1017
- (h) => this.resolveBatch(h),
1018
- this.sleep,
1019
- (id) => this.logger.warn(
1020
- `google batch ${id} did not finish within the cap; leaving its sessions for a later pass`
1021
- )
1022
- );
1023
- return resolved ?? totalize(items, /* @__PURE__ */ new Map());
1024
- }
1025
- };
1026
- var OpenAiCompatibleClient = class {
1027
- constructor(provider, baseUrl, key, model, logger, sleep2 = realSleep) {
1028
- this.provider = provider;
1029
- this.baseUrl = baseUrl;
1030
- this.key = key;
1031
- this.model = model;
1032
- this.logger = logger;
1033
- this.sleep = sleep2;
1034
- if (this.provider === "openai") {
1035
- this.completeJsonBatch = (items) => this.runBatch(items);
1036
- }
1037
- }
1038
- exhausted = false;
1039
- completeJsonBatch;
1040
- async completeJson(request2, attempt = 0) {
1041
- try {
1042
- const res = await fetch(`${this.baseUrl}/chat/completions`, {
1043
- method: "POST",
1044
- headers: this.authHeaders(),
1045
- body: JSON.stringify(this.chatBody(request2))
1046
- });
1047
- if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
1048
- await waitBeforeRetry(res, attempt, this.sleep);
1049
- return this.completeJson(request2, attempt + 1);
1050
- }
1051
- if (!res.ok) {
1052
- if (isExhaustedStatus(res.status, true)) this.exhausted = true;
1053
- this.logger.warn(
1054
- `${this.provider} call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
1055
- );
1056
- return null;
1057
- }
1058
- const body = await res.json();
1059
- const text = body.choices?.[0]?.message?.content;
1060
- return typeof text === "string" ? parseJsonBody(text) : null;
1061
- } catch (err) {
1062
- this.logger.warn(`${this.provider} call failed: ${describeError(err)}`);
1063
- return null;
1064
- }
1065
- }
1066
- authHeaders() {
1067
- return {
1068
- "Content-Type": "application/json",
1069
- // A local server does not want one, and some reject an empty bearer token.
1070
- ...this.key ? { Authorization: `Bearer ${this.key}` } : {}
1071
- };
1072
- }
1073
- // One place a ModelRequest becomes the chat body, so completeJson and the
1074
- // batch path cannot drift on response_format or the pinned temperature.
1075
- chatBody(request2) {
1076
- const strict = this.provider === "openai";
1077
- const shape = request2.schema && !strict ? `
1078
-
1079
- Respond with JSON matching exactly this schema:
1080
- ${JSON.stringify(request2.schema)}` : "";
1081
- return {
1082
- model: request2.model ?? this.model,
1083
- messages: [
1084
- { role: "system", content: request2.system + shape },
1085
- { role: "user", content: request2.user }
1086
- ],
1087
- ...request2.schema && strict ? {
1088
- response_format: {
1089
- type: "json_schema",
1090
- json_schema: {
1091
- name: "evrex_result",
1092
- strict: true,
1093
- schema: request2.schema
813
+ async resolveBatch(handle) {
814
+ try {
815
+ const poll = await fetch(`${_GoogleClient.GEMINI_BASE}/${handle.id}`, {
816
+ headers: { "x-goog-api-key": this.key }
817
+ });
818
+ if (!poll.ok) {
819
+ this.logger.warn(`google batch poll failed: ${poll.status}`);
820
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
821
+ }
822
+ const job = await poll.json();
823
+ const state = job.state ?? "";
824
+ if (state === "JOB_STATE_FAILED" || state === "JOB_STATE_CANCELLED" || state === "JOB_STATE_EXPIRED") {
825
+ this.logger.warn(`google batch ${handle.id} ended ${state}`);
826
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
1094
827
  }
828
+ if (state !== "JOB_STATE_SUCCEEDED") return null;
829
+ const inlined = job.response?.inlinedResponses ?? [];
830
+ const results = /* @__PURE__ */ new Map();
831
+ handle.itemIds.forEach((id, i) => {
832
+ const text = inlined[i]?.response?.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
833
+ results.set(id, text ? parseJsonBody(text) : null);
834
+ });
835
+ return totalizeIds(handle.itemIds, results);
836
+ } catch (err) {
837
+ this.logger.warn(`google batch resolve failed: ${describeError(err)}`);
838
+ return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
1095
839
  }
1096
- } : { response_format: { type: "json_object" } },
1097
- // A reading task with a right answer: temperature pinned so indexing
1098
- // twice is the same history.
1099
- temperature: 0,
1100
- stream: false
1101
- };
1102
- }
1103
- async runBatch(items) {
1104
- const results = /* @__PURE__ */ new Map();
1105
- if (items.length === 0) return results;
1106
- const auth = this.key ? { Authorization: `Bearer ${this.key}` } : {};
1107
- try {
1108
- const jsonl = items.map(
1109
- (item) => JSON.stringify({
1110
- custom_id: item.id,
1111
- method: "POST",
1112
- url: "/v1/chat/completions",
1113
- body: this.chatBody(item.request)
1114
- })
1115
- ).join("\n");
1116
- const form = new FormData();
1117
- form.append("purpose", "batch");
1118
- form.append(
1119
- "file",
1120
- new Blob([jsonl], { type: "application/jsonl" }),
1121
- "evrex-batch.jsonl"
1122
- );
1123
- const upload = await fetch(`${this.baseUrl}/files`, {
1124
- method: "POST",
1125
- headers: auth,
1126
- body: form
1127
- });
1128
- if (!upload.ok) {
1129
- if (isExhaustedStatus(upload.status, true)) this.exhausted = true;
1130
- this.logger.warn(
1131
- `openai batch upload failed: ${upload.status} ${(await upload.text()).slice(0, 200)}`
1132
- );
1133
- return totalize(items, results);
1134
840
  }
1135
- const fileId = (await upload.json()).id;
1136
- if (!fileId) return totalize(items, results);
1137
- const created = await fetch(`${this.baseUrl}/batches`, {
1138
- method: "POST",
1139
- headers: this.authHeaders(),
1140
- body: JSON.stringify({
1141
- input_file_id: fileId,
1142
- endpoint: "/v1/chat/completions",
1143
- completion_window: "24h"
1144
- })
1145
- });
1146
- if (!created.ok) {
1147
- this.logger.warn(
1148
- `openai batch create failed: ${created.status} ${(await created.text()).slice(0, 200)}`
841
+ async completeJsonBatch(items) {
842
+ if (items.length === 0) return /* @__PURE__ */ new Map();
843
+ const handle = await this.submitBatch(items);
844
+ if (!handle) return totalize(items, /* @__PURE__ */ new Map());
845
+ const resolved = await pollToCompletion(
846
+ handle,
847
+ (h) => this.resolveBatch(h),
848
+ this.sleep,
849
+ (id) => this.logger.warn(
850
+ `google batch ${id} did not finish within the cap; leaving its sessions for a later pass`
851
+ )
1149
852
  );
1150
- return totalize(items, results);
853
+ return resolved ?? totalize(items, /* @__PURE__ */ new Map());
1151
854
  }
1152
- const batchId = (await created.json()).id;
1153
- if (!batchId) return totalize(items, results);
1154
- let status = "";
1155
- let outputFileId;
1156
- const startedAt = Date.now();
1157
- while (status !== "completed") {
1158
- if (Date.now() - startedAt > BATCH_TIMEOUT_MS) {
1159
- this.logger.warn(
1160
- `openai batch ${batchId} did not finish within the cap; leaving its sessions for a later pass`
1161
- );
1162
- return totalize(items, results);
1163
- }
1164
- await this.sleep(BATCH_POLL_MS);
1165
- const poll = await fetch(`${this.baseUrl}/batches/${batchId}`, {
1166
- headers: auth
1167
- });
1168
- if (!poll.ok) {
1169
- this.logger.warn(`openai batch poll failed: ${poll.status}`);
1170
- return totalize(items, results);
855
+ };
856
+ OpenAiCompatibleClient = class {
857
+ constructor(provider, baseUrl, key, model, logger, sleep2 = realSleep) {
858
+ this.provider = provider;
859
+ this.baseUrl = baseUrl;
860
+ this.key = key;
861
+ this.model = model;
862
+ this.logger = logger;
863
+ this.sleep = sleep2;
864
+ if (this.provider === "openai") {
865
+ this.completeJsonBatch = (items) => this.runBatch(items);
1171
866
  }
1172
- const job = await poll.json();
1173
- status = job.status ?? "";
1174
- outputFileId = job.output_file_id;
1175
- if (status === "failed" || status === "expired" || status === "cancelled") {
1176
- this.logger.warn(`openai batch ${batchId} ended ${status}`);
1177
- return totalize(items, results);
867
+ }
868
+ exhausted = false;
869
+ completeJsonBatch;
870
+ async completeJson(request2, attempt = 0) {
871
+ try {
872
+ const res = await fetch(`${this.baseUrl}/chat/completions`, {
873
+ method: "POST",
874
+ headers: this.authHeaders(),
875
+ body: JSON.stringify(this.chatBody(request2))
876
+ });
877
+ if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
878
+ await waitBeforeRetry(res, attempt, this.sleep);
879
+ return this.completeJson(request2, attempt + 1);
880
+ }
881
+ if (!res.ok) {
882
+ if (isExhaustedStatus(res.status, true)) this.exhausted = true;
883
+ this.logger.warn(
884
+ `${this.provider} call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
885
+ );
886
+ return null;
887
+ }
888
+ const body = await res.json();
889
+ const text = body.choices?.[0]?.message?.content;
890
+ return typeof text === "string" ? parseJsonBody(text) : null;
891
+ } catch (err) {
892
+ this.logger.warn(`${this.provider} call failed: ${describeError(err)}`);
893
+ return null;
1178
894
  }
1179
895
  }
1180
- if (!outputFileId) return totalize(items, results);
1181
- const out = await fetch(`${this.baseUrl}/files/${outputFileId}/content`, {
1182
- headers: auth
1183
- });
1184
- if (!out.ok) {
1185
- this.logger.warn(`openai batch results fetch failed: ${out.status}`);
1186
- return totalize(items, results);
896
+ authHeaders() {
897
+ return {
898
+ "Content-Type": "application/json",
899
+ // A local server does not want one, and some reject an empty bearer token.
900
+ ...this.key ? { Authorization: `Bearer ${this.key}` } : {}
901
+ };
902
+ }
903
+ // One place a ModelRequest becomes the chat body, so completeJson and the
904
+ // batch path cannot drift on response_format or the pinned temperature.
905
+ chatBody(request2) {
906
+ const strict = this.provider === "openai";
907
+ const shape = request2.schema && !strict ? `
908
+
909
+ Respond with JSON matching exactly this schema:
910
+ ${JSON.stringify(request2.schema)}` : "";
911
+ return {
912
+ model: request2.model ?? this.model,
913
+ messages: [
914
+ { role: "system", content: request2.system + shape },
915
+ { role: "user", content: request2.user }
916
+ ],
917
+ ...request2.schema && strict ? {
918
+ response_format: {
919
+ type: "json_schema",
920
+ json_schema: {
921
+ name: "evrex_result",
922
+ strict: true,
923
+ schema: request2.schema
924
+ }
925
+ }
926
+ } : { response_format: { type: "json_object" } },
927
+ // A reading task with a right answer: temperature pinned so indexing
928
+ // twice is the same history.
929
+ temperature: 0,
930
+ stream: false
931
+ };
1187
932
  }
1188
- for (const line of (await out.text()).split("\n")) {
1189
- if (!line.trim()) continue;
933
+ async runBatch(items) {
934
+ const results = /* @__PURE__ */ new Map();
935
+ if (items.length === 0) return results;
936
+ const auth = this.key ? { Authorization: `Bearer ${this.key}` } : {};
1190
937
  try {
1191
- const row = JSON.parse(line);
1192
- const content = row.response?.body?.choices?.[0]?.message?.content;
1193
- if (row.custom_id) {
1194
- results.set(
1195
- row.custom_id,
1196
- typeof content === "string" ? parseJsonBody(content) : null
938
+ const jsonl = items.map(
939
+ (item) => JSON.stringify({
940
+ custom_id: item.id,
941
+ method: "POST",
942
+ url: "/v1/chat/completions",
943
+ body: this.chatBody(item.request)
944
+ })
945
+ ).join("\n");
946
+ const form = new FormData();
947
+ form.append("purpose", "batch");
948
+ form.append(
949
+ "file",
950
+ new Blob([jsonl], { type: "application/jsonl" }),
951
+ "evrex-batch.jsonl"
952
+ );
953
+ const upload = await fetch(`${this.baseUrl}/files`, {
954
+ method: "POST",
955
+ headers: auth,
956
+ body: form
957
+ });
958
+ if (!upload.ok) {
959
+ if (isExhaustedStatus(upload.status, true)) this.exhausted = true;
960
+ this.logger.warn(
961
+ `openai batch upload failed: ${upload.status} ${(await upload.text()).slice(0, 200)}`
1197
962
  );
963
+ return totalize(items, results);
1198
964
  }
1199
- } catch {
965
+ const fileId = (await upload.json()).id;
966
+ if (!fileId) return totalize(items, results);
967
+ const created = await fetch(`${this.baseUrl}/batches`, {
968
+ method: "POST",
969
+ headers: this.authHeaders(),
970
+ body: JSON.stringify({
971
+ input_file_id: fileId,
972
+ endpoint: "/v1/chat/completions",
973
+ completion_window: "24h"
974
+ })
975
+ });
976
+ if (!created.ok) {
977
+ this.logger.warn(
978
+ `openai batch create failed: ${created.status} ${(await created.text()).slice(0, 200)}`
979
+ );
980
+ return totalize(items, results);
981
+ }
982
+ const batchId = (await created.json()).id;
983
+ if (!batchId) return totalize(items, results);
984
+ let status = "";
985
+ let outputFileId;
986
+ const startedAt = Date.now();
987
+ while (status !== "completed") {
988
+ if (Date.now() - startedAt > BATCH_TIMEOUT_MS) {
989
+ this.logger.warn(
990
+ `openai batch ${batchId} did not finish within the cap; leaving its sessions for a later pass`
991
+ );
992
+ return totalize(items, results);
993
+ }
994
+ await this.sleep(BATCH_POLL_MS);
995
+ const poll = await fetch(`${this.baseUrl}/batches/${batchId}`, {
996
+ headers: auth
997
+ });
998
+ if (!poll.ok) {
999
+ this.logger.warn(`openai batch poll failed: ${poll.status}`);
1000
+ return totalize(items, results);
1001
+ }
1002
+ const job = await poll.json();
1003
+ status = job.status ?? "";
1004
+ outputFileId = job.output_file_id;
1005
+ if (status === "failed" || status === "expired" || status === "cancelled") {
1006
+ this.logger.warn(`openai batch ${batchId} ended ${status}`);
1007
+ return totalize(items, results);
1008
+ }
1009
+ }
1010
+ if (!outputFileId) return totalize(items, results);
1011
+ const out = await fetch(`${this.baseUrl}/files/${outputFileId}/content`, {
1012
+ headers: auth
1013
+ });
1014
+ if (!out.ok) {
1015
+ this.logger.warn(`openai batch results fetch failed: ${out.status}`);
1016
+ return totalize(items, results);
1017
+ }
1018
+ for (const line of (await out.text()).split("\n")) {
1019
+ if (!line.trim()) continue;
1020
+ try {
1021
+ const row = JSON.parse(line);
1022
+ const content = row.response?.body?.choices?.[0]?.message?.content;
1023
+ if (row.custom_id) {
1024
+ results.set(
1025
+ row.custom_id,
1026
+ typeof content === "string" ? parseJsonBody(content) : null
1027
+ );
1028
+ }
1029
+ } catch {
1030
+ }
1031
+ }
1032
+ } catch (err) {
1033
+ this.logger.warn(`openai batch failed: ${describeError(err)}`);
1200
1034
  }
1035
+ return totalize(items, results);
1201
1036
  }
1202
- } catch (err) {
1203
- this.logger.warn(`openai batch failed: ${describeError(err)}`);
1204
- }
1205
- return totalize(items, results);
1037
+ };
1206
1038
  }
1207
- };
1208
- function describeError(err) {
1209
- return err instanceof Error ? err.message : String(err);
1210
- }
1211
- function createModelClient(key, options = {}) {
1212
- const provider = providerForBaseUrl(options.baseUrl) ?? detectProvider(key);
1213
- if (!provider || !key) return null;
1214
- const logger = options.logger ?? NOOP;
1215
- const model = options.model ?? DEFAULT_MODELS[provider];
1216
- const trimmed = key.trim();
1217
- if (provider === "anthropic") return new AnthropicClient(trimmed, model, logger);
1218
- const sleep2 = options.sleep ?? realSleep;
1219
- if (provider === "google")
1220
- return new GoogleClient(trimmed, model, logger, sleep2);
1221
- const cli = CLI_SPECS[provider];
1222
- if (cli) return new CliClient(cli, model, logger);
1223
- if (provider === "local") {
1224
- return new OpenAiCompatibleClient(
1225
- provider,
1226
- options.baseUrl ?? localBaseUrl(trimmed),
1227
- null,
1228
- model,
1229
- logger,
1230
- sleep2
1231
- );
1039
+ });
1040
+
1041
+ // ../../packages/llm-core/src/index.ts
1042
+ var init_src = __esm({
1043
+ "../../packages/llm-core/src/index.ts"() {
1044
+ "use strict";
1045
+ init_types();
1046
+ init_client2();
1047
+ init_complete();
1048
+ init_extraction();
1049
+ init_synthesis();
1050
+ init_retrieval_boost();
1051
+ init_providers();
1052
+ init_cli_client();
1053
+ init_provider_clients();
1232
1054
  }
1233
- const baseUrl = options.baseUrl ?? OPENAI_COMPATIBLE_BASE_URLS[provider];
1234
- if (!baseUrl) return null;
1235
- return new OpenAiCompatibleClient(
1236
- provider,
1237
- baseUrl,
1238
- trimmed,
1239
- model,
1240
- logger,
1241
- sleep2
1242
- );
1243
- }
1055
+ });
1244
1056
 
1245
1057
  // src/synthesize.ts
1246
- var ANTHROPIC_API_KEY = process.env.MODEL_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? void 0;
1247
- var ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL;
1248
- var stderrLogger = {
1249
- warn: (message) => process.stderr.write(`[evrex] ${message}
1250
- `),
1251
- error: (message) => process.stderr.write(`[evrex] ${message}
1252
- `)
1253
- };
1254
- var client = createAnthropicKeyClient();
1255
1058
  function createAnthropicKeyClient() {
1256
1059
  return createModelClient(ANTHROPIC_API_KEY, { logger: stderrLogger });
1257
1060
  }
@@ -1294,6 +1097,22 @@ async function synthesize(question, evidence) {
1294
1097
  }
1295
1098
  return { sentences: result.sentences };
1296
1099
  }
1100
+ var ANTHROPIC_API_KEY, ANTHROPIC_MODEL, stderrLogger, client;
1101
+ var init_synthesize = __esm({
1102
+ "src/synthesize.ts"() {
1103
+ "use strict";
1104
+ init_src();
1105
+ ANTHROPIC_API_KEY = process.env.MODEL_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? void 0;
1106
+ ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL;
1107
+ stderrLogger = {
1108
+ warn: (message) => process.stderr.write(`[evrex] ${message}
1109
+ `),
1110
+ error: (message) => process.stderr.write(`[evrex] ${message}
1111
+ `)
1112
+ };
1113
+ client = createAnthropicKeyClient();
1114
+ }
1115
+ });
1297
1116
 
1298
1117
  // src/hook-runtime.ts
1299
1118
  import { execFileSync } from "node:child_process";
@@ -1332,10 +1151,13 @@ function spentLabel(tokens) {
1332
1151
  const short = tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1)}M` : tokens >= 1e3 ? `${Math.round(tokens / 1e3)}k` : String(tokens);
1333
1152
  return ` \xB7 ${short} spent`;
1334
1153
  }
1154
+ var init_hook_runtime = __esm({
1155
+ "src/hook-runtime.ts"() {
1156
+ "use strict";
1157
+ }
1158
+ });
1335
1159
 
1336
1160
  // src/bottle.ts
1337
- var HUMAN_CAP = 700;
1338
- var AGENT_CAP = 500;
1339
1161
  function cap(text, max) {
1340
1162
  const trimmed = text.trim();
1341
1163
  return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
@@ -1384,15 +1206,23 @@ function bottleFromTurns(turns, who, budgetChars = 12e3) {
1384
1206
  budgetChars
1385
1207
  );
1386
1208
  }
1209
+ var HUMAN_CAP, AGENT_CAP;
1210
+ var init_bottle = __esm({
1211
+ "src/bottle.ts"() {
1212
+ "use strict";
1213
+ HUMAN_CAP = 700;
1214
+ AGENT_CAP = 500;
1215
+ }
1216
+ });
1387
1217
 
1388
1218
  // src/tools.ts
1389
- var MAX_EXCERPT = 220;
1219
+ import { execFileSync as execFileSync2 } from "node:child_process";
1220
+ import { realpathSync } from "node:fs";
1221
+ import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
1390
1222
  function confidenceLabel(p) {
1391
1223
  if (p.status === "inferred") return `${Math.round((p.confidence ?? 0) * 100)}%`;
1392
1224
  return p.status;
1393
1225
  }
1394
- var MAX_ITEMS = 5;
1395
- var INDEX_EXCERPT = 110;
1396
1226
  function evidenceLabel(e) {
1397
1227
  return e.kind === "session" ? e.sourceKind ?? "session" : e.kind;
1398
1228
  }
@@ -1423,6 +1253,16 @@ function normalizeRepoRemote(url) {
1423
1253
  if (!cleanedPath) return null;
1424
1254
  return `${host}/${cleanedPath}`.toLowerCase();
1425
1255
  }
1256
+ function repoRefFrom(input) {
1257
+ const value = input.trim().replace(/\/+$/, "");
1258
+ if (!value) return null;
1259
+ if (/^remote:/i.test(value)) return value.toLowerCase();
1260
+ if (/^(root|path):/.test(value)) return value;
1261
+ const shorthand = /^([\w.-]+)\/([\w.-]+?)(?:\.git)?$/.exec(value);
1262
+ if (shorthand) return `remote:github.com/${shorthand[1]}/${shorthand[2]}`.toLowerCase();
1263
+ const normalized = normalizeRepoRemote(value) ?? (/^[\w-]+(\.[\w-]+)+\/\S+$/.test(value) ? normalizeRepoRemote(`https://${value}`) : null);
1264
+ return normalized ? `remote:${normalized}` : null;
1265
+ }
1426
1266
  function git(cwd, args) {
1427
1267
  try {
1428
1268
  return execFileSync2("git", args, {
@@ -1477,8 +1317,28 @@ function sameFileRef(a, b) {
1477
1317
  if (b.endsWith(`/${a}`)) return true;
1478
1318
  return false;
1479
1319
  }
1480
- async function resolveRepos() {
1481
- const override = process.env.EVREX_REPO_PATH;
1320
+ function namedRepo(ref) {
1321
+ const name = ref.replace(/^remote:[^/]+\//, "");
1322
+ return { id: ref, name, slug: name, commitCount: 0, sessionCount: 0 };
1323
+ }
1324
+ function namedRepoRef(repo) {
1325
+ return (repo ? repoRefFrom(repo) : null) ?? currentRequest()?.repo ?? null;
1326
+ }
1327
+ function unreadableRepo(repo) {
1328
+ if (!repo?.trim() || repoRefFrom(repo)) return null;
1329
+ return `Could not read repo "${repo}", so nothing was searched. Pass the full \`git remote get-url origin\` URL (e.g. git@gitlab.com:acme/api.git), or owner/name (e.g. acme/api) for a github.com repo, or omit repo.`;
1330
+ }
1331
+ function repoTag(repo) {
1332
+ return repo ? ` \xB7 ${repo.name}` : "";
1333
+ }
1334
+ function strongestFirst(results) {
1335
+ const rank = (e) => e.provenance.status === "verified" ? 1 : e.provenance.confidence ?? 0;
1336
+ return [...results].sort((a, b) => rank(b.e) - rank(a.e));
1337
+ }
1338
+ async function resolveRepos(repo) {
1339
+ const named = namedRepoRef(repo);
1340
+ if (named) return [namedRepo(named)];
1341
+ const override = currentRequest() ? void 0 : process.env.EVREX_REPO_PATH;
1482
1342
  if (override) {
1483
1343
  const { repoRef } = resolveRepoContext(override);
1484
1344
  return [{ id: repoRef, name: repoRef, slug: repoRef, commitCount: 0, sessionCount: 0 }];
@@ -1489,20 +1349,34 @@ async function resolveRepos() {
1489
1349
  return [];
1490
1350
  }
1491
1351
  }
1492
- async function evrexWhy(filePath, question) {
1493
- const { repoRef, root } = resolveRepoContext(filePath);
1352
+ async function whyScope(filePath, repo) {
1353
+ const named = namedRepoRef(repo);
1354
+ if (currentRequest()) {
1355
+ return { repos: named ? [namedRepo(named)] : await resolveRepos(), root: null, named: named !== null };
1356
+ }
1357
+ const local = resolveRepoContext(filePath);
1358
+ return { repos: [namedRepo(named ?? local.repoRef)], root: local.root, named: named !== null };
1359
+ }
1360
+ async function evrexWhy(filePath, question, repo) {
1361
+ const unreadable = unreadableRepo(repo);
1362
+ if (unreadable) return unreadable;
1363
+ const { repos, root, named } = await whyScope(filePath, repo);
1494
1364
  const text = question ?? `What decisions, constraints, and rejected approaches apply to ${filePath}?`;
1495
- const { evidence: rawEvidence } = await evrexApi.search(repoRef, text, [
1496
- toRepoRelative(isAbsolute(filePath) ? filePath : resolvePath(process.cwd(), filePath), root)
1497
- ]);
1498
- const evidence = datedEvidence(rawEvidence, root);
1365
+ if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
1366
+ const fileRef = currentRequest() ? filePath.replace(/^\.\//, "") : toRepoRelative(isAbsolute(filePath) ? filePath : resolvePath(process.cwd(), filePath), root);
1367
+ const searchOne = (r) => evrexApi.search(r.id, text, [fileRef]).then((res) => datedEvidence(res.evidence, root).map((e) => ({ repo: r, e })));
1368
+ const multiRepo = repos.length > 1;
1369
+ const found = multiRepo ? strongestFirst((await Promise.all(repos.map((r) => searchOne(r).catch(() => [])))).flat()) : await searchOne(repos[0]);
1370
+ const evidence = found.map((f) => f.e);
1371
+ const repoOf = /* @__PURE__ */ new Map();
1372
+ for (const f of found) if (!repoOf.has(f.e.refId)) repoOf.set(f.e.refId, multiRepo ? f.repo : null);
1373
+ const inRepo = (refId) => repoOf.get(refId) ?? null;
1374
+ const lead = (r) => r ? `[${r.name}] ` : "";
1499
1375
  if (evidence.length === 0) {
1500
- return `No recorded reasoning found for ${filePath}. Nothing in the indexed sessions or commit history covers this file's design. This is an answer, not a failure \u2014 and not a reason to stop: carry on and work it out from the code as you would have without this tool, and say the history does not cover it rather than implying there was nothing to find.`;
1376
+ return `No recorded reasoning found for ${filePath}${named ? ` in ${repos[0].name}` : ""}. Nothing in the indexed sessions or commit history covers this file's design. This is an answer, not a failure \u2014 and not a reason to stop: carry on and work it out from the code as you would have without this tool, and say the history does not cover it rather than implying there was nothing to find.`;
1501
1377
  }
1502
1378
  const sessionIds = [...new Set(evidence.filter((e) => e.kind === "session").map((e) => e.refId))];
1503
- const sessions = (await Promise.all(sessionIds.map((id) => evrexApi.session(id)))).filter(
1504
- (s) => s !== null
1505
- );
1379
+ const sessions = (await Promise.all((multiRepo ? sessionIds.slice(0, MAX_ITEMS) : sessionIds).map((id) => evrexApi.session(id)))).filter((s) => s !== null);
1506
1380
  const target = isAbsolute(filePath) ? toRepoRelative(filePath, root) : filePath;
1507
1381
  const aboutTarget = (item) => (item.files ?? []).some((f) => sameFileRef(toRepoRelative(f, root), target));
1508
1382
  const byRelevance = (items) => [...items].sort(
@@ -1513,16 +1387,17 @@ async function evrexWhy(filePath, question) {
1513
1387
  (c) => c !== null && (c.statedInsights?.length ?? 0) > 0
1514
1388
  );
1515
1389
  const statedOf = (kind) => commits.flatMap(
1516
- (c) => (c.statedInsights ?? []).filter((i) => i.kind === kind).map((i) => ({ text: i.text, stated: `commit:${c.sha.slice(0, 12)}` }))
1390
+ (c) => (c.statedInsights ?? []).filter((i) => i.kind === kind).map((i) => ({ text: i.text, stated: `commit:${c.sha.slice(0, 12)}`, repo: inRepo(c.sha) }))
1517
1391
  );
1518
- const rejected = byRelevance(sessions.flatMap((s) => s.rejected)).slice(0, MAX_ITEMS);
1519
- const constraints = byRelevance(sessions.flatMap((s) => s.constraints)).slice(0, MAX_ITEMS);
1520
- const decisions = byRelevance(sessions.flatMap((s) => s.decisions)).slice(0, MAX_ITEMS);
1521
- const procedures = byRelevance(sessions.flatMap((s) => s.procedures ?? [])).slice(0, MAX_ITEMS);
1392
+ const itemsOf = (pick) => sessions.flatMap((s) => pick(s).map((item) => ({ ...item, repo: inRepo(s.id) })));
1393
+ const rejected = byRelevance(itemsOf((s) => s.rejected)).slice(0, MAX_ITEMS);
1394
+ const constraints = byRelevance(itemsOf((s) => s.constraints)).slice(0, MAX_ITEMS);
1395
+ const decisions = byRelevance(itemsOf((s) => s.decisions)).slice(0, MAX_ITEMS);
1396
+ const procedures = byRelevance(itemsOf((s) => s.procedures ?? [])).slice(0, MAX_ITEMS);
1522
1397
  const statedRejected = statedOf("rejected").slice(0, MAX_ITEMS);
1523
1398
  const statedConstraints = statedOf("constraint").slice(0, MAX_ITEMS);
1524
1399
  const statedDecisions = statedOf("decision").slice(0, MAX_ITEMS);
1525
- const statedLine = (i) => `- ${i.text} (stated in ${i.stated})`;
1400
+ const statedLine = (i) => `- ${lead(i.repo)}${i.text} (stated in ${i.stated})`;
1526
1401
  const parts = [];
1527
1402
  const marked = sessions.filter((s) => (s.feedback ?? []).some((f) => f.itemId === null && f.signal !== "helpful"));
1528
1403
  if (marked.length > 0) {
@@ -1534,22 +1409,22 @@ async function evrexWhy(filePath, question) {
1534
1409
  if (rejected.length + statedRejected.length > 0) {
1535
1410
  parts.push(
1536
1411
  "REJECTED APPROACHES (do not re-propose these without new information):\n" + [
1537
- ...rejected.map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}${itemMark(sessions.flatMap((s) => s.feedback ?? []), r.id)}`),
1412
+ ...rejected.map((r) => `- ${lead(r.repo)}${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}${itemMark(sessions.flatMap((s) => s.feedback ?? []), r.id)}`),
1538
1413
  ...statedRejected.map(statedLine)
1539
1414
  ].join("\n")
1540
1415
  );
1541
1416
  }
1542
1417
  if (constraints.length + statedConstraints.length > 0) {
1543
1418
  parts.push(
1544
- "CONSTRAINTS:\n" + [...constraints.map((c) => `- [${c.source}] ${c.text}`), ...statedConstraints.map(statedLine)].join("\n")
1419
+ "CONSTRAINTS:\n" + [...constraints.map((c) => `- [${c.source}${repoTag(c.repo)}] ${c.text}`), ...statedConstraints.map(statedLine)].join("\n")
1545
1420
  );
1546
1421
  }
1547
1422
  if (decisions.length + statedDecisions.length > 0) {
1548
1423
  parts.push(
1549
- "PRIOR DECISIONS:\n" + [...decisions.map((d) => `- ${d.title}: ${d.detail}`), ...statedDecisions.map(statedLine)].join("\n")
1424
+ "PRIOR DECISIONS:\n" + [...decisions.map((d) => `- ${lead(d.repo)}${d.title}: ${d.detail}`), ...statedDecisions.map(statedLine)].join("\n")
1550
1425
  );
1551
1426
  }
1552
- const proceduresText = proceduresBlock(procedures);
1427
+ const proceduresText = proceduresBlock(procedures.map((p) => ({ ...p, title: `${lead(p.repo)}${p.title}` })));
1553
1428
  if (proceduresText) parts.push(proceduresText);
1554
1429
  if (commits.length > 0) {
1555
1430
  parts.push(
@@ -1572,39 +1447,39 @@ async function evrexWhy(filePath, question) {
1572
1447
  hasBlocks ? "HOW TO USE THIS: treat the constraints and rejected approaches above as binding \u2014 they are what this team already decided, not suggestions. Do not re-propose a rejected approach unless you have new information that specifically invalidates the stated reason, and say so if you do.\nThis is a record of what was said, dated, not a statement of what is true now. Two rules follow, and skipping them is measurably worse than not asking at all: check any claim you are about to make against the code before you make it, and where two records disagree prefer the later one \u2014 a decision here may have been reversed by a commit further down this list. If the evidence does not cover the question, say so rather than inferring." : "HOW TO USE THIS: no decisions, constraints or rejected approaches were extracted for this file \u2014 only the raw evidence below, dated. Treat it as history to read, not as settled policy: verify against the code anything you intend to assert, prefer a later record to an earlier one, and say plainly if it does not cover the question."
1573
1448
  );
1574
1449
  }
1575
- const evidenceLines = evidence.slice(0, MAX_ITEMS).map((e) => {
1576
- return `- [${evidenceLabel(e)} ${confidenceLabel(e.provenance)}${dateLabel(e.at)}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
1450
+ const evidenceLines = found.slice(0, MAX_ITEMS).map(({ repo: from, e }) => {
1451
+ return `- [${evidenceLabel(e)} ${confidenceLabel(e.provenance)}${dateLabel(e.at)}${repoTag(multiRepo ? from : null)}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
1577
1452
  });
1578
1453
  parts.push(`EVIDENCE:
1579
1454
  ${evidenceLines.join("\n")}`);
1580
1455
  return parts.join("\n\n");
1581
1456
  }
1582
- async function evrexSearch(query) {
1457
+ async function evrexSearch(query, repo) {
1583
1458
  if (!query.trim()) return "Empty query.";
1584
- const repos = await resolveRepos();
1459
+ const unreadable = unreadableRepo(repo);
1460
+ if (unreadable) return unreadable;
1461
+ const repos = await resolveRepos(repo);
1585
1462
  if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
1586
1463
  const multiRepo = repos.length > 1;
1587
1464
  const perRepo = await Promise.all(
1588
- repos.map(async (repo) => {
1465
+ repos.map(async (repo2) => {
1589
1466
  try {
1590
- const { evidence } = await evrexApi.search(repo.id, query);
1591
- return evidence.map((e) => ({ repo, e }));
1467
+ const { evidence } = await evrexApi.search(repo2.id, query);
1468
+ return evidence.map((e) => ({ repo: repo2, e }));
1592
1469
  } catch {
1593
1470
  return [];
1594
1471
  }
1595
1472
  })
1596
1473
  );
1597
- const results = perRepo.flat();
1474
+ const results = strongestFirst(perRepo.flat());
1598
1475
  if (results.length === 0) {
1599
- return `No matches for "${query}" across indexed sessions and commits. Nothing was recorded on this, which is an answer rather than an error. Continue with normal exploration \u2014 read the code, git log, git blame \u2014 and answer from that, noting that the reasoning was never written down.`;
1476
+ const where = namedRepoRef(repo) ? ` in ${repos[0].name}` : "";
1477
+ return `No matches for "${query}"${where} across indexed sessions and commits. Nothing was recorded on this, which is an answer rather than an error. Continue with normal exploration \u2014 read the code, git log, git blame \u2014 and answer from that, noting that the reasoning was never written down.`;
1600
1478
  }
1601
- const rank = (e) => e.provenance.status === "verified" ? 1 : e.provenance.confidence ?? 0;
1602
- results.sort((a, b) => rank(b.e) - rank(a.e));
1603
- const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo, e }) => {
1479
+ const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo: repo2, e }) => {
1604
1480
  const conf = confidenceLabel(e.provenance);
1605
- const repoTag = multiRepo ? ` \xB7 ${repo.name}` : "";
1606
1481
  const handle = e.kind === "commit" ? `commit:${e.refId.slice(0, 12)}` : `${e.kind}:${e.refId}`;
1607
- return `- ${handle} [${evidenceLabel(e)} ${conf.trim()}${dateLabel(e.at)}${spentLabel(e.spentTokens)}${repoTag}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), INDEX_EXCERPT)}`;
1482
+ return `- ${handle} [${evidenceLabel(e)} ${conf.trim()}${dateLabel(e.at)}${spentLabel(e.spentTokens)}${repoTag(multiRepo ? repo2 : null)}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), INDEX_EXCERPT)}`;
1608
1483
  });
1609
1484
  return [
1610
1485
  ...lines,
@@ -1612,21 +1487,19 @@ async function evrexSearch(query) {
1612
1487
  "Where a line says `N spent`, that is what the conversation behind it cost in tokens \u2014 the records most expensive to rediscover are usually the ones worth expanding first. This is the index, not the record \u2014 each line is a gist, roughly a quarter of what the underlying item says. If one of them looks like the answer, call evrex_expand with its handle (several at once) to read it in full along with any decisions, constraints and rejected approaches attached to it. Expanding everything costs more than the old single-shot search did; expanding the two that matter costs much less. If none of them look relevant, say the record does not cover it rather than expanding on spec."
1613
1488
  ].join("\n");
1614
1489
  }
1615
- var TIMELINE_DEFAULT_DAYS = 7;
1616
- var TIMELINE_MAX_DAYS = 90;
1617
- var TIMELINE_DEFAULT_LIMIT = 30;
1618
- var TIMELINE_MAX_LIMIT = 200;
1619
- async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFAULT_LIMIT) {
1490
+ async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFAULT_LIMIT, repo) {
1620
1491
  const window = Math.min(TIMELINE_MAX_DAYS, Math.max(1, Math.floor(days) || TIMELINE_DEFAULT_DAYS));
1621
1492
  const cap2 = Math.min(TIMELINE_MAX_LIMIT, Math.max(1, Math.floor(limit) || TIMELINE_DEFAULT_LIMIT));
1622
- const repos = await resolveRepos();
1493
+ const unreadable = unreadableRepo(repo);
1494
+ if (unreadable) return unreadable;
1495
+ const repos = await resolveRepos(repo);
1623
1496
  if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
1624
1497
  const multiRepo = repos.length > 1;
1625
1498
  const perRepo = await Promise.all(
1626
- repos.map(async (repo) => {
1499
+ repos.map(async (repo2) => {
1627
1500
  try {
1628
- const entries = await evrexApi.timeline(repo.id, window);
1629
- return entries.map((entry) => ({ repo, entry }));
1501
+ const entries = await evrexApi.timeline(repo2.id, window);
1502
+ return entries.map((entry) => ({ repo: repo2, entry }));
1630
1503
  } catch {
1631
1504
  return [];
1632
1505
  }
@@ -1638,7 +1511,7 @@ async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFA
1638
1511
  return `Nothing recorded in ${where} in the last ${window} day${window === 1 ? "" : "s"}. No captured session and no ingested commit fall in that window; work done without capture would not appear here. Widen the window with a larger \`days\`.`;
1639
1512
  }
1640
1513
  const shown = all.slice(0, cap2);
1641
- const lines = shown.map(({ repo, entry }) => formatTimelineLine(entry, multiRepo ? repo.name : null));
1514
+ const lines = shown.map(({ repo: repo2, entry }) => formatTimelineLine(entry, multiRepo ? repo2.name : null));
1642
1515
  const omitted = all.length - shown.length;
1643
1516
  return [
1644
1517
  `Last ${window} day${window === 1 ? "" : "s"} in ${where}, newest first \u2014 ${all.length} record${all.length === 1 ? "" : "s"}${omitted > 0 ? `, ${shown.length} shown` : ""}:`,
@@ -1753,7 +1626,6 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
1753
1626
  );
1754
1627
  return parts.join("\n\n");
1755
1628
  }
1756
- var FEEDBACK_SIGNALS = ["helpful", "not_helpful", "stale", "wrong", "superseded"];
1757
1629
  function feedbackLines(feedback) {
1758
1630
  if (!feedback || feedback.length === 0) return "";
1759
1631
  const lines = feedback.slice(0, MAX_ITEMS).map((f) => {
@@ -1790,11 +1662,12 @@ async function evrexFeedback(handle, signal, note, itemId, supersededBy) {
1790
1662
  const effect = signal === "stale" || signal === "wrong" || signal === "superseded" ? itemId ? "The item will carry this label wherever it is shown." : "The record now ranks at half weight in retrieval and carries this label wherever it is shown; it is not hidden." : "Recorded beside the record.";
1791
1663
  return `Recorded ${saved.signal} on ${handle}${where}${saved.supersededBy ? ` \u2014 superseded by ${saved.supersededBy}` : ""}. ${effect}`;
1792
1664
  } catch (err) {
1665
+ if (err instanceof EvrexApiError && err.status === 403) {
1666
+ return "This Evrex token is read-only, so the feedback was not recorded. Leaving feedback takes a token with the write scope.";
1667
+ }
1793
1668
  return `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`;
1794
1669
  }
1795
1670
  }
1796
- var BOTTLE_PAGE = 200;
1797
- var BOTTLE_TAIL_TURNS = 400;
1798
1671
  async function evrexBottle(sessionRef) {
1799
1672
  const id = sessionRef.startsWith("session:") ? sessionRef.slice("session:".length) : sessionRef;
1800
1673
  if (!id.trim()) return "Pass a session id or a session:<id> handle from evrex_search.";
@@ -1889,114 +1762,608 @@ ${lines.join("\n")}`);
1889
1762
  }
1890
1763
  return parts.join("\n\n");
1891
1764
  }
1765
+ var MAX_EXCERPT, MAX_ITEMS, INDEX_EXCERPT, TIMELINE_DEFAULT_DAYS, TIMELINE_MAX_DAYS, TIMELINE_DEFAULT_LIMIT, TIMELINE_MAX_LIMIT, FEEDBACK_SIGNALS, BOTTLE_PAGE, BOTTLE_TAIL_TURNS;
1766
+ var init_tools = __esm({
1767
+ "src/tools.ts"() {
1768
+ "use strict";
1769
+ init_client();
1770
+ init_request_context();
1771
+ init_synthesize();
1772
+ init_hook_runtime();
1773
+ init_bottle();
1774
+ MAX_EXCERPT = 220;
1775
+ MAX_ITEMS = 5;
1776
+ INDEX_EXCERPT = 110;
1777
+ TIMELINE_DEFAULT_DAYS = 7;
1778
+ TIMELINE_MAX_DAYS = 90;
1779
+ TIMELINE_DEFAULT_LIMIT = 30;
1780
+ TIMELINE_MAX_LIMIT = 200;
1781
+ FEEDBACK_SIGNALS = ["helpful", "not_helpful", "stale", "wrong", "superseded"];
1782
+ BOTTLE_PAGE = 200;
1783
+ BOTTLE_TAIL_TURNS = 400;
1784
+ }
1785
+ });
1892
1786
 
1893
- // src/index.ts
1894
- var VERSION = "0.8.0";
1895
- var server = new McpServer({ name: "evrex", version: VERSION });
1896
- server.registerTool(
1897
- "evrex_why",
1898
- {
1899
- title: "Why does this file/change exist?",
1900
- description: "Prior reasoning for a file: decisions, hard constraints, and explicitly REJECTED approaches, with sources and confidence. Call this BEFORE reasoning from scratch about an existing file \u2014 not only when the user says 'why'. Use it when you are about to change, refactor, simplify, or remove existing code; when a design choice looks odd, redundant, or wrong; when you are about to infer a constraint from what the code happens to do; or when you would otherwise say 'probably' or 'this was likely done because'. Cheaper than the alternative: one call returns the recorded answer instead of many tool calls reconstructing a guess from code \u2014 and reconstruction cannot recover a rejected approach at all, since rejected work leaves no trace in the repo. Returns a plain 'no recorded reasoning found' when it has nothing, so the cost of checking is one call. Not for 'what does this code do' \u2014 read the file for that.",
1901
- inputSchema: {
1902
- file_path: z.string().describe("Path to the file, relative to the repo root or absolute"),
1903
- question: z.string().optional().describe("Optional specific question; defaults to a general 'why' query")
1787
+ // src/server.ts
1788
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1789
+ import { z } from "zod";
1790
+ function createEvrexServer() {
1791
+ const server = new McpServer({ name: "evrex", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
1792
+ server.registerTool(
1793
+ "evrex_why",
1794
+ {
1795
+ title: "Why does this file/change exist?",
1796
+ description: "Prior reasoning for a file: decisions, hard constraints, and explicitly REJECTED approaches, with sources and confidence. Call this BEFORE reasoning from scratch about an existing file \u2014 not only when the user says 'why'. Use it when you are about to change, refactor, simplify, or remove existing code; when a design choice looks odd, redundant, or wrong; when you are about to infer a constraint from what the code happens to do; or when you would otherwise say 'probably' or 'this was likely done because'. Cheaper than the alternative: one call returns the recorded answer instead of many tool calls reconstructing a guess from code \u2014 and reconstruction cannot recover a rejected approach at all, since rejected work leaves no trace in the repo. Returns a plain 'no recorded reasoning found' when it has nothing, so the cost of checking is one call. Not for 'what does this code do' \u2014 read the file for that.",
1797
+ inputSchema: {
1798
+ file_path: z.string().describe("Path to the file, relative to the repo root or absolute"),
1799
+ question: z.string().optional().describe("Optional specific question; defaults to a general 'why' query"),
1800
+ repo: repoArg
1801
+ }
1802
+ },
1803
+ async ({ file_path, question, repo }) => {
1804
+ const text = await evrexWhy(file_path, question, repo);
1805
+ return { content: [{ type: "text", text }] };
1904
1806
  }
1905
- },
1906
- async ({ file_path, question }) => {
1907
- const text = await evrexWhy(file_path, question);
1908
- return { content: [{ type: "text", text }] };
1909
- }
1910
- );
1911
- server.registerTool(
1912
- "evrex_search",
1913
- {
1914
- title: "Search Evrex sessions and commits",
1915
- description: "Free-text search across this repo's indexed agent session history and git commits \u2014 prior discussion and reasoning that lives in conversation, not in code, so grep, git log and git blame cannot find it. Use for open-ended questions ('has this been discussed', 'when/why did we start doing X', 'what do we know about Y'), and before concluding that something was never considered \u2014 absence in the code is not absence of a decision. If you have a specific file path, prefer evrex_why.",
1916
- inputSchema: {
1917
- query: z.string().describe("Free-text search query")
1807
+ );
1808
+ server.registerTool(
1809
+ "evrex_search",
1810
+ {
1811
+ title: "Search Evrex sessions and commits",
1812
+ description: "Free-text search across this repo's indexed agent session history and git commits \u2014 prior discussion and reasoning that lives in conversation, not in code, so grep, git log and git blame cannot find it. Use for open-ended questions ('has this been discussed', 'when/why did we start doing X', 'what do we know about Y'), and before concluding that something was never considered \u2014 absence in the code is not absence of a decision. If you have a specific file path, prefer evrex_why.",
1813
+ inputSchema: {
1814
+ query: z.string().describe("Free-text search query"),
1815
+ repo: repoArg
1816
+ }
1817
+ },
1818
+ async ({ query, repo }) => {
1819
+ const text = await evrexSearch(query, repo);
1820
+ return { content: [{ type: "text", text }] };
1918
1821
  }
1919
- },
1920
- async ({ query }) => {
1921
- const text = await evrexSearch(query);
1922
- return { content: [{ type: "text", text }] };
1923
- }
1924
- );
1925
- server.registerTool(
1926
- "evrex_expand",
1927
- {
1928
- title: "Read Evrex records in full",
1929
- description: "The second half of evrex_search. That returns an index of gists with a handle on each line (`commit:069a0b5`, `slack:a1b2\u2026`); this returns the full record for the handles you pick \u2014 the whole excerpt plus any decisions, constraints and rejected approaches attached to it. Batch the handles you actually want in one call. Expanding every result costs more than a single-shot search would have; expanding the two that look like the answer costs far less, which is the entire point of the split. If nothing in the index looked relevant, say the record does not cover it rather than expanding on spec.",
1930
- inputSchema: {
1931
- handles: z.array(z.string()).describe("Handles exactly as evrex_search printed them, e.g. ['commit:069a0b5']")
1822
+ );
1823
+ server.registerTool(
1824
+ "evrex_expand",
1825
+ {
1826
+ title: "Read Evrex records in full",
1827
+ description: "The second half of evrex_search. That returns an index of gists with a handle on each line (`commit:069a0b5`, `slack:a1b2\u2026`); this returns the full record for the handles you pick \u2014 the whole excerpt plus any decisions, constraints and rejected approaches attached to it. Batch the handles you actually want in one call. Expanding every result costs more than a single-shot search would have; expanding the two that look like the answer costs far less, which is the entire point of the split. If nothing in the index looked relevant, say the record does not cover it rather than expanding on spec.",
1828
+ inputSchema: {
1829
+ handles: z.array(z.string()).describe("Handles exactly as evrex_search printed them, e.g. ['commit:069a0b5']")
1830
+ }
1831
+ },
1832
+ async ({ handles }) => {
1833
+ const text = await evrexExpand(handles);
1834
+ return { content: [{ type: "text", text }] };
1932
1835
  }
1933
- },
1934
- async ({ handles }) => {
1935
- const text = await evrexExpand(handles);
1936
- return { content: [{ type: "text", text }] };
1937
- }
1938
- );
1939
- server.registerTool(
1940
- "evrex_timeline",
1941
- {
1942
- title: "What happened in this repo lately",
1943
- description: "Everything recorded in this repo in the last N days, newest first \u2014 captured agent sessions and ingested commits interleaved, each line a handle evrex_expand or evrex_bottle takes. Use it to pick up a repo after time away ('what happened this week', 'what did the team do while I was out', 'what is in progress'), or before proposing work that may already be underway. It is ordered by time, not relevance: evrex_search cannot answer 'what is recent' because nothing about recency is in a query. Returns an index, not the record \u2014 expand only the handles the task needs.",
1944
- inputSchema: {
1945
- days: z.number().int().min(1).max(90).optional().describe("How far back to look, in days. Default 7, maximum 90."),
1946
- limit: z.number().int().min(1).max(200).optional().describe("At most this many lines, newest first. Default 30, maximum 200.")
1836
+ );
1837
+ server.registerTool(
1838
+ "evrex_timeline",
1839
+ {
1840
+ title: "What happened in this repo lately",
1841
+ description: "Everything recorded in this repo in the last N days, newest first \u2014 captured agent sessions and ingested commits interleaved, each line a handle evrex_expand or evrex_bottle takes. Use it to pick up a repo after time away ('what happened this week', 'what did the team do while I was out', 'what is in progress'), or before proposing work that may already be underway. It is ordered by time, not relevance: evrex_search cannot answer 'what is recent' because nothing about recency is in a query. Returns an index, not the record \u2014 expand only the handles the task needs.",
1842
+ inputSchema: {
1843
+ days: z.number().int().min(1).max(90).optional().describe("How far back to look, in days. Default 7, maximum 90."),
1844
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many lines, newest first. Default 30, maximum 200."),
1845
+ repo: repoArg
1846
+ }
1847
+ },
1848
+ async ({ days, limit, repo }) => {
1849
+ const text = await evrexTimeline(days, limit, repo);
1850
+ return { content: [{ type: "text", text }] };
1947
1851
  }
1948
- },
1949
- async ({ days, limit }) => {
1950
- const text = await evrexTimeline(days, limit);
1951
- return { content: [{ type: "text", text }] };
1952
- }
1953
- );
1954
- server.registerTool(
1955
- "evrex_feedback",
1956
- {
1957
- title: "Say something about a record after reading it",
1958
- description: "Mark a record \u2014 a session or a commit from evrex_search / evrex_why / evrex_expand \u2014 as helpful, not_helpful, stale, wrong, or superseded by another record. Use it when the record led you astray: a decision a later change reversed, a rejected approach that turned out to be the right one, a constraint that no longer holds \u2014 or when it saved real work. A stale/wrong/superseded record ranks lower afterwards and carries the label on every surface; it is never hidden. Pass item_id (e.g. rej:llm:2) to mark one item rather than the whole record.",
1959
- inputSchema: {
1960
- handle: z.string().describe("session:<id> or commit:<sha>, as evrex_search prints it"),
1961
- signal: z.enum(["helpful", "not_helpful", "stale", "wrong", "superseded"]),
1962
- note: z.string().optional().describe("One line on why \u2014 what you found instead"),
1963
- item_id: z.string().optional().describe("One item within the record, e.g. rej:llm:2, con:llm:0, dec:llm:1"),
1964
- superseded_by: z.string().optional().describe("For superseded: the handle of the record that replaced it")
1852
+ );
1853
+ server.registerTool(
1854
+ "evrex_feedback",
1855
+ {
1856
+ title: "Say something about a record after reading it",
1857
+ description: "Mark a record \u2014 a session or a commit from evrex_search / evrex_why / evrex_expand \u2014 as helpful, not_helpful, stale, wrong, or superseded by another record. Use it when the record led you astray: a decision a later change reversed, a rejected approach that turned out to be the right one, a constraint that no longer holds \u2014 or when it saved real work. A stale/wrong/superseded record ranks lower afterwards and carries the label on every surface; it is never hidden. Pass item_id (e.g. rej:llm:2) to mark one item rather than the whole record.",
1858
+ inputSchema: {
1859
+ handle: z.string().describe("session:<id> or commit:<sha>, as evrex_search prints it"),
1860
+ signal: z.enum(["helpful", "not_helpful", "stale", "wrong", "superseded"]),
1861
+ note: z.string().optional().describe("One line on why \u2014 what you found instead"),
1862
+ item_id: z.string().optional().describe("One item within the record, e.g. rej:llm:2, con:llm:0, dec:llm:1"),
1863
+ superseded_by: z.string().optional().describe("For superseded: the handle of the record that replaced it")
1864
+ }
1865
+ },
1866
+ async ({ handle, signal, note, item_id, superseded_by }) => {
1867
+ const text = await evrexFeedback(handle, signal, note, item_id, superseded_by);
1868
+ return { content: [{ type: "text", text }] };
1965
1869
  }
1966
- },
1967
- async ({ handle, signal, note, item_id, superseded_by }) => {
1968
- const text = await evrexFeedback(handle, signal, note, item_id, superseded_by);
1969
- return { content: [{ type: "text", text }] };
1970
- }
1971
- );
1972
- server.registerTool(
1973
- "evrex_bottle",
1974
- {
1975
- title: "Read a session's conversation, ready to continue",
1976
- description: "The recorded conversation of an ingested session \u2014 a teammate's, or an earlier one of your own \u2014 with the tool traffic wrung out: human and assistant messages verbatim, each tool call one line, tool output dropped. Use it to pick up unfinished work mid-flow ('continue Jeff's session'): the last exchanges carry the plan half-stated and the instruction not yet acted on, which the extracted insights do not. For what a session concluded rather than how it went, use evrex_expand instead. Takes a session id or a session:<id> handle from evrex_search.",
1977
- inputSchema: {
1978
- session: z.string().describe("Session id, or a session:<id> handle as evrex_search prints it")
1870
+ );
1871
+ server.registerTool(
1872
+ "evrex_bottle",
1873
+ {
1874
+ title: "Read a session's conversation, ready to continue",
1875
+ description: "The recorded conversation of an ingested session \u2014 a teammate's, or an earlier one of your own \u2014 with the tool traffic wrung out: human and assistant messages verbatim, each tool call one line, tool output dropped. Use it to pick up unfinished work mid-flow ('continue Jeff's session'): the last exchanges carry the plan half-stated and the instruction not yet acted on, which the extracted insights do not. For what a session concluded rather than how it went, use evrex_expand instead. Takes a session id or a session:<id> handle from evrex_search.",
1876
+ inputSchema: {
1877
+ session: z.string().describe("Session id, or a session:<id> handle as evrex_search prints it")
1878
+ }
1879
+ },
1880
+ async ({ session }) => {
1881
+ const text = await evrexBottle(session);
1882
+ return { content: [{ type: "text", text }] };
1979
1883
  }
1980
- },
1981
- async ({ session }) => {
1982
- const text = await evrexBottle(session);
1983
- return { content: [{ type: "text", text }] };
1984
- }
1985
- );
1986
- server.registerTool(
1987
- "evrex_commit_context",
1988
- {
1989
- title: "Commit context",
1990
- description: "The session, tradeoffs, and related commits behind a specific git commit. Use when asked about the story or rationale behind a specific SHA (from `git log`/`git blame`/a PR). If you have a file path instead of a SHA, prefer evrex_why.",
1991
- inputSchema: {
1992
- sha: z.string().describe("Git commit SHA (full or abbreviated)")
1884
+ );
1885
+ server.registerTool(
1886
+ "evrex_commit_context",
1887
+ {
1888
+ title: "Commit context",
1889
+ description: "The session, tradeoffs, and related commits behind a specific git commit. Use when asked about the story or rationale behind a specific SHA (from `git log`/`git blame`/a PR). If you have a file path instead of a SHA, prefer evrex_why.",
1890
+ inputSchema: {
1891
+ sha: z.string().describe("Git commit SHA (full or abbreviated)")
1892
+ }
1893
+ },
1894
+ async ({ sha }) => {
1895
+ const text = await evrexCommitContext(sha);
1896
+ return { content: [{ type: "text", text }] };
1993
1897
  }
1994
- },
1995
- async ({ sha }) => {
1996
- const text = await evrexCommitContext(sha);
1997
- return { content: [{ type: "text", text }] };
1898
+ );
1899
+ return server;
1900
+ }
1901
+ var VERSION, SERVER_INSTRUCTIONS, repoArg;
1902
+ var init_server = __esm({
1903
+ "src/server.ts"() {
1904
+ "use strict";
1905
+ init_tools();
1906
+ VERSION = "0.10.0";
1907
+ SERVER_INSTRUCTIONS = "Evrex holds this team's recorded reasoning: decisions, hard constraints, and approaches already tried and REJECTED, taken from past agent sessions, commits and Slack threads. None of it is in the code, git log or git blame. Before changing an existing file, call evrex_why with its repo-relative path. Before designing a change or concluding something was never considered, call evrex_search, then evrex_expand on the handles that look like the answer. For a commit SHA, call evrex_commit_context. Pass repo as the full `git remote get-url origin` URL, or as owner/name for a github.com repo; locally the server already knows the repo, so repo is only needed when it doesn't. Treat rejected approaches and constraints as settled unless you have new information, and check claims against the code.";
1908
+ repoArg = z.string().optional().describe(
1909
+ "The repository as its full `git remote get-url origin` URL, or as owner/name (e.g. acme/api) for a github.com repo. Locally the server already knows the repo, so this is only needed when it doesn't. Omit to use this connection's default repo, or every repo the team has indexed."
1910
+ );
1911
+ }
1912
+ });
1913
+
1914
+ // src/credential-store.ts
1915
+ var credential_store_exports = {};
1916
+ __export(credential_store_exports, {
1917
+ CredentialStore: () => CredentialStore,
1918
+ NoKeychainError: () => NoKeychainError,
1919
+ systemRunner: () => systemRunner
1920
+ });
1921
+ import { spawn } from "node:child_process";
1922
+ var SERVICE, ACCOUNT, systemRunner, NoKeychainError, CredentialStore, WINDOWS_PATH, WINDOWS_STORE, WINDOWS_RETRIEVE, WINDOWS_REMOVE;
1923
+ var init_credential_store = __esm({
1924
+ "src/credential-store.ts"() {
1925
+ "use strict";
1926
+ SERVICE = "evrex-capture";
1927
+ ACCOUNT = "evrex";
1928
+ systemRunner = {
1929
+ platform: process.platform,
1930
+ run(command, args, stdin) {
1931
+ return new Promise((resolve) => {
1932
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
1933
+ let stdout = "";
1934
+ let stderr = "";
1935
+ child.stdout.on("data", (d) => stdout += d.toString());
1936
+ child.stderr.on("data", (d) => stderr += d.toString());
1937
+ child.on("error", () => resolve({ code: 127, stdout: "", stderr: "" }));
1938
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
1939
+ if (stdin !== void 0) child.stdin.write(stdin);
1940
+ child.stdin.end();
1941
+ });
1942
+ }
1943
+ };
1944
+ NoKeychainError = class extends Error {
1945
+ constructor(platform) {
1946
+ super(
1947
+ `evrex could not find a credential store on this machine (${platform}).
1948
+ macOS needs \`security\`, which ships with the system.
1949
+ Linux needs \`secret-tool\` \u2014 install libsecret-tools (Debian/Ubuntu)
1950
+ or libsecret (Fedora/Arch), and make sure a keyring daemon is running.
1951
+ Windows needs PowerShell.
1952
+ evrex will not fall back to writing the credential in a plain file.`
1953
+ );
1954
+ this.name = "NoKeychainError";
1955
+ }
1956
+ };
1957
+ CredentialStore = class {
1958
+ constructor(runner = systemRunner) {
1959
+ this.runner = runner;
1960
+ }
1961
+ async available() {
1962
+ switch (this.runner.platform) {
1963
+ case "darwin":
1964
+ return (await this.runner.run("security", ["help"])).code !== 127;
1965
+ case "win32":
1966
+ return (await this.runner.run("powershell", ["-Command", "$PSVersionTable.PSVersion.Major"])).code !== 127;
1967
+ default:
1968
+ return (await this.runner.run("secret-tool", ["--version"])).code !== 127;
1969
+ }
1970
+ }
1971
+ /**
1972
+ * Replaces any existing credential rather than adding a second one. A
1973
+ * machine that re-enrols after expiry must end up with exactly one entry, or
1974
+ * the next read is a coin flip between the live credential and a dead one.
1975
+ */
1976
+ async store(secret) {
1977
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
1978
+ switch (this.runner.platform) {
1979
+ case "darwin": {
1980
+ if (/["\\\r\n]/.test(secret)) {
1981
+ throw new Error("Keychain write refused: the credential has a character `security -i` cannot carry.");
1982
+ }
1983
+ const result = await this.runner.run(
1984
+ "security",
1985
+ ["-i"],
1986
+ `add-generic-password -a "${ACCOUNT}" -s "${SERVICE}" -U -w "${secret}"
1987
+ `
1988
+ );
1989
+ if (result.code !== 0) throw new Error(`Keychain write failed: ${result.stderr.trim()}`);
1990
+ return;
1991
+ }
1992
+ case "win32": {
1993
+ const result = await this.runner.run(
1994
+ "powershell",
1995
+ ["-NoProfile", "-Command", WINDOWS_STORE],
1996
+ secret
1997
+ );
1998
+ if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
1999
+ return;
2000
+ }
2001
+ default: {
2002
+ const result = await this.runner.run(
2003
+ "secret-tool",
2004
+ ["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
2005
+ secret
2006
+ );
2007
+ if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
2008
+ return;
2009
+ }
2010
+ }
2011
+ }
2012
+ /** Null when there is nothing stored, which is the normal pre-enrolment state. */
2013
+ async retrieve() {
2014
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
2015
+ switch (this.runner.platform) {
2016
+ case "darwin": {
2017
+ const r = await this.runner.run("security", [
2018
+ "find-generic-password",
2019
+ "-a",
2020
+ ACCOUNT,
2021
+ "-s",
2022
+ SERVICE,
2023
+ "-w"
2024
+ ]);
2025
+ return r.code === 0 ? r.stdout.trim() || null : null;
2026
+ }
2027
+ case "win32": {
2028
+ const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
2029
+ return r.code === 0 ? r.stdout.trim() || null : null;
2030
+ }
2031
+ default: {
2032
+ const r = await this.runner.run("secret-tool", [
2033
+ "lookup",
2034
+ "service",
2035
+ SERVICE,
2036
+ "account",
2037
+ ACCOUNT
2038
+ ]);
2039
+ return r.code === 0 ? r.stdout.trim() || null : null;
2040
+ }
2041
+ }
2042
+ }
2043
+ /** Idempotent: removing a credential that is not there is not an error. */
2044
+ async remove() {
2045
+ if (!await this.available()) return;
2046
+ switch (this.runner.platform) {
2047
+ case "darwin":
2048
+ await this.runner.run("security", [
2049
+ "delete-generic-password",
2050
+ "-a",
2051
+ ACCOUNT,
2052
+ "-s",
2053
+ SERVICE
2054
+ ]);
2055
+ return;
2056
+ case "win32":
2057
+ await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
2058
+ return;
2059
+ default:
2060
+ await this.runner.run("secret-tool", [
2061
+ "clear",
2062
+ "service",
2063
+ SERVICE,
2064
+ "account",
2065
+ ACCOUNT
2066
+ ]);
2067
+ return;
2068
+ }
2069
+ }
2070
+ };
2071
+ WINDOWS_PATH = "$env:APPDATA\\evrex\\capture.cred";
2072
+ WINDOWS_STORE = `
2073
+ $ErrorActionPreference = 'Stop'
2074
+ $p = "${WINDOWS_PATH}"
2075
+ New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
2076
+ $secret = [Console]::In.ReadToEnd().Trim()
2077
+ ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
2078
+ `.trim();
2079
+ WINDOWS_RETRIEVE = `
2080
+ $ErrorActionPreference = 'Stop'
2081
+ $p = "${WINDOWS_PATH}"
2082
+ if (-not (Test-Path $p)) { exit 1 }
2083
+ $sec = Get-Content $p | ConvertTo-SecureString
2084
+ [Runtime.InteropServices.Marshal]::PtrToStringAuto(
2085
+ [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
2086
+ `.trim();
2087
+ WINDOWS_REMOVE = `
2088
+ $p = "${WINDOWS_PATH}"
2089
+ if (Test-Path $p) { Remove-Item $p -Force }
2090
+ `.trim();
2091
+ }
2092
+ });
2093
+
2094
+ // src/enrol.ts
2095
+ var enrol_exports = {};
2096
+ __export(enrol_exports, {
2097
+ RENEW_WITHIN_MS: () => RENEW_WITHIN_MS,
2098
+ enrol: () => enrol,
2099
+ needsRenewal: () => needsRenewal
2100
+ });
2101
+ async function keychainReady(deps) {
2102
+ try {
2103
+ return await deps.store.available();
2104
+ } catch {
2105
+ return false;
2106
+ }
2107
+ }
2108
+ async function enrol(deps) {
2109
+ if (!await keychainReady(deps)) {
2110
+ deps.log(new NoKeychainError(process.platform).message);
2111
+ return { ok: false, reason: "no_keychain" };
2112
+ }
2113
+ let started;
2114
+ try {
2115
+ const res = await deps.fetch(`${deps.baseUrl}/auth/device/start`, {
2116
+ method: "POST",
2117
+ headers: { "content-type": "application/json" },
2118
+ body: JSON.stringify({
2119
+ deviceName: deps.hostname(),
2120
+ agentKind: "claude-code",
2121
+ clientVersion: deps.clientVersion
2122
+ })
2123
+ });
2124
+ if (!res.ok) throw new Error(String(res.status));
2125
+ started = await res.json();
2126
+ } catch {
2127
+ deps.log(`Could not reach evrex at ${deps.baseUrl}.`);
2128
+ return { ok: false, reason: "unreachable" };
1998
2129
  }
1999
- );
2130
+ deps.log("");
2131
+ deps.log(` Enrolling ${deps.hostname()}.`);
2132
+ deps.log("");
2133
+ deps.log(` 1. Open ${deps.dashboardUrl}/devices`);
2134
+ deps.log(` 2. Enter this code: ${started.userCode}`);
2135
+ deps.log("");
2136
+ deps.log(" Waiting for approval. Ctrl-C to stop.");
2137
+ let interval = started.intervalMs;
2138
+ for (; ; ) {
2139
+ await deps.sleep(interval);
2140
+ let outcome;
2141
+ try {
2142
+ const res = await deps.fetch(`${deps.baseUrl}/auth/device/poll`, {
2143
+ method: "POST",
2144
+ headers: { "content-type": "application/json" },
2145
+ body: JSON.stringify({ deviceCode: started.deviceCode })
2146
+ });
2147
+ outcome = await res.json();
2148
+ } catch {
2149
+ continue;
2150
+ }
2151
+ switch (outcome.status) {
2152
+ case "pending":
2153
+ continue;
2154
+ case "slow_down":
2155
+ interval = Math.max(interval, outcome.retryAfterMs);
2156
+ continue;
2157
+ case "denied":
2158
+ deps.log(" Enrolment was declined.");
2159
+ return { ok: false, reason: "denied" };
2160
+ case "expired":
2161
+ deps.log(" That code expired. Run enrolment again for a new one.");
2162
+ return { ok: false, reason: "expired" };
2163
+ case "granted": {
2164
+ await deps.store.store(outcome.token);
2165
+ deps.log("");
2166
+ deps.log(` ${deps.hostname()} is enrolled.`);
2167
+ if (outcome.expiresAt) {
2168
+ deps.log(` The credential renews automatically before ${outcome.expiresAt.slice(0, 10)}.`);
2169
+ }
2170
+ return { ok: true, expiresAt: outcome.expiresAt };
2171
+ }
2172
+ }
2173
+ }
2174
+ }
2175
+ function needsRenewal(expiresAt, now) {
2176
+ if (!expiresAt) return false;
2177
+ const expiry = new Date(expiresAt).getTime();
2178
+ if (Number.isNaN(expiry)) return false;
2179
+ return expiry - now <= RENEW_WITHIN_MS;
2180
+ }
2181
+ var RENEW_WITHIN_MS;
2182
+ var init_enrol = __esm({
2183
+ "src/enrol.ts"() {
2184
+ "use strict";
2185
+ init_credential_store();
2186
+ RENEW_WITHIN_MS = 7 * 24 * 60 * 60 * 1e3;
2187
+ }
2188
+ });
2189
+
2190
+ // src/serve.ts
2191
+ var serve_exports = {};
2192
+ __export(serve_exports, {
2193
+ DEFAULT_ALLOWED_ORIGINS: () => DEFAULT_ALLOWED_ORIGINS,
2194
+ createServeHandler: () => createServeHandler,
2195
+ serve: () => serve
2196
+ });
2197
+ import { createHash } from "node:crypto";
2198
+ import { createServer } from "node:http";
2199
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2200
+ function createServeHandler(options) {
2201
+ const now = options.now ?? Date.now;
2202
+ const log = options.log ?? ((line) => console.error(line));
2203
+ const toolNames = new Set(Object.keys(createEvrexServer()._registeredTools));
2204
+ const verdicts = /* @__PURE__ */ new Map();
2205
+ async function verify(token2) {
2206
+ const key = createHash("sha256").update(token2).digest("hex");
2207
+ const cached = verdicts.get(key);
2208
+ if (cached && cached.until > now()) return cached.verdict;
2209
+ let verdict;
2210
+ try {
2211
+ const me = await runInRequest({ token: token2, repo: null }, () => evrexApi.me());
2212
+ verdict = { ok: true, orgId: me.org?.id ?? null };
2213
+ } catch (err) {
2214
+ if (err instanceof EvrexApiError && err.status === 401) verdict = { ok: false, reason: "invalid" };
2215
+ else if (err instanceof EvrexApiError && err.status === 403) verdict = { ok: false, reason: "cannot-read" };
2216
+ else {
2217
+ const cause = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
2218
+ log(JSON.stringify({ at: new Date(now()).toISOString(), backend: "unreachable", cause }));
2219
+ return { ok: "unavailable" };
2220
+ }
2221
+ }
2222
+ if (verdicts.size >= MAX_CACHED_TOKENS) verdicts.delete(verdicts.keys().next().value);
2223
+ verdicts.set(key, { verdict, until: now() + (verdict.ok === true ? VALID_FOR_MS : INVALID_FOR_MS) });
2224
+ return verdict;
2225
+ }
2226
+ return async function handle(req, res) {
2227
+ const started = now();
2228
+ const entry = { org: null, tools: [] };
2229
+ let logged = false;
2230
+ const record = (status) => {
2231
+ if (logged) return;
2232
+ logged = true;
2233
+ log(JSON.stringify({ at: new Date(started).toISOString(), tools: entry.tools, org: entry.org, status, ms: now() - started }));
2234
+ };
2235
+ res.on("finish", () => record(res.statusCode));
2236
+ res.on("close", () => record("aborted"));
2237
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
2238
+ if (path === "/healthz" && req.method === "GET") return send(res, 200, { ok: true, version: VERSION });
2239
+ if (path !== "/mcp") return send(res, 404, { error: "Not found. The MCP endpoint is POST /mcp." });
2240
+ if (req.method !== "POST") {
2241
+ res.setHeader("Allow", "POST");
2242
+ return send(res, 405, { error: "This server is stateless: POST JSON-RPC to /mcp." });
2243
+ }
2244
+ const origin = req.headers.origin;
2245
+ if (origin && !options.allowedOrigins.includes(origin)) return send(res, 403, { error: "Origin not allowed." });
2246
+ const token2 = /^Bearer\s+(evx_\S+)$/i.exec(req.headers.authorization ?? "")?.[1];
2247
+ if (!token2) return unauthorized(res, `An Evrex bearer token is required. ${MINT_HINT}`);
2248
+ const verdict = await verify(token2);
2249
+ if (verdict.ok === "unavailable") return send(res, 503, { error: "Evrex is unreachable right now; retry shortly." });
2250
+ if (verdict.ok === false) {
2251
+ return unauthorized(
2252
+ res,
2253
+ verdict.reason === "cannot-read" ? `This Evrex token cannot read (it may be a capture-only credential). ${MINT_HINT}` : `This Evrex token was not recognised; it may be revoked or expired. ${MINT_HINT}`
2254
+ );
2255
+ }
2256
+ entry.org = verdict.orgId;
2257
+ let body;
2258
+ try {
2259
+ body = JSON.parse(await readBody(req));
2260
+ } catch (err) {
2261
+ if (err instanceof TooLarge) return send(res, 413, { error: "Request body exceeds 1 MiB." });
2262
+ return send(res, 400, { jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null });
2263
+ }
2264
+ if (Array.isArray(body)) {
2265
+ return send(res, 400, { jsonrpc: "2.0", error: { code: -32600, message: "Batch requests are not supported" }, id: null });
2266
+ }
2267
+ entry.tools = [toolLabel(body, toolNames)];
2268
+ const repoHeader = req.headers["x-evrex-repo"];
2269
+ const named = typeof repoHeader === "string" && repoHeader.trim() ? repoHeader : null;
2270
+ const repo = named ? repoRefFrom(named) : null;
2271
+ if (named && !repo) return send(res, 400, { error: "X-Evrex-Repo must be owner/name or a git remote URL." });
2272
+ await runInRequest({ token: token2, repo }, async () => {
2273
+ const server = createEvrexServer();
2274
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true });
2275
+ res.on("close", () => {
2276
+ void transport.close();
2277
+ void server.close();
2278
+ });
2279
+ await server.connect(transport);
2280
+ await transport.handleRequest(req, res, body);
2281
+ });
2282
+ };
2283
+ }
2284
+ function toolLabel(body, toolNames) {
2285
+ const message = body;
2286
+ const method = message?.method;
2287
+ if (method === "tools/call") {
2288
+ const name = message?.params?.name;
2289
+ return typeof name === "string" && toolNames.has(name) ? name : "unknown";
2290
+ }
2291
+ return typeof method === "string" && method.length <= 64 && /^[a-z/_]+$/.test(method) ? method : "unknown";
2292
+ }
2293
+ function readBody(req) {
2294
+ return new Promise((resolve, reject) => {
2295
+ const chunks = [];
2296
+ let size = 0;
2297
+ req.on("data", (chunk) => {
2298
+ size += chunk.length;
2299
+ if (size <= MAX_BODY_BYTES) chunks.push(chunk);
2300
+ });
2301
+ req.on("end", () => size > MAX_BODY_BYTES ? reject(new TooLarge()) : resolve(Buffer.concat(chunks).toString("utf8")));
2302
+ req.on("error", reject);
2303
+ req.on("close", () => {
2304
+ if (!req.complete) reject(new Error("Client disconnected."));
2305
+ });
2306
+ if (req.destroyed) reject(new Error("Client disconnected."));
2307
+ });
2308
+ }
2309
+ function send(res, status, value) {
2310
+ res.writeHead(status, { "Content-Type": "application/json" });
2311
+ res.end(JSON.stringify(value));
2312
+ }
2313
+ function unauthorized(res, message) {
2314
+ res.setHeader("WWW-Authenticate", 'Bearer realm="evrex"');
2315
+ send(res, 401, { error: message });
2316
+ }
2317
+ async function serve(env = process.env) {
2318
+ const port = Number(env.PORT ?? 3100);
2319
+ const configured = (env.EVREX_MCP_ALLOWED_ORIGINS ?? "").split(",").map((o) => o.trim()).filter(Boolean);
2320
+ const handler = createServeHandler({ allowedOrigins: configured.length > 0 ? configured : DEFAULT_ALLOWED_ORIGINS });
2321
+ const server = createServer((req, res) => {
2322
+ handler(req, res).catch((err) => {
2323
+ console.error("evrex-mcp serve: request failed:", err instanceof Error ? err.message : String(err));
2324
+ if (!res.headersSent) send(res, 500, { error: "Internal error." });
2325
+ });
2326
+ });
2327
+ await new Promise((resolve) => server.listen(port, resolve));
2328
+ console.error(`evrex-mcp serve ${VERSION}: listening on :${port}, backend ${evrexApi.baseUrl}`);
2329
+ for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => server.close(() => process.exit(0)));
2330
+ return server;
2331
+ }
2332
+ var MAX_BODY_BYTES, VALID_FOR_MS, INVALID_FOR_MS, MAX_CACHED_TOKENS, MINT_HINT, DEFAULT_ALLOWED_ORIGINS, TooLarge;
2333
+ var init_serve = __esm({
2334
+ "src/serve.ts"() {
2335
+ "use strict";
2336
+ init_client();
2337
+ init_request_context();
2338
+ init_server();
2339
+ init_tools();
2340
+ MAX_BODY_BYTES = 1024 * 1024;
2341
+ VALID_FOR_MS = 6e4;
2342
+ INVALID_FOR_MS = 1e4;
2343
+ MAX_CACHED_TOKENS = 1e3;
2344
+ MINT_HINT = "Mint a read-only token at https://app.evrex.ai/agents.";
2345
+ DEFAULT_ALLOWED_ORIGINS = ["https://claude.ai", "https://claude.com", "https://chatgpt.com", "https://app.evrex.ai"];
2346
+ TooLarge = class extends Error {
2347
+ };
2348
+ }
2349
+ });
2350
+
2351
+ // src/index.ts
2352
+ init_server();
2353
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2354
+
2355
+ // src/subcommands.ts
2356
+ var SUBCOMMANDS = {
2357
+ "evrex-hook": "hook.js",
2358
+ "evrex-capture": "capture.js",
2359
+ "evrex-import": "import.js",
2360
+ "evrex-tickets": "tickets.js",
2361
+ "evrex-pretool": "pretool.js",
2362
+ "evrex-continuity": "continuity.js",
2363
+ "evrex-account": "account.js"
2364
+ };
2365
+
2366
+ // src/index.ts
2000
2367
  function assertConfigured() {
2001
2368
  if (process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN) return;
2002
2369
  console.error(
@@ -2036,11 +2403,16 @@ async function main() {
2036
2403
  if (process.argv[2] === "enrol" || process.argv[2] === "enroll") {
2037
2404
  await runEnrolment();
2038
2405
  }
2406
+ if (process.argv[2] === "serve") {
2407
+ const { serve: serve2 } = await Promise.resolve().then(() => (init_serve(), serve_exports));
2408
+ await serve2();
2409
+ return;
2410
+ }
2039
2411
  const subcommand = SUBCOMMANDS[process.argv[2] ?? ""];
2040
2412
  if (subcommand) await runSubcommand(subcommand);
2041
2413
  assertConfigured();
2042
2414
  const transport = new StdioServerTransport();
2043
- await server.connect(transport);
2415
+ await createEvrexServer().connect(transport);
2044
2416
  }
2045
2417
  main().catch((err) => {
2046
2418
  console.error("evrex-mcp fatal error:", err);