jev-agent-tools 0.1.3 → 0.2.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/CHANGELOG.md +88 -3
- package/CONTRIBUTING.md +40 -0
- package/README.md +64 -9
- package/SECURITY.md +27 -0
- package/dist/adapters/analysis-context.js +75 -0
- package/dist/adapters/ask-files.js +189 -0
- package/dist/adapters/ask-proof.js +144 -0
- package/dist/adapters/ask-syntax.js +385 -0
- package/dist/adapters/canonical-path.js +17 -0
- package/dist/adapters/command.js +181 -0
- package/dist/adapters/docs.js +172 -0
- package/dist/adapters/exec.js +207 -0
- package/dist/adapters/files.js +293 -0
- package/dist/adapters/find.js +122 -0
- package/dist/adapters/git-base.js +26 -0
- package/dist/adapters/git-inventory.js +71 -0
- package/dist/adapters/git.js +439 -0
- package/dist/adapters/locate-file.js +159 -0
- package/dist/adapters/output-lines.js +46 -0
- package/dist/adapters/private-storage.js +98 -0
- package/dist/adapters/risk-callers.js +426 -0
- package/dist/adapters/runner-version.js +78 -0
- package/dist/adapters/shell.js +76 -0
- package/dist/adapters/syntax.js +187 -0
- package/dist/adapters/test-inventory.js +131 -0
- package/dist/adapters/usage.js +20 -0
- package/dist/adapters/utf8.js +47 -0
- package/dist/configuration.js +257 -0
- package/dist/constants.js +119 -0
- package/dist/core/ask-closure.js +282 -0
- package/dist/core/ask-proof.js +1 -0
- package/dist/core/ask-references.js +194 -0
- package/dist/core/asks.js +436 -0
- package/dist/core/batches.js +65 -0
- package/dist/core/command-output.js +224 -0
- package/dist/core/diff.js +178 -0
- package/dist/core/docs.js +302 -0
- package/dist/core/find.js +108 -0
- package/dist/core/git.js +1 -0
- package/dist/core/imports.js +550 -0
- package/dist/core/integrity.js +45 -0
- package/dist/core/lexical.js +132 -0
- package/dist/core/locate.js +169 -0
- package/dist/core/output.js +120 -0
- package/dist/core/pointer.js +29 -0
- package/dist/core/risk-callers.js +851 -0
- package/dist/core/runner-version.js +45 -0
- package/dist/core/sections.js +230 -0
- package/dist/core/state.js +44 -0
- package/dist/core/syntax.js +1 -0
- package/dist/core/test-commands.js +334 -0
- package/dist/core/test-coverage.js +74 -0
- package/dist/core/test-discovery.js +1382 -0
- package/dist/core/test-evidence.js +527 -0
- package/dist/core/test-state.js +81 -0
- package/dist/core/truncate.js +12 -0
- package/dist/core/units.js +349 -0
- package/dist/describe.js +23 -0
- package/dist/guide.js +33 -0
- package/dist/host.js +24 -0
- package/dist/jev/client.js +434 -0
- package/dist/jev/pool.js +54 -0
- package/dist/jev/types.js +1 -0
- package/dist/mcp/main.js +124 -0
- package/dist/mcp/protocol.js +187 -0
- package/dist/mcp/tools.js +116 -0
- package/dist/presets/docs.js +62 -0
- package/dist/presets/risk.js +179 -0
- package/dist/presets/spec.js +81 -0
- package/dist/presets/witnesses.js +249 -0
- package/dist/render.js +42 -0
- package/dist/result.js +3 -0
- package/dist/runtime.js +1 -0
- package/dist/session.js +147 -0
- package/dist/texts/ask-files.js +1 -0
- package/dist/texts/ask.js +2 -0
- package/dist/texts/check-diff.js +17 -0
- package/dist/texts/configuration.js +1 -0
- package/dist/texts/find.js +14 -0
- package/dist/texts/guide.js +16 -0
- package/dist/texts/locate.js +10 -0
- package/dist/texts/select-tests.js +2 -0
- package/dist/tools/ask-files.js +217 -0
- package/dist/tools/ask-schema.js +70 -0
- package/dist/tools/ask.js +686 -0
- package/dist/tools/check-diff.js +402 -0
- package/dist/tools/docs-check.js +299 -0
- package/dist/tools/find.js +389 -0
- package/dist/tools/locate.js +303 -0
- package/dist/tools/select-tests.js +567 -0
- package/dist/tools/spec-check.js +166 -0
- package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
- package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
- package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
- package/docs/adr/0004-compiled-typed-intents.md +19 -0
- package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
- package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
- package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
- package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
- package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
- package/docs/adr/0010-mcp-server-thin-host.md +23 -0
- package/docs/agent-instructions.md +91 -0
- package/docs/design.md +3 -3
- package/docs/mcp.md +231 -0
- package/package.json +19 -4
- package/server.json +57 -0
- package/src/adapters/canonical-path.ts +18 -0
- package/src/adapters/command.ts +7 -4
- package/src/adapters/exec.ts +226 -0
- package/src/adapters/private-storage.ts +143 -0
- package/src/adapters/risk-callers.ts +4 -2
- package/src/adapters/shell.ts +97 -0
- package/src/configuration.ts +294 -0
- package/src/constants.ts +11 -0
- package/src/core/command-output.ts +17 -1
- package/src/host-tui.d.ts +14 -0
- package/src/host.ts +11 -0
- package/src/index.ts +13 -5
- package/src/jev/client.ts +12 -0
- package/src/jev/types.ts +6 -0
- package/src/mcp/main.ts +135 -0
- package/src/mcp/protocol.ts +282 -0
- package/src/mcp/tools.ts +166 -0
- package/src/secret-input.ts +222 -0
- package/src/session.ts +59 -0
- package/src/setup.ts +170 -0
- package/src/texts/configuration.ts +1 -1
- package/src/tools/ask-files.ts +8 -13
- package/src/tools/ask.ts +29 -28
- package/src/tools/check-diff.ts +11 -11
- package/src/tools/docs-check.ts +1 -0
- package/src/tools/find.ts +8 -8
- package/src/tools/locate.ts +8 -9
- package/src/tools/select-tests.ts +10 -10
- package/src/tools/spec-check.ts +1 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { setTimeout as timerSleep } from "node:timers/promises";
|
|
3
|
+
import { HTTP_ERROR_MAX_CHARS, REQUEST_ATTEMPTS, RETRY_BASE_MS, RETRY_MAX_MS, TIMEOUT_MS, } from "../constants.js";
|
|
4
|
+
import { prepareBatches, splitGroups } from "../core/batches.js";
|
|
5
|
+
import { truncate } from "../core/truncate.js";
|
|
6
|
+
import { isRecord } from "../result.js";
|
|
7
|
+
import { createPool } from "./pool.js";
|
|
8
|
+
export function readConfig(env) {
|
|
9
|
+
return env.JEV_TOOLS_URL && env.JEV_TOOLS_API_KEY
|
|
10
|
+
? {
|
|
11
|
+
url: env.JEV_TOOLS_URL,
|
|
12
|
+
apiKey: env.JEV_TOOLS_API_KEY,
|
|
13
|
+
model: env.JEV_TOOLS_MODEL || "openjev",
|
|
14
|
+
}
|
|
15
|
+
: undefined;
|
|
16
|
+
}
|
|
17
|
+
function httpError(body, text, apiKey) {
|
|
18
|
+
const error = isRecord(body) ? (body.error ?? body.message) : undefined;
|
|
19
|
+
const detail = typeof error === "string"
|
|
20
|
+
? error
|
|
21
|
+
: isRecord(error) && typeof error.message === "string"
|
|
22
|
+
? error.message
|
|
23
|
+
: body === undefined
|
|
24
|
+
? text
|
|
25
|
+
: "No error message provided.";
|
|
26
|
+
const redacted = detail.replaceAll(apiKey, "[redacted]");
|
|
27
|
+
return redacted.length > HTTP_ERROR_MAX_CHARS
|
|
28
|
+
? `${truncate(redacted, HTTP_ERROR_MAX_CHARS)}…`
|
|
29
|
+
: redacted;
|
|
30
|
+
}
|
|
31
|
+
function isJson(value) {
|
|
32
|
+
return (value === null ||
|
|
33
|
+
typeof value === "string" ||
|
|
34
|
+
typeof value === "boolean" ||
|
|
35
|
+
(typeof value === "number" && Number.isFinite(value)) ||
|
|
36
|
+
(Array.isArray(value)
|
|
37
|
+
? value.every(isJson)
|
|
38
|
+
: isRecord(value) && Object.values(value).every(isJson)));
|
|
39
|
+
}
|
|
40
|
+
function probabilities(value) {
|
|
41
|
+
return (isRecord(value) &&
|
|
42
|
+
Object.keys(value).length > 0 &&
|
|
43
|
+
Object.values(value).every((p) => typeof p === "number" && Number.isFinite(p) && p >= 0 && p <= 1));
|
|
44
|
+
}
|
|
45
|
+
function normalize(value, question) {
|
|
46
|
+
const invalid = {
|
|
47
|
+
type: "unjudged",
|
|
48
|
+
reason: "Invalid or missing Jev answer.",
|
|
49
|
+
};
|
|
50
|
+
if (!isRecord(value))
|
|
51
|
+
return invalid;
|
|
52
|
+
if (question.type === "bool")
|
|
53
|
+
return typeof value.noul === "number" && value.noul >= 0 && value.noul <= 1
|
|
54
|
+
? { type: "bool", p: value.noul }
|
|
55
|
+
: invalid;
|
|
56
|
+
if (typeof value.confidence !== "number" ||
|
|
57
|
+
!Number.isFinite(value.confidence) ||
|
|
58
|
+
!probabilities(value.probabilities))
|
|
59
|
+
return invalid;
|
|
60
|
+
if (question.type === "choice")
|
|
61
|
+
return typeof value.choice === "string" &&
|
|
62
|
+
Object.hasOwn(question.criteria, value.choice) &&
|
|
63
|
+
Object.hasOwn(value.probabilities, value.choice) &&
|
|
64
|
+
Object.keys(value.probabilities).every((key) => Object.hasOwn(question.criteria, key))
|
|
65
|
+
? {
|
|
66
|
+
type: "choice",
|
|
67
|
+
choice: value.choice,
|
|
68
|
+
confidence: value.confidence,
|
|
69
|
+
probabilities: value.probabilities,
|
|
70
|
+
}
|
|
71
|
+
: invalid;
|
|
72
|
+
return typeof value.score === "number" &&
|
|
73
|
+
Number.isFinite(value.score) &&
|
|
74
|
+
value.score >= 0 &&
|
|
75
|
+
value.score <= question.criteria.length - 1 &&
|
|
76
|
+
isJson(value.legend)
|
|
77
|
+
? {
|
|
78
|
+
type: "score",
|
|
79
|
+
score: value.score,
|
|
80
|
+
confidence: value.confidence,
|
|
81
|
+
legend: value.legend,
|
|
82
|
+
probabilities: value.probabilities,
|
|
83
|
+
}
|
|
84
|
+
: invalid;
|
|
85
|
+
}
|
|
86
|
+
function metadata(body) {
|
|
87
|
+
if (!isRecord(body))
|
|
88
|
+
return {};
|
|
89
|
+
const usage = body.usage;
|
|
90
|
+
return {
|
|
91
|
+
...(typeof body.model === "string" ? { model: body.model } : {}),
|
|
92
|
+
...(isRecord(usage) &&
|
|
93
|
+
typeof usage.input_tokens === "number" &&
|
|
94
|
+
Number.isFinite(usage.input_tokens) &&
|
|
95
|
+
usage.input_tokens >= 0 &&
|
|
96
|
+
typeof usage.cost === "number" &&
|
|
97
|
+
Number.isFinite(usage.cost) &&
|
|
98
|
+
usage.cost >= 0
|
|
99
|
+
? { usage: { inputTokens: usage.input_tokens, costUsd: usage.cost } }
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function canonical(value) {
|
|
104
|
+
if (Array.isArray(value))
|
|
105
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
106
|
+
if (isRecord(value))
|
|
107
|
+
return `{${Object.keys(value)
|
|
108
|
+
.sort()
|
|
109
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
110
|
+
.join(",")}}`;
|
|
111
|
+
return JSON.stringify(value);
|
|
112
|
+
}
|
|
113
|
+
function hash(value) {
|
|
114
|
+
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
115
|
+
}
|
|
116
|
+
function wireQuestions(questions) {
|
|
117
|
+
return Object.fromEntries(Object.entries(questions).map(([id, q]) => [
|
|
118
|
+
id,
|
|
119
|
+
{
|
|
120
|
+
...q,
|
|
121
|
+
type: q.type === "bool" ? "noul" : q.type,
|
|
122
|
+
...(q.type === "bool"
|
|
123
|
+
? {
|
|
124
|
+
criteria: {
|
|
125
|
+
true: q.criteria?.true ?? "Yes",
|
|
126
|
+
false: q.criteria?.false ?? "No",
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
: {}),
|
|
130
|
+
},
|
|
131
|
+
]));
|
|
132
|
+
}
|
|
133
|
+
export function createJevClient(config, injected) {
|
|
134
|
+
const clock = {
|
|
135
|
+
now: injected?.now ?? (() => performance.now()),
|
|
136
|
+
sleep: injected?.sleep ??
|
|
137
|
+
((ms, signal) => timerSleep(ms, undefined, { signal })),
|
|
138
|
+
};
|
|
139
|
+
const processPoolKey = Symbol.for("jev-tools.request-pool");
|
|
140
|
+
const processPools = globalThis;
|
|
141
|
+
if (!injected)
|
|
142
|
+
processPools[processPoolKey] ??= createPool(clock);
|
|
143
|
+
const pool = injected?.pool ??
|
|
144
|
+
(injected ? createPool(clock) : processPools[processPoolKey]);
|
|
145
|
+
const request = injected?.fetch ?? fetch;
|
|
146
|
+
const timeoutMs = injected?.timeoutMs ?? TIMEOUT_MS;
|
|
147
|
+
const cache = new Map();
|
|
148
|
+
const witnessCache = new Map();
|
|
149
|
+
let generation = 0;
|
|
150
|
+
return {
|
|
151
|
+
clearCache() {
|
|
152
|
+
cache.clear();
|
|
153
|
+
witnessCache.clear();
|
|
154
|
+
generation++;
|
|
155
|
+
},
|
|
156
|
+
async judge(state, questions, options = {}) {
|
|
157
|
+
const planned = prepareBatches(state, questions, options);
|
|
158
|
+
if (!planned.ok)
|
|
159
|
+
return planned;
|
|
160
|
+
const answers = {};
|
|
161
|
+
const meta = {
|
|
162
|
+
calls: 0,
|
|
163
|
+
questions: 0,
|
|
164
|
+
cacheHits: 0,
|
|
165
|
+
cacheRequests: Object.keys(questions).length,
|
|
166
|
+
batches: [],
|
|
167
|
+
};
|
|
168
|
+
const epoch = generation;
|
|
169
|
+
const stateKey = hash(state);
|
|
170
|
+
const keys = new Map(Object.entries(questions).map(([id, q]) => [
|
|
171
|
+
id,
|
|
172
|
+
`${stateKey}:${hash(JSON.parse(JSON.stringify(q)))}:${config.model}`,
|
|
173
|
+
]));
|
|
174
|
+
const witnesses = options.witnesses ?? [];
|
|
175
|
+
const witnessSet = new Set(witnesses);
|
|
176
|
+
const witnessKey = (group) => JSON.stringify([
|
|
177
|
+
group.map((id) => keys.get(id)),
|
|
178
|
+
witnesses.map((id) => keys.get(id)),
|
|
179
|
+
]);
|
|
180
|
+
let stopped;
|
|
181
|
+
let stateChecked = false;
|
|
182
|
+
let failure;
|
|
183
|
+
const addMetadata = (body) => {
|
|
184
|
+
const next = metadata(body);
|
|
185
|
+
if (next.model)
|
|
186
|
+
meta.model = next.model;
|
|
187
|
+
if (next.usage) {
|
|
188
|
+
meta.usage ??= { inputTokens: 0, costUsd: 0 };
|
|
189
|
+
meta.usage.inputTokens += next.usage.inputTokens;
|
|
190
|
+
meta.usage.costUsd += next.usage.costUsd;
|
|
191
|
+
options.onUsage?.(next.usage);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const missing = (ids, reason) => {
|
|
195
|
+
const batchAnswers = Object.fromEntries(ids.map((id) => [id, { type: "unjudged", reason }]));
|
|
196
|
+
for (const [id, answer] of Object.entries(batchAnswers))
|
|
197
|
+
if (!witnessSet.has(id) || !answers[id])
|
|
198
|
+
answers[id] = answer;
|
|
199
|
+
meta.batches?.push({ questionIds: [...ids], answers: batchAnswers });
|
|
200
|
+
};
|
|
201
|
+
const send = async (groups, diagnostic = false) => {
|
|
202
|
+
const ids = [...groups.flat(), ...(diagnostic ? [] : witnesses)];
|
|
203
|
+
if (stopped) {
|
|
204
|
+
missing(ids, stopped);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const selected = Object.fromEntries(ids.map((id) => [id, questions[id]]));
|
|
208
|
+
const payload = JSON.stringify({
|
|
209
|
+
model: config.model,
|
|
210
|
+
state,
|
|
211
|
+
questions: wireQuestions(selected),
|
|
212
|
+
});
|
|
213
|
+
for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt++) {
|
|
214
|
+
let release;
|
|
215
|
+
let settle;
|
|
216
|
+
const timeoutCancellation = new AbortController();
|
|
217
|
+
let retryMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
|
|
218
|
+
try {
|
|
219
|
+
release = await pool.acquire(options.signal);
|
|
220
|
+
if (stopped) {
|
|
221
|
+
missing(ids, stopped);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// The reservation is held from here on; `finally` releases it on
|
|
225
|
+
// every exit path (stopped, refused, failed, aborted, answered).
|
|
226
|
+
settle = await options.awaitAdmission?.(options.signal);
|
|
227
|
+
if (stopped) {
|
|
228
|
+
missing(ids, stopped);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const admission = options.beforeRequest?.(ids.length);
|
|
232
|
+
if (admission && !admission.ok) {
|
|
233
|
+
// A diagnostic denied admission supplies no judgment batch and
|
|
234
|
+
// must not replace the token refusal with a budget reason.
|
|
235
|
+
if (diagnostic)
|
|
236
|
+
return;
|
|
237
|
+
stopped = admission.error;
|
|
238
|
+
missing(ids, stopped);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const timeout = new AbortController();
|
|
242
|
+
void clock.sleep(timeoutMs, timeoutCancellation.signal).then(() => timeout.abort(new Error("Jev request timeout")), () => { });
|
|
243
|
+
meta.calls = (meta.calls ?? 0) + 1;
|
|
244
|
+
meta.questions = (meta.questions ?? 0) + ids.length;
|
|
245
|
+
const response = await request(config.url, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: {
|
|
248
|
+
"content-type": "application/json",
|
|
249
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
250
|
+
},
|
|
251
|
+
body: payload,
|
|
252
|
+
signal: options.signal
|
|
253
|
+
? AbortSignal.any([options.signal, timeout.signal])
|
|
254
|
+
: timeout.signal,
|
|
255
|
+
});
|
|
256
|
+
const text = await response.text();
|
|
257
|
+
let body;
|
|
258
|
+
try {
|
|
259
|
+
body = JSON.parse(text);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
body = undefined;
|
|
263
|
+
}
|
|
264
|
+
addMetadata(body);
|
|
265
|
+
// Release the USD gate before any subdivision re-enters send().
|
|
266
|
+
settle?.();
|
|
267
|
+
settle = undefined;
|
|
268
|
+
release();
|
|
269
|
+
release = undefined;
|
|
270
|
+
if (response.status === 400 &&
|
|
271
|
+
text.includes("max_tokens_exceeded")) {
|
|
272
|
+
if (diagnostic) {
|
|
273
|
+
stopped =
|
|
274
|
+
"Jev max_tokens_exceeded: state too large for a single question; remaining questions not judged.";
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (ids.length === 1) {
|
|
278
|
+
missing(ids, "Jev max_tokens_exceeded: state too large for a single question; question not judged.");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (!stateChecked && groups.length > 1) {
|
|
282
|
+
stateChecked = true;
|
|
283
|
+
// Assume token cost grows with serialized question size on the
|
|
284
|
+
// same state. This is not a tokenizer guarantee: non-monotone
|
|
285
|
+
// cases are more conservative than main (unjudged), never
|
|
286
|
+
// more optimistic. The probe answer is discarded, not cached.
|
|
287
|
+
let probeId = ids[0];
|
|
288
|
+
let probeSize = Infinity;
|
|
289
|
+
for (const [id, question] of Object.entries(wireQuestions(questions))) {
|
|
290
|
+
const size = JSON.stringify(question).length;
|
|
291
|
+
if (size < probeSize) {
|
|
292
|
+
probeId = id;
|
|
293
|
+
probeSize = size;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
await send([[probeId]], true);
|
|
297
|
+
if (stopped) {
|
|
298
|
+
missing(ids, stopped);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const split = splitGroups(groups);
|
|
303
|
+
if (split.ok)
|
|
304
|
+
await Promise.all(split.halves.map((half) => send(half)));
|
|
305
|
+
else
|
|
306
|
+
missing(ids, split.error);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (response.ok && isRecord(body)) {
|
|
310
|
+
if (diagnostic)
|
|
311
|
+
return;
|
|
312
|
+
const raw = isRecord(body.answers) ? body.answers : {};
|
|
313
|
+
const batchAnswers = Object.fromEntries(ids.map((id) => [
|
|
314
|
+
id,
|
|
315
|
+
normalize(raw[id], selected[id]),
|
|
316
|
+
]));
|
|
317
|
+
meta.batches?.push({ questionIds: ids, answers: batchAnswers });
|
|
318
|
+
for (const [id, answer] of Object.entries(batchAnswers)) {
|
|
319
|
+
if (!witnessSet.has(id) || !answers[id])
|
|
320
|
+
answers[id] = answer;
|
|
321
|
+
if (options.cache !== false &&
|
|
322
|
+
!witnessSet.has(id) &&
|
|
323
|
+
epoch === generation &&
|
|
324
|
+
answer.type !== "unjudged")
|
|
325
|
+
cache.set(keys.get(id), structuredClone(answer));
|
|
326
|
+
}
|
|
327
|
+
if (witnesses.length > 0 &&
|
|
328
|
+
options.cache !== false &&
|
|
329
|
+
epoch === generation &&
|
|
330
|
+
witnesses.every((id) => batchAnswers[id]?.type !== "unjudged")) {
|
|
331
|
+
for (const group of groups) {
|
|
332
|
+
if (group.every((id) => batchAnswers[id]?.type !== "unjudged"))
|
|
333
|
+
witnessCache.set(witnessKey(group), structuredClone(Object.fromEntries(witnesses.map((id) => [
|
|
334
|
+
id,
|
|
335
|
+
batchAnswers[id],
|
|
336
|
+
]))));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (diagnostic)
|
|
342
|
+
return;
|
|
343
|
+
const retryAfter = response.status === 429
|
|
344
|
+
? Number(response.headers.get("retry-after"))
|
|
345
|
+
: 0;
|
|
346
|
+
if (Number.isFinite(retryAfter) &&
|
|
347
|
+
retryAfter * 1_000 > RETRY_MAX_MS) {
|
|
348
|
+
const reason = `Jev HTTP 429, retry after ${retryAfter} s`;
|
|
349
|
+
failure ??= reason;
|
|
350
|
+
missing(ids, reason);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const reason = response.ok
|
|
354
|
+
? "Invalid Jev response: expected a JSON object."
|
|
355
|
+
: `Jev HTTP ${response.status}: ${httpError(body, text, config.apiKey)}${response.status === 401 || response.status === 403 ? "; check JEV_TOOLS_API_KEY." : ""}`;
|
|
356
|
+
if (!(response.status === 408 ||
|
|
357
|
+
response.status === 429 ||
|
|
358
|
+
response.status >= 500) ||
|
|
359
|
+
attempt === REQUEST_ATTEMPTS - 1) {
|
|
360
|
+
failure ??= reason;
|
|
361
|
+
if (response.status === 401 || response.status === 403)
|
|
362
|
+
stopped = reason;
|
|
363
|
+
missing(ids, reason);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (Number.isFinite(retryAfter) && retryAfter >= 0)
|
|
367
|
+
retryMs = Math.max(retryMs, retryAfter * 1_000);
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
// A failed diagnostic is not evidence of a token limit. Continue
|
|
371
|
+
// ordinary subdivision without delivering its answer or error.
|
|
372
|
+
if (diagnostic)
|
|
373
|
+
return;
|
|
374
|
+
const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
|
|
375
|
+
if (options.signal?.aborted || attempt === REQUEST_ATTEMPTS - 1) {
|
|
376
|
+
failure ??= reason;
|
|
377
|
+
missing(ids, reason);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
settle?.();
|
|
383
|
+
release?.();
|
|
384
|
+
timeoutCancellation.abort();
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
await clock.sleep(retryMs, options.signal);
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
|
|
391
|
+
failure ??= reason;
|
|
392
|
+
missing(ids, reason);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const pending = planned.batches
|
|
398
|
+
.map((batch) => batch.filter((group) => {
|
|
399
|
+
if (options.cache === false ||
|
|
400
|
+
!group.every((id) => cache.has(keys.get(id))) ||
|
|
401
|
+
(witnesses.length > 0 && !witnessCache.has(witnessKey(group))))
|
|
402
|
+
return true;
|
|
403
|
+
for (const id of group) {
|
|
404
|
+
answers[id] = structuredClone(cache.get(keys.get(id)));
|
|
405
|
+
meta.cacheHits = (meta.cacheHits ?? 0) + 1;
|
|
406
|
+
}
|
|
407
|
+
const observedWitnesses = structuredClone(witnessCache.get(witnessKey(group)) ?? {});
|
|
408
|
+
for (const [id, answer] of Object.entries(observedWitnesses)) {
|
|
409
|
+
if (!answers[id]) {
|
|
410
|
+
answers[id] = answer;
|
|
411
|
+
meta.cacheHits = (meta.cacheHits ?? 0) + 1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const cachedIds = [...group, ...witnesses];
|
|
415
|
+
meta.batches?.push({
|
|
416
|
+
questionIds: cachedIds,
|
|
417
|
+
answers: Object.fromEntries(cachedIds.map((id) => [
|
|
418
|
+
id,
|
|
419
|
+
witnessSet.has(id)
|
|
420
|
+
? observedWitnesses[id]
|
|
421
|
+
: structuredClone(cache.get(keys.get(id))),
|
|
422
|
+
])),
|
|
423
|
+
});
|
|
424
|
+
return false;
|
|
425
|
+
}))
|
|
426
|
+
.filter((batch) => batch.length);
|
|
427
|
+
await Promise.all(pending.map((batch) => send(batch)));
|
|
428
|
+
if (failure &&
|
|
429
|
+
!Object.values(answers).some((answer) => answer.type !== "unjudged"))
|
|
430
|
+
return { ok: false, error: failure, ...meta };
|
|
431
|
+
return { ok: true, answers, ...meta };
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
package/dist/jev/pool.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CONCURRENCY, RATE_PER_SECOND } from "../constants.js";
|
|
2
|
+
/** A fresh pool owns its sliding window; share it between clients in one process. */
|
|
3
|
+
export function createPool(clock) {
|
|
4
|
+
let active = 0;
|
|
5
|
+
const starts = [];
|
|
6
|
+
let gate = Promise.resolve();
|
|
7
|
+
let released = Promise.withResolvers();
|
|
8
|
+
return {
|
|
9
|
+
async acquire(signal) {
|
|
10
|
+
const previous = gate;
|
|
11
|
+
const turn = Promise.withResolvers();
|
|
12
|
+
gate = turn.promise;
|
|
13
|
+
await previous;
|
|
14
|
+
try {
|
|
15
|
+
for (;;) {
|
|
16
|
+
signal?.throwIfAborted();
|
|
17
|
+
const now = clock.now();
|
|
18
|
+
while (starts.length && now - (starts[0] ?? now) >= 1_000)
|
|
19
|
+
starts.shift();
|
|
20
|
+
if (active >= CONCURRENCY) {
|
|
21
|
+
const cancelled = Promise.withResolvers();
|
|
22
|
+
const abort = () => cancelled.reject(signal?.reason);
|
|
23
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
24
|
+
try {
|
|
25
|
+
await Promise.race([released.promise, cancelled.promise]);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
signal?.removeEventListener("abort", abort);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else if (starts.length >= RATE_PER_SECOND) {
|
|
32
|
+
await clock.sleep(Math.max(1, 1_000 - (now - (starts[0] ?? now))), signal);
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
active++;
|
|
38
|
+
starts.push(clock.now());
|
|
39
|
+
let done = false;
|
|
40
|
+
return () => {
|
|
41
|
+
if (done)
|
|
42
|
+
return;
|
|
43
|
+
done = true;
|
|
44
|
+
active--;
|
|
45
|
+
released.resolve();
|
|
46
|
+
released = Promise.withResolvers();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
turn.resolve();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/mcp/main.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { canonicalPath } from "../adapters/canonical-path.js";
|
|
6
|
+
import { MCP_SHUTDOWN_FLUSH_TIMEOUT_MS } from "../constants.js";
|
|
7
|
+
import { McpServer, PARSE_ERROR } from "./protocol.js";
|
|
8
|
+
import { createMcpTools } from "./tools.js";
|
|
9
|
+
const usage = `jev-agent-tools-mcp - MCP stdio server for the six jev_* tools
|
|
10
|
+
|
|
11
|
+
Usage: jev-agent-tools-mcp [--root <repository-directory>]
|
|
12
|
+
|
|
13
|
+
The repository directory is --root, else JEV_TOOLS_ROOT, else the current
|
|
14
|
+
directory. Configuration uses the same JEV_TOOLS_* environment variables as
|
|
15
|
+
the pi and omp extension. Protocol messages use stdout; diagnostics stderr.`;
|
|
16
|
+
function packageVersion() {
|
|
17
|
+
try {
|
|
18
|
+
// src/mcp/main.ts and dist/mcp/main.js both sit two levels below the root.
|
|
19
|
+
const text = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
|
|
20
|
+
const parsed = JSON.parse(text);
|
|
21
|
+
return typeof parsed.version === "string" ? parsed.version : "0.0.0";
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "0.0.0";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
let root;
|
|
29
|
+
for (let index = 0; index < argv.length; index++) {
|
|
30
|
+
const arg = argv[index];
|
|
31
|
+
if (arg === "--help" || arg === "-h")
|
|
32
|
+
return { exit: usage };
|
|
33
|
+
if (arg === "--version")
|
|
34
|
+
return { exit: packageVersion() };
|
|
35
|
+
if (arg === "--root") {
|
|
36
|
+
root = argv[++index];
|
|
37
|
+
if (!root)
|
|
38
|
+
throw new Error("--root requires a directory");
|
|
39
|
+
}
|
|
40
|
+
else if (arg?.startsWith("--root="))
|
|
41
|
+
root = arg.slice("--root=".length);
|
|
42
|
+
else
|
|
43
|
+
throw new Error(`Unknown argument: ${arg}\n\n${usage}`);
|
|
44
|
+
}
|
|
45
|
+
return { root };
|
|
46
|
+
}
|
|
47
|
+
async function main() {
|
|
48
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
49
|
+
if (parsed.exit !== undefined) {
|
|
50
|
+
process.stdout.write(`${parsed.exit}\n`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const root = await canonicalPath(resolve(parsed.root ?? process.env.JEV_TOOLS_ROOT ?? process.cwd()));
|
|
54
|
+
if (!statSync(root, { throwIfNoEntry: false })?.isDirectory())
|
|
55
|
+
throw new Error(`Repository directory not found: ${root}`);
|
|
56
|
+
const { tools, instructions, configured, warning } = await createMcpTools({
|
|
57
|
+
root,
|
|
58
|
+
});
|
|
59
|
+
const server = new McpServer({ name: "jev-agent-tools", version: packageVersion(), instructions }, tools);
|
|
60
|
+
if (warning)
|
|
61
|
+
process.stderr.write(`jev-agent-tools MCP: ${warning}\n`);
|
|
62
|
+
process.stderr.write(`jev-agent-tools MCP server ready (root ${root}; ${configured ? "endpoint configured" : "no endpoint: set JEV_TOOLS_URL and JEV_TOOLS_API_KEY, or save them with /jev-setup in pi or omp"})\n`);
|
|
63
|
+
let closing = false;
|
|
64
|
+
const send = (response) => {
|
|
65
|
+
if (closing ||
|
|
66
|
+
response === undefined ||
|
|
67
|
+
(Array.isArray(response) && !response.length))
|
|
68
|
+
return;
|
|
69
|
+
// Newline-delimited JSON; JSON.stringify never emits raw newlines.
|
|
70
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
71
|
+
};
|
|
72
|
+
const pending = new Set();
|
|
73
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
74
|
+
const shutdown = async (code) => {
|
|
75
|
+
if (closing)
|
|
76
|
+
return;
|
|
77
|
+
closing = true;
|
|
78
|
+
lines.close();
|
|
79
|
+
process.stdin.destroy();
|
|
80
|
+
server.abortAll();
|
|
81
|
+
// Tool promises include bounded process-tree escalation. Do not exit when
|
|
82
|
+
// only the direct shell has closed: descendants may still need SIGKILL.
|
|
83
|
+
await Promise.allSettled(pending);
|
|
84
|
+
if (process.stdout.destroyed)
|
|
85
|
+
process.exit(code);
|
|
86
|
+
// A client may leave its stdout pipe open without draining it. Cleanup
|
|
87
|
+
// is already complete; never let that client's backpressure hold us alive.
|
|
88
|
+
setTimeout(() => process.exit(code), MCP_SHUTDOWN_FLUSH_TIMEOUT_MS);
|
|
89
|
+
process.stdout.end(() => process.exit(code));
|
|
90
|
+
};
|
|
91
|
+
process.on("SIGTERM", () => void shutdown(143));
|
|
92
|
+
process.on("SIGINT", () => void shutdown(130));
|
|
93
|
+
process.stdout.on("error", () => void shutdown(1));
|
|
94
|
+
lines.on("line", (line) => {
|
|
95
|
+
if (closing || !line.trim())
|
|
96
|
+
return;
|
|
97
|
+
let message;
|
|
98
|
+
try {
|
|
99
|
+
message = JSON.parse(line);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
send(server.error(null, PARSE_ERROR, "Parse error: invalid JSON."));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const work = (async () => {
|
|
106
|
+
if (Array.isArray(message)) {
|
|
107
|
+
// JSON-RPC batches (protocol 2025-03-26) answer as one array.
|
|
108
|
+
const responses = await Promise.all(message.map((item) => server.handle(item)));
|
|
109
|
+
send(responses.filter((item) => item !== undefined));
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
send(await server.handle(message));
|
|
113
|
+
})().catch((error) => {
|
|
114
|
+
process.stderr.write(`jev-agent-tools MCP: ${String(error)}\n`);
|
|
115
|
+
});
|
|
116
|
+
pending.add(work);
|
|
117
|
+
void work.finally(() => pending.delete(work));
|
|
118
|
+
});
|
|
119
|
+
lines.on("close", () => void shutdown(0));
|
|
120
|
+
}
|
|
121
|
+
main().catch((error) => {
|
|
122
|
+
process.stderr.write(`jev-agent-tools MCP: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
});
|