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,299 @@
|
|
|
1
|
+
import { createAnalysisContext } from "../adapters/analysis-context.js";
|
|
2
|
+
import { collectDocsInventory, docsDeclarationSearch, } from "../adapters/docs.js";
|
|
3
|
+
import { collectUnits } from "../adapters/git.js";
|
|
4
|
+
import { resolveBase } from "../adapters/git-base.js";
|
|
5
|
+
import { shareGitInventory } from "../adapters/git-inventory.js";
|
|
6
|
+
import { CHOICE_MAX_OPTIONS, DOCS_COLLECT_BUDGET_MS, DOCS_DISPLAY_MAX_SECTIONS, DOCS_MAX_SECTIONS, STATE_MAX_CHARS, } from "../constants.js";
|
|
7
|
+
import { collectDocsCandidates } from "../core/docs.js";
|
|
8
|
+
import { buildEnvelope, } from "../core/output.js";
|
|
9
|
+
import { prepareDocsCheck, readDocsJudgment, } from "../presets/docs.js";
|
|
10
|
+
import { NOT_CONFIGURED } from "../texts/configuration.js";
|
|
11
|
+
/** Shared judgment seam: no guide delivery, rendering, or session answer recording. */
|
|
12
|
+
export async function runDocsCheck(deps, input) {
|
|
13
|
+
const started = performance.now();
|
|
14
|
+
deps = { ...deps, exec: shareGitInventory(deps.exec) };
|
|
15
|
+
const timeout = input.budgetMs === undefined
|
|
16
|
+
? undefined
|
|
17
|
+
: AbortSignal.timeout(Math.max(0, Math.ceil(input.budgetMs)));
|
|
18
|
+
const signals = [input.signal, timeout].filter((value) => value !== undefined);
|
|
19
|
+
const signal = signals.length ? AbortSignal.any(signals) : undefined;
|
|
20
|
+
const judgments = [];
|
|
21
|
+
const findings = [];
|
|
22
|
+
const unchecked = [];
|
|
23
|
+
let unjudged;
|
|
24
|
+
let collection;
|
|
25
|
+
const limitations = [
|
|
26
|
+
{
|
|
27
|
+
fact: "Measured D22 limit: 2/45 documentation obligations found across 180 partial got/zod commits.",
|
|
28
|
+
next: "Also review missing documentation: this check evaluates existing sentences, not documentation completeness.",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
let budget;
|
|
32
|
+
let sent = 0;
|
|
33
|
+
let emptyBase;
|
|
34
|
+
const finish = (refusal) => {
|
|
35
|
+
if (unchecked.length > 5) {
|
|
36
|
+
unjudged = {
|
|
37
|
+
count: unchecked.length,
|
|
38
|
+
sections: unchecked.slice(0, DOCS_MAX_SECTIONS),
|
|
39
|
+
truncated: unchecked.length > DOCS_MAX_SECTIONS,
|
|
40
|
+
};
|
|
41
|
+
limitations.push({
|
|
42
|
+
fact: `${unchecked.length} matching sections unjudged.`,
|
|
43
|
+
next: "Narrow the diff with base=… or rerun on the relevant documentation.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const answers = findings.map((finding) => ({
|
|
47
|
+
label: `${finding.section.path}:${finding.section.start}-${finding.section.end} § ${finding.section.heading} — ${finding.sentence ? JSON.stringify(finding.sentence.text) : "sentence not identified"} — ${finding.band === "verdict" ? "now false" : "needs checking"} after ${finding.units.map((unit) => `${unit.name} (${unit.file})`).join(", ")}`,
|
|
48
|
+
value: { head: "now_false", p: finding.probability },
|
|
49
|
+
band: finding.band,
|
|
50
|
+
reason: finding.reason,
|
|
51
|
+
}));
|
|
52
|
+
const envelope = buildEnvelope({
|
|
53
|
+
answers,
|
|
54
|
+
limitations: emptyBase !== undefined && !refusal
|
|
55
|
+
? [
|
|
56
|
+
...limitations,
|
|
57
|
+
{
|
|
58
|
+
fact: `no changed units against ${emptyBase}`,
|
|
59
|
+
next: "nothing judged; pass base= or check the working directory",
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
: limitations,
|
|
63
|
+
...(collection || unjudged
|
|
64
|
+
? {
|
|
65
|
+
lines: [
|
|
66
|
+
...(collection
|
|
67
|
+
? [
|
|
68
|
+
{
|
|
69
|
+
type: "collection",
|
|
70
|
+
title: "anchored documentation unexplored; directly cited sections were checked",
|
|
71
|
+
count: collection.count,
|
|
72
|
+
items: collection.sections.slice(0, DOCS_DISPLAY_MAX_SECTIONS),
|
|
73
|
+
next: "Read these sections; narrow the diff with base=… to target their dependency.",
|
|
74
|
+
},
|
|
75
|
+
]
|
|
76
|
+
: []),
|
|
77
|
+
...(unjudged
|
|
78
|
+
? [
|
|
79
|
+
{
|
|
80
|
+
type: "collection",
|
|
81
|
+
title: "matching documentation unjudged",
|
|
82
|
+
count: unjudged.count,
|
|
83
|
+
items: unjudged.sections.slice(0, DOCS_DISPLAY_MAX_SECTIONS),
|
|
84
|
+
next: "Read these sections or rerun on the relevant documentation.",
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
: []),
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
: {}),
|
|
91
|
+
unchecked: unjudged ? [] : unchecked,
|
|
92
|
+
budget,
|
|
93
|
+
refusal,
|
|
94
|
+
...(!refusal &&
|
|
95
|
+
!budget &&
|
|
96
|
+
!collection &&
|
|
97
|
+
!unchecked.length &&
|
|
98
|
+
!findings.length &&
|
|
99
|
+
emptyBase === undefined
|
|
100
|
+
? {
|
|
101
|
+
lines: [
|
|
102
|
+
{
|
|
103
|
+
type: "list",
|
|
104
|
+
title: "docs: no stale sentences reported",
|
|
105
|
+
items: [],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
: {}),
|
|
110
|
+
yield: {
|
|
111
|
+
calls: judgments.reduce((n, j) => n + (j.calls ?? 0), 0),
|
|
112
|
+
questions: judgments.reduce((n, j) => n + (j.questions ?? 0), 0),
|
|
113
|
+
costUsd: judgments.reduce((n, j) => n + (j.usage?.costUsd ?? 0), 0),
|
|
114
|
+
cacheHits: judgments.reduce((n, j) => n + (j.cacheHits ?? 0), 0),
|
|
115
|
+
cacheRequests: judgments.reduce((n, j) => n + (j.cacheRequests ?? 0), 0),
|
|
116
|
+
elapsedMs: performance.now() - started,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
ok: !refusal,
|
|
121
|
+
status: timeout?.aborted
|
|
122
|
+
? "budget_exceeded"
|
|
123
|
+
: refusal
|
|
124
|
+
? "refused"
|
|
125
|
+
: collection
|
|
126
|
+
? "collect_budget_reached"
|
|
127
|
+
: "completed",
|
|
128
|
+
envelope,
|
|
129
|
+
judgments,
|
|
130
|
+
findings,
|
|
131
|
+
...(collection ? { collection } : {}),
|
|
132
|
+
...(unjudged ? { unjudged } : {}),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
const client = deps.client;
|
|
136
|
+
if (!client)
|
|
137
|
+
return finish(NOT_CONFIGURED);
|
|
138
|
+
const sessionRefusal = deps.runtime.session.refusal();
|
|
139
|
+
if (sessionRefusal)
|
|
140
|
+
return finish(sessionRefusal);
|
|
141
|
+
try {
|
|
142
|
+
const comparison = await resolveBase(deps.exec, input.cwd, input.base, signal);
|
|
143
|
+
if (!comparison.ok)
|
|
144
|
+
return finish(comparison.error);
|
|
145
|
+
const base = comparison.base;
|
|
146
|
+
const analysis = await createAnalysisContext();
|
|
147
|
+
const [collected, inventory] = await Promise.all([
|
|
148
|
+
collectUnits(deps.exec, { cwd: input.cwd, base, signal }, analysis.parser),
|
|
149
|
+
collectDocsInventory(deps.exec, input.cwd, signal),
|
|
150
|
+
]);
|
|
151
|
+
if (!collected.ok)
|
|
152
|
+
return finish(collected.error);
|
|
153
|
+
if (!inventory.ok)
|
|
154
|
+
return finish(inventory.error);
|
|
155
|
+
for (const limit of collected.limits)
|
|
156
|
+
limitations.push({
|
|
157
|
+
fact: `${limit.file} : ${limit.kind}`,
|
|
158
|
+
next: "Read the complete change before concluding.",
|
|
159
|
+
});
|
|
160
|
+
if (!collected.units.length) {
|
|
161
|
+
emptyBase = base;
|
|
162
|
+
return finish();
|
|
163
|
+
}
|
|
164
|
+
const collectDeadline = performance.now() + DOCS_COLLECT_BUDGET_MS;
|
|
165
|
+
const candidates = await collectDocsCandidates(inventory.files, collected.units, {
|
|
166
|
+
known: inventory.tracked,
|
|
167
|
+
read: inventory.read,
|
|
168
|
+
declarations: docsDeclarationSearch(deps.exec, inventory, signal),
|
|
169
|
+
changedFiles: collected.files,
|
|
170
|
+
lexical: analysis,
|
|
171
|
+
shouldStop: () => performance.now() >= collectDeadline || !!signal?.aborted,
|
|
172
|
+
});
|
|
173
|
+
for (const limit of inventory.limits)
|
|
174
|
+
limitations.push({
|
|
175
|
+
fact: `${limit.path} : ${limit.reason}`,
|
|
176
|
+
next: "Read the omitted file; its documentation or imports are unchecked.",
|
|
177
|
+
});
|
|
178
|
+
for (const limit of candidates.limits) {
|
|
179
|
+
if (limit.kind === "collection_budget") {
|
|
180
|
+
const changedParts = collected.units.map((unit) => unit.file.split("/"));
|
|
181
|
+
const sections = (limit.sections ?? [])
|
|
182
|
+
.map((section) => {
|
|
183
|
+
const parts = (section.split(" § ")[0] ?? "").split("/");
|
|
184
|
+
const proximity = changedParts.reduce((best, changed) => {
|
|
185
|
+
let common = 0;
|
|
186
|
+
while (common < parts.length &&
|
|
187
|
+
common < changed.length &&
|
|
188
|
+
parts[common] === changed[common])
|
|
189
|
+
common++;
|
|
190
|
+
return Math.max(best, common);
|
|
191
|
+
}, 0);
|
|
192
|
+
return { section, proximity };
|
|
193
|
+
})
|
|
194
|
+
.sort((a, b) => b.proximity - a.proximity);
|
|
195
|
+
collection = {
|
|
196
|
+
kind: limit.kind,
|
|
197
|
+
count: sections.length,
|
|
198
|
+
sections: sections
|
|
199
|
+
.slice(0, DOCS_MAX_SECTIONS)
|
|
200
|
+
.map((item) => item.section),
|
|
201
|
+
truncated: sections.length > DOCS_MAX_SECTIONS,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
else
|
|
205
|
+
limitations.push({
|
|
206
|
+
fact: `${limit.path} : ${limit.reason}`,
|
|
207
|
+
next: "Read the unresolved dependency; closure follows static imports only.",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
for (const omitted of candidates.omitted)
|
|
211
|
+
unchecked.push(`${omitted.path} § ${omitted.heading} (DOCS_MAX_SECTIONS)`);
|
|
212
|
+
await Promise.all(candidates.candidates.map(async (candidate) => {
|
|
213
|
+
const label = `${candidate.path} § ${candidate.heading}`;
|
|
214
|
+
if (candidate.units.some((unit) => unit.before === null && unit.after === null)) {
|
|
215
|
+
unchecked.push(`${label} (changed source unavailable)`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (budget?.kind === "session") {
|
|
219
|
+
unchecked.push(`${label} (${budget.message})`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const prepared = prepareDocsCheck(candidate);
|
|
223
|
+
if (JSON.stringify(prepared.state).length > STATE_MAX_CHARS ||
|
|
224
|
+
candidate.sentences.length + 1 > CHOICE_MAX_OPTIONS) {
|
|
225
|
+
unchecked.push(`${label} (state or pointer too large)`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (signal?.aborted) {
|
|
229
|
+
unchecked.push(`${label} (budget exceeded or canceled)`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const judgment = await client.judge(prepared.state, prepared.questions, {
|
|
233
|
+
signal,
|
|
234
|
+
...deps.runtime.session.requestGate(),
|
|
235
|
+
beforeRequest(questionCount) {
|
|
236
|
+
if (budget?.kind === "session")
|
|
237
|
+
return { ok: false, error: budget.message };
|
|
238
|
+
if (signal?.aborted)
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: "Budget exceeded or call canceled.",
|
|
242
|
+
};
|
|
243
|
+
if (input.maxCalls !== undefined && sent >= input.maxCalls) {
|
|
244
|
+
budget = {
|
|
245
|
+
kind: "max_calls",
|
|
246
|
+
message: `max_calls=${input.maxCalls} reached`,
|
|
247
|
+
};
|
|
248
|
+
return { ok: false, error: budget.message };
|
|
249
|
+
}
|
|
250
|
+
const admitted = deps.runtime.session.admit(questionCount);
|
|
251
|
+
if (!admitted.ok)
|
|
252
|
+
budget = { kind: "session", message: admitted.error };
|
|
253
|
+
else
|
|
254
|
+
sent++;
|
|
255
|
+
return admitted;
|
|
256
|
+
},
|
|
257
|
+
onUsage: (usage) => deps.runtime.session.recordUsage(usage),
|
|
258
|
+
});
|
|
259
|
+
judgments.push(judgment);
|
|
260
|
+
if (!judgment.ok) {
|
|
261
|
+
unchecked.push(`${label} (${judgment.error})`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!judgment.answers.status ||
|
|
265
|
+
!judgment.answers.sentence ||
|
|
266
|
+
Object.values(judgment.answers).some((answer) => answer.type === "unjudged")) {
|
|
267
|
+
unchecked.push(`${label} (unjudged question)`);
|
|
268
|
+
const finding = readDocsJudgment(candidate, judgment.answers);
|
|
269
|
+
if (finding)
|
|
270
|
+
findings.push({
|
|
271
|
+
...finding,
|
|
272
|
+
band: "unsure",
|
|
273
|
+
reason: "unjudged question: read the section",
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const finding = readDocsJudgment(candidate, judgment.answers);
|
|
278
|
+
if (finding) {
|
|
279
|
+
const incomplete = candidate.units.some((unit) => collected.limits.some((limit) => limit.file === unit.file));
|
|
280
|
+
findings.push(incomplete
|
|
281
|
+
? {
|
|
282
|
+
...finding,
|
|
283
|
+
band: "unsure",
|
|
284
|
+
reason: "incomplete change evidence: read the omitted pieces",
|
|
285
|
+
}
|
|
286
|
+
: finding);
|
|
287
|
+
}
|
|
288
|
+
}));
|
|
289
|
+
findings.sort((a, b) => a.section.path.localeCompare(b.section.path) ||
|
|
290
|
+
a.section.start - b.section.start);
|
|
291
|
+
unchecked.sort();
|
|
292
|
+
return finish();
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (signal?.aborted)
|
|
296
|
+
return finish(timeout?.aborted ? "Docs budget exceeded." : "Docs check canceled.");
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { canonicalPath } from "../adapters/canonical-path.js";
|
|
3
|
+
import { collectSearchExcerpts } from "../adapters/files.js";
|
|
4
|
+
import { prefilter } from "../adapters/find.js";
|
|
5
|
+
import { shareGitInventory } from "../adapters/git-inventory.js";
|
|
6
|
+
import { hostUsage } from "../adapters/usage.js";
|
|
7
|
+
import * as C from "../constants.js";
|
|
8
|
+
import { contentWords, findKeywords, rankFiles, retainFiles, } from "../core/find.js";
|
|
9
|
+
import { buildEnvelope, } from "../core/output.js";
|
|
10
|
+
import { needsReverse, pointerQuestion, readPointer } from "../core/pointer.js";
|
|
11
|
+
import { interpolate } from "../describe.js";
|
|
12
|
+
import { renderEnvelope } from "../render.js";
|
|
13
|
+
import { NOT_CONFIGURED } from "../texts/configuration.js";
|
|
14
|
+
import { FIND_DESCRIPTION } from "../texts/find.js";
|
|
15
|
+
export const findParameters = Type.Object({
|
|
16
|
+
goal: Type.String({ minLength: 1 }),
|
|
17
|
+
keywords: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
18
|
+
scope: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])),
|
|
19
|
+
exclude: Type.Optional(Type.Array(Type.String())),
|
|
20
|
+
effort: Type.Optional(Type.Union([
|
|
21
|
+
Type.Literal("quick"),
|
|
22
|
+
Type.Literal("default"),
|
|
23
|
+
Type.Literal("thorough"),
|
|
24
|
+
])),
|
|
25
|
+
max_calls: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
26
|
+
}, { additionalProperties: false });
|
|
27
|
+
export function createFindFilesTool(dependencies) {
|
|
28
|
+
const { host, runtime, exec: execute } = dependencies;
|
|
29
|
+
return {
|
|
30
|
+
name: "jev_find_files",
|
|
31
|
+
label: "Jev find files",
|
|
32
|
+
description: interpolate(FIND_DESCRIPTION, host.names),
|
|
33
|
+
parameters: findParameters,
|
|
34
|
+
...(host.isOmp
|
|
35
|
+
? {
|
|
36
|
+
approval: "read",
|
|
37
|
+
loadMode: "essential",
|
|
38
|
+
defaultInactive: true,
|
|
39
|
+
}
|
|
40
|
+
: {
|
|
41
|
+
promptSnippet: "Find the files for a goal described in plain words, ranked, with an entry point",
|
|
42
|
+
promptGuidelines: [
|
|
43
|
+
"jev_find_files: describe the behavior in one sentence of five words or more, not a guessed identifier",
|
|
44
|
+
],
|
|
45
|
+
}),
|
|
46
|
+
async execute(_id, args, signal, _update, ctx) {
|
|
47
|
+
const client = dependencies.client;
|
|
48
|
+
const cwd = await canonicalPath(ctx.cwd);
|
|
49
|
+
const started = performance.now();
|
|
50
|
+
const exec = shareGitInventory(execute);
|
|
51
|
+
const totals = {
|
|
52
|
+
calls: 0,
|
|
53
|
+
questions: 0,
|
|
54
|
+
cacheHits: 0,
|
|
55
|
+
cacheRequests: 0,
|
|
56
|
+
usage: { inputTokens: 0, costUsd: 0 },
|
|
57
|
+
};
|
|
58
|
+
const limitations = [];
|
|
59
|
+
const unchecked = [];
|
|
60
|
+
let budget;
|
|
61
|
+
let sent = 0;
|
|
62
|
+
const finish = (input) => {
|
|
63
|
+
const envelope = buildEnvelope({
|
|
64
|
+
...input,
|
|
65
|
+
limitations: [...limitations, ...(input.limitations ?? [])],
|
|
66
|
+
...(budget ? { budget, unchecked: [...new Set(unchecked)] } : {}),
|
|
67
|
+
yield: {
|
|
68
|
+
...totals,
|
|
69
|
+
costUsd: totals.usage.costUsd,
|
|
70
|
+
elapsedMs: performance.now() - started,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
runtime.session.record(envelope);
|
|
74
|
+
runtime.guide.deliver(ctx);
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text", text: renderEnvelope(envelope) }],
|
|
77
|
+
details: totals,
|
|
78
|
+
...hostUsage(host.isOmp, totals.usage),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
if (!client)
|
|
82
|
+
return finish({ refusal: NOT_CONFIGURED });
|
|
83
|
+
const words = contentWords(args.goal);
|
|
84
|
+
const short = words.length < C.FIND_GOAL_MIN_WORDS;
|
|
85
|
+
if (short)
|
|
86
|
+
limitations.push({
|
|
87
|
+
fact: `goal has ${words.length} content words: verdicts are capped at unsure`,
|
|
88
|
+
next: "describe the behavior in one sentence",
|
|
89
|
+
});
|
|
90
|
+
const keywords = findKeywords(args.goal, args.keywords);
|
|
91
|
+
const candidateLimit = args.effort === "quick"
|
|
92
|
+
? C.FIND_QUICK_CANDIDATES
|
|
93
|
+
: args.effort === "thorough"
|
|
94
|
+
? C.FIND_THOROUGH_CANDIDATES
|
|
95
|
+
: C.FIND_NAME_CANDIDATES;
|
|
96
|
+
const readLimit = args.effort === "quick"
|
|
97
|
+
? C.FIND_QUICK_READ
|
|
98
|
+
: args.effort === "thorough"
|
|
99
|
+
? C.FIND_THOROUGH_READ
|
|
100
|
+
: C.FIND_FILES_READ;
|
|
101
|
+
const candidates = await prefilter(exec, cwd, keywords, candidateLimit, args.scope, args.exclude, signal);
|
|
102
|
+
if (!candidates.ok)
|
|
103
|
+
return finish({ refusal: candidates.error });
|
|
104
|
+
const entry = (path, p, band, alternatives = [], orderDependent = false, orderProbabilities, relevant = []) => ({
|
|
105
|
+
type: "entry",
|
|
106
|
+
candidate: { label: path, value: { head: path, p } },
|
|
107
|
+
band,
|
|
108
|
+
candidates: alternatives.map((item) => ({
|
|
109
|
+
label: item.path,
|
|
110
|
+
value: { head: item.path, p: item.p },
|
|
111
|
+
})),
|
|
112
|
+
orderDependent,
|
|
113
|
+
orderProbabilities,
|
|
114
|
+
relevant: relevant.map((item) => ({
|
|
115
|
+
label: item.path,
|
|
116
|
+
value: { head: item.path, p: item.p },
|
|
117
|
+
})),
|
|
118
|
+
nextRead: path === "none"
|
|
119
|
+
? []
|
|
120
|
+
: [
|
|
121
|
+
path,
|
|
122
|
+
...(band === "unsure"
|
|
123
|
+
? alternatives
|
|
124
|
+
.map((item) => item.path)
|
|
125
|
+
.filter((path) => path !== "none")
|
|
126
|
+
: []),
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
if (!candidates.paths.length) {
|
|
130
|
+
limitations.push({
|
|
131
|
+
fact: candidates.scopeFiles === 0
|
|
132
|
+
? "scope matched 0 files"
|
|
133
|
+
: "no file shares a word with the goal",
|
|
134
|
+
next: "rephrase goal or widen scope",
|
|
135
|
+
});
|
|
136
|
+
return finish({
|
|
137
|
+
lines: [entry("none", 1, short ? "unsure" : "verdict")],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const judge = async (state, questions) => {
|
|
141
|
+
const result = await client.judge(state, questions, {
|
|
142
|
+
signal,
|
|
143
|
+
...runtime.session.requestGate(),
|
|
144
|
+
beforeRequest: (count) => {
|
|
145
|
+
if (args.max_calls !== undefined && sent >= args.max_calls) {
|
|
146
|
+
budget = {
|
|
147
|
+
kind: "max_calls",
|
|
148
|
+
message: `max_calls=${args.max_calls} reached`,
|
|
149
|
+
};
|
|
150
|
+
return { ok: false, error: budget.message };
|
|
151
|
+
}
|
|
152
|
+
const admission = runtime.session.admit(count);
|
|
153
|
+
if (!admission.ok) {
|
|
154
|
+
budget = { kind: "session", message: admission.error };
|
|
155
|
+
return admission;
|
|
156
|
+
}
|
|
157
|
+
sent++;
|
|
158
|
+
return admission;
|
|
159
|
+
},
|
|
160
|
+
onUsage: (usage) => runtime.session.recordUsage(usage),
|
|
161
|
+
});
|
|
162
|
+
for (const key of [
|
|
163
|
+
"calls",
|
|
164
|
+
"questions",
|
|
165
|
+
"cacheHits",
|
|
166
|
+
"cacheRequests",
|
|
167
|
+
])
|
|
168
|
+
totals[key] += result[key] ?? 0;
|
|
169
|
+
totals.usage.inputTokens += result.usage?.inputTokens ?? 0;
|
|
170
|
+
totals.usage.costUsd += result.usage?.costUsd ?? 0;
|
|
171
|
+
return result;
|
|
172
|
+
};
|
|
173
|
+
const boolQuestions = (paths, content) => Object.fromEntries(paths.map((path) => [
|
|
174
|
+
path,
|
|
175
|
+
{
|
|
176
|
+
type: "bool",
|
|
177
|
+
instructions: content
|
|
178
|
+
? `Does the passage from ${path} implement or document behavior that helps with this goal: ${args.goal}?`
|
|
179
|
+
: `Could the file ${path} help with this goal: ${args.goal}?`,
|
|
180
|
+
criteria: {
|
|
181
|
+
true: "The file could help with the goal",
|
|
182
|
+
false: "The file does not help with the goal",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
]));
|
|
186
|
+
const names = [];
|
|
187
|
+
const nameBatches = Array.from({ length: Math.ceil(candidates.paths.length / C.FIND_NAME_BATCH) }, (_, index) => candidates.paths.slice(index * C.FIND_NAME_BATCH, (index + 1) * C.FIND_NAME_BATCH));
|
|
188
|
+
const nameResults = await Promise.all(nameBatches.map(async (paths) => ({
|
|
189
|
+
paths,
|
|
190
|
+
result: await judge({ goal: args.goal, paths }, boolQuestions(paths, false)),
|
|
191
|
+
})));
|
|
192
|
+
for (const { paths, result } of nameResults) {
|
|
193
|
+
if (!result.ok && !budget)
|
|
194
|
+
return finish({ refusal: result.error });
|
|
195
|
+
for (const path of paths) {
|
|
196
|
+
const answer = result.ok ? result.answers[path] : undefined;
|
|
197
|
+
if (answer?.type === "bool")
|
|
198
|
+
names.push({ path, p: answer.p });
|
|
199
|
+
else
|
|
200
|
+
unchecked.push(path);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const rankedNames = rankFiles(names);
|
|
204
|
+
let bestRanking = rankedNames;
|
|
205
|
+
const fallback = () => {
|
|
206
|
+
const top = bestRanking.slice(0, 2);
|
|
207
|
+
return finish({
|
|
208
|
+
lines: [
|
|
209
|
+
entry(top[0]?.path ?? "none", top[0]?.p ?? 0, "unsure", top.slice(1)),
|
|
210
|
+
],
|
|
211
|
+
answers: bestRanking.map((item) => ({
|
|
212
|
+
label: item.path,
|
|
213
|
+
value: { head: "relevant", p: item.p },
|
|
214
|
+
band: "unsure",
|
|
215
|
+
})),
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
if (budget) {
|
|
219
|
+
unchecked.push(...candidates.paths.filter((path) => !names.some((item) => item.path === path)));
|
|
220
|
+
return fallback();
|
|
221
|
+
}
|
|
222
|
+
const readPaths = rankedNames
|
|
223
|
+
.slice(0, readLimit)
|
|
224
|
+
.map((item) => item.path);
|
|
225
|
+
const files = {};
|
|
226
|
+
const content = [];
|
|
227
|
+
const contentBatches = Array.from({ length: Math.ceil(readPaths.length / C.FIND_READ_PER_CALL) }, (_, index) => readPaths.slice(index * C.FIND_READ_PER_CALL, (index + 1) * C.FIND_READ_PER_CALL));
|
|
228
|
+
const contentResults = await Promise.all(contentBatches.map(async (paths) => {
|
|
229
|
+
const read = await collectSearchExcerpts(cwd, paths, keywords, signal, exec);
|
|
230
|
+
if (!read.ok)
|
|
231
|
+
return { paths, result: read };
|
|
232
|
+
Object.assign(files, read.pointers);
|
|
233
|
+
limitations.push(...read.limits.map((limit) => ({
|
|
234
|
+
fact: `${limit.path}: ${limit.reason}`,
|
|
235
|
+
next: "provide a UTF-8 text file",
|
|
236
|
+
})));
|
|
237
|
+
if (read.ignored.length)
|
|
238
|
+
limitations.push({
|
|
239
|
+
fact: `${read.ignored.length} binary, empty or unreadable paths ignored: ${read.ignored.join(", ")}`,
|
|
240
|
+
next: "narrow scope to text files",
|
|
241
|
+
});
|
|
242
|
+
const textPaths = paths.filter((path) => read.files[path] !== undefined);
|
|
243
|
+
if (!textPaths.length)
|
|
244
|
+
return { paths: [], result: { ok: true, answers: {} } };
|
|
245
|
+
return {
|
|
246
|
+
paths: textPaths,
|
|
247
|
+
result: await judge({
|
|
248
|
+
goal: args.goal,
|
|
249
|
+
files: Object.fromEntries(Object.entries(read.files).sort(([a], [b]) => a.localeCompare(b))),
|
|
250
|
+
}, boolQuestions(textPaths, true)),
|
|
251
|
+
};
|
|
252
|
+
}));
|
|
253
|
+
for (const { paths, result } of contentResults) {
|
|
254
|
+
if (!result.ok && !budget)
|
|
255
|
+
return finish({ refusal: result.error });
|
|
256
|
+
for (const path of paths) {
|
|
257
|
+
const answer = result.ok ? result.answers[path] : undefined;
|
|
258
|
+
if (answer?.type === "bool")
|
|
259
|
+
content.push({ path, p: answer.p });
|
|
260
|
+
else
|
|
261
|
+
unchecked.push(path);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (content.length)
|
|
265
|
+
bestRanking = rankFiles(content);
|
|
266
|
+
if (budget) {
|
|
267
|
+
unchecked.push(...readPaths.filter((path) => !content.some((item) => item.path === path)));
|
|
268
|
+
return fallback();
|
|
269
|
+
}
|
|
270
|
+
const retained = retainFiles(content, rankedNames);
|
|
271
|
+
limitations.push({
|
|
272
|
+
fact: `cascade: ${names.length} names judged → ${content.length} files read → ${retained.length} shown to the pointer`,
|
|
273
|
+
next: "",
|
|
274
|
+
});
|
|
275
|
+
const relevant = rankFiles(content).filter((item) => retained.some((file) => file.path === item.path));
|
|
276
|
+
const output = (path, p, band, alternatives = [], orderDependent = false, orderProbabilities) => finish({
|
|
277
|
+
lines: [
|
|
278
|
+
entry(path, p, band, alternatives, orderDependent, orderProbabilities, relevant),
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
if (!retained.length) {
|
|
282
|
+
limitations.push({
|
|
283
|
+
fact: "no file retained by content or names guard",
|
|
284
|
+
next: "rephrase goal or widen scope",
|
|
285
|
+
});
|
|
286
|
+
return output("none", 1, short ? "unsure" : "verdict");
|
|
287
|
+
}
|
|
288
|
+
const idByPath = Object.fromEntries(retained.map((item, index) => [item.path, `c${index + 1}`]));
|
|
289
|
+
const pathById = Object.fromEntries(retained.map((item, index) => [`c${index + 1}`, item.path]));
|
|
290
|
+
const pointer = async (paths) => {
|
|
291
|
+
return judge({
|
|
292
|
+
goal: args.goal,
|
|
293
|
+
candidates: paths.map((path) => ({
|
|
294
|
+
id: idByPath[path] ?? "",
|
|
295
|
+
path,
|
|
296
|
+
excerpt: files[path] ?? "",
|
|
297
|
+
})),
|
|
298
|
+
}, {
|
|
299
|
+
entry: pointerQuestion({
|
|
300
|
+
instructions: `Which candidate is the best entry point for this goal: ${args.goal}? Choose none if no passage helps.`,
|
|
301
|
+
noneLabel: "No candidate implements or documents the goal",
|
|
302
|
+
candidates: paths.map((path) => ({
|
|
303
|
+
id: idByPath[path] ?? "",
|
|
304
|
+
label: `Candidate ${idByPath[path]}`,
|
|
305
|
+
})),
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
const paths = retained.map((item) => item.path);
|
|
310
|
+
const firstResult = await pointer(paths);
|
|
311
|
+
if (!firstResult.ok && !budget)
|
|
312
|
+
return finish({ refusal: firstResult.error });
|
|
313
|
+
const first = firstResult.ok ? firstResult.answers.entry : undefined;
|
|
314
|
+
if (first?.type !== "choice") {
|
|
315
|
+
unchecked.push("entry");
|
|
316
|
+
return fallback();
|
|
317
|
+
}
|
|
318
|
+
const ranked = (probabilities) => readPointer(probabilities).ranked.map(({ id, p }) => ({ path: id, p }));
|
|
319
|
+
const firstRanks = ranked(first.probabilities);
|
|
320
|
+
const firstTop = firstRanks[0];
|
|
321
|
+
if (!firstTop)
|
|
322
|
+
return finish({ refusal: "Pointer returned no probabilities." });
|
|
323
|
+
let ranks = firstRanks;
|
|
324
|
+
let band = firstTop.p >= C.ORDER_REVERSE_BELOW ? "verdict" : "unsure";
|
|
325
|
+
let disagreement = false;
|
|
326
|
+
let orderDependent = false;
|
|
327
|
+
let orderProbabilities;
|
|
328
|
+
if (retained.length > 1 &&
|
|
329
|
+
(args.effort === "thorough" ||
|
|
330
|
+
(args.effort !== "quick" &&
|
|
331
|
+
needsReverse(first.probabilities, retained.length)))) {
|
|
332
|
+
const secondResult = await pointer([...paths].reverse());
|
|
333
|
+
if (!secondResult.ok && !budget)
|
|
334
|
+
return finish({ refusal: secondResult.error });
|
|
335
|
+
const second = secondResult.ok ? secondResult.answers.entry : undefined;
|
|
336
|
+
if (second?.type !== "choice") {
|
|
337
|
+
unchecked.push("entry confirmation");
|
|
338
|
+
band = "unsure";
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
const secondRanks = ranked(second.probabilities);
|
|
342
|
+
const combined = readPointer(first.probabilities, second.probabilities);
|
|
343
|
+
ranks = combined.ranked.map(({ id, p }) => ({ path: id, p }));
|
|
344
|
+
disagreement = firstTop.path !== secondRanks[0]?.path;
|
|
345
|
+
const chosen = ranks[0]?.path ?? firstTop.path;
|
|
346
|
+
orderProbabilities = [
|
|
347
|
+
first.probabilities[chosen] ?? 0,
|
|
348
|
+
second.probabilities[chosen] ?? 0,
|
|
349
|
+
];
|
|
350
|
+
orderDependent = combined.orderDependent;
|
|
351
|
+
band =
|
|
352
|
+
!disagreement && (ranks[0]?.p ?? 0) >= C.FIND_CONFIRM_MIN
|
|
353
|
+
? "verdict"
|
|
354
|
+
: "unsure";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (disagreement)
|
|
358
|
+
limitations.push({
|
|
359
|
+
fact: "the two orders disagree",
|
|
360
|
+
next: ranks.slice(0, 2).every((item) => item.path !== "none")
|
|
361
|
+
? "read both"
|
|
362
|
+
: "read the named file if present; rephrase goal or widen scope",
|
|
363
|
+
});
|
|
364
|
+
else if (band === "unsure")
|
|
365
|
+
limitations.push({
|
|
366
|
+
fact: "entry probability below confirmation threshold",
|
|
367
|
+
next: ranks.slice(0, 2).every((item) => item.path !== "none")
|
|
368
|
+
? "read both"
|
|
369
|
+
: "read the named file if present; rephrase goal or widen scope",
|
|
370
|
+
});
|
|
371
|
+
const top = ranks[0];
|
|
372
|
+
if (!top)
|
|
373
|
+
return finish({ refusal: "Pointer returned no probabilities." });
|
|
374
|
+
if (top.path === "none")
|
|
375
|
+
limitations.push({
|
|
376
|
+
fact: "pointer chose none",
|
|
377
|
+
next: "rephrase goal or widen scope",
|
|
378
|
+
});
|
|
379
|
+
return output(top.path === "none" ? "none" : (pathById[top.path] ?? top.path), top.p, short || budget ? "unsure" : band, band === "unsure" || short
|
|
380
|
+
? ranks.slice(1, 2).map((item) => ({
|
|
381
|
+
path: item.path === "none"
|
|
382
|
+
? "none"
|
|
383
|
+
: (pathById[item.path] ?? item.path),
|
|
384
|
+
p: item.p,
|
|
385
|
+
}))
|
|
386
|
+
: [], orderDependent, orderProbabilities);
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|