jev-agent-tools 0.1.4 → 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.
Files changed (131) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +42 -9
  4. package/SECURITY.md +27 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +189 -0
  7. package/dist/adapters/ask-proof.js +144 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +181 -0
  11. package/dist/adapters/docs.js +172 -0
  12. package/dist/adapters/exec.js +207 -0
  13. package/dist/adapters/files.js +293 -0
  14. package/dist/adapters/find.js +122 -0
  15. package/dist/adapters/git-base.js +26 -0
  16. package/dist/adapters/git-inventory.js +71 -0
  17. package/dist/adapters/git.js +439 -0
  18. package/dist/adapters/locate-file.js +159 -0
  19. package/dist/adapters/output-lines.js +46 -0
  20. package/dist/adapters/private-storage.js +98 -0
  21. package/dist/adapters/risk-callers.js +426 -0
  22. package/dist/adapters/runner-version.js +78 -0
  23. package/dist/adapters/shell.js +76 -0
  24. package/dist/adapters/syntax.js +187 -0
  25. package/dist/adapters/test-inventory.js +131 -0
  26. package/dist/adapters/usage.js +20 -0
  27. package/dist/adapters/utf8.js +47 -0
  28. package/dist/configuration.js +257 -0
  29. package/dist/constants.js +119 -0
  30. package/dist/core/ask-closure.js +282 -0
  31. package/dist/core/ask-proof.js +1 -0
  32. package/dist/core/ask-references.js +194 -0
  33. package/dist/core/asks.js +436 -0
  34. package/dist/core/batches.js +65 -0
  35. package/dist/core/command-output.js +224 -0
  36. package/dist/core/diff.js +178 -0
  37. package/dist/core/docs.js +302 -0
  38. package/dist/core/find.js +108 -0
  39. package/dist/core/git.js +1 -0
  40. package/dist/core/imports.js +550 -0
  41. package/dist/core/integrity.js +45 -0
  42. package/dist/core/lexical.js +132 -0
  43. package/dist/core/locate.js +169 -0
  44. package/dist/core/output.js +120 -0
  45. package/dist/core/pointer.js +29 -0
  46. package/dist/core/risk-callers.js +851 -0
  47. package/dist/core/runner-version.js +45 -0
  48. package/dist/core/sections.js +230 -0
  49. package/dist/core/state.js +44 -0
  50. package/dist/core/syntax.js +1 -0
  51. package/dist/core/test-commands.js +334 -0
  52. package/dist/core/test-coverage.js +74 -0
  53. package/dist/core/test-discovery.js +1382 -0
  54. package/dist/core/test-evidence.js +527 -0
  55. package/dist/core/test-state.js +81 -0
  56. package/dist/core/truncate.js +12 -0
  57. package/dist/core/units.js +349 -0
  58. package/dist/describe.js +23 -0
  59. package/dist/guide.js +33 -0
  60. package/dist/host.js +24 -0
  61. package/dist/jev/client.js +434 -0
  62. package/dist/jev/pool.js +54 -0
  63. package/dist/jev/types.js +1 -0
  64. package/dist/mcp/main.js +124 -0
  65. package/dist/mcp/protocol.js +187 -0
  66. package/dist/mcp/tools.js +116 -0
  67. package/dist/presets/docs.js +62 -0
  68. package/dist/presets/risk.js +179 -0
  69. package/dist/presets/spec.js +81 -0
  70. package/dist/presets/witnesses.js +249 -0
  71. package/dist/render.js +42 -0
  72. package/dist/result.js +3 -0
  73. package/dist/runtime.js +1 -0
  74. package/dist/session.js +147 -0
  75. package/dist/texts/ask-files.js +1 -0
  76. package/dist/texts/ask.js +2 -0
  77. package/dist/texts/check-diff.js +17 -0
  78. package/dist/texts/configuration.js +1 -0
  79. package/dist/texts/find.js +14 -0
  80. package/dist/texts/guide.js +16 -0
  81. package/dist/texts/locate.js +10 -0
  82. package/dist/texts/select-tests.js +2 -0
  83. package/dist/tools/ask-files.js +217 -0
  84. package/dist/tools/ask-schema.js +70 -0
  85. package/dist/tools/ask.js +686 -0
  86. package/dist/tools/check-diff.js +402 -0
  87. package/dist/tools/docs-check.js +299 -0
  88. package/dist/tools/find.js +389 -0
  89. package/dist/tools/locate.js +303 -0
  90. package/dist/tools/select-tests.js +567 -0
  91. package/dist/tools/spec-check.js +166 -0
  92. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  93. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  94. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  95. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  96. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  97. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  98. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  99. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  100. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  101. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  102. package/docs/agent-instructions.md +91 -0
  103. package/docs/design.md +3 -3
  104. package/docs/mcp.md +231 -0
  105. package/package.json +19 -4
  106. package/server.json +57 -0
  107. package/src/adapters/canonical-path.ts +18 -0
  108. package/src/adapters/command.ts +7 -4
  109. package/src/adapters/exec.ts +226 -0
  110. package/src/adapters/private-storage.ts +143 -0
  111. package/src/adapters/risk-callers.ts +4 -2
  112. package/src/adapters/shell.ts +97 -0
  113. package/src/configuration.ts +39 -12
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host.ts +11 -0
  117. package/src/jev/client.ts +12 -0
  118. package/src/jev/types.ts +6 -0
  119. package/src/mcp/main.ts +135 -0
  120. package/src/mcp/protocol.ts +282 -0
  121. package/src/mcp/tools.ts +166 -0
  122. package/src/session.ts +59 -0
  123. package/src/setup.ts +13 -5
  124. package/src/tools/ask-files.ts +5 -7
  125. package/src/tools/ask.ts +26 -22
  126. package/src/tools/check-diff.ts +8 -5
  127. package/src/tools/docs-check.ts +1 -0
  128. package/src/tools/find.ts +5 -2
  129. package/src/tools/locate.ts +5 -8
  130. package/src/tools/select-tests.ts +7 -4
  131. 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
+ }
@@ -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 {};
@@ -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
+ });