pi-supernova 0.0.7 → 0.0.8
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 +26 -0
- package/README.md +22 -15
- package/bottleneck.js +32 -41
- package/catalog.js +61 -15
- package/config.default.json +2 -1
- package/config.js +19 -10
- package/diff.js +12 -4
- package/format.js +95 -0
- package/guest-worker.js +344 -0
- package/host-bridge.js +130 -439
- package/index.js +75 -97
- package/omp-frame.js +68 -51
- package/package.json +6 -1
- package/parallel.js +19 -20
- package/patch.js +106 -0
- package/render-measure.js +63 -49
- package/render.js +193 -166
- package/runtime.js +266 -241
- package/snap.js +120 -101
- package/surface.js +13 -30
- package/vfs.js +138 -0
- package/workspace.js +112 -0
package/host-bridge.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
4
|
import { packageHostResult } from "./bottleneck.js";
|
|
6
5
|
import { isString, isNumber, isFunction, isObject } from "./decode.js";
|
|
7
6
|
import { isMutatingTool, runParallelWave } from "./parallel.js";
|
|
7
|
+
import { unknownToolMessage } from "./catalog.js";
|
|
8
8
|
import { extractStructuralSurface } from "./surface.js";
|
|
9
9
|
import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "./diff.js";
|
|
10
10
|
import { executeSnap } from "./snap.js";
|
|
11
|
+
import { CausalVfs } from "./vfs.js";
|
|
12
|
+
import { applyPatchToText } from "./patch.js";
|
|
13
|
+
import { resolveWorkspacePath, runCommand } from "./workspace.js";
|
|
11
14
|
|
|
12
15
|
function textResult(text, details) {
|
|
13
16
|
return {
|
|
@@ -27,338 +30,87 @@ function unwrapIfFullyQuoted(s) {
|
|
|
27
30
|
return inner;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
cachedResolvedCwd = path.resolve(cwd);
|
|
37
|
-
return cachedResolvedCwd;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
|
|
41
|
-
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
42
|
-
throw new Error(`${opName} requires path`);
|
|
43
|
-
}
|
|
44
|
-
const resolvedCwd = getResolvedCwd(cwd);
|
|
45
|
-
const target = path.resolve(resolvedCwd, inputPath.trim());
|
|
46
|
-
const rel = path.relative(resolvedCwd, target);
|
|
47
|
-
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
48
|
-
throw new Error(`${opName} path escapes workspace`);
|
|
49
|
-
}
|
|
50
|
-
if (!allowRoot && target === resolvedCwd) {
|
|
51
|
-
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const realRoot = await fs.realpath(resolvedCwd);
|
|
55
|
-
let probe = target;
|
|
56
|
-
while (true) {
|
|
57
|
-
try {
|
|
58
|
-
probe = await fs.realpath(probe);
|
|
59
|
-
break;
|
|
60
|
-
} catch (err) {
|
|
61
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
62
|
-
const parent = path.dirname(probe);
|
|
63
|
-
if (parent === probe) throw err;
|
|
64
|
-
probe = parent;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
const realRel = path.relative(realRoot, probe);
|
|
68
|
-
if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
|
|
69
|
-
throw new Error(`${opName} path escapes workspace through symlink`);
|
|
70
|
-
}
|
|
71
|
-
return target;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function runCommand(argv, options = {}) {
|
|
75
|
-
const cwd = options.cwd || process.cwd();
|
|
76
|
-
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
77
|
-
const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
|
|
78
|
-
return await new Promise((resolve, reject) => {
|
|
79
|
-
const child = spawn(argv[0], argv.slice(1), {
|
|
80
|
-
cwd,
|
|
81
|
-
env: process.env,
|
|
82
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
83
|
-
});
|
|
84
|
-
let stdout = "";
|
|
85
|
-
let stderr = "";
|
|
86
|
-
let settled = false;
|
|
87
|
-
let outputTruncated = false;
|
|
88
|
-
let onAbort;
|
|
89
|
-
|
|
90
|
-
const cleanup = () => {
|
|
91
|
-
clearTimeout(timer);
|
|
92
|
-
if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
|
|
93
|
-
};
|
|
94
|
-
const fail = (err) => {
|
|
95
|
-
if (settled) return;
|
|
96
|
-
settled = true;
|
|
97
|
-
cleanup();
|
|
98
|
-
reject(err);
|
|
99
|
-
};
|
|
100
|
-
const append = (current, chunk) => {
|
|
101
|
-
const remaining = Math.max(0, maxOutputChars - current.length);
|
|
102
|
-
if (chunk.length > remaining) outputTruncated = true;
|
|
103
|
-
return remaining > 0 ? current + chunk.slice(0, remaining) : current;
|
|
104
|
-
};
|
|
105
|
-
const timer = setTimeout(() => {
|
|
106
|
-
child.kill("SIGTERM");
|
|
107
|
-
fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
|
|
108
|
-
}, timeoutMs);
|
|
109
|
-
|
|
110
|
-
child.stdout.setEncoding("utf8");
|
|
111
|
-
child.stderr.setEncoding("utf8");
|
|
112
|
-
child.stdout.on("data", (chunk) => {
|
|
113
|
-
stdout = append(stdout, chunk);
|
|
114
|
-
});
|
|
115
|
-
child.stderr.on("data", (chunk) => {
|
|
116
|
-
stderr = append(stderr, chunk);
|
|
117
|
-
});
|
|
118
|
-
child.on("error", fail);
|
|
119
|
-
child.on("close", (code) => {
|
|
120
|
-
if (settled) return;
|
|
121
|
-
settled = true;
|
|
122
|
-
cleanup();
|
|
123
|
-
resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
|
|
124
|
-
});
|
|
125
|
-
if (options.signal) {
|
|
126
|
-
onAbort = () => {
|
|
127
|
-
child.kill("SIGTERM");
|
|
128
|
-
fail(new Error("aborted"));
|
|
129
|
-
};
|
|
130
|
-
if (options.signal.aborted) onAbort();
|
|
131
|
-
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
132
|
-
}
|
|
133
|
-
});
|
|
33
|
+
function sliceLines(text, offset, limit) {
|
|
34
|
+
if (!isNumber(offset) && !isNumber(limit)) return text;
|
|
35
|
+
const lines = text.split("\n");
|
|
36
|
+
const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
|
|
37
|
+
const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
|
|
38
|
+
return lines.slice(startIndex, startIndex + count).join("\n");
|
|
134
39
|
}
|
|
135
40
|
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
lines: [],
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function parsePatchHunks(patchText) {
|
|
149
|
-
const patchLines = patchText.replace(/\r\n/g, "\n").split("\n");
|
|
150
|
-
const hunks = [];
|
|
151
|
-
let current = null;
|
|
152
|
-
|
|
153
|
-
for (const line of patchLines) {
|
|
154
|
-
const header = parseHunkHeader(line);
|
|
155
|
-
if (header) {
|
|
156
|
-
if (current) hunks.push(current);
|
|
157
|
-
current = header;
|
|
158
|
-
} else if (current && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
|
|
159
|
-
current.lines.push(line);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (current) hunks.push(current);
|
|
163
|
-
if (hunks.length === 0) {
|
|
164
|
-
throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
|
|
165
|
-
}
|
|
166
|
-
return hunks;
|
|
41
|
+
function looksLikePath(target) {
|
|
42
|
+
return (
|
|
43
|
+
isString(target) &&
|
|
44
|
+
(target.includes("/") ||
|
|
45
|
+
target.includes("\\") ||
|
|
46
|
+
target.startsWith(".") ||
|
|
47
|
+
(!/\s/.test(target) && path.extname(target).length > 0))
|
|
48
|
+
);
|
|
167
49
|
}
|
|
168
50
|
|
|
169
|
-
function
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
return
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const maxDelta = Math.max(fileLines.length, 100);
|
|
180
|
-
for (let delta = 1; delta <= maxDelta; delta++) {
|
|
181
|
-
if (matchAt(nominal + delta)) return nominal + delta;
|
|
182
|
-
if (matchAt(nominal - delta)) return nominal - delta;
|
|
51
|
+
async function probeExistingFile(cwd, targetParam, vfs) {
|
|
52
|
+
const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
53
|
+
if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return targetPath;
|
|
54
|
+
try {
|
|
55
|
+
const st = await fs.stat(targetPath);
|
|
56
|
+
if (st.isDirectory()) throw new Error(`read path is a directory, not a file: ${targetPath} (use ls)`);
|
|
57
|
+
return targetPath;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
60
|
+
return null;
|
|
183
61
|
}
|
|
184
|
-
return -1;
|
|
185
62
|
}
|
|
186
63
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const hasTrailingNewline = originalText.endsWith("\n");
|
|
195
|
-
let offsetShift = 0;
|
|
196
|
-
|
|
197
|
-
for (let h = 0; h < hunks.length; h++) {
|
|
198
|
-
const hunk = hunks[h];
|
|
199
|
-
const expectedOld = [];
|
|
200
|
-
const newLines = [];
|
|
201
|
-
|
|
202
|
-
for (const hLine of hunk.lines) {
|
|
203
|
-
if (hLine.startsWith("-")) {
|
|
204
|
-
expectedOld.push(hLine.slice(1));
|
|
205
|
-
} else if (hLine.startsWith("+")) {
|
|
206
|
-
newLines.push(hLine.slice(1));
|
|
207
|
-
} else {
|
|
208
|
-
const val = hLine.startsWith(" ") ? hLine.slice(1) : "";
|
|
209
|
-
expectedOld.push(val);
|
|
210
|
-
newLines.push(val);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (expectedOld.length !== hunk.oldLength || newLines.length !== hunk.newLength) {
|
|
215
|
-
throw new Error(`patch hunk ${h + 1} length does not match its header`);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const nominal = Math.max(0, hunk.oldStart - 1 + offsetShift);
|
|
219
|
-
const matchIdx = findHunkMatch(fileLines, expectedOld, nominal);
|
|
220
|
-
if (matchIdx === -1) {
|
|
221
|
-
throw new Error(`patch hunk ${h + 1} rejected at line ${hunk.oldStart}: context did not match`);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
fileLines.splice(matchIdx, expectedOld.length, ...newLines);
|
|
225
|
-
offsetShift += (matchIdx - nominal) + (newLines.length - expectedOld.length);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
let resultText = fileLines.join("\n");
|
|
229
|
-
if (hasTrailingNewline && !resultText.endsWith("\n")) resultText += "\n";
|
|
230
|
-
return { resultText, hunkCount: hunks.length };
|
|
64
|
+
function patchModeOf(params) {
|
|
65
|
+
return (
|
|
66
|
+
isString(params?.patch) ||
|
|
67
|
+
(params?.newText === undefined &&
|
|
68
|
+
isString(params?.oldText) &&
|
|
69
|
+
(params.oldText.includes("@@ -") || params.oldText.startsWith("---")))
|
|
70
|
+
);
|
|
231
71
|
}
|
|
232
72
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
this.overlays = [];
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
setCache(target, content) {
|
|
242
|
-
if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
|
|
243
|
-
const oldest = this.cache.keys().next().value;
|
|
244
|
-
if (oldest !== undefined) this.cache.delete(oldest);
|
|
73
|
+
function applyReplacements(target, content, requestedEdits) {
|
|
74
|
+
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
75
|
+
const matches = requestedEdits.map((replacement) => {
|
|
76
|
+
if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
|
|
77
|
+
throw new Error("edit requires non-empty oldText");
|
|
245
78
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
for (let i = this.overlays.length - 1; i >= 0; i--) {
|
|
251
|
-
if (this.overlays[i].has(target)) return this.overlays[i].get(target);
|
|
79
|
+
if (!isString(replacement?.newText)) throw new Error("edit requires newText");
|
|
80
|
+
const index = content.indexOf(replacement.oldText);
|
|
81
|
+
if (index < 0) {
|
|
82
|
+
throw new Error(`edit target not found in ${target}: oldText must match the file byte-for-byte (read() it first; check whitespace and quotes)`);
|
|
252
83
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
getOverlayPaths() {
|
|
257
|
-
const paths = new Set();
|
|
258
|
-
for (const overlay of this.overlays) {
|
|
259
|
-
for (const target of overlay.keys()) paths.add(target);
|
|
84
|
+
if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
|
|
85
|
+
throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
|
|
260
86
|
}
|
|
261
|
-
return
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (overlay !== undefined) return overlay;
|
|
267
|
-
|
|
268
|
-
const cached = this.cache.get(target);
|
|
269
|
-
if (cached !== undefined) return cached;
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
const text = await fs.readFile(target, "utf8");
|
|
273
|
-
this.setCache(target, text);
|
|
274
|
-
return text;
|
|
275
|
-
} catch (err) {
|
|
276
|
-
if (err.code === "EISDIR") {
|
|
277
|
-
throw new Error(`read path is a directory, not a file: ${target}`);
|
|
278
|
-
}
|
|
279
|
-
throw err;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
async write(target, content) {
|
|
284
|
-
if (this.overlays.length > 0) {
|
|
285
|
-
this.overlays[this.overlays.length - 1].set(target, content);
|
|
286
|
-
return { speculative: true };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
const stat = await fs.stat(target);
|
|
291
|
-
if (stat.isDirectory()) {
|
|
292
|
-
throw new Error(`cannot write to a directory: ${target}`);
|
|
293
|
-
}
|
|
294
|
-
} catch (err) {
|
|
295
|
-
if (err.code !== "ENOENT") throw err;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
299
|
-
await fs.writeFile(target, content, "utf8");
|
|
300
|
-
this.setCache(target, content);
|
|
301
|
-
return { speculative: false };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
begin() {
|
|
305
|
-
this.overlays.push(new Map());
|
|
306
|
-
return this.overlays.length;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async commit() {
|
|
310
|
-
if (this.overlays.length === 0) return { committed: 0, depth: 0 };
|
|
311
|
-
const top = this.overlays.pop();
|
|
312
|
-
if (this.overlays.length > 0) {
|
|
313
|
-
const parent = this.overlays[this.overlays.length - 1];
|
|
314
|
-
for (const [k, v] of top.entries()) parent.set(k, v);
|
|
315
|
-
return { committed: top.size, depth: this.overlays.length };
|
|
316
|
-
}
|
|
317
|
-
for (const [filePath, fileContent] of top.entries()) {
|
|
318
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
319
|
-
await fs.writeFile(filePath, fileContent, "utf8");
|
|
320
|
-
this.setCache(filePath, fileContent);
|
|
321
|
-
}
|
|
322
|
-
return { committed: top.size, depth: 0 };
|
|
87
|
+
return { ...replacement, index, end: index + replacement.oldText.length };
|
|
88
|
+
});
|
|
89
|
+
matches.sort((a, b) => a.index - b.index);
|
|
90
|
+
for (let i = 1; i < matches.length; i++) {
|
|
91
|
+
if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
|
|
323
92
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return { rolledBack: top.size, depth: this.overlays.length };
|
|
93
|
+
let updated = content;
|
|
94
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
95
|
+
const match = matches[i];
|
|
96
|
+
updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
|
|
329
97
|
}
|
|
98
|
+
return { updated, matches };
|
|
99
|
+
}
|
|
330
100
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
101
|
+
async function formatLsEntry(dirPath, entry) {
|
|
102
|
+
const isDir = entry.isDirectory();
|
|
103
|
+
const isSym = entry.isSymbolicLink();
|
|
104
|
+
const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
|
|
105
|
+
let size = 0;
|
|
106
|
+
try {
|
|
107
|
+
if (!isDir && !isSym) {
|
|
108
|
+
const st = await fs.stat(path.join(dirPath, entry.name));
|
|
109
|
+
size = st.size;
|
|
334
110
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
339
|
-
await fs.writeFile(filePath, fileContent, "utf8");
|
|
340
|
-
this.setCache(filePath, fileContent);
|
|
341
|
-
}
|
|
342
|
-
this.overlays[0] = new Map();
|
|
343
|
-
return pending.size > 0;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
invalidateCache() {
|
|
347
|
-
this.cache.clear();
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
clear() {
|
|
351
|
-
this.invalidateCache();
|
|
352
|
-
this.overlays.length = 0;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
getCacheSize() {
|
|
356
|
-
return this.cache.size;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
getOverlayDepth() {
|
|
360
|
-
return this.overlays.length;
|
|
361
|
-
}
|
|
111
|
+
} catch {}
|
|
112
|
+
const sizeSuffix = size ? `, ${size} bytes` : "";
|
|
113
|
+
return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
|
|
362
114
|
}
|
|
363
115
|
|
|
364
116
|
function createNativeAdapters(getCwd, vfs, config) {
|
|
@@ -370,57 +122,20 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
370
122
|
const results = await Promise.all(
|
|
371
123
|
targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
|
|
372
124
|
);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
batch: true,
|
|
376
|
-
items: results.map((r) => r.value),
|
|
377
|
-
});
|
|
125
|
+
const items = results.map((r) => r.content[0].text);
|
|
126
|
+
return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
|
|
378
127
|
}
|
|
379
128
|
|
|
380
|
-
|
|
381
|
-
isString(targetParam) &&
|
|
382
|
-
(targetParam.includes("/") ||
|
|
383
|
-
targetParam.includes("\\") ||
|
|
384
|
-
targetParam.startsWith(".") ||
|
|
385
|
-
(!/\s/.test(targetParam) && path.extname(targetParam).length > 0));
|
|
386
|
-
|
|
387
|
-
if (looksLikePath) {
|
|
129
|
+
if (looksLikePath(targetParam)) {
|
|
388
130
|
const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const lines = text.split("\n");
|
|
392
|
-
const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
|
|
393
|
-
const startIndex = offset - 1;
|
|
394
|
-
const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
|
|
395
|
-
text = lines.slice(startIndex, startIndex + limit).join("\n");
|
|
396
|
-
}
|
|
397
|
-
return textResult(text, { path: targetPath });
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
let isExistingFile = false;
|
|
401
|
-
let targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
402
|
-
const overlay = vfs.getOverlay(targetPath);
|
|
403
|
-
if (overlay !== undefined || vfs.cache.has(targetPath)) {
|
|
404
|
-
isExistingFile = true;
|
|
405
|
-
} else {
|
|
406
|
-
try {
|
|
407
|
-
const st = await fs.stat(targetPath);
|
|
408
|
-
isExistingFile = !st.isDirectory();
|
|
409
|
-
} catch (err) {
|
|
410
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
411
|
-
}
|
|
131
|
+
const text = await vfs.read(targetPath);
|
|
132
|
+
return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
|
|
412
133
|
}
|
|
413
134
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
|
|
419
|
-
const startIndex = offset - 1;
|
|
420
|
-
const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
|
|
421
|
-
text = lines.slice(startIndex, startIndex + limit).join("\n");
|
|
422
|
-
}
|
|
423
|
-
return textResult(text, { path: targetPath });
|
|
135
|
+
const existing = await probeExistingFile(cwd, targetParam, vfs);
|
|
136
|
+
if (existing) {
|
|
137
|
+
const text = await vfs.read(existing);
|
|
138
|
+
return textResult(sliceLines(text, params?.offset, params?.limit), { path: existing });
|
|
424
139
|
}
|
|
425
140
|
|
|
426
141
|
if (isString(targetParam) && targetParam.trim()) {
|
|
@@ -435,17 +150,9 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
435
150
|
} catch {}
|
|
436
151
|
}
|
|
437
152
|
|
|
438
|
-
targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (isNumber(params?.offset) || isNumber(params?.limit)) {
|
|
442
|
-
const lines = text.split("\n");
|
|
443
|
-
const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
|
|
444
|
-
const startIndex = offset - 1;
|
|
445
|
-
const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
|
|
446
|
-
text = lines.slice(startIndex, startIndex + limit).join("\n");
|
|
447
|
-
}
|
|
448
|
-
return textResult(text, { path: targetPath });
|
|
153
|
+
const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
154
|
+
const text = await vfs.read(targetPath);
|
|
155
|
+
return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
|
|
449
156
|
}
|
|
450
157
|
|
|
451
158
|
return {
|
|
@@ -469,11 +176,7 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
469
176
|
const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
|
|
470
177
|
if (signal?.aborted) throw new Error("aborted");
|
|
471
178
|
|
|
472
|
-
|
|
473
|
-
isString(params?.patch) ||
|
|
474
|
-
(params?.newText === undefined && isString(params?.oldText) && (params.oldText.includes("@@ -") || params.oldText.startsWith("---")));
|
|
475
|
-
|
|
476
|
-
if (isPatchMode) {
|
|
179
|
+
if (patchModeOf(params)) {
|
|
477
180
|
const patchContent = params.patch || params.oldText;
|
|
478
181
|
const original = await vfs.read(target);
|
|
479
182
|
const { resultText, hunkCount } = applyPatchToText(original, patchContent);
|
|
@@ -491,31 +194,8 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
491
194
|
const requestedEdits = Array.isArray(params?.edits)
|
|
492
195
|
? params.edits
|
|
493
196
|
: [{ oldText: params?.oldText, newText: params?.newText }];
|
|
494
|
-
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
495
|
-
|
|
496
197
|
const content = await vfs.read(target);
|
|
497
|
-
const matches =
|
|
498
|
-
if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
|
|
499
|
-
throw new Error("edit requires non-empty oldText");
|
|
500
|
-
}
|
|
501
|
-
if (!isString(replacement?.newText)) throw new Error("edit requires newText");
|
|
502
|
-
const index = content.indexOf(replacement.oldText);
|
|
503
|
-
if (index < 0) throw new Error(`edit target not found in ${target}`);
|
|
504
|
-
if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
|
|
505
|
-
throw new Error(`edit target is not unique in ${target}`);
|
|
506
|
-
}
|
|
507
|
-
return { ...replacement, index, end: index + replacement.oldText.length };
|
|
508
|
-
});
|
|
509
|
-
matches.sort((a, b) => a.index - b.index);
|
|
510
|
-
for (let i = 1; i < matches.length; i++) {
|
|
511
|
-
if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
let updated = content;
|
|
515
|
-
for (let i = matches.length - 1; i >= 0; i--) {
|
|
516
|
-
const match = matches[i];
|
|
517
|
-
updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
|
|
518
|
-
}
|
|
198
|
+
const { updated, matches } = applyReplacements(target, content, requestedEdits);
|
|
519
199
|
const { speculative } = await vfs.write(target, updated);
|
|
520
200
|
const diff =
|
|
521
201
|
matches.length === 1
|
|
@@ -607,7 +287,8 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
607
287
|
} finally {
|
|
608
288
|
vfs.invalidateCache();
|
|
609
289
|
}
|
|
610
|
-
const
|
|
290
|
+
const { stdout, stderr } = res;
|
|
291
|
+
const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
|
|
611
292
|
return {
|
|
612
293
|
content: [{ type: "text", text }],
|
|
613
294
|
details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
|
|
@@ -670,17 +351,7 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
670
351
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
671
352
|
const lines = [];
|
|
672
353
|
for (const entry of entries) {
|
|
673
|
-
|
|
674
|
-
const isSym = entry.isSymbolicLink();
|
|
675
|
-
const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
|
|
676
|
-
let size = 0;
|
|
677
|
-
try {
|
|
678
|
-
if (!isDir && !isSym) {
|
|
679
|
-
const st = await fs.stat(path.join(dirPath, entry.name));
|
|
680
|
-
size = st.size;
|
|
681
|
-
}
|
|
682
|
-
} catch {}
|
|
683
|
-
lines.push(`${entry.name}${isDir ? "/" : ""} (${typeLabel}${size ? `, ${size} bytes` : ""})`);
|
|
354
|
+
lines.push(await formatLsEntry(dirPath, entry));
|
|
684
355
|
}
|
|
685
356
|
return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
|
|
686
357
|
},
|
|
@@ -722,6 +393,8 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
722
393
|
function resetCallBudget() {
|
|
723
394
|
callCount = 0;
|
|
724
395
|
trace = [];
|
|
396
|
+
// Files may change between programs (editor, git); never serve a stale run.
|
|
397
|
+
vfs.invalidateCache();
|
|
725
398
|
}
|
|
726
399
|
|
|
727
400
|
function getTrace() {
|
|
@@ -771,15 +444,19 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
771
444
|
} catch {}
|
|
772
445
|
}
|
|
773
446
|
|
|
774
|
-
|
|
447
|
+
function checkCallBudget(name) {
|
|
775
448
|
const maxCalls = config.maxBridgeCalls ?? 256;
|
|
776
449
|
callCount += 1;
|
|
777
450
|
if (callCount > maxCalls) {
|
|
778
|
-
throw new Error(
|
|
451
|
+
throw new Error(
|
|
452
|
+
`host call budget exceeded (${maxCalls} calls per program): batch with read([paths]) or nova.callMany, or split the work across programs`,
|
|
453
|
+
);
|
|
779
454
|
}
|
|
780
455
|
if (activeSignal?.aborted) throw new Error("aborted");
|
|
781
456
|
if (!isString(name) || !name) throw new Error("tool name required");
|
|
457
|
+
}
|
|
782
458
|
|
|
459
|
+
function assertCallableTarget(name) {
|
|
783
460
|
// Never re-enter supernova or other excluded composition tools via the bridge.
|
|
784
461
|
const excluded = new Set(config.excludeTools || []);
|
|
785
462
|
if (name === "supernova" || excluded.has(name)) {
|
|
@@ -787,6 +464,30 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
787
464
|
`nova.call("${name}") is blocked (excluded / non-reentrant). Use nova.search/describe for discovery, or call a concrete host tool.`,
|
|
788
465
|
);
|
|
789
466
|
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function writeFallbackDiff(name, args) {
|
|
470
|
+
if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
|
|
471
|
+
const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
|
|
472
|
+
let previous = "";
|
|
473
|
+
try {
|
|
474
|
+
previous = await vfs.read(target);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error?.code !== "ENOENT") throw error;
|
|
477
|
+
}
|
|
478
|
+
return buildWriteDiff(target, previous, args.content);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function completeRecord(record, res, fallbackDiff) {
|
|
482
|
+
const diff = resultDiff(res) || fallbackDiff;
|
|
483
|
+
finishRecord(record, res);
|
|
484
|
+
if (diff && record.ok) record.diff = diff;
|
|
485
|
+
notifyCall(record);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function invokeRaw(name, args) {
|
|
489
|
+
checkCallBudget(name);
|
|
490
|
+
assertCallableTarget(name);
|
|
790
491
|
|
|
791
492
|
const record = { name, args: args || {}, time: Date.now() };
|
|
792
493
|
trace.push(record);
|
|
@@ -795,46 +496,36 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
795
496
|
try {
|
|
796
497
|
const exec = executors.get(name);
|
|
797
498
|
if (exec) {
|
|
798
|
-
|
|
799
|
-
if (name === "write" && isString(args?.path) && isString(args?.content)) {
|
|
800
|
-
const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
|
|
801
|
-
let previous = "";
|
|
802
|
-
try {
|
|
803
|
-
previous = await vfs.read(target);
|
|
804
|
-
} catch (error) {
|
|
805
|
-
if (error?.code !== "ENOENT") throw error;
|
|
806
|
-
}
|
|
807
|
-
fallbackDiff = buildWriteDiff(target, previous, args.content);
|
|
808
|
-
}
|
|
499
|
+
const fallbackDiff = await writeFallbackDiff(name, args);
|
|
809
500
|
if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
|
|
810
501
|
const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
|
|
811
|
-
|
|
812
|
-
record.ok = res?.isError !== true && res?.details?.ok !== false;
|
|
813
|
-
if (diff && record.ok) record.diff = diff;
|
|
814
|
-
notifyCall(record);
|
|
502
|
+
completeRecord(record, res, fallbackDiff);
|
|
815
503
|
return res;
|
|
816
504
|
}
|
|
817
505
|
|
|
818
506
|
const native = natives[name];
|
|
819
507
|
if (native) {
|
|
820
508
|
const res = await native(args || {}, activeSignal);
|
|
821
|
-
|
|
822
|
-
record.ok = res?.isError !== true && res?.details?.ok !== false;
|
|
823
|
-
if (diff && record.ok) record.diff = diff;
|
|
824
|
-
notifyCall(record);
|
|
509
|
+
completeRecord(record, res);
|
|
825
510
|
return res;
|
|
826
511
|
}
|
|
827
512
|
|
|
828
|
-
throw new Error(
|
|
829
|
-
`no executor for tool "${name}" (not captured via registerTool and no native adapter). Use nova.describe to inspect; ensure pi-supernova loads before other extensions, or call a core adapter: ${Object.keys(natives).join(", ")}`,
|
|
830
|
-
);
|
|
513
|
+
throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
|
|
831
514
|
} catch (error) {
|
|
832
515
|
record.ok = false;
|
|
516
|
+
record.ms = Date.now() - record.time;
|
|
833
517
|
notifyCall(record);
|
|
834
518
|
throw error;
|
|
835
519
|
}
|
|
836
520
|
}
|
|
837
521
|
|
|
522
|
+
function finishRecord(record, res) {
|
|
523
|
+
record.ms = Date.now() - record.time;
|
|
524
|
+
record.ok = res?.isError !== true && res?.details?.ok !== false;
|
|
525
|
+
const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
|
|
526
|
+
if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
|
|
527
|
+
}
|
|
528
|
+
|
|
838
529
|
async function call(name, args) {
|
|
839
530
|
if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
|
|
840
531
|
const raw = await invokeRaw(name, args);
|