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/README.md +1 -1
- package/dist/capture.js +1012 -0
- package/dist/hook.js +134 -0
- package/dist/import.js +1667 -0
- package/dist/index.js +734 -109
- package/dist/tickets.js +636 -0
- package/package.json +11 -2
package/dist/index.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
30
|
+
method,
|
|
36
31
|
headers: headers(),
|
|
37
|
-
body: JSON.stringify(body)
|
|
32
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
38
33
|
});
|
|
39
|
-
if (
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 (
|
|
133
|
-
options.logger?.warn("synthesis
|
|
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/
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
676
|
+
async completeJson(request2) {
|
|
181
677
|
try {
|
|
182
678
|
const message = await this.sdk.messages.create({
|
|
183
|
-
model:
|
|
184
|
-
max_tokens:
|
|
185
|
-
system
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
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
|
|
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 = "
|
|
208
|
-
|
|
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(
|
|
759
|
+
const res = await fetch(url, {
|
|
211
760
|
method: "POST",
|
|
212
761
|
headers: {
|
|
213
762
|
"Content-Type": "application/json",
|
|
214
|
-
|
|
763
|
+
"x-goog-api-key": this.key
|
|
215
764
|
},
|
|
216
765
|
body: JSON.stringify({
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
`
|
|
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.
|
|
242
|
-
return
|
|
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(`
|
|
786
|
+
this.logger.warn(`google call failed: ${describeError(err)}`);
|
|
245
787
|
return null;
|
|
246
788
|
}
|
|
247
789
|
}
|
|
248
790
|
};
|
|
249
|
-
var
|
|
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
|
-
|
|
256
|
-
async completeJson(
|
|
257
|
-
const
|
|
258
|
-
const
|
|
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(
|
|
806
|
+
${JSON.stringify(request2.schema)}` : "";
|
|
263
807
|
try {
|
|
264
|
-
const res = await fetch(
|
|
808
|
+
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
265
809
|
method: "POST",
|
|
266
810
|
headers: {
|
|
267
811
|
"Content-Type": "application/json",
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
547
|
-
const conf =
|
|
548
|
-
|
|
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
|
|
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);
|