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