pi-supernova 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -11
- package/docs/CHANGELOG.md +150 -0
- package/docs/TOKEN_COSTS.md +71 -29
- package/index.js +126 -82
- package/package.json +2 -2
- package/src/adapters/bash.js +73 -0
- package/src/adapters/edit.js +249 -0
- package/src/adapters/errors.js +31 -0
- package/src/adapters/index.js +31 -0
- package/src/adapters/list.js +102 -0
- package/src/adapters/read.js +805 -0
- package/src/adapters/refs.js +41 -0
- package/src/adapters/write.js +96 -0
- package/src/bridge/catalog.js +30 -220
- package/src/bridge/host-bridge.js +142 -1032
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -188
- package/src/context/evidence.js +142 -70
- package/src/context/fuzzy.js +61 -22
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +26 -12
- package/src/context/repo-index.js +242 -71
- package/src/context/search.js +189 -56
- package/src/context/snap.js +306 -150
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +29 -14
- package/src/contract/bash.js +31 -0
- package/src/contract/edit.js +95 -0
- package/src/contract/read.js +220 -0
- package/src/fs/check.js +19 -7
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +97 -51
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +289 -162
- package/src/fs/workspace.js +122 -105
- package/src/output/bottleneck.js +211 -107
- package/src/output/format.js +112 -63
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +306 -213
- package/src/runtime/parallel.js +99 -63
- package/src/runtime/program-batch.js +189 -69
- package/src/runtime/program-file.js +6 -3
- package/src/runtime/reference.js +13 -12
- package/src/runtime/runtime.js +327 -176
- package/src/shared/decode.js +61 -27
- package/src/ui/omp-frame.js +70 -46
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +242 -146
|
@@ -1,959 +1,19 @@
|
|
|
1
|
-
|
|
2
1
|
import * as fs from "node:fs/promises";
|
|
3
2
|
import * as path from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
3
|
import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
|
|
6
|
-
import {
|
|
4
|
+
import { truncateChars } from "../output/format.js";
|
|
5
|
+
import { isString, isFunction, isObject } from "../shared/decode.js";
|
|
7
6
|
import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
|
|
8
7
|
import { unknownToolMessage } from "./catalog.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
|
|
12
|
-
import { executeSnap } from "../context/snap.js";
|
|
13
|
-
import { selectEvidence } from "../context/evidence.js";
|
|
8
|
+
import { toolIsCallable, resolveInvokeTarget } from "./invoke.js";
|
|
9
|
+
import { buildWriteDiff } from "../fs/diff.js";
|
|
14
10
|
import { WorkspaceIndex } from "../context/repo-index.js";
|
|
15
|
-
import { outlineFile } from "../context/outline.js";
|
|
16
11
|
import { SeenLedger } from "../context/ledger.js";
|
|
17
|
-
import { quickCheck } from "../fs/check.js";
|
|
18
|
-
import { declaredName } from "../context/repo-index.js";
|
|
19
12
|
import { CausalVfs } from "../fs/vfs.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
|
|
24
|
-
|
|
25
|
-
function textResult(text, details) {
|
|
26
|
-
return {
|
|
27
|
-
content: [{ type: "text", text: String(text ?? "") }],
|
|
28
|
-
details: details || {},
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function resultDiff(response) {
|
|
33
|
-
let details = response?.details;
|
|
34
|
-
|
|
35
|
-
if (isString(details)) {
|
|
36
|
-
try {
|
|
37
|
-
details = JSON.parse(details);
|
|
38
|
-
} catch {
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return isObject(details) ? details.diff : undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Unwrap a single matching quote pair around the whole string (`'git status'`). */
|
|
47
|
-
function unwrapIfFullyQuoted(s) {
|
|
48
|
-
if (s.length < 2) return s;
|
|
49
|
-
const q = s[0];
|
|
50
|
-
|
|
51
|
-
if (q !== "'" && q !== '"') return s;
|
|
52
|
-
|
|
53
|
-
if (s[s.length - 1] !== q) return s;
|
|
54
|
-
const inner = s.slice(1, -1);
|
|
55
|
-
|
|
56
|
-
if (inner.includes(q)) return s;
|
|
57
|
-
|
|
58
|
-
return inner;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function sliceLines(text, offset, limit) {
|
|
62
|
-
if (!isNumber(offset) && !isNumber(limit)) return text;
|
|
63
|
-
const lines = text.split("\n");
|
|
64
|
-
const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
|
|
65
|
-
const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
|
|
66
|
-
|
|
67
|
-
return lines.slice(startIndex, startIndex + count).join("\n");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function resolveReadPath(cwd, target) {
|
|
71
|
-
if (!isString(target) || !target.trim()) throw new Error("read requires path");
|
|
72
|
-
const input = assertFilesystemPath(target, "read");
|
|
73
|
-
|
|
74
|
-
return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function probeExistingPath(cwd, targetParam, vfs) {
|
|
78
|
-
const targetPath = resolveReadPath(cwd, targetParam);
|
|
79
|
-
|
|
80
|
-
if (vfs.getOverlay(targetPath) !== undefined) return { path: targetPath, directory: false };
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
const st = await fs.stat(targetPath);
|
|
84
|
-
|
|
85
|
-
return { path: targetPath, directory: st.isDirectory() };
|
|
86
|
-
} catch (err) {
|
|
87
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
88
|
-
|
|
89
|
-
if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
|
|
90
|
-
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const EDIT_PREVIEW_LINES = 16;
|
|
96
|
-
|
|
97
|
-
function sourceLines(content) {
|
|
98
|
-
const raw = content.split("\n");
|
|
99
|
-
|
|
100
|
-
if (raw.at(-1) === "") raw.pop();
|
|
101
|
-
|
|
102
|
-
return raw;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function lineNumberAt(content, index) {
|
|
106
|
-
let line = 1;
|
|
107
|
-
|
|
108
|
-
for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) line++;
|
|
109
|
-
|
|
110
|
-
return line;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function formatNumberedLine(n, text) {
|
|
114
|
-
return String(n).padStart(5) + " " + text;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
|
|
118
|
-
const lines = sourceLines(content);
|
|
119
|
-
|
|
120
|
-
if (lines.length === 0) return "0 lines";
|
|
121
|
-
const shown = lines.slice(0, cap);
|
|
122
|
-
const body = shown.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
|
|
123
|
-
const suffix = lines.length > cap ? lines.length + " lines total" : lines.length + " lines";
|
|
124
|
-
|
|
125
|
-
return body + "\n" + suffix;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function applyReplacements(target, content, requestedEdits) {
|
|
129
|
-
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
130
|
-
|
|
131
|
-
const matches = requestedEdits.map((replacement) => {
|
|
132
|
-
if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
|
|
133
|
-
throw new Error("edit requires non-empty oldText");
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (!isString(replacement?.newText)) throw new Error("edit requires newText");
|
|
137
|
-
const index = content.indexOf(replacement.oldText);
|
|
138
|
-
|
|
139
|
-
if (index < 0) {
|
|
140
|
-
throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
|
|
141
|
-
}
|
|
142
|
-
const second = content.indexOf(replacement.oldText, index + 1);
|
|
143
|
-
|
|
144
|
-
if (second >= 0) {
|
|
145
|
-
const lines = sourceLines(content);
|
|
146
|
-
const a = lineNumberAt(content, index);
|
|
147
|
-
const b = lineNumberAt(content, second);
|
|
148
|
-
|
|
149
|
-
throw new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lines[a - 1] ?? "") + "\n" + formatNumberedLine(b, lines[b - 1] ?? ""));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return { ...replacement, index, end: index + replacement.oldText.length };
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
matches.sort((a, b) => a.index - b.index);
|
|
156
|
-
|
|
157
|
-
for (let i = 1; i < matches.length; i++) {
|
|
158
|
-
if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
let updated = content;
|
|
162
|
-
|
|
163
|
-
for (let i = matches.length - 1; i >= 0; i--) {
|
|
164
|
-
const match = matches[i];
|
|
165
|
-
updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return { updated, matches };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function applyViewReplace(target, content, start, end, oldText, newText) {
|
|
172
|
-
const current = sliceLines(content, start, end - start + 1);
|
|
173
|
-
|
|
174
|
-
if (current !== oldText) {
|
|
175
|
-
const shown = current.length ? current : content;
|
|
176
|
-
|
|
177
|
-
throw new Error("edit view is stale in " + target + ": lines " + start + "-" + end + " changed\n" + numberedPreview(shown));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const hadTrail = content.endsWith("\n");
|
|
181
|
-
const lines = content.split("\n");
|
|
182
|
-
|
|
183
|
-
if (hadTrail && lines.at(-1) === "") lines.pop();
|
|
184
|
-
const insert = newText.split("\n");
|
|
185
|
-
|
|
186
|
-
if (newText.endsWith("\n") && insert.at(-1) === "") insert.pop();
|
|
187
|
-
const updated = [...lines.slice(0, start - 1), ...insert, ...lines.slice(end)].join("\n") + (hadTrail ? "\n" : "");
|
|
188
|
-
|
|
189
|
-
return { updated, oldText, newText };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function formatDirectoryEntry(name, type, size = 0) {
|
|
193
|
-
const sizeSuffix = size ? `, ${size} bytes` : "";
|
|
194
|
-
|
|
195
|
-
return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function formatLsEntry(dirPath, entry) {
|
|
199
|
-
const isDir = entry.isDirectory();
|
|
200
|
-
const isSym = entry.isSymbolicLink();
|
|
201
|
-
const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
|
|
202
|
-
let size = 0;
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
if (!isDir && !isSym) {
|
|
206
|
-
const st = await fs.stat(path.join(dirPath, entry.name));
|
|
207
|
-
size = st.size;
|
|
208
|
-
}
|
|
209
|
-
} catch {}
|
|
210
|
-
|
|
211
|
-
return formatDirectoryEntry(entry.name, typeLabel, size);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
215
|
-
const reads = createNativeScheduler();
|
|
216
|
-
|
|
217
|
-
async function sourceRead(query, searchDir, signal, params = {}) {
|
|
218
|
-
params = { ...params, resolve: params.resolve !== false };
|
|
219
|
-
const cwd = getCwd();
|
|
220
|
-
|
|
221
|
-
const includeHidden = path.relative(cwd, searchDir).split(path.sep)
|
|
222
|
-
.some(segment => segment.startsWith(".") && segment.length > 1);
|
|
223
|
-
|
|
224
|
-
const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
|
|
225
|
-
pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
|
|
226
|
-
overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
227
|
-
|
|
228
|
-
return openSource(result, params, signal, undefined, query);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
async function openSource(result, params, signal, resolvedPath, query) {
|
|
232
|
-
const cwd = getCwd();
|
|
233
|
-
|
|
234
|
-
if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
|
|
235
|
-
signal?.throwIfAborted();
|
|
236
|
-
const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path, query);
|
|
237
|
-
const block = opened.content[0];
|
|
238
|
-
|
|
239
|
-
if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
|
|
240
|
-
const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
|
|
241
|
-
|
|
242
|
-
const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
|
|
243
|
-
text: block.text.slice(0, sourceChars), complete, nextOffset };
|
|
244
|
-
|
|
245
|
-
return textResult(params.resolve ? JSON.stringify(source) : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
|
|
246
|
-
{ ...opened.details, isSnap: true });
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function readDirectory(dirPath, signal) {
|
|
250
|
-
signal?.throwIfAborted();
|
|
251
|
-
const rows = new Map();
|
|
252
|
-
|
|
253
|
-
for (const file of vfs.getOverlayPaths()) {
|
|
254
|
-
const relative = path.relative(dirPath, file);
|
|
255
|
-
|
|
256
|
-
if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
|
|
257
|
-
const [name, child] = relative.split(path.sep);
|
|
258
|
-
rows.set(name, child === undefined
|
|
259
|
-
? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
|
|
260
|
-
: formatDirectoryEntry(name, "dir"));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
let entries;
|
|
264
|
-
|
|
265
|
-
try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
|
|
266
|
-
if (error.code !== "ENOENT" || rows.size === 0) throw error;
|
|
267
|
-
entries = [];
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
|
|
271
|
-
|
|
272
|
-
return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function readAdapter(params, signal) {
|
|
276
|
-
signal?.throwIfAborted();
|
|
277
|
-
const cwd = getCwd();
|
|
278
|
-
const targetParam = params?.path ?? params?.target;
|
|
279
|
-
|
|
280
|
-
if (Array.isArray(targetParam)) {
|
|
281
|
-
if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
282
|
-
|
|
283
|
-
for (const p of targetParam) if (!isString(p) || !p.trim()) throw new Error("read paths must be non-empty strings");
|
|
284
|
-
|
|
285
|
-
const results = await Promise.all(targetParam.map(async p => {
|
|
286
|
-
try {
|
|
287
|
-
const block = (await readAdapter({ ...params, path: p }, signal)).content[0];
|
|
288
|
-
|
|
289
|
-
return { text: block.type === "image" ? block : block.text };
|
|
290
|
-
} catch (error) {
|
|
291
|
-
signal?.throwIfAborted();
|
|
292
|
-
|
|
293
|
-
return { text: `[read error: ${p}] ${error.message}`, error: { path: p, message: error.message } };
|
|
294
|
-
}
|
|
295
|
-
}));
|
|
296
|
-
|
|
297
|
-
signal?.throwIfAborted();
|
|
298
|
-
const response = textResult("", { count: results.length, batch: true, independent: params._independent === true, items: results.map(r => r.text), itemErrors: results.map(r => r.error?.message ?? null), errors: results.filter(r => r.error).map(r => r.error) });
|
|
299
|
-
response.isError = params._independent !== true && results.some(r => r.error);
|
|
300
|
-
|
|
301
|
-
return response;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
async function resolveSessionResource(uri, signal) {
|
|
308
|
-
const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
|
|
309
|
-
|
|
310
|
-
if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
|
|
311
|
-
const kind = match[1].toLowerCase();
|
|
312
|
-
const id = decodeURIComponent(match[2]);
|
|
313
|
-
|
|
314
|
-
if (!id || id === "." || id === ".." || (/[/\\]/u.test(id) || Array.from(id).some(char => char.charCodeAt(0) < 32)) || (kind === "artifact" && !/^\d+$/.test(id))) throw new Error("invalid session resource ID");
|
|
315
|
-
const dir = hooks.artifactsDir?.();
|
|
316
|
-
|
|
317
|
-
if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
|
|
318
|
-
signal?.throwIfAborted();
|
|
319
|
-
const root = await fs.realpath(dir);
|
|
320
|
-
let file = id + ".md";
|
|
321
|
-
|
|
322
|
-
if (kind === "artifact") {
|
|
323
|
-
const matches = [];
|
|
324
|
-
let count = 0;
|
|
325
|
-
|
|
326
|
-
for await (const entry of await fs.opendir(root)) {
|
|
327
|
-
signal?.throwIfAborted();
|
|
328
|
-
|
|
329
|
-
if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
|
|
330
|
-
|
|
331
|
-
if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
|
|
335
|
-
file = matches[0];
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const target = await fs.realpath(path.join(root, file));
|
|
339
|
-
|
|
340
|
-
if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
|
|
341
|
-
|
|
342
|
-
if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
|
|
343
|
-
signal?.throwIfAborted();
|
|
344
|
-
|
|
345
|
-
return target;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function readSingle(params, cwd, targetParam, signal) {
|
|
349
|
-
params = sessionJsonArgs({ ...params, path: targetParam });
|
|
350
|
-
validateJsonRead(params);
|
|
351
|
-
targetParam = params.path;
|
|
352
|
-
|
|
353
|
-
if (isString(targetParam) && /^(?:agent|artifact):\/\//i.test(targetParam)) {
|
|
354
|
-
const target = await resolveSessionResource(targetParam, signal);
|
|
355
|
-
|
|
356
|
-
return params.resolve
|
|
357
|
-
? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
|
|
358
|
-
: readFile(target, params, undefined, targetParam);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (isString(params?.query)) {
|
|
362
|
-
const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
|
|
363
|
-
|
|
364
|
-
return sourceRead(params.query, scope, signal, params);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const existing = await probeExistingPath(cwd, targetParam, vfs);
|
|
368
|
-
|
|
369
|
-
if (existing) {
|
|
370
|
-
if (!existing.directory) return params.resolve
|
|
371
|
-
? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
|
|
372
|
-
: readFile(existing.path, params);
|
|
373
|
-
|
|
374
|
-
if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
|
|
375
|
-
|
|
376
|
-
return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (params.json === undefined && !looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
|
|
380
|
-
const targetPath = resolveReadPath(cwd, targetParam);
|
|
381
|
-
|
|
382
|
-
return readFile(targetPath, params);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
|
|
386
|
-
async function readFile(targetPath, params, sourceLine, displayPath, query) {
|
|
387
|
-
const cwd = getCwd();
|
|
388
|
-
const rel = displayPath ?? relativeSlash(cwd, targetPath);
|
|
389
|
-
|
|
390
|
-
if (params.json !== undefined) {
|
|
391
|
-
const project = jsonProjector(params.json);
|
|
392
|
-
const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES });
|
|
393
|
-
let document;
|
|
394
|
-
|
|
395
|
-
try { document = JSON.parse(text); }
|
|
396
|
-
catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
|
|
397
|
-
|
|
398
|
-
const many = Array.isArray(params.json);
|
|
399
|
-
let remaining = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256) - (many ? params.json.length + 1 : 0);
|
|
400
|
-
const parts = [];
|
|
401
|
-
|
|
402
|
-
for (const value of project(document)) {
|
|
403
|
-
const encoded = JSON.stringify(value);
|
|
404
|
-
remaining -= encoded.length;
|
|
405
|
-
|
|
406
|
-
if (remaining < 0) throw new Error("JSON selection exceeds the read budget; select narrower fields or an array slice such as .items[0:10]");
|
|
407
|
-
parts.push(encoded);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return textResult(many ? "[" + parts.join(",") + "]" : parts[0], { path: targetPath, json: true, complete: true });
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
|
|
414
|
-
|
|
415
|
-
if (mime) {
|
|
416
|
-
if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
|
|
417
|
-
const staged = vfs.getOverlay(targetPath);
|
|
418
|
-
const bytes = staged === undefined ? await fs.readFile(targetPath) : Buffer.from(staged);
|
|
419
|
-
|
|
420
|
-
return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const text = await vfs.read(targetPath);
|
|
424
|
-
index.touch(rel);
|
|
425
|
-
|
|
426
|
-
if (isString(params?.about)) {
|
|
427
|
-
const entry = WorkspaceIndex.fromText(targetPath, text);
|
|
428
|
-
const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
|
|
429
|
-
|
|
430
|
-
if (outline) {
|
|
431
|
-
recordOutlineOrigins(rel, outline.text);
|
|
432
|
-
|
|
433
|
-
return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const explicit = isNumber(params?.offset) || isNumber(params?.limit);
|
|
438
|
-
const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
|
|
439
|
-
let offset = params?.offset;
|
|
440
|
-
let limit = params?.limit;
|
|
441
|
-
|
|
442
|
-
if (!explicit && params.resolve) {
|
|
443
|
-
const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(targetPath, text));
|
|
444
|
-
const span = pickSpan(spans, { line: sourceLine, name: query });
|
|
445
|
-
|
|
446
|
-
if (span) {
|
|
447
|
-
offset = span.start;
|
|
448
|
-
limit = span.end - span.start + 1;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
offset ??= sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1;
|
|
453
|
-
const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
454
|
-
const sliced = sliceLines(text, offset, limit);
|
|
455
|
-
|
|
456
|
-
if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
|
|
457
|
-
throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use json:".field" for JSON reports, about for text selection, edit() for replacements, or reconstruct resolve:true source windows`);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
|
|
461
|
-
if (!explicit && !params.resolve && path.extname(targetPath).toLowerCase() === ".json") throw new Error("incomplete JSON read of " + rel + "; use the json selector option to parse the whole document before projection, or explicit offset/limit for raw text windows");
|
|
462
|
-
let cap = budget - 160;
|
|
463
|
-
|
|
464
|
-
if (params.resolve) {
|
|
465
|
-
// Budget the actual JSON string, not a pessimistic fixed escape multiplier.
|
|
466
|
-
let low = 0, high = Math.max(0, cap);
|
|
467
|
-
|
|
468
|
-
while (low < high) {
|
|
469
|
-
const mid = Math.ceil((low + high) / 2);
|
|
470
|
-
|
|
471
|
-
if (JSON.stringify(sliced.slice(0, mid)).length <= budget - 160) low = mid;
|
|
472
|
-
else high = mid - 1;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
cap = low;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const end = sliced.lastIndexOf("\n", cap);
|
|
479
|
-
|
|
480
|
-
if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
|
|
481
|
-
const body = sliced.slice(0, end + 1);
|
|
482
|
-
const next = firstLine + body.split("\n").length - 1;
|
|
483
|
-
|
|
484
|
-
return textResult(body + `\n[read truncated; continue with read({path:${JSON.stringify(rel)}, offset:${next}})]`, { path: targetPath, outputTruncated: true, nextOffset: next, firstLine, lastLine: next - 1, sourceChars: body.length, complete: false });
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
|
|
488
|
-
|
|
489
|
-
return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + sliced.split("\n").length - 1 - Number(sliced.endsWith("\n")), sourceChars: sliced.length, complete: sliced === text });
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
|
|
494
|
-
* lines with numbers, a quick structural check, and bounded lexical reference hints.
|
|
495
|
-
* These do not replace tests or semantic caller resolution.
|
|
496
|
-
*/
|
|
497
|
-
async function editSummary(cwd, target, original, updated, diff, signal, span) {
|
|
498
|
-
const rel = relativeSlash(cwd, target);
|
|
499
|
-
const newLines = updated.split("\n");
|
|
500
|
-
const ranges = [];
|
|
501
|
-
|
|
502
|
-
if (span && Number.isInteger(span.start) && Number.isInteger(span.end) && span.start >= 1 && span.end >= span.start) {
|
|
503
|
-
ranges.push({ start: span.start, end: Math.min(newLines.length, span.end) });
|
|
504
|
-
} else {
|
|
505
|
-
const positions = diff.lines.filter(row => row.type !== "context")
|
|
506
|
-
.map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
|
|
507
|
-
|
|
508
|
-
for (const line of positions) {
|
|
509
|
-
const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
|
|
510
|
-
|
|
511
|
-
if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
|
|
512
|
-
else ranges.push({ start, end });
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const blocks = [];
|
|
517
|
-
const perRange = Math.max(1, Math.floor(40 / Math.max(1, ranges.length)));
|
|
518
|
-
|
|
519
|
-
for (const { start, end } of ranges) {
|
|
520
|
-
const last = Math.min(end, start + perRange - 1);
|
|
521
|
-
const lines = newLines.slice(start - 1, last);
|
|
522
|
-
ledger.recordOrigin(rel, start, lines);
|
|
523
|
-
blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
|
|
524
|
-
|
|
525
|
-
if (last < end) blocks.push("[continue with read({path:" + JSON.stringify(rel) + ",offset:" + (last + 1) + ",limit:" + (end - last) + "})]");
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
let out = blocks.join("\n");
|
|
529
|
-
const check = quickCheck(updated, path.extname(target));
|
|
13
|
+
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
|
|
14
|
+
import { createNativeAdapters } from "../adapters/index.js";
|
|
15
|
+
import { resultDiff, boundedWriteDiff, writeSnapshot } from "../fs/text-ops.js";
|
|
530
16
|
|
|
531
|
-
if (check && !check.ok) out += `\ncheck: ${check.message}`;
|
|
532
|
-
const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
|
|
533
|
-
|
|
534
|
-
if (refs) out += `\n${refs}`;
|
|
535
|
-
|
|
536
|
-
return out;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
|
|
540
|
-
// Diff rows carry the replaced fragments; declarations live on whole file lines.
|
|
541
|
-
const oldLines = original.split("\n");
|
|
542
|
-
const newLines = updated.split("\n");
|
|
543
|
-
const names = new Set();
|
|
544
|
-
const spans = new Map();
|
|
545
|
-
|
|
546
|
-
for (const l of diff.lines) {
|
|
547
|
-
if (l.type === "context") continue;
|
|
548
|
-
const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
|
|
549
|
-
const name = declaredName((l.type === "remove" ? oldLines : newLines)[number - 1] ?? "");
|
|
550
|
-
|
|
551
|
-
if (name) names.add(name);
|
|
552
|
-
else {
|
|
553
|
-
if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
|
|
554
|
-
const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
|
|
555
|
-
|
|
556
|
-
if (owner?.name) names.add(owner.name);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
if (names.size >= 3) break;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
if (names.size === 0) return "";
|
|
563
|
-
|
|
564
|
-
try {
|
|
565
|
-
const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
|
|
566
|
-
excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
567
|
-
|
|
568
|
-
const parts = [];
|
|
569
|
-
|
|
570
|
-
for (const [name, refs] of references) {
|
|
571
|
-
if (refs.length) parts.push(name + " also referenced in " + refs.slice(0, 6).join(", ") + (refs.length > 6 ? " (more matches)" : ""));
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
if (incomplete) parts.push("references incomplete: search budget reached");
|
|
575
|
-
|
|
576
|
-
return parts.join("\n");
|
|
577
|
-
} catch (error) {
|
|
578
|
-
signal?.throwIfAborted();
|
|
579
|
-
|
|
580
|
-
return "references unavailable: " + error.message;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const SOURCE_REF = /((?:\/|[A-Za-z]:[\\/])?(?:[\w.@-]+[\\/])*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
|
|
585
|
-
|
|
586
|
-
/** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
|
|
587
|
-
async function sourceWindow(cwd, commandCwd, file, lineNo) {
|
|
588
|
-
const candidate = path.resolve(commandCwd, file);
|
|
589
|
-
let text, rel;
|
|
590
|
-
|
|
591
|
-
try {
|
|
592
|
-
const root = await fs.realpath(cwd);
|
|
593
|
-
|
|
594
|
-
if (!candidate.startsWith(path.resolve(cwd) + path.sep) && !candidate.startsWith(root + path.sep)) return null;
|
|
595
|
-
const real = await fs.realpath(candidate);
|
|
596
|
-
|
|
597
|
-
if (!real.startsWith(root + path.sep) || (await fs.stat(real)).size > 1024 * 1024) return null;
|
|
598
|
-
text = await fs.readFile(real, "utf8");
|
|
599
|
-
rel = relativeSlash(root, real);
|
|
600
|
-
} catch { return null; }
|
|
601
|
-
|
|
602
|
-
const raw = text.split("\n");
|
|
603
|
-
|
|
604
|
-
if (lineNo < 1 || lineNo > raw.length) return null;
|
|
605
|
-
const start = Math.max(1, lineNo - 2);
|
|
606
|
-
const rows = [];
|
|
607
|
-
|
|
608
|
-
for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
|
|
609
|
-
ledger.recordOrigin(rel, start, rows);
|
|
610
|
-
|
|
611
|
-
return rel + ":" + lineNo + "\n" + rows.join("\n");
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
|
|
615
|
-
async function sourceForReferences(cwd, commandCwd, output) {
|
|
616
|
-
const seen = new Set();
|
|
617
|
-
const blocks = [];
|
|
618
|
-
|
|
619
|
-
for (const m of output.matchAll(SOURCE_REF)) {
|
|
620
|
-
const key = m[1] + ":" + m[2];
|
|
621
|
-
|
|
622
|
-
if (seen.has(key)) continue;
|
|
623
|
-
|
|
624
|
-
if (seen.size >= 4) break;
|
|
625
|
-
seen.add(key);
|
|
626
|
-
const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
|
|
627
|
-
|
|
628
|
-
if (block) blocks.push(block);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function outlineOptions(params, references) {
|
|
635
|
-
const options = { references };
|
|
636
|
-
|
|
637
|
-
if (params?.maxChars) options.maxChars = params.maxChars;
|
|
638
|
-
|
|
639
|
-
return options;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/** Outline lines carry their own line numbers (" 330 text"); provenance follows them. */
|
|
643
|
-
function recordOutlineOrigins(rel, outlineText) {
|
|
644
|
-
for (const line of outlineText.split("\n")) {
|
|
645
|
-
const m = /^\s*(\d+) (.*)$/.exec(line);
|
|
646
|
-
|
|
647
|
-
if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/** Where else a name appears (declaration line excluded), for outlines and edit results. */
|
|
652
|
-
async function referenceFinder(cwd, targetPath) {
|
|
653
|
-
const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
|
|
654
|
-
|
|
655
|
-
if (!index.canScan(files)) return () => [];
|
|
656
|
-
|
|
657
|
-
return (name, excludeLine) => {
|
|
658
|
-
if (!name || name.length < 3) return [];
|
|
659
|
-
const escaped = name.replace(/[$]/g, (c) => "\\" + c);
|
|
660
|
-
const regex = new RegExp("\\b" + escaped + "\\b");
|
|
661
|
-
|
|
662
|
-
return index
|
|
663
|
-
.grepRows(files, regex, cwd, file => vfs.getOverlay(file))
|
|
664
|
-
.filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
|
|
665
|
-
.map((r) => r.rel + ":" + r.line);
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
hooks.summarizeEdit = editSummary;
|
|
670
|
-
|
|
671
|
-
return {
|
|
672
|
-
read: readAdapter,
|
|
673
|
-
async write(params, signal) {
|
|
674
|
-
const cwd = getCwd();
|
|
675
|
-
const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
|
|
676
|
-
|
|
677
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
678
|
-
|
|
679
|
-
if (!isString(params?.content)) throw new Error("write requires string content");
|
|
680
|
-
|
|
681
|
-
if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
|
|
682
|
-
let content = params.content;
|
|
683
|
-
|
|
684
|
-
if (params.allowReadArtifacts !== true && /\[read truncated;|…\[(?:host-result|output|value) truncated \d+ chars\]…/u.test(content)) {
|
|
685
|
-
throw new Error("refusing to write truncated read output; use edit() or reconstruct complete source windows. Set allowReadArtifacts:true only to intentionally write literal truncation-marker text");
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
let prevText = "";
|
|
689
|
-
|
|
690
|
-
try {
|
|
691
|
-
prevText = await vfs.read(target, { preserveRead: true });
|
|
692
|
-
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
693
|
-
|
|
694
|
-
if (params.append === true) content = prevText + content;
|
|
695
|
-
const { speculative } = await vfs.write(target, content);
|
|
696
|
-
index.touch(relativeSlash(cwd, target));
|
|
697
|
-
const diff = buildWriteDiff(target, prevText, content);
|
|
698
|
-
const tag = speculative ? " (speculative)" : "";
|
|
699
|
-
const check = quickCheck(content, path.extname(target));
|
|
700
|
-
const warning = check && !check.ok ? "\ncheck: " + check.message : "";
|
|
701
|
-
|
|
702
|
-
return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
|
|
703
|
-
},
|
|
704
|
-
async edit(params, signal) {
|
|
705
|
-
const cwd = getCwd();
|
|
706
|
-
const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
|
|
707
|
-
|
|
708
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
709
|
-
|
|
710
|
-
const content = await vfs.read(target);
|
|
711
|
-
|
|
712
|
-
if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
|
|
713
|
-
const windowNext = isString(params.oldText)
|
|
714
|
-
? applyReplacements(target, params.viewText, [{ oldText: params.oldText, newText: params.newText }]).updated
|
|
715
|
-
: params.newText;
|
|
716
|
-
const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, params.viewText, windowNext);
|
|
717
|
-
const { speculative } = await vfs.write(target, updated);
|
|
718
|
-
index.touch(relativeSlash(cwd, target));
|
|
719
|
-
const diffFrom = isString(params.oldText) ? params.oldText : params.viewText;
|
|
720
|
-
const diffTo = isString(params.oldText) ? params.newText : windowNext;
|
|
721
|
-
const diff = buildEditDiff(target, content, diffFrom, diffTo);
|
|
722
|
-
const inserted = sourceLines(windowNext);
|
|
723
|
-
const spanEnd = params.viewStart + Math.max(inserted.length, 1) - 1;
|
|
724
|
-
const summary = await editSummary(cwd, target, content, updated, diff, signal, { start: params.viewStart, end: spanEnd });
|
|
725
|
-
|
|
726
|
-
return textResult(summary, { path: target, speculative, diff });
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const requestedEdits = Array.isArray(params?.edits)
|
|
730
|
-
? params.edits
|
|
731
|
-
: [{ oldText: params?.oldText, newText: params?.newText }];
|
|
732
|
-
const { updated, matches } = applyReplacements(target, content, requestedEdits);
|
|
733
|
-
const { speculative } = await vfs.write(target, updated);
|
|
734
|
-
index.touch(relativeSlash(cwd, target));
|
|
735
|
-
|
|
736
|
-
const diff =
|
|
737
|
-
matches.length === 1
|
|
738
|
-
? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
|
|
739
|
-
: buildMultiEditDiff(target, content, matches);
|
|
740
|
-
|
|
741
|
-
const summary = await editSummary(cwd, target, content, updated, diff, signal);
|
|
742
|
-
|
|
743
|
-
return textResult(summary, { path: target, speculative, diff });
|
|
744
|
-
},
|
|
745
|
-
async apply_patch(params, signal) {
|
|
746
|
-
const cwd = getCwd();
|
|
747
|
-
let inputPath = params?.path;
|
|
748
|
-
|
|
749
|
-
if (!inputPath && isString(params?.patch)) {
|
|
750
|
-
const headerMatch = /^\+\+\+\s+[ab]\/(.+)$/m.exec(params.patch) || /^---\s+[ab]\/(.+)$/m.exec(params.patch);
|
|
751
|
-
|
|
752
|
-
if (headerMatch) inputPath = headerMatch[1].trim();
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const target = await resolveWorkspacePath(cwd, inputPath, "apply_patch", false);
|
|
756
|
-
|
|
757
|
-
if (!isString(params?.patch) || !params.patch.trim()) {
|
|
758
|
-
throw new Error("apply_patch requires patch");
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
762
|
-
|
|
763
|
-
const original = await vfs.read(target);
|
|
764
|
-
const { resultText, hunkCount } = applyPatchToText(original, params.patch);
|
|
765
|
-
const { speculative } = await vfs.write(target, resultText);
|
|
766
|
-
const diff = buildPatchDiff(target, params.patch);
|
|
767
|
-
index.touch(relativeSlash(cwd, target));
|
|
768
|
-
const summary = await editSummary(cwd, target, original, resultText, diff, signal);
|
|
769
|
-
|
|
770
|
-
return textResult(summary, {
|
|
771
|
-
path: target,
|
|
772
|
-
hunks: hunkCount,
|
|
773
|
-
speculative,
|
|
774
|
-
diff,
|
|
775
|
-
});
|
|
776
|
-
},
|
|
777
|
-
async snap(params, signal) {
|
|
778
|
-
const cwd = getCwd();
|
|
779
|
-
|
|
780
|
-
if (!isString(params?.query) || !params.query.trim()) {
|
|
781
|
-
throw new Error("snap requires query");
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
785
|
-
const snapTarget = params?.path ? await resolveWorkspacePath(cwd, params.path, "snap", true) : cwd;
|
|
786
|
-
const relativeRoot = path.relative(cwd, snapTarget);
|
|
787
|
-
|
|
788
|
-
const includeHidden = Boolean(params?.path) && relativeRoot
|
|
789
|
-
.split(path.sep)
|
|
790
|
-
.some((segment) => segment.startsWith(".") && segment.length > 1);
|
|
791
|
-
|
|
792
|
-
const res = await executeSnap({
|
|
793
|
-
query: params.query,
|
|
794
|
-
searchDir: snapTarget,
|
|
795
|
-
root: cwd,
|
|
796
|
-
includeHidden,
|
|
797
|
-
overlayText: (p) => vfs.getOverlay(p),
|
|
798
|
-
pendingPaths: vfs.getOverlayPaths(),
|
|
799
|
-
signal,
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
return textResult(JSON.stringify(res, null, 2), res);
|
|
803
|
-
},
|
|
804
|
-
async evidence(params, signal) {
|
|
805
|
-
const cwd = getCwd();
|
|
806
|
-
|
|
807
|
-
if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
|
|
808
|
-
|
|
809
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
810
|
-
const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
|
|
811
|
-
const options = {};
|
|
812
|
-
|
|
813
|
-
if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
|
|
814
|
-
|
|
815
|
-
if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
|
|
816
|
-
const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
|
|
817
|
-
|
|
818
|
-
for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
|
|
819
|
-
|
|
820
|
-
return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
|
|
821
|
-
},
|
|
822
|
-
async surface(params, signal) {
|
|
823
|
-
const cwd = getCwd();
|
|
824
|
-
const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
|
|
825
|
-
|
|
826
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
827
|
-
const text = await vfs.read(target);
|
|
828
|
-
const ext = path.extname(target);
|
|
829
|
-
const outline = extractStructuralSurface(text, ext);
|
|
830
|
-
|
|
831
|
-
return textResult(JSON.stringify(outline, null, 2), { path: target, count: outline.items.length });
|
|
832
|
-
},
|
|
833
|
-
async bash(params, signal) {
|
|
834
|
-
const cwd = getCwd();
|
|
835
|
-
const literal = Array.isArray(params?.args) && process.platform !== "win32" && params.args.length === Object.keys(params.args).length && params.args.every(isString);
|
|
836
|
-
|
|
837
|
-
if (literal && (!isString(params.command) || !Array.isArray(params.args) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
|
|
838
|
-
const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
|
|
839
|
-
|
|
840
|
-
if (!command.trim()) throw new Error("bash requires command");
|
|
841
|
-
const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
|
|
842
|
-
|
|
843
|
-
// String commands keep shell semantics; literal argv needs no quoting or shell startup.
|
|
844
|
-
const argv = literal ? [command, ...params.args] : ["bash", "-c", command];
|
|
845
|
-
|
|
846
|
-
const transactionBarrier = await vfs.prepareExternalMutation("bash");
|
|
847
|
-
let res;
|
|
848
|
-
|
|
849
|
-
try {
|
|
850
|
-
res = await runCommand(argv, {
|
|
851
|
-
cwd: targetCwd,
|
|
852
|
-
env: hooks.commandEnv(),
|
|
853
|
-
commandLabel: literal ? command : undefined,
|
|
854
|
-
timeoutMs: params?.timeoutMs,
|
|
855
|
-
signal,
|
|
856
|
-
maxOutputChars: config.maxCallResultChars,
|
|
857
|
-
});
|
|
858
|
-
} catch (error) {
|
|
859
|
-
if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message);
|
|
860
|
-
throw error;
|
|
861
|
-
} finally {
|
|
862
|
-
vfs.invalidateCache();
|
|
863
|
-
index.invalidate();
|
|
864
|
-
clearPathCache();
|
|
865
|
-
hooks.workspaceChanged();
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const { stdout, stderr } = res;
|
|
869
|
-
let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
|
|
870
|
-
|
|
871
|
-
if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
|
|
872
|
-
|
|
873
|
-
return {
|
|
874
|
-
content: [{ type: "text", text }],
|
|
875
|
-
details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
|
|
876
|
-
isError: res.exitCode !== 0,
|
|
877
|
-
};
|
|
878
|
-
},
|
|
879
|
-
async grep(params, signal) {
|
|
880
|
-
const cwd = getCwd();
|
|
881
|
-
const pattern = String(params?.pattern || "");
|
|
882
|
-
|
|
883
|
-
if (!pattern) throw new Error("grep requires pattern");
|
|
884
|
-
const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
|
|
885
|
-
const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
|
|
886
|
-
|
|
887
|
-
if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
|
|
888
|
-
// Large tree: real rg keeps its own output format.
|
|
889
|
-
const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
|
|
890
|
-
|
|
891
|
-
if (res.exitCode !== 0 && res.exitCode !== 1) {
|
|
892
|
-
throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
return textResult(res.stdout, { exitCode: res.exitCode });
|
|
896
|
-
},
|
|
897
|
-
async glob(params, signal) {
|
|
898
|
-
const cwd = getCwd();
|
|
899
|
-
const pattern = String(params?.pattern || "");
|
|
900
|
-
|
|
901
|
-
if (!pattern) throw new Error("glob requires pattern");
|
|
902
|
-
const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
|
|
903
|
-
|
|
904
|
-
if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
|
|
905
|
-
const indexed = await listIndexed(index, cwd, cwd, pattern);
|
|
906
|
-
|
|
907
|
-
if (indexed !== null) return textResult(indexed, { via: "index" });
|
|
908
|
-
|
|
909
|
-
const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
|
|
910
|
-
() => null,
|
|
911
|
-
);
|
|
912
|
-
|
|
913
|
-
if (rg && (rg.exitCode === 0 || rg.exitCode === 1)) {
|
|
914
|
-
return textResult(rg.stdout, { via: "rg" });
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
const findPattern = pattern.startsWith("./") ? pattern : `./${pattern}`;
|
|
918
|
-
|
|
919
|
-
const fallback = await runCommand(["find", ".", "-type", "f", "-path", findPattern], {
|
|
920
|
-
cwd,
|
|
921
|
-
timeoutMs: 30_000,
|
|
922
|
-
signal,
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
return textResult(fallback.stdout, { via: "find" });
|
|
926
|
-
},
|
|
927
|
-
async find(params, signal) {
|
|
928
|
-
const cwd = getCwd();
|
|
929
|
-
const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
|
|
930
|
-
const pattern = params?.pattern || params?.glob;
|
|
931
|
-
|
|
932
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
933
|
-
const globPattern = pattern ? String(pattern) : null;
|
|
934
|
-
const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
|
|
935
|
-
|
|
936
|
-
if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
|
|
937
|
-
const indexed = await listIndexed(index, searchDir, cwd, globPattern);
|
|
938
|
-
|
|
939
|
-
if (indexed !== null) return textResult(indexed, { via: "index" });
|
|
940
|
-
|
|
941
|
-
return listWithTools(searchDir, globPattern, cwd, signal);
|
|
942
|
-
},
|
|
943
|
-
async ls(params, signal) {
|
|
944
|
-
const cwd = getCwd();
|
|
945
|
-
const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
|
|
946
|
-
|
|
947
|
-
return readDirectory(dirPath, signal);
|
|
948
|
-
},
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
/**
|
|
953
|
-
* Fused INVOKE kernel. Guest RPC is the only caller; fuel is cwd + vfs + signal.
|
|
954
|
-
* BIND stays downward (see tests/contracts/layers.test.mjs). Do not split this
|
|
955
|
-
* closure into pass-through files that re-import each other.
|
|
956
|
-
*/
|
|
957
17
|
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
|
|
958
18
|
const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
959
19
|
const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
|
|
@@ -1012,17 +72,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1012
72
|
return env;
|
|
1013
73
|
};
|
|
1014
74
|
|
|
1015
|
-
|
|
75
|
+
function captureHostTool(tool, excluded) {
|
|
76
|
+
return tool && isString(tool.name) && isFunction(tool.execute) && tool.name !== "supernova" && !excluded.has(tool.name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function wrapHostRegister() {
|
|
80
|
+
if (registry || !pi || !isFunction(pi.registerTool)) return;
|
|
1016
81
|
const original = pi.registerTool.bind(pi);
|
|
1017
82
|
const excluded = new Set(config.excludeTools || []);
|
|
1018
83
|
pi.registerTool = (tool) => {
|
|
1019
|
-
if (
|
|
1020
|
-
tool &&
|
|
1021
|
-
isString(tool.name) &&
|
|
1022
|
-
isFunction(tool.execute) &&
|
|
1023
|
-
tool.name !== "supernova" &&
|
|
1024
|
-
!excluded.has(tool.name)
|
|
1025
|
-
) {
|
|
84
|
+
if (captureHostTool(tool, excluded)) {
|
|
1026
85
|
executors.set(tool.name, tool.execute.bind(tool));
|
|
1027
86
|
definitions.set(tool.name, tool);
|
|
1028
87
|
}
|
|
@@ -1031,18 +90,29 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1031
90
|
};
|
|
1032
91
|
}
|
|
1033
92
|
|
|
93
|
+
wrapHostRegister();
|
|
94
|
+
|
|
1034
95
|
function bindCallContext(ctx, signal) {
|
|
1035
96
|
activeCtx = ctx || null;
|
|
1036
97
|
const sessionId = ctx?.sessionManager?.getSessionId?.();
|
|
1037
98
|
boundSessionId = sessionId;
|
|
1038
99
|
const registry = pi?.pi?.AgentRegistry?.global?.();
|
|
1039
|
-
|
|
1040
|
-
|
|
100
|
+
let sessions = [];
|
|
101
|
+
|
|
102
|
+
try { sessions = registry?.list?.() ?? []; } catch {}
|
|
103
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
104
|
+
hostSession = sessionId
|
|
105
|
+
? sessions.map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
|
|
1041
106
|
: null;
|
|
1042
107
|
activeSignal = signal;
|
|
1043
108
|
vfs.signal = signal;
|
|
1044
109
|
}
|
|
1045
110
|
|
|
111
|
+
function evalToolNames() {
|
|
112
|
+
try { return hostSession?.getEvalBridgeToolNames?.() ?? []; }
|
|
113
|
+
catch { return []; }
|
|
114
|
+
}
|
|
115
|
+
|
|
1046
116
|
function hostTool(name) {
|
|
1047
117
|
if (!hostSession) return undefined;
|
|
1048
118
|
const metadata = definitions.get(name);
|
|
@@ -1050,33 +120,46 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1050
120
|
// Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
|
|
1051
121
|
if (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
|
|
1052
122
|
|
|
1053
|
-
return hostSession.getToolForEvalBridge?.(name);
|
|
123
|
+
try { return hostSession.getToolForEvalBridge?.(name); }
|
|
124
|
+
catch { return undefined; }
|
|
1054
125
|
}
|
|
1055
126
|
|
|
1056
|
-
function
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
127
|
+
function callableEnv() {
|
|
128
|
+
return {
|
|
129
|
+
excluded: new Set(config.excludeTools || []),
|
|
130
|
+
hostSession,
|
|
131
|
+
natives,
|
|
132
|
+
executors,
|
|
133
|
+
sessionInvalid: () => hostSession && (hostSession.isDisposed || hostSession.sessionManager?.getSessionId?.() !== boundSessionId),
|
|
134
|
+
nativeOwned: name => Object.hasOwn(natives, name) && !executors.has(name)
|
|
135
|
+
&& (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"),
|
|
136
|
+
evalAllows: name => {
|
|
137
|
+
if (!evalToolNames().includes(name) && definitions.has(name)) return false;
|
|
1060
138
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
if (nativeOwned) return true;
|
|
139
|
+
return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
|
|
140
|
+
},
|
|
141
|
+
listed: name => {
|
|
142
|
+
let activeTools;
|
|
1066
143
|
|
|
1067
|
-
|
|
1068
|
-
if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
|
|
144
|
+
try { activeTools = isFunction(pi?.getActiveTools) ? pi.getActiveTools() : undefined; } catch {}
|
|
1069
145
|
|
|
1070
|
-
|
|
1071
|
-
}
|
|
146
|
+
if (definitions.has(name) && Array.isArray(activeTools) && !activeTools.includes(name)) return false;
|
|
1072
147
|
|
|
1073
|
-
|
|
148
|
+
return executors.has(name) || Object.hasOwn(natives, name);
|
|
149
|
+
},
|
|
150
|
+
hostTool,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
1074
153
|
|
|
1075
|
-
|
|
154
|
+
function isCallable(name) {
|
|
155
|
+
return toolIsCallable(name, callableEnv());
|
|
1076
156
|
}
|
|
1077
157
|
|
|
1078
158
|
function refreshTools() {
|
|
1079
|
-
|
|
159
|
+
let listed = [];
|
|
160
|
+
|
|
161
|
+
try { listed = pi?.getAllTools?.() ?? []; } catch {}
|
|
162
|
+
const tools = Array.isArray(listed) ? listed : [];
|
|
1080
163
|
|
|
1081
164
|
for (const tool of tools) {
|
|
1082
165
|
if (!isString(tool?.name)) continue;
|
|
@@ -1098,7 +181,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1098
181
|
callCount = 0;
|
|
1099
182
|
trace = [];
|
|
1100
183
|
// Files may change between programs (editor, git); never serve a stale run.
|
|
1101
|
-
vfs.
|
|
184
|
+
vfs.invalidateObserved();
|
|
1102
185
|
clearPathCache();
|
|
1103
186
|
}
|
|
1104
187
|
|
|
@@ -1130,8 +213,15 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1130
213
|
} catch {}
|
|
1131
214
|
}
|
|
1132
215
|
|
|
1133
|
-
function
|
|
216
|
+
function assertRunOpen(name) {
|
|
1134
217
|
if (closed) throw new Error("program is already complete");
|
|
218
|
+
|
|
219
|
+
if (activeSignal?.aborted) throw new Error("aborted");
|
|
220
|
+
|
|
221
|
+
if (!isString(name) || !name) throw new Error("tool name required");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function chargeCallBudget() {
|
|
1135
225
|
const maxCalls = config.maxBridgeCalls ?? 256;
|
|
1136
226
|
|
|
1137
227
|
if (budget && ++budget.calls > maxCalls) throw new Error("host call budget exceeded (" + maxCalls + " calls per program batch): split the batch");
|
|
@@ -1142,10 +232,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1142
232
|
`host call budget exceeded (${maxCalls} calls per program): split the work across programs`,
|
|
1143
233
|
);
|
|
1144
234
|
}
|
|
1145
|
-
|
|
1146
|
-
if (activeSignal?.aborted) throw new Error("aborted");
|
|
1147
|
-
|
|
1148
|
-
if (!isString(name) || !name) throw new Error("tool name required");
|
|
1149
235
|
}
|
|
1150
236
|
|
|
1151
237
|
function assertCallableTarget(name) {
|
|
@@ -1162,15 +248,9 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1162
248
|
async function writeFallbackDiff(name, args) {
|
|
1163
249
|
if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
|
|
1164
250
|
const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
try {
|
|
1168
|
-
previous = await vfs.read(target);
|
|
1169
|
-
} catch (error) {
|
|
1170
|
-
if (error?.code !== "ENOENT") throw error;
|
|
1171
|
-
}
|
|
251
|
+
const { previous, removedLines } = await writeSnapshot(vfs, target, activeSignal);
|
|
1172
252
|
|
|
1173
|
-
return buildWriteDiff(target, previous, args.content);
|
|
253
|
+
return removedLines === undefined ? buildWriteDiff(target, previous, args.content) : boundedWriteDiff(target, args.content, removedLines);
|
|
1174
254
|
}
|
|
1175
255
|
|
|
1176
256
|
function completeRecord(record, res, fallbackDiff) {
|
|
@@ -1181,64 +261,89 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1181
261
|
notifyCall(record);
|
|
1182
262
|
}
|
|
1183
263
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
const
|
|
1187
|
-
assertCallableTarget(name);
|
|
264
|
+
function traceArgs(args) {
|
|
265
|
+
if (!isObject(args)) return {};
|
|
266
|
+
const out = {};
|
|
1188
267
|
|
|
1189
|
-
|
|
268
|
+
for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "op"]) {
|
|
269
|
+
const value = args[key];
|
|
1190
270
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
271
|
+
if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
|
|
272
|
+
// eslint-disable-next-line anti-slop/no-runtime-typeof -- display-only label: the value is already handled, this names its kind for the trace.
|
|
273
|
+
else if (Array.isArray(value)) out[key] = value.slice(0, 128).map(item => isString(item) ? truncateChars(item, 240, "trace").text : typeof item);
|
|
274
|
+
}
|
|
1195
275
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
276
|
+
if (isString(args.content)) out.content = args.content.length + " chars";
|
|
277
|
+
if (Array.isArray(args.edits)) out.edits = args.edits.length + " edits";
|
|
278
|
+
if (Array.isArray(args.args)) out.args = args.args.length + " argv";
|
|
279
|
+
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
1200
282
|
|
|
1201
|
-
|
|
1202
|
-
|
|
283
|
+
function assertOwnedOverride(name, args) {
|
|
284
|
+
if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
|
|
285
|
+
if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
|
|
286
|
+
}
|
|
1203
287
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
288
|
+
async function invokeOverride(target, name, args, record, callId) {
|
|
289
|
+
assertOwnedOverride(name, args);
|
|
290
|
+
const fallbackDiff = await writeFallbackDiff(name, args);
|
|
291
|
+
const mutating = isMutatingTool(name, config, args, definitions.get(name));
|
|
1207
292
|
|
|
1208
|
-
|
|
293
|
+
if (mutating) await vfs.prepareExternalMutation(name);
|
|
294
|
+
if (activeSignal?.aborted || closed) throw new Error("aborted");
|
|
295
|
+
if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
|
|
1209
296
|
|
|
1210
|
-
|
|
297
|
+
try {
|
|
298
|
+
const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated
|
|
299
|
+
? { ...activeCtx, settings: hostSession.settings, toolNames: evalToolNames(), autoApprove: false }
|
|
300
|
+
: activeCtx);
|
|
1211
301
|
|
|
1212
|
-
|
|
302
|
+
completeRecord(record, res, fallbackDiff);
|
|
1213
303
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
304
|
+
return res;
|
|
305
|
+
} finally {
|
|
306
|
+
if (mutating) { vfs.invalidateObserved(); index.invalidate(); clearPathCache(); notifyWorkspaceChanged(); }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
1218
309
|
|
|
1219
|
-
|
|
310
|
+
async function invokeNative(target, args, record) {
|
|
311
|
+
const res = await target.native(target.argvOwned ? { ...args, args: args.args.map(String) } : args || {}, activeSignal);
|
|
312
|
+
completeRecord(record, res);
|
|
1220
313
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
314
|
+
return res;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function failRecord(record, error) {
|
|
318
|
+
record.ok = false;
|
|
319
|
+
record.ms = Date.now() - record.time;
|
|
320
|
+
record.error = error instanceof Error ? error.message : String(error);
|
|
321
|
+
notifyCall(record);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function invokeRaw(name, args) {
|
|
325
|
+
assertRunOpen(name);
|
|
326
|
+
const callId = ++sharedRegistry.callSeq;
|
|
327
|
+
assertCallableTarget(name);
|
|
1226
328
|
|
|
1227
|
-
|
|
329
|
+
if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
|
|
330
|
+
// Refused calls are free: only charge the budget once a target will run.
|
|
331
|
+
chargeCallBudget();
|
|
332
|
+
|
|
333
|
+
const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
|
|
334
|
+
const record = { name: command, adapter: name, args: traceArgs(args), time: Date.now() };
|
|
335
|
+
trace.push(record);
|
|
336
|
+
notifyCall(record);
|
|
1228
337
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
completeRecord(record, res);
|
|
338
|
+
try {
|
|
339
|
+
const target = resolveInvokeTarget(name, args, { hostTool, hostSession, executors, natives });
|
|
1232
340
|
|
|
1233
|
-
|
|
1234
|
-
|
|
341
|
+
if (target.kind === "override") return await invokeOverride(target, name, args, record, callId);
|
|
342
|
+
if (target.kind === "native") return await invokeNative(target, args, record);
|
|
1235
343
|
|
|
1236
344
|
throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
|
|
1237
345
|
} catch (error) {
|
|
1238
|
-
record
|
|
1239
|
-
record.ms = Date.now() - record.time;
|
|
1240
|
-
record.error = error instanceof Error ? error.message : String(error);
|
|
1241
|
-
notifyCall(record);
|
|
346
|
+
failRecord(record, error);
|
|
1242
347
|
throw error;
|
|
1243
348
|
}
|
|
1244
349
|
}
|
|
@@ -1253,7 +358,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1253
358
|
? res.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n")
|
|
1254
359
|
: undefined;
|
|
1255
360
|
|
|
1256
|
-
if (text) record.resultText = text;
|
|
361
|
+
if (text) record.resultText = truncateChars(text, 4096, "trace").text;
|
|
1257
362
|
}
|
|
1258
363
|
|
|
1259
364
|
async function call(name, args) {
|
|
@@ -1302,7 +407,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1302
407
|
// Windows command shims need shell handling; preserve the existing route there.
|
|
1303
408
|
supportsNativeArgv: () => process.platform !== "win32" && !hostTool("bash") && !executors.has("bash"),
|
|
1304
409
|
summarizeEdit: (target, before, after, diff) => hooks.summarizeEdit(getCwd(), target, before, after, diff),
|
|
1305
|
-
invalidateFiles() { vfs.
|
|
410
|
+
invalidateFiles() { vfs.invalidateObserved(); index.invalidate(); clearPathCache(); },
|
|
411
|
+
describeMemory() {
|
|
412
|
+
const overlays = vfs.describeOverlays();
|
|
413
|
+
|
|
414
|
+
// No body cache: reads always hit disk, so retained VFS bytes are always zero.
|
|
415
|
+
return { vfsCacheBytes: 0, indexBytes: index.getEntryBytes(), overlayFiles: overlays.files, overlayBytes: overlays.bytes };
|
|
416
|
+
},
|
|
1306
417
|
fileOperations: {
|
|
1307
418
|
access: (target, mode) => fs.access(target, mode),
|
|
1308
419
|
readFile: async target => Buffer.from(await vfs.read(target), "utf8"),
|
|
@@ -1327,7 +438,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1327
438
|
beginSpeculation,
|
|
1328
439
|
commitSpeculation,
|
|
1329
440
|
rollbackSpeculation,
|
|
1330
|
-
getVfsCacheSize: () => vfs.getCacheSize(),
|
|
1331
441
|
getOverlayDepth: () => vfs.getOverlayDepth(),
|
|
1332
442
|
call,
|
|
1333
443
|
callMany,
|