evrex-mcp 0.3.0 → 0.5.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
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { z } from "zod";
7
-
8
- // src/tools.ts
9
- import { execFileSync } from "node:child_process";
10
- import { realpathSync } from "node:fs";
11
- import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
12
11
 
13
12
  // src/client.ts
14
- var DEFAULT_API_BASE_URL = "https://evrex-backend-production.up.railway.app";
15
- var API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
16
- var EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
13
+ var client_exports = {};
14
+ __export(client_exports, {
15
+ evrexApi: () => evrexApi
16
+ });
17
17
  function headers() {
18
18
  const base = { "Content-Type": "application/json" };
19
19
  if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
@@ -25,33 +25,328 @@ function describeFailure(method, path, status, statusText) {
25
25
  }
26
26
  return `${method} ${path} -> ${status} ${statusText}`;
27
27
  }
28
- async function get(path) {
29
- const res = await fetch(`${API_BASE_URL}${path}`, { headers: headers() });
30
- if (!res.ok) throw new Error(describeFailure("GET", path, res.status, res.statusText));
31
- return await res.json();
32
- }
33
- async function post(path, body) {
28
+ async function request(method, path, { body, absentIsAnswer } = {}) {
34
29
  const res = await fetch(`${API_BASE_URL}${path}`, {
35
- method: "POST",
30
+ method,
36
31
  headers: headers(),
37
- body: JSON.stringify(body)
32
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
38
33
  });
39
- if (!res.ok) throw new Error(describeFailure("POST", path, res.status, res.statusText));
34
+ if (res.status === 404 && absentIsAnswer) return null;
35
+ if (!res.ok) throw new Error(describeFailure(method, path, res.status, res.statusText));
40
36
  return await res.json();
41
37
  }
42
- var evrexApi = {
43
- baseUrl: API_BASE_URL,
44
- repos: () => get("/repos"),
45
- commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
46
- commit: (sha) => get(`/commits/${sha}`).catch(() => null),
47
- sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
48
- session: (id) => get(`/sessions/${id}`).catch(() => null),
49
- ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
50
- // Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
51
- // apps/backend/src/query/query.service.ts#search. Used by evrex_search,
52
- // which wants ranked hits fast, not a synthesized paragraph.
53
- search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
54
- };
38
+ var DEFAULT_API_BASE_URL, API_BASE_URL, EVREX_TOKEN, get, getOrNull, post, evrexApi;
39
+ var init_client = __esm({
40
+ "src/client.ts"() {
41
+ "use strict";
42
+ DEFAULT_API_BASE_URL = "https://api.evrex.ai";
43
+ API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
44
+ EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
45
+ get = (path) => request("GET", path);
46
+ getOrNull = (path) => request("GET", path, { absentIsAnswer: true });
47
+ post = (path, body) => request("POST", path, { body });
48
+ evrexApi = {
49
+ baseUrl: API_BASE_URL,
50
+ repos: () => get("/repos"),
51
+ commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
52
+ // Abbreviated shas resolve server-side, so a value pasted from `git log`
53
+ // works here (apps/backend/src/reads/reads.service.ts#resolveSha).
54
+ commit: (sha) => getOrNull(`/commits/${encodeURIComponent(sha)}`),
55
+ sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
56
+ session: (id) => getOrNull(`/sessions/${encodeURIComponent(id)}`),
57
+ ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
58
+ // Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
59
+ // apps/backend/src/query/query.service.ts#search. Used by evrex_search,
60
+ // which wants ranked hits fast, not a synthesized paragraph.
61
+ search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
62
+ };
63
+ }
64
+ });
65
+
66
+ // src/credential-store.ts
67
+ var credential_store_exports = {};
68
+ __export(credential_store_exports, {
69
+ CredentialStore: () => CredentialStore,
70
+ NoKeychainError: () => NoKeychainError,
71
+ systemRunner: () => systemRunner
72
+ });
73
+ import { spawn } from "node:child_process";
74
+ var SERVICE, ACCOUNT, systemRunner, NoKeychainError, CredentialStore, WINDOWS_PATH, WINDOWS_STORE, WINDOWS_RETRIEVE, WINDOWS_REMOVE;
75
+ var init_credential_store = __esm({
76
+ "src/credential-store.ts"() {
77
+ "use strict";
78
+ SERVICE = "evrex-capture";
79
+ ACCOUNT = "evrex";
80
+ systemRunner = {
81
+ platform: process.platform,
82
+ run(command, args, stdin) {
83
+ return new Promise((resolve) => {
84
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
85
+ let stdout = "";
86
+ let stderr = "";
87
+ child.stdout.on("data", (d) => stdout += d.toString());
88
+ child.stderr.on("data", (d) => stderr += d.toString());
89
+ child.on("error", () => resolve({ code: 127, stdout: "", stderr: "" }));
90
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
91
+ if (stdin !== void 0) child.stdin.write(stdin);
92
+ child.stdin.end();
93
+ });
94
+ }
95
+ };
96
+ NoKeychainError = class extends Error {
97
+ constructor(platform) {
98
+ super(
99
+ `evrex could not find a credential store on this machine (${platform}).
100
+ macOS needs \`security\`, which ships with the system.
101
+ Linux needs \`secret-tool\` \u2014 install libsecret-tools (Debian/Ubuntu)
102
+ or libsecret (Fedora/Arch), and make sure a keyring daemon is running.
103
+ Windows needs PowerShell.
104
+ evrex will not fall back to writing the credential in a plain file.`
105
+ );
106
+ this.name = "NoKeychainError";
107
+ }
108
+ };
109
+ CredentialStore = class {
110
+ constructor(runner = systemRunner) {
111
+ this.runner = runner;
112
+ }
113
+ async available() {
114
+ switch (this.runner.platform) {
115
+ case "darwin":
116
+ return (await this.runner.run("security", ["help"])).code !== 127;
117
+ case "win32":
118
+ return (await this.runner.run("powershell", ["-Command", "$PSVersionTable.PSVersion.Major"])).code !== 127;
119
+ default:
120
+ return (await this.runner.run("secret-tool", ["--version"])).code !== 127;
121
+ }
122
+ }
123
+ /**
124
+ * Replaces any existing credential rather than adding a second one. A
125
+ * machine that re-enrols after expiry must end up with exactly one entry, or
126
+ * the next read is a coin flip between the live credential and a dead one.
127
+ */
128
+ async store(secret) {
129
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
130
+ switch (this.runner.platform) {
131
+ case "darwin": {
132
+ const result = await this.runner.run(
133
+ "security",
134
+ ["add-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-U", "-w"],
135
+ `${secret}
136
+ ${secret}
137
+ `
138
+ );
139
+ if (result.code !== 0) throw new Error(`Keychain write failed: ${result.stderr.trim()}`);
140
+ return;
141
+ }
142
+ case "win32": {
143
+ const result = await this.runner.run(
144
+ "powershell",
145
+ ["-NoProfile", "-Command", WINDOWS_STORE],
146
+ secret
147
+ );
148
+ if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
149
+ return;
150
+ }
151
+ default: {
152
+ const result = await this.runner.run(
153
+ "secret-tool",
154
+ ["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
155
+ secret
156
+ );
157
+ if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
158
+ return;
159
+ }
160
+ }
161
+ }
162
+ /** Null when there is nothing stored, which is the normal pre-enrolment state. */
163
+ async retrieve() {
164
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
165
+ switch (this.runner.platform) {
166
+ case "darwin": {
167
+ const r = await this.runner.run("security", [
168
+ "find-generic-password",
169
+ "-a",
170
+ ACCOUNT,
171
+ "-s",
172
+ SERVICE,
173
+ "-w"
174
+ ]);
175
+ return r.code === 0 ? r.stdout.trim() || null : null;
176
+ }
177
+ case "win32": {
178
+ const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
179
+ return r.code === 0 ? r.stdout.trim() || null : null;
180
+ }
181
+ default: {
182
+ const r = await this.runner.run("secret-tool", [
183
+ "lookup",
184
+ "service",
185
+ SERVICE,
186
+ "account",
187
+ ACCOUNT
188
+ ]);
189
+ return r.code === 0 ? r.stdout.trim() || null : null;
190
+ }
191
+ }
192
+ }
193
+ /** Idempotent: removing a credential that is not there is not an error. */
194
+ async remove() {
195
+ if (!await this.available()) return;
196
+ switch (this.runner.platform) {
197
+ case "darwin":
198
+ await this.runner.run("security", [
199
+ "delete-generic-password",
200
+ "-a",
201
+ ACCOUNT,
202
+ "-s",
203
+ SERVICE
204
+ ]);
205
+ return;
206
+ case "win32":
207
+ await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
208
+ return;
209
+ default:
210
+ await this.runner.run("secret-tool", [
211
+ "clear",
212
+ "service",
213
+ SERVICE,
214
+ "account",
215
+ ACCOUNT
216
+ ]);
217
+ return;
218
+ }
219
+ }
220
+ };
221
+ WINDOWS_PATH = "$env:APPDATA\\evrex\\capture.cred";
222
+ WINDOWS_STORE = `
223
+ $ErrorActionPreference = 'Stop'
224
+ $p = "${WINDOWS_PATH}"
225
+ New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
226
+ $secret = [Console]::In.ReadToEnd().Trim()
227
+ ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
228
+ `.trim();
229
+ WINDOWS_RETRIEVE = `
230
+ $ErrorActionPreference = 'Stop'
231
+ $p = "${WINDOWS_PATH}"
232
+ if (-not (Test-Path $p)) { exit 1 }
233
+ $sec = Get-Content $p | ConvertTo-SecureString
234
+ [Runtime.InteropServices.Marshal]::PtrToStringAuto(
235
+ [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
236
+ `.trim();
237
+ WINDOWS_REMOVE = `
238
+ $p = "${WINDOWS_PATH}"
239
+ if (Test-Path $p) { Remove-Item $p -Force }
240
+ `.trim();
241
+ }
242
+ });
243
+
244
+ // src/enrol.ts
245
+ var enrol_exports = {};
246
+ __export(enrol_exports, {
247
+ RENEW_WITHIN_MS: () => RENEW_WITHIN_MS,
248
+ enrol: () => enrol,
249
+ needsRenewal: () => needsRenewal
250
+ });
251
+ async function keychainReady(deps) {
252
+ try {
253
+ return await deps.store.available();
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+ async function enrol(deps) {
259
+ if (!await keychainReady(deps)) {
260
+ deps.log(new NoKeychainError(process.platform).message);
261
+ return { ok: false, reason: "no_keychain" };
262
+ }
263
+ let started;
264
+ try {
265
+ const res = await deps.fetch(`${deps.baseUrl}/auth/device/start`, {
266
+ method: "POST",
267
+ headers: { "content-type": "application/json" },
268
+ body: JSON.stringify({
269
+ deviceName: deps.hostname(),
270
+ agentKind: "claude-code",
271
+ clientVersion: deps.clientVersion
272
+ })
273
+ });
274
+ if (!res.ok) throw new Error(String(res.status));
275
+ started = await res.json();
276
+ } catch {
277
+ deps.log(`Could not reach evrex at ${deps.baseUrl}.`);
278
+ return { ok: false, reason: "unreachable" };
279
+ }
280
+ deps.log("");
281
+ deps.log(` Enrolling ${deps.hostname()}.`);
282
+ deps.log("");
283
+ deps.log(` 1. Open ${deps.dashboardUrl}/devices`);
284
+ deps.log(` 2. Enter this code: ${started.userCode}`);
285
+ deps.log("");
286
+ deps.log(" Waiting for approval. Ctrl-C to stop.");
287
+ let interval = started.intervalMs;
288
+ for (; ; ) {
289
+ await deps.sleep(interval);
290
+ let outcome;
291
+ try {
292
+ const res = await deps.fetch(`${deps.baseUrl}/auth/device/poll`, {
293
+ method: "POST",
294
+ headers: { "content-type": "application/json" },
295
+ body: JSON.stringify({ deviceCode: started.deviceCode })
296
+ });
297
+ outcome = await res.json();
298
+ } catch {
299
+ continue;
300
+ }
301
+ switch (outcome.status) {
302
+ case "pending":
303
+ continue;
304
+ case "slow_down":
305
+ interval = Math.max(interval, outcome.retryAfterMs);
306
+ continue;
307
+ case "denied":
308
+ deps.log(" Enrolment was declined.");
309
+ return { ok: false, reason: "denied" };
310
+ case "expired":
311
+ deps.log(" That code expired. Run enrolment again for a new one.");
312
+ return { ok: false, reason: "expired" };
313
+ case "granted": {
314
+ await deps.store.store(outcome.token);
315
+ deps.log("");
316
+ deps.log(` ${deps.hostname()} is enrolled.`);
317
+ if (outcome.expiresAt) {
318
+ deps.log(` The credential renews automatically before ${outcome.expiresAt.slice(0, 10)}.`);
319
+ }
320
+ return { ok: true, expiresAt: outcome.expiresAt };
321
+ }
322
+ }
323
+ }
324
+ }
325
+ function needsRenewal(expiresAt, now) {
326
+ if (!expiresAt) return false;
327
+ const expiry = new Date(expiresAt).getTime();
328
+ if (Number.isNaN(expiry)) return false;
329
+ return expiry - now <= RENEW_WITHIN_MS;
330
+ }
331
+ var RENEW_WITHIN_MS;
332
+ var init_enrol = __esm({
333
+ "src/enrol.ts"() {
334
+ "use strict";
335
+ init_credential_store();
336
+ RENEW_WITHIN_MS = 7 * 24 * 60 * 60 * 1e3;
337
+ }
338
+ });
339
+
340
+ // src/index.ts
341
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
342
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
343
+ import { z } from "zod";
344
+
345
+ // src/tools.ts
346
+ init_client();
347
+ import { execFileSync } from "node:child_process";
348
+ import { realpathSync } from "node:fs";
349
+ import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
55
350
 
56
351
  // ../../packages/llm-core/src/client.ts
57
352
  import Anthropic from "@anthropic-ai/sdk";
@@ -60,6 +355,75 @@ var DEFAULT_MAX_TOKENS = 16e3;
60
355
  // ../../packages/llm-core/src/complete.ts
61
356
  import Anthropic2 from "@anthropic-ai/sdk";
62
357
 
358
+ // ../../packages/llm-core/src/providers.ts
359
+ var OPENAI_COMPATIBLE_BASE_URLS = {
360
+ openai: "https://api.openai.com/v1",
361
+ groq: "https://api.groq.com/openai/v1",
362
+ cerebras: "https://api.cerebras.ai/v1",
363
+ openrouter: "https://openrouter.ai/api/v1"
364
+ };
365
+ var DEFAULT_MODELS = {
366
+ anthropic: "claude-haiku-4-5",
367
+ openai: "gpt-4o",
368
+ // gemini-2.0-flash is retired: Google answers a request for it with
369
+ // "no longer available to new users. Please update your code to use
370
+ // models/gemini-3.6-flash". Second time a stale default has done this —
371
+ // Groq's llama-3.3-70b-versatile went the same way — and the failure looks
372
+ // identical to having no key at all, so it is worth checking against the
373
+ // provider's own model list when insights stop appearing.
374
+ google: "gemini-3.6-flash",
375
+ // Free tiers, chosen for extraction: a capable model behind a rate limit
376
+ // beats a weak one behind none, because indexing is a background batch and
377
+ // can wait out a limit but cannot recover a missed rejected approach.
378
+ // The smaller of the two open models, and the better one here: measured on
379
+ // ten real sessions it beat gpt-oss-120b on every category and ran faster.
380
+ // Groq's catalogue also turns over — llama-3.3-70b-versatile was the obvious
381
+ // default and 404s now — and a wrong name is a 404 that degrades to no
382
+ // insights, so `GET /models` is the check when this breaks.
383
+ groq: "openai/gpt-oss-20b",
384
+ cerebras: "llama-3.3-70b",
385
+ openrouter: "meta-llama/llama-3.3-70b-instruct:free",
386
+ // Cheapest credible host of this model measured at the time of writing
387
+ // ($0.03/$0.14 per million against Cloudflare's $0.20/$0.30), and the model
388
+ // that scored best on decisions and constraints in
389
+ // apps/backend/scripts/eval-extraction.mjs.
390
+ "openai-compatible": "openai/gpt-oss-20b",
391
+ // Small on purpose. Every invocation re-sends the CLI's own system prompt
392
+ // and is billed to the operator, so the default is the cheapest model that
393
+ // can read a transcript rather than the CLI's own default.
394
+ "claude-cli": "haiku",
395
+ "codex-cli": "",
396
+ // Measured the best of the models that fit on a laptop — see
397
+ // apps/backend/scripts/eval-extraction.mjs. Overridden with
398
+ // EVREX_LOCAL_MODEL for whatever is already pulled.
399
+ local: "qwen3:8b"
400
+ };
401
+ var DEFAULT_LOCAL_BASE_URL = "http://localhost:11434/v1";
402
+ function providerForBaseUrl(baseUrl) {
403
+ const url = (baseUrl ?? "").trim();
404
+ if (!url) return null;
405
+ return /^https?:\/\//i.test(url) ? "openai-compatible" : null;
406
+ }
407
+ function detectProvider(key) {
408
+ const k = (key ?? "").trim();
409
+ if (!k) return null;
410
+ if (k.startsWith("sk-ant-")) return "anthropic";
411
+ if (k.startsWith("AIza") || k.startsWith("AQ.")) return "google";
412
+ if (k.startsWith("gsk_")) return "groq";
413
+ if (k.startsWith("csk-")) return "cerebras";
414
+ if (k.startsWith("sk-or-")) return "openrouter";
415
+ if (k.startsWith("sk-")) return "openai";
416
+ if (/^https?:\/\//i.test(k)) return "local";
417
+ if (/^(ollama|local)$/i.test(k)) return "local";
418
+ if (/^claude-?cli$/i.test(k)) return "claude-cli";
419
+ if (/^codex-?cli$/i.test(k)) return "codex-cli";
420
+ return null;
421
+ }
422
+ function localBaseUrl(key) {
423
+ const k = key.trim();
424
+ return /^https?:\/\//i.test(k) ? k.replace(/\/+$/, "") : DEFAULT_LOCAL_BASE_URL;
425
+ }
426
+
63
427
  // ../../packages/llm-core/src/extraction.ts
64
428
  var MAX_ITEMS_PER_CATEGORY = 6;
65
429
  var EXTRACTION_SYSTEM_PROMPT = [
@@ -129,8 +493,14 @@ async function synthesizeAnswer(question, evidence, client2, options = {}) {
129
493
  );
130
494
  return null;
131
495
  }
132
- if (typeof body !== "object" || body === null || !isValidSynthesis(body, evidence.length)) {
133
- options.logger?.warn("synthesis response cited evidence it was not given");
496
+ if (body === null) {
497
+ options.logger?.warn("synthesis skipped: no answer from the model");
498
+ return null;
499
+ }
500
+ if (typeof body !== "object" || !isValidSynthesis(body, evidence.length)) {
501
+ options.logger?.warn(
502
+ "synthesis response was not usable: wrong shape, wrong sentence count, or a citation to evidence it was not given"
503
+ );
134
504
  return null;
135
505
  }
136
506
  const raw = body;
@@ -141,24 +511,149 @@ async function synthesizeAnswer(question, evidence, client2, options = {}) {
141
511
  };
142
512
  }
143
513
 
144
- // ../../packages/llm-core/src/providers.ts
145
- var DEFAULT_MODELS = {
146
- anthropic: "claude-opus-5",
147
- openai: "gpt-4o",
148
- google: "gemini-2.0-flash"
514
+ // ../../packages/llm-core/src/cli-client.ts
515
+ import { execFile } from "node:child_process";
516
+ var TIMEOUT_MS = 12e4;
517
+ var CLAUDE = {
518
+ provider: "claude-cli",
519
+ command: "claude",
520
+ args: (model) => [
521
+ "-p",
522
+ // `--bare` never reads OAuth credentials or the system keychain, so this
523
+ // cannot run on a Claude.ai subscription — it requires ANTHROPIC_API_KEY.
524
+ //
525
+ // That is deliberate and it is the whole compliance question. Anthropic's
526
+ // consumer terms prohibit accessing the service "through automated or
527
+ // non-human means, whether through a bot, script, or otherwise" except
528
+ // via an API key, and evrex spawning the CLI is a script doing exactly
529
+ // that. Anthropic's own headless documentation points the same way:
530
+ // `--bare` is "the recommended mode for scripted and SDK calls" and "will
531
+ // become the default for `-p` in a future release".
532
+ //
533
+ // It costs this provider its reason to exist — with an API key in the
534
+ // environment there is little point going through the CLI at all — which
535
+ // is the honest answer to "can we spend a subscription instead of a
536
+ // budget": no, and evrex had already recorded that conclusion once.
537
+ "--bare",
538
+ "--output-format",
539
+ "json",
540
+ // Extraction is a reading task; the CLI's default is far larger than it
541
+ // needs and is billed to the operator.
542
+ ...model ? ["--model", model] : ["--model", "haiku"]
543
+ ],
544
+ parse: (stdout) => {
545
+ try {
546
+ const envelope = JSON.parse(stdout);
547
+ if (envelope.is_error) return null;
548
+ return typeof envelope.result === "string" ? envelope.result : null;
549
+ } catch {
550
+ return null;
551
+ }
552
+ }
149
553
  };
150
- function detectProvider(key) {
151
- const k = (key ?? "").trim();
152
- if (!k) return null;
153
- if (k.startsWith("sk-ant-")) return "anthropic";
154
- if (k.startsWith("AIza")) return "google";
155
- if (k.startsWith("sk-")) return "openai";
156
- return null;
554
+ var CODEX = {
555
+ provider: "codex-cli",
556
+ command: "codex",
557
+ // `exec` is Codex's non-interactive mode. It prints the answer plainly, so
558
+ // there is no envelope to unwrap.
559
+ args: (model) => ["exec", ...model ? ["--model", model] : []],
560
+ parse: (stdout) => stdout.trim() || null,
561
+ requiresEnv: "OPENAI_API_KEY"
562
+ };
563
+ var CLI_SPECS = {
564
+ "claude-cli": CLAUDE,
565
+ "codex-cli": CODEX
566
+ };
567
+ function unfence(text) {
568
+ const fenced = /```(?:json)?\s*([\s\S]*?)\s*```/.exec(text);
569
+ return (fenced ? fenced[1] : text).trim();
157
570
  }
571
+ var CliClient = class {
572
+ constructor(spec, model, logger, run = execFile) {
573
+ this.spec = spec;
574
+ this.model = model;
575
+ this.logger = logger;
576
+ this.run = run;
577
+ this.provider = spec.provider;
578
+ }
579
+ /**
580
+ * Never set. A CLI bills the operator's existing subscription rather than a
581
+ * metered key, so there is no quota to spend that handing over to evrex's
582
+ * own would relieve — and a CLI that refuses is a configuration problem the
583
+ * operator has to fix, not one another provider can absorb.
584
+ */
585
+ exhausted = false;
586
+ provider;
587
+ async completeJson(request2) {
588
+ const required = this.spec.requiresEnv;
589
+ if (required && !process.env[required]) {
590
+ this.logger.warn(
591
+ `${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.`
592
+ );
593
+ return null;
594
+ }
595
+ const shape = request2.schema ? `
596
+
597
+ Respond with JSON matching exactly this schema, and nothing else:
598
+ ${JSON.stringify(request2.schema)}` : "\n\nRespond with JSON and nothing else.";
599
+ const prompt = `${request2.system}${shape}
600
+
601
+ ${request2.user}`;
602
+ const stdout = await new Promise((resolve) => {
603
+ const child = this.run(
604
+ this.spec.command,
605
+ [...this.spec.args(request2.model ?? this.model)],
606
+ { timeout: TIMEOUT_MS, maxBuffer: 1 << 26 },
607
+ (error, out) => {
608
+ if (error) {
609
+ this.logger.warn(
610
+ `${this.spec.command} call failed: ${error.message.slice(0, 200)}`
611
+ );
612
+ resolve(null);
613
+ return;
614
+ }
615
+ resolve(String(out));
616
+ }
617
+ );
618
+ child.stdin?.end(prompt);
619
+ });
620
+ if (stdout === null) return null;
621
+ const answer = this.spec.parse(stdout);
622
+ if (!answer) return null;
623
+ try {
624
+ return JSON.parse(unfence(answer));
625
+ } catch {
626
+ this.logger.warn(`${this.spec.command} did not return usable JSON`);
627
+ return null;
628
+ }
629
+ }
630
+ };
158
631
 
159
632
  // ../../packages/llm-core/src/provider-clients.ts
160
633
  import Anthropic3 from "@anthropic-ai/sdk";
161
634
  var NOOP = { warn: () => void 0, error: () => void 0 };
635
+ var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
636
+ var MAX_RETRIES = 4;
637
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
638
+ function isExhaustedStatus(status, retriesSpent) {
639
+ if (status === 401 || status === 403) return true;
640
+ return status === 429 && retriesSpent;
641
+ }
642
+ async function waitBeforeRetry(response, attempt, sleep) {
643
+ const header = response.headers.get("retry-after") ?? response.headers.get("x-ratelimit-reset-tokens");
644
+ const seconds = header ? parseDuration(header) : null;
645
+ const backoff = Math.min(8 * 2 ** attempt, 60);
646
+ const wait = Math.min(seconds ?? backoff, MAX_RETRY_WAIT_SECONDS);
647
+ await sleep(wait * 1e3);
648
+ }
649
+ var MAX_RETRY_WAIT_SECONDS = 90;
650
+ function parseDuration(value) {
651
+ const plain = Number(value);
652
+ if (Number.isFinite(plain)) return plain;
653
+ const match = /^(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$/.exec(value.trim());
654
+ if (!match || !match[1] && !match[2]) return null;
655
+ return Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0);
656
+ }
162
657
  function parseJsonBody(text) {
163
658
  const trimmed = text.trim();
164
659
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(trimmed);
@@ -176,114 +671,189 @@ var AnthropicClient = class {
176
671
  this.sdk = new Anthropic3({ apiKey: key });
177
672
  }
178
673
  provider = "anthropic";
674
+ exhausted = false;
179
675
  sdk;
180
- async completeJson(request) {
676
+ async completeJson(request2) {
181
677
  try {
182
678
  const message = await this.sdk.messages.create({
183
- model: request.model ?? this.model,
184
- max_tokens: request.maxTokens ?? DEFAULT_MAX_TOKENS,
185
- system: request.system,
186
- messages: [{ role: "user", content: request.user }],
187
- ...request.schema ? {
679
+ model: request2.model ?? this.model,
680
+ max_tokens: request2.maxTokens ?? DEFAULT_MAX_TOKENS,
681
+ // The breakpoint sits at the end of the system prompt, which is the
682
+ // only part of these requests that repeats. Rendering order is
683
+ // tools -> system -> messages, so a marker here caches everything
684
+ // ahead of the transcript.
685
+ //
686
+ // Deliberately NOT on the user block. That block is a different
687
+ // transcript chunk on every call, so a breakpoint there would write a
688
+ // fresh cache entry per request and read none of them back — paying
689
+ // the write premium for nothing, which is worse than not caching.
690
+ system: [
691
+ {
692
+ type: "text",
693
+ text: request2.system,
694
+ cache_control: { type: "ephemeral" }
695
+ }
696
+ ],
697
+ messages: [{ role: "user", content: request2.user }],
698
+ ...request2.schema ? {
188
699
  output_config: {
189
- format: { type: "json_schema", schema: request.schema }
700
+ format: { type: "json_schema", schema: request2.schema }
190
701
  }
191
702
  } : {}
192
703
  });
704
+ this.reportCacheUsage(message.usage);
193
705
  const text = message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
194
706
  return parseJsonBody(text);
195
707
  } catch (err) {
708
+ const status = err?.status;
709
+ if (typeof status === "number" && isExhaustedStatus(status, true)) {
710
+ this.exhausted = true;
711
+ }
196
712
  this.logger.warn(`anthropic call failed: ${describeError(err)}`);
197
713
  return null;
198
714
  }
199
715
  }
716
+ /**
717
+ * Says out loud whether the cache was actually used.
718
+ *
719
+ * A prompt shorter than the model's minimum cacheable prefix is not an
720
+ * error and produces no warning from the API — it simply never caches, and
721
+ * `cache_control` sits in the request looking as though it works. The
722
+ * minimum is per-model and not monotonic across generations (4,096 tokens
723
+ * on Haiku 4.5, 512 on Opus 5), so the same prompt can cache on one model
724
+ * and silently not on another.
725
+ *
726
+ * Reported once per process rather than per call: this is a fact about the
727
+ * prompt and the model, identical for every session in a run, and a line
728
+ * per extraction would be noise.
729
+ */
730
+ reportedCacheState = false;
731
+ reportCacheUsage(usage) {
732
+ if (this.reportedCacheState) return;
733
+ this.reportedCacheState = true;
734
+ const read = usage.cache_read_input_tokens ?? 0;
735
+ const written = usage.cache_creation_input_tokens ?? 0;
736
+ if (read > 0 || written > 0) return;
737
+ this.logger.warn(
738
+ `[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.`
739
+ );
740
+ }
200
741
  };
201
- var OpenAiClient = class {
202
- constructor(key, model, logger) {
742
+ var GoogleClient = class {
743
+ constructor(key, model, logger, sleep = realSleep) {
203
744
  this.key = key;
204
745
  this.model = model;
205
746
  this.logger = logger;
747
+ this.sleep = sleep;
206
748
  }
207
- provider = "openai";
208
- async completeJson(request) {
749
+ provider = "google";
750
+ exhausted = false;
751
+ async completeJson(request2, attempt = 0) {
752
+ const model = request2.model ?? this.model;
753
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
754
+ const shape = request2.schema ? `
755
+
756
+ Respond with JSON matching exactly this schema:
757
+ ${JSON.stringify(request2.schema)}` : "";
209
758
  try {
210
- const res = await fetch("https://api.openai.com/v1/chat/completions", {
759
+ const res = await fetch(url, {
211
760
  method: "POST",
212
761
  headers: {
213
762
  "Content-Type": "application/json",
214
- Authorization: `Bearer ${this.key}`
763
+ "x-goog-api-key": this.key
215
764
  },
216
765
  body: JSON.stringify({
217
- model: request.model ?? this.model,
218
- messages: [
219
- { role: "system", content: request.system },
220
- { role: "user", content: request.user }
221
- ],
222
- ...request.schema ? {
223
- response_format: {
224
- type: "json_schema",
225
- json_schema: {
226
- name: "evrex_result",
227
- strict: true,
228
- schema: request.schema
229
- }
230
- }
231
- } : { response_format: { type: "json_object" } }
766
+ systemInstruction: { parts: [{ text: request2.system + shape }] },
767
+ contents: [{ role: "user", parts: [{ text: request2.user }] }],
768
+ generationConfig: { responseMimeType: "application/json" }
232
769
  })
233
770
  });
771
+ if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
772
+ await waitBeforeRetry(res, attempt, this.sleep);
773
+ return this.completeJson(request2, attempt + 1);
774
+ }
234
775
  if (!res.ok) {
776
+ if (isExhaustedStatus(res.status, true)) this.exhausted = true;
235
777
  this.logger.warn(
236
- `openai call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
778
+ `google call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
237
779
  );
238
780
  return null;
239
781
  }
240
782
  const body = await res.json();
241
- const text = body.choices?.[0]?.message?.content;
242
- return typeof text === "string" ? parseJsonBody(text) : null;
783
+ const text = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
784
+ return text ? parseJsonBody(text) : null;
243
785
  } catch (err) {
244
- this.logger.warn(`openai call failed: ${describeError(err)}`);
786
+ this.logger.warn(`google call failed: ${describeError(err)}`);
245
787
  return null;
246
788
  }
247
789
  }
248
790
  };
249
- var GoogleClient = class {
250
- constructor(key, model, logger) {
791
+ var OpenAiCompatibleClient = class {
792
+ constructor(provider, baseUrl, key, model, logger, sleep = realSleep) {
793
+ this.provider = provider;
794
+ this.baseUrl = baseUrl;
251
795
  this.key = key;
252
796
  this.model = model;
253
797
  this.logger = logger;
798
+ this.sleep = sleep;
254
799
  }
255
- provider = "google";
256
- async completeJson(request) {
257
- const model = request.model ?? this.model;
258
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
259
- const shape = request.schema ? `
800
+ exhausted = false;
801
+ async completeJson(request2, attempt = 0) {
802
+ const strict = this.provider === "openai";
803
+ const shape = request2.schema && !strict ? `
260
804
 
261
805
  Respond with JSON matching exactly this schema:
262
- ${JSON.stringify(request.schema)}` : "";
806
+ ${JSON.stringify(request2.schema)}` : "";
263
807
  try {
264
- const res = await fetch(url, {
808
+ const res = await fetch(`${this.baseUrl}/chat/completions`, {
265
809
  method: "POST",
266
810
  headers: {
267
811
  "Content-Type": "application/json",
268
- "x-goog-api-key": this.key
812
+ // A local server does not want one, and some reject an empty bearer
813
+ // token outright.
814
+ ...this.key ? { Authorization: `Bearer ${this.key}` } : {}
269
815
  },
270
816
  body: JSON.stringify({
271
- systemInstruction: { parts: [{ text: request.system + shape }] },
272
- contents: [{ role: "user", parts: [{ text: request.user }] }],
273
- generationConfig: { responseMimeType: "application/json" }
817
+ model: request2.model ?? this.model,
818
+ messages: [
819
+ { role: "system", content: request2.system + shape },
820
+ { role: "user", content: request2.user }
821
+ ],
822
+ ...request2.schema && strict ? {
823
+ response_format: {
824
+ type: "json_schema",
825
+ json_schema: {
826
+ name: "evrex_result",
827
+ strict: true,
828
+ schema: request2.schema
829
+ }
830
+ }
831
+ } : { response_format: { type: "json_object" } },
832
+ // Extraction is a reading task with a right answer, not a writing
833
+ // task, and a default sampling temperature makes it a different
834
+ // answer each run: one model scored 53% and then 20% on the same ten
835
+ // sessions before this was pinned. Indexing twice must not produce
836
+ // two different histories.
837
+ temperature: 0,
838
+ stream: false
274
839
  })
275
840
  });
841
+ if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
842
+ await waitBeforeRetry(res, attempt, this.sleep);
843
+ return this.completeJson(request2, attempt + 1);
844
+ }
276
845
  if (!res.ok) {
846
+ if (isExhaustedStatus(res.status, true)) this.exhausted = true;
277
847
  this.logger.warn(
278
- `google call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
848
+ `${this.provider} call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
279
849
  );
280
850
  return null;
281
851
  }
282
852
  const body = await res.json();
283
- const text = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
284
- return text ? parseJsonBody(text) : null;
853
+ const text = body.choices?.[0]?.message?.content;
854
+ return typeof text === "string" ? parseJsonBody(text) : null;
285
855
  } catch (err) {
286
- this.logger.warn(`google call failed: ${describeError(err)}`);
856
+ this.logger.warn(`${this.provider} call failed: ${describeError(err)}`);
287
857
  return null;
288
858
  }
289
859
  }
@@ -292,14 +862,37 @@ function describeError(err) {
292
862
  return err instanceof Error ? err.message : String(err);
293
863
  }
294
864
  function createModelClient(key, options = {}) {
295
- const provider = detectProvider(key);
865
+ const provider = providerForBaseUrl(options.baseUrl) ?? detectProvider(key);
296
866
  if (!provider || !key) return null;
297
867
  const logger = options.logger ?? NOOP;
298
868
  const model = options.model ?? DEFAULT_MODELS[provider];
299
869
  const trimmed = key.trim();
300
870
  if (provider === "anthropic") return new AnthropicClient(trimmed, model, logger);
301
- if (provider === "openai") return new OpenAiClient(trimmed, model, logger);
302
- return new GoogleClient(trimmed, model, logger);
871
+ const sleep = options.sleep ?? realSleep;
872
+ if (provider === "google")
873
+ return new GoogleClient(trimmed, model, logger, sleep);
874
+ const cli = CLI_SPECS[provider];
875
+ if (cli) return new CliClient(cli, model, logger);
876
+ if (provider === "local") {
877
+ return new OpenAiCompatibleClient(
878
+ provider,
879
+ options.baseUrl ?? localBaseUrl(trimmed),
880
+ null,
881
+ model,
882
+ logger,
883
+ sleep
884
+ );
885
+ }
886
+ const baseUrl = options.baseUrl ?? OPENAI_COMPATIBLE_BASE_URLS[provider];
887
+ if (!baseUrl) return null;
888
+ return new OpenAiCompatibleClient(
889
+ provider,
890
+ baseUrl,
891
+ trimmed,
892
+ model,
893
+ logger,
894
+ sleep
895
+ );
303
896
  }
304
897
 
305
898
  // src/synthesize.ts
@@ -357,6 +950,10 @@ async function synthesize(question, evidence) {
357
950
 
358
951
  // src/tools.ts
359
952
  var MAX_EXCERPT = 220;
953
+ function confidenceLabel(p) {
954
+ if (p.status === "inferred") return `${Math.round((p.confidence ?? 0) * 100)}%`;
955
+ return p.status;
956
+ }
360
957
  var MAX_ITEMS = 5;
361
958
  function truncate(text, max) {
362
959
  return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
@@ -490,6 +1087,7 @@ async function evrexWhy(filePath, question) {
490
1087
  "NOTE: the blocks above were extracted by cue-phrase matching, not by a model reading the conversation \u2014 they may be incomplete or miss context."
491
1088
  );
492
1089
  }
1090
+ const hasBlocks = rejected.length > 0 || constraints.length > 0 || decisions.length > 0;
493
1091
  if (synthesisEnabled()) {
494
1092
  const answer = await synthesize(text, evidence);
495
1093
  parts.push(
@@ -497,12 +1095,11 @@ async function evrexWhy(filePath, question) {
497
1095
  );
498
1096
  } else {
499
1097
  parts.push(
500
- "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. Answer the user from the evidence below; if it does not actually cover their question, say that rather than inferring."
1098
+ 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. Answer the user from the evidence below; if it does not actually cover their question, say that rather than inferring." : "HOW TO USE THIS: no decisions, constraints or rejected approaches were extracted for this file \u2014 only the raw evidence below. Treat it as history to read, not as settled policy, and say so if it does not cover the question."
501
1099
  );
502
1100
  }
503
1101
  const evidenceLines = evidence.slice(0, MAX_ITEMS).map((e) => {
504
- const conf = e.provenance.status === "verified" ? "verified" : `${Math.round((e.provenance.confidence ?? 0) * 100)}%`;
505
- return `- [${e.kind} ${conf}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
1102
+ return `- [${e.kind} ${confidenceLabel(e.provenance)}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
506
1103
  });
507
1104
  parts.push(`EVIDENCE:
508
1105
  ${evidenceLines.join("\n")}`);
@@ -528,7 +1125,7 @@ async function evrexSearch(query) {
528
1125
  const rank = (e) => e.provenance.status === "verified" ? 1 : e.provenance.confidence ?? 0;
529
1126
  results.sort((a, b) => rank(b.e) - rank(a.e));
530
1127
  const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo, e }) => {
531
- const conf = e.provenance.status === "verified" ? "verified" : `${Math.round((e.provenance.confidence ?? 0) * 100)}%`;
1128
+ const conf = confidenceLabel(e.provenance);
532
1129
  const repoTag = multiRepo ? ` \xB7 ${repo.name}` : "";
533
1130
  return `- [${e.kind} ${e.refId.slice(0, 8)} ${conf}${repoTag}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), MAX_EXCERPT)}`;
534
1131
  });
@@ -543,9 +1140,15 @@ ${truncate(commit.body, MAX_EXCERPT)}` : ""}`
543
1140
  ];
544
1141
  if (commit.link) {
545
1142
  const session = await evrexApi.session(commit.link.sessionId);
546
- const status = commit.link.provenance.status === "verified" ? "verified" : "inferred";
547
- const conf = commit.link.provenance.status === "verified" ? "" : ` (${Math.round((commit.link.provenance.confidence ?? 0) * 100)}%)`;
548
- parts.push(`LINKED SESSION (${status}${conf}): ${session ? session.intent : commit.link.sessionId}`);
1143
+ const status = commit.link.provenance.status;
1144
+ const conf = status === "inferred" ? ` (${Math.round((commit.link.provenance.confidence ?? 0) * 100)}%)` : "";
1145
+ if (session) {
1146
+ parts.push(`LINKED SESSION (${status}${conf}): ${session.intent}`);
1147
+ } else {
1148
+ parts.push(
1149
+ `LINKED SESSION (${status}${conf}): ${commit.link.sessionId} \u2014 NOT INDEXED. The commit was stamped as belonging to this session, but the transcript was never uploaded, so no reasoning is available for it. Do not treat this as recovered context.`
1150
+ );
1151
+ }
549
1152
  if (session) {
550
1153
  if (session.rejected.length > 0) {
551
1154
  parts.push(
@@ -575,7 +1178,8 @@ ${lines.join("\n")}`);
575
1178
  }
576
1179
 
577
1180
  // src/index.ts
578
- var server = new McpServer({ name: "evrex", version: "0.1.0" });
1181
+ var VERSION = "0.4.0";
1182
+ var server = new McpServer({ name: "evrex", version: VERSION });
579
1183
  server.registerTool(
580
1184
  "evrex_why",
581
1185
  {
@@ -626,7 +1230,28 @@ function assertConfigured() {
626
1230
  );
627
1231
  process.exit(1);
628
1232
  }
1233
+ async function runEnrolment() {
1234
+ const { enrol: enrol2 } = await Promise.resolve().then(() => (init_enrol(), enrol_exports));
1235
+ const { CredentialStore: CredentialStore3 } = await Promise.resolve().then(() => (init_credential_store(), credential_store_exports));
1236
+ const { hostname } = await import("node:os");
1237
+ const { evrexApi: evrexApi2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1238
+ const result = await enrol2({
1239
+ fetch: globalThis.fetch,
1240
+ store: new CredentialStore3(),
1241
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
1242
+ log: (line) => console.error(line),
1243
+ now: () => Date.now(),
1244
+ hostname,
1245
+ baseUrl: evrexApi2.baseUrl,
1246
+ dashboardUrl: process.env.EVREX_DASHBOARD_URL ?? "https://app.evrex.ai",
1247
+ clientVersion: VERSION
1248
+ });
1249
+ process.exit(result.ok ? 0 : 1);
1250
+ }
629
1251
  async function main() {
1252
+ if (process.argv[2] === "enrol" || process.argv[2] === "enroll") {
1253
+ await runEnrolment();
1254
+ }
630
1255
  assertConfigured();
631
1256
  const transport = new StdioServerTransport();
632
1257
  await server.connect(transport);