evrex-mcp 0.9.1 → 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/README.md +24 -0
- package/dist/account.js +24 -2
- package/dist/capture.js +35 -5
- package/dist/continuity.js +24 -2
- package/dist/hook.js +24 -2
- package/dist/import.js +42 -12
- package/dist/index.js +1534 -1164
- package/dist/pretool.js +24 -2
- package/dist/tickets.js +36 -6
- package/package.json +16 -15
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
|
-
|
|
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
|
|
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,424 +108,142 @@ var init_client = __esm({
|
|
|
75
108
|
}
|
|
76
109
|
});
|
|
77
110
|
|
|
78
|
-
// src/
|
|
79
|
-
var
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
case "win32": {
|
|
157
|
-
const result = await this.runner.run(
|
|
158
|
-
"powershell",
|
|
159
|
-
["-NoProfile", "-Command", WINDOWS_STORE],
|
|
160
|
-
secret
|
|
161
|
-
);
|
|
162
|
-
if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
default: {
|
|
166
|
-
const result = await this.runner.run(
|
|
167
|
-
"secret-tool",
|
|
168
|
-
["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
|
|
169
|
-
secret
|
|
170
|
-
);
|
|
171
|
-
if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
/** Null when there is nothing stored, which is the normal pre-enrolment state. */
|
|
177
|
-
async retrieve() {
|
|
178
|
-
if (!await this.available()) throw new NoKeychainError(this.runner.platform);
|
|
179
|
-
switch (this.runner.platform) {
|
|
180
|
-
case "darwin": {
|
|
181
|
-
const r = await this.runner.run("security", [
|
|
182
|
-
"find-generic-password",
|
|
183
|
-
"-a",
|
|
184
|
-
ACCOUNT,
|
|
185
|
-
"-s",
|
|
186
|
-
SERVICE,
|
|
187
|
-
"-w"
|
|
188
|
-
]);
|
|
189
|
-
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
190
|
-
}
|
|
191
|
-
case "win32": {
|
|
192
|
-
const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
|
|
193
|
-
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
194
|
-
}
|
|
195
|
-
default: {
|
|
196
|
-
const r = await this.runner.run("secret-tool", [
|
|
197
|
-
"lookup",
|
|
198
|
-
"service",
|
|
199
|
-
SERVICE,
|
|
200
|
-
"account",
|
|
201
|
-
ACCOUNT
|
|
202
|
-
]);
|
|
203
|
-
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
/** Idempotent: removing a credential that is not there is not an error. */
|
|
208
|
-
async remove() {
|
|
209
|
-
if (!await this.available()) return;
|
|
210
|
-
switch (this.runner.platform) {
|
|
211
|
-
case "darwin":
|
|
212
|
-
await this.runner.run("security", [
|
|
213
|
-
"delete-generic-password",
|
|
214
|
-
"-a",
|
|
215
|
-
ACCOUNT,
|
|
216
|
-
"-s",
|
|
217
|
-
SERVICE
|
|
218
|
-
]);
|
|
219
|
-
return;
|
|
220
|
-
case "win32":
|
|
221
|
-
await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
|
|
222
|
-
return;
|
|
223
|
-
default:
|
|
224
|
-
await this.runner.run("secret-tool", [
|
|
225
|
-
"clear",
|
|
226
|
-
"service",
|
|
227
|
-
SERVICE,
|
|
228
|
-
"account",
|
|
229
|
-
ACCOUNT
|
|
230
|
-
]);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
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"
|
|
234
207
|
};
|
|
235
|
-
|
|
236
|
-
WINDOWS_STORE = `
|
|
237
|
-
$ErrorActionPreference = 'Stop'
|
|
238
|
-
$p = "${WINDOWS_PATH}"
|
|
239
|
-
New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
|
|
240
|
-
$secret = [Console]::In.ReadToEnd().Trim()
|
|
241
|
-
ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
|
|
242
|
-
`.trim();
|
|
243
|
-
WINDOWS_RETRIEVE = `
|
|
244
|
-
$ErrorActionPreference = 'Stop'
|
|
245
|
-
$p = "${WINDOWS_PATH}"
|
|
246
|
-
if (-not (Test-Path $p)) { exit 1 }
|
|
247
|
-
$sec = Get-Content $p | ConvertTo-SecureString
|
|
248
|
-
[Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
249
|
-
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
|
|
250
|
-
`.trim();
|
|
251
|
-
WINDOWS_REMOVE = `
|
|
252
|
-
$p = "${WINDOWS_PATH}"
|
|
253
|
-
if (Test-Path $p) { Remove-Item $p -Force }
|
|
254
|
-
`.trim();
|
|
208
|
+
DEFAULT_LOCAL_BASE_URL = "http://localhost:11434/v1";
|
|
255
209
|
}
|
|
256
210
|
});
|
|
257
211
|
|
|
258
|
-
// src/
|
|
259
|
-
var
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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(" ");
|
|
270
230
|
}
|
|
231
|
+
});
|
|
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}`;
|
|
271
242
|
}
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
return
|
|
276
|
-
}
|
|
277
|
-
let started;
|
|
278
|
-
try {
|
|
279
|
-
const res = await deps.fetch(`${deps.baseUrl}/auth/device/start`, {
|
|
280
|
-
method: "POST",
|
|
281
|
-
headers: { "content-type": "application/json" },
|
|
282
|
-
body: JSON.stringify({
|
|
283
|
-
deviceName: deps.hostname(),
|
|
284
|
-
agentKind: "claude-code",
|
|
285
|
-
clientVersion: deps.clientVersion
|
|
286
|
-
})
|
|
287
|
-
});
|
|
288
|
-
if (!res.ok) throw new Error(String(res.status));
|
|
289
|
-
started = await res.json();
|
|
290
|
-
} catch {
|
|
291
|
-
deps.log(`Could not reach evrex at ${deps.baseUrl}.`);
|
|
292
|
-
return { ok: false, reason: "unreachable" };
|
|
293
|
-
}
|
|
294
|
-
deps.log("");
|
|
295
|
-
deps.log(` Enrolling ${deps.hostname()}.`);
|
|
296
|
-
deps.log("");
|
|
297
|
-
deps.log(` 1. Open ${deps.dashboardUrl}/devices`);
|
|
298
|
-
deps.log(` 2. Enter this code: ${started.userCode}`);
|
|
299
|
-
deps.log("");
|
|
300
|
-
deps.log(" Waiting for approval. Ctrl-C to stop.");
|
|
301
|
-
let interval = started.intervalMs;
|
|
302
|
-
for (; ; ) {
|
|
303
|
-
await deps.sleep(interval);
|
|
304
|
-
let outcome;
|
|
305
|
-
try {
|
|
306
|
-
const res = await deps.fetch(`${deps.baseUrl}/auth/device/poll`, {
|
|
307
|
-
method: "POST",
|
|
308
|
-
headers: { "content-type": "application/json" },
|
|
309
|
-
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
310
|
-
});
|
|
311
|
-
outcome = await res.json();
|
|
312
|
-
} catch {
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
switch (outcome.status) {
|
|
316
|
-
case "pending":
|
|
317
|
-
continue;
|
|
318
|
-
case "slow_down":
|
|
319
|
-
interval = Math.max(interval, outcome.retryAfterMs);
|
|
320
|
-
continue;
|
|
321
|
-
case "denied":
|
|
322
|
-
deps.log(" Enrolment was declined.");
|
|
323
|
-
return { ok: false, reason: "denied" };
|
|
324
|
-
case "expired":
|
|
325
|
-
deps.log(" That code expired. Run enrolment again for a new one.");
|
|
326
|
-
return { ok: false, reason: "expired" };
|
|
327
|
-
case "granted": {
|
|
328
|
-
await deps.store.store(outcome.token);
|
|
329
|
-
deps.log("");
|
|
330
|
-
deps.log(` ${deps.hostname()} is enrolled.`);
|
|
331
|
-
if (outcome.expiresAt) {
|
|
332
|
-
deps.log(` The credential renews automatically before ${outcome.expiresAt.slice(0, 10)}.`);
|
|
333
|
-
}
|
|
334
|
-
return { ok: true, expiresAt: outcome.expiresAt };
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
function needsRenewal(expiresAt, now) {
|
|
340
|
-
if (!expiresAt) return false;
|
|
341
|
-
const expiry = new Date(expiresAt).getTime();
|
|
342
|
-
if (Number.isNaN(expiry)) return false;
|
|
343
|
-
return expiry - now <= RENEW_WITHIN_MS;
|
|
344
|
-
}
|
|
345
|
-
var RENEW_WITHIN_MS;
|
|
346
|
-
var init_enrol = __esm({
|
|
347
|
-
"src/enrol.ts"() {
|
|
348
|
-
"use strict";
|
|
349
|
-
init_credential_store();
|
|
350
|
-
RENEW_WITHIN_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
351
|
-
}
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// src/index.ts
|
|
355
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
356
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
357
|
-
import { z } from "zod";
|
|
358
|
-
|
|
359
|
-
// src/subcommands.ts
|
|
360
|
-
var SUBCOMMANDS = {
|
|
361
|
-
"evrex-hook": "hook.js",
|
|
362
|
-
"evrex-capture": "capture.js",
|
|
363
|
-
"evrex-import": "import.js",
|
|
364
|
-
"evrex-tickets": "tickets.js",
|
|
365
|
-
"evrex-pretool": "pretool.js",
|
|
366
|
-
"evrex-continuity": "continuity.js",
|
|
367
|
-
"evrex-account": "account.js"
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
// src/tools.ts
|
|
371
|
-
init_client();
|
|
372
|
-
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
373
|
-
import { realpathSync } from "node:fs";
|
|
374
|
-
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
375
|
-
|
|
376
|
-
// ../../packages/llm-core/src/client.ts
|
|
377
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
378
|
-
var DEFAULT_MAX_TOKENS = 16e3;
|
|
379
|
-
|
|
380
|
-
// ../../packages/llm-core/src/complete.ts
|
|
381
|
-
import Anthropic2 from "@anthropic-ai/sdk";
|
|
382
|
-
|
|
383
|
-
// ../../packages/llm-core/src/providers.ts
|
|
384
|
-
var OPENAI_COMPATIBLE_BASE_URLS = {
|
|
385
|
-
openai: "https://api.openai.com/v1",
|
|
386
|
-
groq: "https://api.groq.com/openai/v1",
|
|
387
|
-
cerebras: "https://api.cerebras.ai/v1",
|
|
388
|
-
openrouter: "https://openrouter.ai/api/v1"
|
|
389
|
-
};
|
|
390
|
-
var DEFAULT_MODELS = {
|
|
391
|
-
anthropic: "claude-haiku-4-5",
|
|
392
|
-
openai: "gpt-4o",
|
|
393
|
-
// gemini-2.0-flash is retired: Google answers a request for it with
|
|
394
|
-
// "no longer available to new users. Please update your code to use
|
|
395
|
-
// models/gemini-3.6-flash". Second time a stale default has done this —
|
|
396
|
-
// Groq's llama-3.3-70b-versatile went the same way — and the failure looks
|
|
397
|
-
// identical to having no key at all, so it is worth checking against the
|
|
398
|
-
// provider's own model list when insights stop appearing.
|
|
399
|
-
google: "gemini-3.6-flash",
|
|
400
|
-
// Free tiers, chosen for extraction: a capable model behind a rate limit
|
|
401
|
-
// beats a weak one behind none, because indexing is a background batch and
|
|
402
|
-
// can wait out a limit but cannot recover a missed rejected approach.
|
|
403
|
-
// The smaller of the two open models, and the better one here: measured on
|
|
404
|
-
// ten real sessions it beat gpt-oss-120b on every category and ran faster.
|
|
405
|
-
// Groq's catalogue also turns over — llama-3.3-70b-versatile was the obvious
|
|
406
|
-
// default and 404s now — and a wrong name is a 404 that degrades to no
|
|
407
|
-
// insights, so `GET /models` is the check when this breaks.
|
|
408
|
-
groq: "openai/gpt-oss-20b",
|
|
409
|
-
cerebras: "llama-3.3-70b",
|
|
410
|
-
openrouter: "meta-llama/llama-3.3-70b-instruct:free",
|
|
411
|
-
// Cheapest credible host of this model measured at the time of writing
|
|
412
|
-
// ($0.03/$0.14 per million against Cloudflare's $0.20/$0.30), and the model
|
|
413
|
-
// that scored best on decisions and constraints in
|
|
414
|
-
// apps/backend/scripts/eval-extraction.mjs.
|
|
415
|
-
"openai-compatible": "openai/gpt-oss-20b",
|
|
416
|
-
// Small on purpose. Every invocation re-sends the CLI's own system prompt
|
|
417
|
-
// and is billed to the operator, so the default is the cheapest model that
|
|
418
|
-
// can read a transcript rather than the CLI's own default.
|
|
419
|
-
"claude-cli": "haiku",
|
|
420
|
-
"codex-cli": "",
|
|
421
|
-
// Measured the best of the models that fit on a laptop — see
|
|
422
|
-
// apps/backend/scripts/eval-extraction.mjs. Overridden with
|
|
423
|
-
// EVREX_LOCAL_MODEL for whatever is already pulled.
|
|
424
|
-
local: "qwen3:8b"
|
|
425
|
-
};
|
|
426
|
-
var DEFAULT_LOCAL_BASE_URL = "http://localhost:11434/v1";
|
|
427
|
-
function providerForBaseUrl(baseUrl) {
|
|
428
|
-
const url = (baseUrl ?? "").trim();
|
|
429
|
-
if (!url) return null;
|
|
430
|
-
return /^https?:\/\//i.test(url) ? "openai-compatible" : null;
|
|
431
|
-
}
|
|
432
|
-
function detectProvider(key) {
|
|
433
|
-
const k = (key ?? "").trim();
|
|
434
|
-
if (!k) return null;
|
|
435
|
-
if (k.startsWith("sk-ant-")) return "anthropic";
|
|
436
|
-
if (k.startsWith("AIza") || k.startsWith("AQ.")) return "google";
|
|
437
|
-
if (k.startsWith("gsk_")) return "groq";
|
|
438
|
-
if (k.startsWith("csk-")) return "cerebras";
|
|
439
|
-
if (k.startsWith("sk-or-")) return "openrouter";
|
|
440
|
-
if (k.startsWith("sk-")) return "openai";
|
|
441
|
-
if (/^https?:\/\//i.test(k)) return "local";
|
|
442
|
-
if (/^(ollama|local)$/i.test(k)) return "local";
|
|
443
|
-
if (/^claude-?cli$/i.test(k)) return "claude-cli";
|
|
444
|
-
if (/^codex-?cli$/i.test(k)) return "codex-cli";
|
|
445
|
-
return null;
|
|
446
|
-
}
|
|
447
|
-
function localBaseUrl(key) {
|
|
448
|
-
const k = key.trim();
|
|
449
|
-
return /^https?:\/\//i.test(k) ? k.replace(/\/+$/, "") : DEFAULT_LOCAL_BASE_URL;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// ../../packages/llm-core/src/extraction.ts
|
|
453
|
-
var MAX_ITEMS_PER_CATEGORY = 6;
|
|
454
|
-
var EXTRACTION_SYSTEM_PROMPT = [
|
|
455
|
-
"You extract structured reasoning from a real coding-agent session transcript for Evrex, a tool that recovers WHY code changed, not just what changed.",
|
|
456
|
-
`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).`,
|
|
457
|
-
'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.',
|
|
458
|
-
"Only extract items explicitly present in the transcript below. Never invent, infer beyond the text, or pad categories with generic filler.",
|
|
459
|
-
"If a category has nothing genuinely present, return an empty array for it \u2014 an empty result is correct and expected, not a failure.",
|
|
460
|
-
`Cap each array at ${MAX_ITEMS_PER_CATEGORY} items \u2014 pick the most consequential ones.`,
|
|
461
|
-
'"atMessage" is the [N] index of the transcript line the item came from.',
|
|
462
|
-
'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.',
|
|
463
|
-
'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.'
|
|
464
|
-
].join(" ");
|
|
465
|
-
|
|
466
|
-
// ../../packages/llm-core/src/synthesis.ts
|
|
467
|
-
var MIN_SENTENCES = 1;
|
|
468
|
-
var MAX_SENTENCES = 4;
|
|
469
|
-
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.`;
|
|
470
|
-
var SYNTHESIS_SCHEMA = {
|
|
471
|
-
type: "object",
|
|
472
|
-
additionalProperties: false,
|
|
473
|
-
required: ["sentences", "sentenceEvidence", "noRecordedReasoning"],
|
|
474
|
-
properties: {
|
|
475
|
-
sentences: { type: "array", items: { type: "string" } },
|
|
476
|
-
sentenceEvidence: {
|
|
477
|
-
type: "array",
|
|
478
|
-
items: { type: "array", items: { type: "integer" } }
|
|
479
|
-
},
|
|
480
|
-
noRecordedReasoning: { type: "boolean" }
|
|
481
|
-
}
|
|
482
|
-
};
|
|
483
|
-
function buildSynthesisPrompt(question, evidence) {
|
|
484
|
-
const list = evidence.map(
|
|
485
|
-
(e, i) => `[${i}] (${e.kind}${e.at ? `, ${e.at.slice(0, 10)}` : ""}, confidence ${(e.confidence ?? 1).toFixed(2)}): ${e.excerpt}`
|
|
486
|
-
).join("\n");
|
|
487
|
-
return `Question: ${question}
|
|
488
|
-
|
|
489
|
-
Evidence:
|
|
490
|
-
${list}`;
|
|
491
|
-
}
|
|
492
|
-
function isValidSynthesis(result, evidenceCount) {
|
|
493
|
-
if (result.noRecordedReasoning === true) return true;
|
|
494
|
-
if (!Array.isArray(result.sentences) || !Array.isArray(result.sentenceEvidence)) {
|
|
495
|
-
return false;
|
|
243
|
+
function isValidSynthesis(result, evidenceCount) {
|
|
244
|
+
if (result.noRecordedReasoning === true) return true;
|
|
245
|
+
if (!Array.isArray(result.sentences) || !Array.isArray(result.sentenceEvidence)) {
|
|
246
|
+
return false;
|
|
496
247
|
}
|
|
497
248
|
if (result.sentences.length < MIN_SENTENCES || result.sentences.length > MAX_SENTENCES) {
|
|
498
249
|
return false;
|
|
@@ -537,20 +288,46 @@ async function synthesizeAnswer(question, evidence, client2, options = {}) {
|
|
|
537
288
|
noRecordedReasoning: Boolean(raw.noRecordedReasoning)
|
|
538
289
|
};
|
|
539
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
|
+
});
|
|
540
313
|
|
|
541
314
|
// ../../packages/llm-core/src/retrieval-boost.ts
|
|
542
|
-
var CORPUS
|
|
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:
|
|
543
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.
|
|
544
321
|
- Extracted insights: short titled records of decisions made, approaches REJECTED (with the reason), constraints, and procedures.
|
|
545
322
|
- Commit messages: conventional-commit subjects and explanatory bodies.`;
|
|
546
|
-
|
|
323
|
+
EXPAND_SYSTEM = `You expand a search query into terms that will match an engineering record.
|
|
547
324
|
|
|
548
325
|
${CORPUS}
|
|
549
326
|
|
|
550
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.
|
|
551
328
|
|
|
552
329
|
Respond with JSON only: {"terms": ["...", ...]} \u2014 3 to 8 terms, each 1 to 4 words, no duplicates of the question's own words.`;
|
|
553
|
-
|
|
330
|
+
RERANK_SYSTEM = `You order search results for an engineer's question about their own project's history.
|
|
554
331
|
|
|
555
332
|
${CORPUS}
|
|
556
333
|
|
|
@@ -560,131 +337,135 @@ You are given the question and a numbered list of candidate results that lexical
|
|
|
560
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.
|
|
561
338
|
|
|
562
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
|
+
});
|
|
563
342
|
|
|
564
343
|
// ../../packages/llm-core/src/cli-client.ts
|
|
565
344
|
import { execFile } from "node:child_process";
|
|
566
|
-
var TIMEOUT_MS = 12e4;
|
|
567
|
-
var CLAUDE = {
|
|
568
|
-
provider: "claude-cli",
|
|
569
|
-
command: "claude",
|
|
570
|
-
args: (model) => [
|
|
571
|
-
"-p",
|
|
572
|
-
// `--bare` never reads OAuth credentials or the system keychain, so this
|
|
573
|
-
// cannot run on a Claude.ai subscription — it requires ANTHROPIC_API_KEY.
|
|
574
|
-
//
|
|
575
|
-
// That is deliberate and it is the whole compliance question. Anthropic's
|
|
576
|
-
// consumer terms prohibit accessing the service "through automated or
|
|
577
|
-
// non-human means, whether through a bot, script, or otherwise" except
|
|
578
|
-
// via an API key, and evrex spawning the CLI is a script doing exactly
|
|
579
|
-
// that. Anthropic's own headless documentation points the same way:
|
|
580
|
-
// `--bare` is "the recommended mode for scripted and SDK calls" and "will
|
|
581
|
-
// become the default for `-p` in a future release".
|
|
582
|
-
//
|
|
583
|
-
// It costs this provider its reason to exist — with an API key in the
|
|
584
|
-
// environment there is little point going through the CLI at all — which
|
|
585
|
-
// is the honest answer to "can we spend a subscription instead of a
|
|
586
|
-
// budget": no, and evrex had already recorded that conclusion once.
|
|
587
|
-
"--bare",
|
|
588
|
-
"--output-format",
|
|
589
|
-
"json",
|
|
590
|
-
// Extraction is a reading task; the CLI's default is far larger than it
|
|
591
|
-
// needs and is billed to the operator.
|
|
592
|
-
...model ? ["--model", model] : ["--model", "haiku"]
|
|
593
|
-
],
|
|
594
|
-
parse: (stdout) => {
|
|
595
|
-
try {
|
|
596
|
-
const envelope = JSON.parse(stdout);
|
|
597
|
-
if (envelope.is_error) return null;
|
|
598
|
-
return typeof envelope.result === "string" ? envelope.result : null;
|
|
599
|
-
} catch {
|
|
600
|
-
return null;
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
};
|
|
604
|
-
var CODEX = {
|
|
605
|
-
provider: "codex-cli",
|
|
606
|
-
command: "codex",
|
|
607
|
-
// `exec` is Codex's non-interactive mode. It prints the answer plainly, so
|
|
608
|
-
// there is no envelope to unwrap.
|
|
609
|
-
args: (model) => ["exec", ...model ? ["--model", model] : []],
|
|
610
|
-
parse: (stdout) => stdout.trim() || null,
|
|
611
|
-
requiresEnv: "OPENAI_API_KEY"
|
|
612
|
-
};
|
|
613
|
-
var CLI_SPECS = {
|
|
614
|
-
"claude-cli": CLAUDE,
|
|
615
|
-
"codex-cli": CODEX
|
|
616
|
-
};
|
|
617
345
|
function unfence(text) {
|
|
618
346
|
const fenced = /```(?:json)?\s*([\s\S]*?)\s*```/.exec(text);
|
|
619
347
|
return (fenced ? fenced[1] : text).trim();
|
|
620
348
|
}
|
|
621
|
-
var
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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 ? `
|
|
646
429
|
|
|
647
430
|
Respond with JSON matching exactly this schema, and nothing else:
|
|
648
431
|
${JSON.stringify(request2.schema)}` : "\n\nRespond with JSON and nothing else.";
|
|
649
|
-
|
|
432
|
+
const prompt = `${request2.system}${shape}
|
|
650
433
|
|
|
651
434
|
${request2.user}`;
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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;
|
|
666
461
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
});
|
|
670
|
-
if (stdout === null) return null;
|
|
671
|
-
const answer = this.spec.parse(stdout);
|
|
672
|
-
if (!answer) return null;
|
|
673
|
-
try {
|
|
674
|
-
return JSON.parse(unfence(answer));
|
|
675
|
-
} catch {
|
|
676
|
-
this.logger.warn(`${this.spec.command} did not return usable JSON`);
|
|
677
|
-
return null;
|
|
678
|
-
}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
679
464
|
}
|
|
680
|
-
};
|
|
465
|
+
});
|
|
681
466
|
|
|
682
467
|
// ../../packages/llm-core/src/provider-clients.ts
|
|
683
468
|
import Anthropic3 from "@anthropic-ai/sdk";
|
|
684
|
-
var NOOP = { warn: () => void 0, error: () => void 0 };
|
|
685
|
-
var BATCH_POLL_MS = 5e3;
|
|
686
|
-
var BATCH_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
|
|
687
|
-
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
688
469
|
function totalize(items, results) {
|
|
689
470
|
for (const item of items) if (!results.has(item.id)) results.set(item.id, null);
|
|
690
471
|
return results;
|
|
@@ -705,9 +486,6 @@ async function pollToCompletion(handle, resolve, sleepFn, onTimeout) {
|
|
|
705
486
|
await sleepFn(BATCH_POLL_MS);
|
|
706
487
|
}
|
|
707
488
|
}
|
|
708
|
-
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
709
|
-
var MAX_RETRIES = 4;
|
|
710
|
-
var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
711
489
|
function isExhaustedStatus(status, retriesSpent) {
|
|
712
490
|
if (status === 401 || status === 403) return true;
|
|
713
491
|
return status === 429 && retriesSpent;
|
|
@@ -719,7 +497,6 @@ async function waitBeforeRetry(response, attempt, sleep2) {
|
|
|
719
497
|
const wait = Math.min(seconds ?? backoff, MAX_RETRY_WAIT_SECONDS);
|
|
720
498
|
await sleep2(wait * 1e3);
|
|
721
499
|
}
|
|
722
|
-
var MAX_RETRY_WAIT_SECONDS = 90;
|
|
723
500
|
function parseDuration(value) {
|
|
724
501
|
const plain = Number(value);
|
|
725
502
|
if (Number.isFinite(plain)) return plain;
|
|
@@ -737,523 +514,547 @@ function parseJsonBody(text) {
|
|
|
737
514
|
return null;
|
|
738
515
|
}
|
|
739
516
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
+
);
|
|
745
541
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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;
|
|
770
625
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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;
|
|
776
644
|
}
|
|
777
|
-
} : {}
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
static messageText(message) {
|
|
781
|
-
return message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
782
|
-
}
|
|
783
|
-
async completeJson(request2) {
|
|
784
|
-
try {
|
|
785
|
-
const message = await this.sdk.messages.create(
|
|
786
|
-
this.messageParams(request2)
|
|
787
|
-
);
|
|
788
|
-
this.reportCacheUsage(message.usage);
|
|
789
|
-
return parseJsonBody(_AnthropicClient.messageText(message));
|
|
790
|
-
} catch (err) {
|
|
791
|
-
const status = err?.status;
|
|
792
|
-
if (typeof status === "number" && isExhaustedStatus(status, true)) {
|
|
793
|
-
this.exhausted = true;
|
|
794
645
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
+
}
|
|
813
664
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
)) {
|
|
826
|
-
results.set(
|
|
827
|
-
entry.custom_id,
|
|
828
|
-
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
|
+
)
|
|
829
676
|
);
|
|
677
|
+
return resolved ?? totalize(items, /* @__PURE__ */ new Map());
|
|
830
678
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const written = usage.cache_creation_input_tokens ?? 0;
|
|
871
|
-
if (read > 0 || written > 0) return;
|
|
872
|
-
this.logger.warn(
|
|
873
|
-
`[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.`
|
|
874
|
-
);
|
|
875
|
-
}
|
|
876
|
-
};
|
|
877
|
-
var GoogleClient = class _GoogleClient {
|
|
878
|
-
constructor(key, model, logger, sleep2 = realSleep) {
|
|
879
|
-
this.key = key;
|
|
880
|
-
this.model = model;
|
|
881
|
-
this.logger = logger;
|
|
882
|
-
this.sleep = sleep2;
|
|
883
|
-
}
|
|
884
|
-
provider = "google";
|
|
885
|
-
exhausted = false;
|
|
886
|
-
async completeJson(request2, attempt = 0) {
|
|
887
|
-
const model = request2.model ?? this.model;
|
|
888
|
-
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
|
|
889
|
-
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 ? `
|
|
890
718
|
|
|
891
719
|
Respond with JSON matching exactly this schema:
|
|
892
720
|
${JSON.stringify(request2.schema)}` : "";
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
+
}
|
|
916
752
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
},
|
|
938
|
-
body: JSON.stringify({
|
|
939
|
-
batch: {
|
|
940
|
-
display_name: "evrex-extraction",
|
|
941
|
-
input_config: {
|
|
942
|
-
requests: {
|
|
943
|
-
requests: items.map((item) => {
|
|
944
|
-
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 ? `
|
|
945
773
|
|
|
946
774
|
Respond with JSON matching exactly this schema:
|
|
947
775
|
${JSON.stringify(item.request.schema)}` : "";
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
+
}
|
|
961
791
|
}
|
|
962
|
-
}
|
|
792
|
+
})
|
|
963
793
|
}
|
|
964
|
-
|
|
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;
|
|
965
811
|
}
|
|
966
|
-
);
|
|
967
|
-
if (!create.ok) {
|
|
968
|
-
if (isExhaustedStatus(create.status, true)) this.exhausted = true;
|
|
969
|
-
this.logger.warn(
|
|
970
|
-
`google batch submit failed: ${create.status} ${(await create.text()).slice(0, 200)}`
|
|
971
|
-
);
|
|
972
|
-
return null;
|
|
973
|
-
}
|
|
974
|
-
const name = (await create.json()).name;
|
|
975
|
-
if (!name) {
|
|
976
|
-
this.logger.warn("google batch: no job name returned");
|
|
977
|
-
return null;
|
|
978
|
-
}
|
|
979
|
-
return { provider: "google", id: name, itemIds: items.map((i) => i.id) };
|
|
980
|
-
} catch (err) {
|
|
981
|
-
this.logger.warn(`google batch submit failed: ${describeError(err)}`);
|
|
982
|
-
return null;
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
async resolveBatch(handle) {
|
|
986
|
-
try {
|
|
987
|
-
const poll = await fetch(`${_GoogleClient.GEMINI_BASE}/${handle.id}`, {
|
|
988
|
-
headers: { "x-goog-api-key": this.key }
|
|
989
|
-
});
|
|
990
|
-
if (!poll.ok) {
|
|
991
|
-
this.logger.warn(`google batch poll failed: ${poll.status}`);
|
|
992
|
-
return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
|
|
993
|
-
}
|
|
994
|
-
const job = await poll.json();
|
|
995
|
-
const state = job.state ?? "";
|
|
996
|
-
if (state === "JOB_STATE_FAILED" || state === "JOB_STATE_CANCELLED" || state === "JOB_STATE_EXPIRED") {
|
|
997
|
-
this.logger.warn(`google batch ${handle.id} ended ${state}`);
|
|
998
|
-
return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
|
|
999
|
-
}
|
|
1000
|
-
if (state !== "JOB_STATE_SUCCEEDED") return null;
|
|
1001
|
-
const inlined = job.response?.inlinedResponses ?? [];
|
|
1002
|
-
const results = /* @__PURE__ */ new Map();
|
|
1003
|
-
handle.itemIds.forEach((id, i) => {
|
|
1004
|
-
const text = inlined[i]?.response?.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
|
|
1005
|
-
results.set(id, text ? parseJsonBody(text) : null);
|
|
1006
|
-
});
|
|
1007
|
-
return totalizeIds(handle.itemIds, results);
|
|
1008
|
-
} catch (err) {
|
|
1009
|
-
this.logger.warn(`google batch resolve failed: ${describeError(err)}`);
|
|
1010
|
-
return totalizeIds(handle.itemIds, /* @__PURE__ */ new Map());
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
async completeJsonBatch(items) {
|
|
1014
|
-
if (items.length === 0) return /* @__PURE__ */ new Map();
|
|
1015
|
-
const handle = await this.submitBatch(items);
|
|
1016
|
-
if (!handle) return totalize(items, /* @__PURE__ */ new Map());
|
|
1017
|
-
const resolved = await pollToCompletion(
|
|
1018
|
-
handle,
|
|
1019
|
-
(h) => this.resolveBatch(h),
|
|
1020
|
-
this.sleep,
|
|
1021
|
-
(id) => this.logger.warn(
|
|
1022
|
-
`google batch ${id} did not finish within the cap; leaving its sessions for a later pass`
|
|
1023
|
-
)
|
|
1024
|
-
);
|
|
1025
|
-
return resolved ?? totalize(items, /* @__PURE__ */ new Map());
|
|
1026
|
-
}
|
|
1027
|
-
};
|
|
1028
|
-
var OpenAiCompatibleClient = class {
|
|
1029
|
-
constructor(provider, baseUrl, key, model, logger, sleep2 = realSleep) {
|
|
1030
|
-
this.provider = provider;
|
|
1031
|
-
this.baseUrl = baseUrl;
|
|
1032
|
-
this.key = key;
|
|
1033
|
-
this.model = model;
|
|
1034
|
-
this.logger = logger;
|
|
1035
|
-
this.sleep = sleep2;
|
|
1036
|
-
if (this.provider === "openai") {
|
|
1037
|
-
this.completeJsonBatch = (items) => this.runBatch(items);
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
exhausted = false;
|
|
1041
|
-
completeJsonBatch;
|
|
1042
|
-
async completeJson(request2, attempt = 0) {
|
|
1043
|
-
try {
|
|
1044
|
-
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
1045
|
-
method: "POST",
|
|
1046
|
-
headers: this.authHeaders(),
|
|
1047
|
-
body: JSON.stringify(this.chatBody(request2))
|
|
1048
|
-
});
|
|
1049
|
-
if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
|
|
1050
|
-
await waitBeforeRetry(res, attempt, this.sleep);
|
|
1051
|
-
return this.completeJson(request2, attempt + 1);
|
|
1052
812
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
const text = body.choices?.[0]?.message?.content;
|
|
1062
|
-
return typeof text === "string" ? parseJsonBody(text) : null;
|
|
1063
|
-
} catch (err) {
|
|
1064
|
-
this.logger.warn(`${this.provider} call failed: ${describeError(err)}`);
|
|
1065
|
-
return null;
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
authHeaders() {
|
|
1069
|
-
return {
|
|
1070
|
-
"Content-Type": "application/json",
|
|
1071
|
-
// A local server does not want one, and some reject an empty bearer token.
|
|
1072
|
-
...this.key ? { Authorization: `Bearer ${this.key}` } : {}
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
1075
|
-
// One place a ModelRequest becomes the chat body, so completeJson and the
|
|
1076
|
-
// batch path cannot drift on response_format or the pinned temperature.
|
|
1077
|
-
chatBody(request2) {
|
|
1078
|
-
const strict = this.provider === "openai";
|
|
1079
|
-
const shape = request2.schema && !strict ? `
|
|
1080
|
-
|
|
1081
|
-
Respond with JSON matching exactly this schema:
|
|
1082
|
-
${JSON.stringify(request2.schema)}` : "";
|
|
1083
|
-
return {
|
|
1084
|
-
model: request2.model ?? this.model,
|
|
1085
|
-
messages: [
|
|
1086
|
-
{ role: "system", content: request2.system + shape },
|
|
1087
|
-
{ role: "user", content: request2.user }
|
|
1088
|
-
],
|
|
1089
|
-
...request2.schema && strict ? {
|
|
1090
|
-
response_format: {
|
|
1091
|
-
type: "json_schema",
|
|
1092
|
-
json_schema: {
|
|
1093
|
-
name: "evrex_result",
|
|
1094
|
-
strict: true,
|
|
1095
|
-
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());
|
|
1096
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());
|
|
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());
|
|
1097
839
|
}
|
|
1098
|
-
} : { response_format: { type: "json_object" } },
|
|
1099
|
-
// A reading task with a right answer: temperature pinned so indexing
|
|
1100
|
-
// twice is the same history.
|
|
1101
|
-
temperature: 0,
|
|
1102
|
-
stream: false
|
|
1103
|
-
};
|
|
1104
|
-
}
|
|
1105
|
-
async runBatch(items) {
|
|
1106
|
-
const results = /* @__PURE__ */ new Map();
|
|
1107
|
-
if (items.length === 0) return results;
|
|
1108
|
-
const auth = this.key ? { Authorization: `Bearer ${this.key}` } : {};
|
|
1109
|
-
try {
|
|
1110
|
-
const jsonl = items.map(
|
|
1111
|
-
(item) => JSON.stringify({
|
|
1112
|
-
custom_id: item.id,
|
|
1113
|
-
method: "POST",
|
|
1114
|
-
url: "/v1/chat/completions",
|
|
1115
|
-
body: this.chatBody(item.request)
|
|
1116
|
-
})
|
|
1117
|
-
).join("\n");
|
|
1118
|
-
const form = new FormData();
|
|
1119
|
-
form.append("purpose", "batch");
|
|
1120
|
-
form.append(
|
|
1121
|
-
"file",
|
|
1122
|
-
new Blob([jsonl], { type: "application/jsonl" }),
|
|
1123
|
-
"evrex-batch.jsonl"
|
|
1124
|
-
);
|
|
1125
|
-
const upload = await fetch(`${this.baseUrl}/files`, {
|
|
1126
|
-
method: "POST",
|
|
1127
|
-
headers: auth,
|
|
1128
|
-
body: form
|
|
1129
|
-
});
|
|
1130
|
-
if (!upload.ok) {
|
|
1131
|
-
if (isExhaustedStatus(upload.status, true)) this.exhausted = true;
|
|
1132
|
-
this.logger.warn(
|
|
1133
|
-
`openai batch upload failed: ${upload.status} ${(await upload.text()).slice(0, 200)}`
|
|
1134
|
-
);
|
|
1135
|
-
return totalize(items, results);
|
|
1136
840
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
if (!created.ok) {
|
|
1149
|
-
this.logger.warn(
|
|
1150
|
-
`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
|
+
)
|
|
1151
852
|
);
|
|
1152
|
-
return totalize(items,
|
|
853
|
+
return resolved ?? totalize(items, /* @__PURE__ */ new Map());
|
|
1153
854
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
}
|
|
1166
|
-
await this.sleep(BATCH_POLL_MS);
|
|
1167
|
-
const poll = await fetch(`${this.baseUrl}/batches/${batchId}`, {
|
|
1168
|
-
headers: auth
|
|
1169
|
-
});
|
|
1170
|
-
if (!poll.ok) {
|
|
1171
|
-
this.logger.warn(`openai batch poll failed: ${poll.status}`);
|
|
1172
|
-
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);
|
|
1173
866
|
}
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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;
|
|
1180
894
|
}
|
|
1181
895
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
+
};
|
|
1189
932
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
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}` } : {};
|
|
1192
937
|
try {
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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)}`
|
|
1199
962
|
);
|
|
963
|
+
return totalize(items, results);
|
|
1200
964
|
}
|
|
1201
|
-
|
|
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)}`);
|
|
1202
1034
|
}
|
|
1035
|
+
return totalize(items, results);
|
|
1203
1036
|
}
|
|
1204
|
-
}
|
|
1205
|
-
this.logger.warn(`openai batch failed: ${describeError(err)}`);
|
|
1206
|
-
}
|
|
1207
|
-
return totalize(items, results);
|
|
1037
|
+
};
|
|
1208
1038
|
}
|
|
1209
|
-
};
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
if (cli) return new CliClient(cli, model, logger);
|
|
1225
|
-
if (provider === "local") {
|
|
1226
|
-
return new OpenAiCompatibleClient(
|
|
1227
|
-
provider,
|
|
1228
|
-
options.baseUrl ?? localBaseUrl(trimmed),
|
|
1229
|
-
null,
|
|
1230
|
-
model,
|
|
1231
|
-
logger,
|
|
1232
|
-
sleep2
|
|
1233
|
-
);
|
|
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();
|
|
1234
1054
|
}
|
|
1235
|
-
|
|
1236
|
-
if (!baseUrl) return null;
|
|
1237
|
-
return new OpenAiCompatibleClient(
|
|
1238
|
-
provider,
|
|
1239
|
-
baseUrl,
|
|
1240
|
-
trimmed,
|
|
1241
|
-
model,
|
|
1242
|
-
logger,
|
|
1243
|
-
sleep2
|
|
1244
|
-
);
|
|
1245
|
-
}
|
|
1055
|
+
});
|
|
1246
1056
|
|
|
1247
1057
|
// src/synthesize.ts
|
|
1248
|
-
var ANTHROPIC_API_KEY = process.env.MODEL_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? void 0;
|
|
1249
|
-
var ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL;
|
|
1250
|
-
var stderrLogger = {
|
|
1251
|
-
warn: (message) => process.stderr.write(`[evrex] ${message}
|
|
1252
|
-
`),
|
|
1253
|
-
error: (message) => process.stderr.write(`[evrex] ${message}
|
|
1254
|
-
`)
|
|
1255
|
-
};
|
|
1256
|
-
var client = createAnthropicKeyClient();
|
|
1257
1058
|
function createAnthropicKeyClient() {
|
|
1258
1059
|
return createModelClient(ANTHROPIC_API_KEY, { logger: stderrLogger });
|
|
1259
1060
|
}
|
|
@@ -1296,6 +1097,22 @@ async function synthesize(question, evidence) {
|
|
|
1296
1097
|
}
|
|
1297
1098
|
return { sentences: result.sentences };
|
|
1298
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
|
+
});
|
|
1299
1116
|
|
|
1300
1117
|
// src/hook-runtime.ts
|
|
1301
1118
|
import { execFileSync } from "node:child_process";
|
|
@@ -1334,10 +1151,13 @@ function spentLabel(tokens) {
|
|
|
1334
1151
|
const short = tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1)}M` : tokens >= 1e3 ? `${Math.round(tokens / 1e3)}k` : String(tokens);
|
|
1335
1152
|
return ` \xB7 ${short} spent`;
|
|
1336
1153
|
}
|
|
1154
|
+
var init_hook_runtime = __esm({
|
|
1155
|
+
"src/hook-runtime.ts"() {
|
|
1156
|
+
"use strict";
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1337
1159
|
|
|
1338
1160
|
// src/bottle.ts
|
|
1339
|
-
var HUMAN_CAP = 700;
|
|
1340
|
-
var AGENT_CAP = 500;
|
|
1341
1161
|
function cap(text, max) {
|
|
1342
1162
|
const trimmed = text.trim();
|
|
1343
1163
|
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
|
|
@@ -1386,15 +1206,23 @@ function bottleFromTurns(turns, who, budgetChars = 12e3) {
|
|
|
1386
1206
|
budgetChars
|
|
1387
1207
|
);
|
|
1388
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
|
+
});
|
|
1389
1217
|
|
|
1390
1218
|
// src/tools.ts
|
|
1391
|
-
|
|
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";
|
|
1392
1222
|
function confidenceLabel(p) {
|
|
1393
1223
|
if (p.status === "inferred") return `${Math.round((p.confidence ?? 0) * 100)}%`;
|
|
1394
1224
|
return p.status;
|
|
1395
1225
|
}
|
|
1396
|
-
var MAX_ITEMS = 5;
|
|
1397
|
-
var INDEX_EXCERPT = 110;
|
|
1398
1226
|
function evidenceLabel(e) {
|
|
1399
1227
|
return e.kind === "session" ? e.sourceKind ?? "session" : e.kind;
|
|
1400
1228
|
}
|
|
@@ -1425,6 +1253,16 @@ function normalizeRepoRemote(url) {
|
|
|
1425
1253
|
if (!cleanedPath) return null;
|
|
1426
1254
|
return `${host}/${cleanedPath}`.toLowerCase();
|
|
1427
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
|
+
}
|
|
1428
1266
|
function git(cwd, args) {
|
|
1429
1267
|
try {
|
|
1430
1268
|
return execFileSync2("git", args, {
|
|
@@ -1479,8 +1317,28 @@ function sameFileRef(a, b) {
|
|
|
1479
1317
|
if (b.endsWith(`/${a}`)) return true;
|
|
1480
1318
|
return false;
|
|
1481
1319
|
}
|
|
1482
|
-
|
|
1483
|
-
const
|
|
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;
|
|
1484
1342
|
if (override) {
|
|
1485
1343
|
const { repoRef } = resolveRepoContext(override);
|
|
1486
1344
|
return [{ id: repoRef, name: repoRef, slug: repoRef, commitCount: 0, sessionCount: 0 }];
|
|
@@ -1491,20 +1349,34 @@ async function resolveRepos() {
|
|
|
1491
1349
|
return [];
|
|
1492
1350
|
}
|
|
1493
1351
|
}
|
|
1494
|
-
async function
|
|
1495
|
-
const
|
|
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);
|
|
1496
1364
|
const text = question ?? `What decisions, constraints, and rejected approaches apply to ${filePath}?`;
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
]);
|
|
1500
|
-
const
|
|
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}] ` : "";
|
|
1501
1375
|
if (evidence.length === 0) {
|
|
1502
|
-
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.`;
|
|
1503
1377
|
}
|
|
1504
1378
|
const sessionIds = [...new Set(evidence.filter((e) => e.kind === "session").map((e) => e.refId))];
|
|
1505
|
-
const sessions = (await Promise.all(sessionIds.map((id) => evrexApi.session(id)))).filter(
|
|
1506
|
-
(s) => s !== null
|
|
1507
|
-
);
|
|
1379
|
+
const sessions = (await Promise.all((multiRepo ? sessionIds.slice(0, MAX_ITEMS) : sessionIds).map((id) => evrexApi.session(id)))).filter((s) => s !== null);
|
|
1508
1380
|
const target = isAbsolute(filePath) ? toRepoRelative(filePath, root) : filePath;
|
|
1509
1381
|
const aboutTarget = (item) => (item.files ?? []).some((f) => sameFileRef(toRepoRelative(f, root), target));
|
|
1510
1382
|
const byRelevance = (items) => [...items].sort(
|
|
@@ -1515,16 +1387,17 @@ async function evrexWhy(filePath, question) {
|
|
|
1515
1387
|
(c) => c !== null && (c.statedInsights?.length ?? 0) > 0
|
|
1516
1388
|
);
|
|
1517
1389
|
const statedOf = (kind) => commits.flatMap(
|
|
1518
|
-
(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) }))
|
|
1519
1391
|
);
|
|
1520
|
-
const
|
|
1521
|
-
const
|
|
1522
|
-
const
|
|
1523
|
-
const
|
|
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);
|
|
1524
1397
|
const statedRejected = statedOf("rejected").slice(0, MAX_ITEMS);
|
|
1525
1398
|
const statedConstraints = statedOf("constraint").slice(0, MAX_ITEMS);
|
|
1526
1399
|
const statedDecisions = statedOf("decision").slice(0, MAX_ITEMS);
|
|
1527
|
-
const statedLine = (i) => `- ${i.text} (stated in ${i.stated})`;
|
|
1400
|
+
const statedLine = (i) => `- ${lead(i.repo)}${i.text} (stated in ${i.stated})`;
|
|
1528
1401
|
const parts = [];
|
|
1529
1402
|
const marked = sessions.filter((s) => (s.feedback ?? []).some((f) => f.itemId === null && f.signal !== "helpful"));
|
|
1530
1403
|
if (marked.length > 0) {
|
|
@@ -1536,22 +1409,22 @@ async function evrexWhy(filePath, question) {
|
|
|
1536
1409
|
if (rejected.length + statedRejected.length > 0) {
|
|
1537
1410
|
parts.push(
|
|
1538
1411
|
"REJECTED APPROACHES (do not re-propose these without new information):\n" + [
|
|
1539
|
-
...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)}`),
|
|
1540
1413
|
...statedRejected.map(statedLine)
|
|
1541
1414
|
].join("\n")
|
|
1542
1415
|
);
|
|
1543
1416
|
}
|
|
1544
1417
|
if (constraints.length + statedConstraints.length > 0) {
|
|
1545
1418
|
parts.push(
|
|
1546
|
-
"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")
|
|
1547
1420
|
);
|
|
1548
1421
|
}
|
|
1549
1422
|
if (decisions.length + statedDecisions.length > 0) {
|
|
1550
1423
|
parts.push(
|
|
1551
|
-
"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")
|
|
1552
1425
|
);
|
|
1553
1426
|
}
|
|
1554
|
-
const proceduresText = proceduresBlock(procedures);
|
|
1427
|
+
const proceduresText = proceduresBlock(procedures.map((p) => ({ ...p, title: `${lead(p.repo)}${p.title}` })));
|
|
1555
1428
|
if (proceduresText) parts.push(proceduresText);
|
|
1556
1429
|
if (commits.length > 0) {
|
|
1557
1430
|
parts.push(
|
|
@@ -1574,39 +1447,39 @@ async function evrexWhy(filePath, question) {
|
|
|
1574
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."
|
|
1575
1448
|
);
|
|
1576
1449
|
}
|
|
1577
|
-
const evidenceLines =
|
|
1578
|
-
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)}`;
|
|
1579
1452
|
});
|
|
1580
1453
|
parts.push(`EVIDENCE:
|
|
1581
1454
|
${evidenceLines.join("\n")}`);
|
|
1582
1455
|
return parts.join("\n\n");
|
|
1583
1456
|
}
|
|
1584
|
-
async function evrexSearch(query) {
|
|
1457
|
+
async function evrexSearch(query, repo) {
|
|
1585
1458
|
if (!query.trim()) return "Empty query.";
|
|
1586
|
-
const
|
|
1459
|
+
const unreadable = unreadableRepo(repo);
|
|
1460
|
+
if (unreadable) return unreadable;
|
|
1461
|
+
const repos = await resolveRepos(repo);
|
|
1587
1462
|
if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
|
|
1588
1463
|
const multiRepo = repos.length > 1;
|
|
1589
1464
|
const perRepo = await Promise.all(
|
|
1590
|
-
repos.map(async (
|
|
1465
|
+
repos.map(async (repo2) => {
|
|
1591
1466
|
try {
|
|
1592
|
-
const { evidence } = await evrexApi.search(
|
|
1593
|
-
return evidence.map((e) => ({ repo, e }));
|
|
1467
|
+
const { evidence } = await evrexApi.search(repo2.id, query);
|
|
1468
|
+
return evidence.map((e) => ({ repo: repo2, e }));
|
|
1594
1469
|
} catch {
|
|
1595
1470
|
return [];
|
|
1596
1471
|
}
|
|
1597
1472
|
})
|
|
1598
1473
|
);
|
|
1599
|
-
const results = perRepo.flat();
|
|
1474
|
+
const results = strongestFirst(perRepo.flat());
|
|
1600
1475
|
if (results.length === 0) {
|
|
1601
|
-
|
|
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.`;
|
|
1602
1478
|
}
|
|
1603
|
-
const
|
|
1604
|
-
results.sort((a, b) => rank(b.e) - rank(a.e));
|
|
1605
|
-
const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo, e }) => {
|
|
1479
|
+
const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo: repo2, e }) => {
|
|
1606
1480
|
const conf = confidenceLabel(e.provenance);
|
|
1607
|
-
const repoTag = multiRepo ? ` \xB7 ${repo.name}` : "";
|
|
1608
1481
|
const handle = e.kind === "commit" ? `commit:${e.refId.slice(0, 12)}` : `${e.kind}:${e.refId}`;
|
|
1609
|
-
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)}`;
|
|
1610
1483
|
});
|
|
1611
1484
|
return [
|
|
1612
1485
|
...lines,
|
|
@@ -1614,21 +1487,19 @@ async function evrexSearch(query) {
|
|
|
1614
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."
|
|
1615
1488
|
].join("\n");
|
|
1616
1489
|
}
|
|
1617
|
-
|
|
1618
|
-
var TIMELINE_MAX_DAYS = 90;
|
|
1619
|
-
var TIMELINE_DEFAULT_LIMIT = 30;
|
|
1620
|
-
var TIMELINE_MAX_LIMIT = 200;
|
|
1621
|
-
async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFAULT_LIMIT) {
|
|
1490
|
+
async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFAULT_LIMIT, repo) {
|
|
1622
1491
|
const window = Math.min(TIMELINE_MAX_DAYS, Math.max(1, Math.floor(days) || TIMELINE_DEFAULT_DAYS));
|
|
1623
1492
|
const cap2 = Math.min(TIMELINE_MAX_LIMIT, Math.max(1, Math.floor(limit) || TIMELINE_DEFAULT_LIMIT));
|
|
1624
|
-
const
|
|
1493
|
+
const unreadable = unreadableRepo(repo);
|
|
1494
|
+
if (unreadable) return unreadable;
|
|
1495
|
+
const repos = await resolveRepos(repo);
|
|
1625
1496
|
if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
|
|
1626
1497
|
const multiRepo = repos.length > 1;
|
|
1627
1498
|
const perRepo = await Promise.all(
|
|
1628
|
-
repos.map(async (
|
|
1499
|
+
repos.map(async (repo2) => {
|
|
1629
1500
|
try {
|
|
1630
|
-
const entries = await evrexApi.timeline(
|
|
1631
|
-
return entries.map((entry) => ({ repo, entry }));
|
|
1501
|
+
const entries = await evrexApi.timeline(repo2.id, window);
|
|
1502
|
+
return entries.map((entry) => ({ repo: repo2, entry }));
|
|
1632
1503
|
} catch {
|
|
1633
1504
|
return [];
|
|
1634
1505
|
}
|
|
@@ -1640,7 +1511,7 @@ async function evrexTimeline(days = TIMELINE_DEFAULT_DAYS, limit = TIMELINE_DEFA
|
|
|
1640
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\`.`;
|
|
1641
1512
|
}
|
|
1642
1513
|
const shown = all.slice(0, cap2);
|
|
1643
|
-
const lines = shown.map(({ repo, entry }) => formatTimelineLine(entry, multiRepo ?
|
|
1514
|
+
const lines = shown.map(({ repo: repo2, entry }) => formatTimelineLine(entry, multiRepo ? repo2.name : null));
|
|
1644
1515
|
const omitted = all.length - shown.length;
|
|
1645
1516
|
return [
|
|
1646
1517
|
`Last ${window} day${window === 1 ? "" : "s"} in ${where}, newest first \u2014 ${all.length} record${all.length === 1 ? "" : "s"}${omitted > 0 ? `, ${shown.length} shown` : ""}:`,
|
|
@@ -1755,7 +1626,6 @@ Open in Evrex: evrex://open?handle=commit:${id}`);
|
|
|
1755
1626
|
);
|
|
1756
1627
|
return parts.join("\n\n");
|
|
1757
1628
|
}
|
|
1758
|
-
var FEEDBACK_SIGNALS = ["helpful", "not_helpful", "stale", "wrong", "superseded"];
|
|
1759
1629
|
function feedbackLines(feedback) {
|
|
1760
1630
|
if (!feedback || feedback.length === 0) return "";
|
|
1761
1631
|
const lines = feedback.slice(0, MAX_ITEMS).map((f) => {
|
|
@@ -1792,11 +1662,12 @@ async function evrexFeedback(handle, signal, note, itemId, supersededBy) {
|
|
|
1792
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.";
|
|
1793
1663
|
return `Recorded ${saved.signal} on ${handle}${where}${saved.supersededBy ? ` \u2014 superseded by ${saved.supersededBy}` : ""}. ${effect}`;
|
|
1794
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
|
+
}
|
|
1795
1668
|
return `Could not record feedback: ${err instanceof Error ? err.message : String(err)}`;
|
|
1796
1669
|
}
|
|
1797
1670
|
}
|
|
1798
|
-
var BOTTLE_PAGE = 200;
|
|
1799
|
-
var BOTTLE_TAIL_TURNS = 400;
|
|
1800
1671
|
async function evrexBottle(sessionRef) {
|
|
1801
1672
|
const id = sessionRef.startsWith("session:") ? sessionRef.slice("session:".length) : sessionRef;
|
|
1802
1673
|
if (!id.trim()) return "Pass a session id or a session:<id> handle from evrex_search.";
|
|
@@ -1891,114 +1762,608 @@ ${lines.join("\n")}`);
|
|
|
1891
1762
|
}
|
|
1892
1763
|
return parts.join("\n\n");
|
|
1893
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
|
+
});
|
|
1894
1786
|
|
|
1895
|
-
// src/
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
"
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
);
|
|
1913
|
-
|
|
1914
|
-
"evrex_search",
|
|
1915
|
-
{
|
|
1916
|
-
title: "Search Evrex sessions and commits",
|
|
1917
|
-
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.",
|
|
1918
|
-
inputSchema: {
|
|
1919
|
-
query: z.string().describe("Free-text search 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 }] };
|
|
1920
1806
|
}
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
)
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
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 }] };
|
|
1934
1821
|
}
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
);
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
limit: z.number().int().min(1).max(200).optional().describe("At most this many lines, newest first. Default 30, maximum 200.")
|
|
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 }] };
|
|
1949
1835
|
}
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
)
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
item_id: z.string().optional().describe("One item within the record, e.g. rej:llm:2, con:llm:0, dec:llm:1"),
|
|
1966
|
-
superseded_by: z.string().optional().describe("For superseded: the handle of the record that replaced it")
|
|
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 }] };
|
|
1967
1851
|
}
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
)
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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 }] };
|
|
1981
1869
|
}
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
)
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
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 }] };
|
|
1995
1883
|
}
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
)
|
|
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 }] };
|
|
1897
|
+
}
|
|
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" };
|
|
2129
|
+
}
|
|
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
|
|
2002
2367
|
function assertConfigured() {
|
|
2003
2368
|
if (process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN) return;
|
|
2004
2369
|
console.error(
|
|
@@ -2038,11 +2403,16 @@ async function main() {
|
|
|
2038
2403
|
if (process.argv[2] === "enrol" || process.argv[2] === "enroll") {
|
|
2039
2404
|
await runEnrolment();
|
|
2040
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
|
+
}
|
|
2041
2411
|
const subcommand = SUBCOMMANDS[process.argv[2] ?? ""];
|
|
2042
2412
|
if (subcommand) await runSubcommand(subcommand);
|
|
2043
2413
|
assertConfigured();
|
|
2044
2414
|
const transport = new StdioServerTransport();
|
|
2045
|
-
await
|
|
2415
|
+
await createEvrexServer().connect(transport);
|
|
2046
2416
|
}
|
|
2047
2417
|
main().catch((err) => {
|
|
2048
2418
|
console.error("evrex-mcp fatal error:", err);
|