pi-supernova 0.0.6 → 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 +33 -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 +143 -440
- package/index.js +77 -99
- 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 -235
- 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 {
|
|
@@ -16,338 +19,98 @@ function textResult(text, details) {
|
|
|
16
19
|
};
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return
|
|
22
|
+
/** Unwrap a single matching quote pair around the whole string (`'git status'`). */
|
|
23
|
+
function unwrapIfFullyQuoted(s) {
|
|
24
|
+
if (s.length < 2) return s;
|
|
25
|
+
const q = s[0];
|
|
26
|
+
if (q !== "'" && q !== '"') return s;
|
|
27
|
+
if (s[s.length - 1] !== q) return s;
|
|
28
|
+
const inner = s.slice(1, -1);
|
|
29
|
+
if (inner.includes(q)) return s;
|
|
30
|
+
return inner;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const rel = path.relative(resolvedCwd, target);
|
|
36
|
-
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
37
|
-
throw new Error(`${opName} path escapes workspace`);
|
|
38
|
-
}
|
|
39
|
-
if (!allowRoot && target === resolvedCwd) {
|
|
40
|
-
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const realRoot = await fs.realpath(resolvedCwd);
|
|
44
|
-
let probe = target;
|
|
45
|
-
while (true) {
|
|
46
|
-
try {
|
|
47
|
-
probe = await fs.realpath(probe);
|
|
48
|
-
break;
|
|
49
|
-
} catch (err) {
|
|
50
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
51
|
-
const parent = path.dirname(probe);
|
|
52
|
-
if (parent === probe) throw err;
|
|
53
|
-
probe = parent;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const realRel = path.relative(realRoot, probe);
|
|
57
|
-
if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
|
|
58
|
-
throw new Error(`${opName} path escapes workspace through symlink`);
|
|
59
|
-
}
|
|
60
|
-
return target;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function runCommand(argv, options = {}) {
|
|
64
|
-
const cwd = options.cwd || process.cwd();
|
|
65
|
-
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
66
|
-
const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
|
|
67
|
-
return await new Promise((resolve, reject) => {
|
|
68
|
-
const child = spawn(argv[0], argv.slice(1), {
|
|
69
|
-
cwd,
|
|
70
|
-
env: process.env,
|
|
71
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
-
});
|
|
73
|
-
let stdout = "";
|
|
74
|
-
let stderr = "";
|
|
75
|
-
let settled = false;
|
|
76
|
-
let outputTruncated = false;
|
|
77
|
-
let onAbort;
|
|
78
|
-
|
|
79
|
-
const cleanup = () => {
|
|
80
|
-
clearTimeout(timer);
|
|
81
|
-
if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
|
|
82
|
-
};
|
|
83
|
-
const fail = (err) => {
|
|
84
|
-
if (settled) return;
|
|
85
|
-
settled = true;
|
|
86
|
-
cleanup();
|
|
87
|
-
reject(err);
|
|
88
|
-
};
|
|
89
|
-
const append = (current, chunk) => {
|
|
90
|
-
const remaining = Math.max(0, maxOutputChars - current.length);
|
|
91
|
-
if (chunk.length > remaining) outputTruncated = true;
|
|
92
|
-
return remaining > 0 ? current + chunk.slice(0, remaining) : current;
|
|
93
|
-
};
|
|
94
|
-
const timer = setTimeout(() => {
|
|
95
|
-
child.kill("SIGTERM");
|
|
96
|
-
fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
|
|
97
|
-
}, timeoutMs);
|
|
98
|
-
|
|
99
|
-
child.stdout.setEncoding("utf8");
|
|
100
|
-
child.stderr.setEncoding("utf8");
|
|
101
|
-
child.stdout.on("data", (chunk) => {
|
|
102
|
-
stdout = append(stdout, chunk);
|
|
103
|
-
});
|
|
104
|
-
child.stderr.on("data", (chunk) => {
|
|
105
|
-
stderr = append(stderr, chunk);
|
|
106
|
-
});
|
|
107
|
-
child.on("error", fail);
|
|
108
|
-
child.on("close", (code) => {
|
|
109
|
-
if (settled) return;
|
|
110
|
-
settled = true;
|
|
111
|
-
cleanup();
|
|
112
|
-
resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
|
|
113
|
-
});
|
|
114
|
-
if (options.signal) {
|
|
115
|
-
onAbort = () => {
|
|
116
|
-
child.kill("SIGTERM");
|
|
117
|
-
fail(new Error("aborted"));
|
|
118
|
-
};
|
|
119
|
-
if (options.signal.aborted) onAbort();
|
|
120
|
-
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
121
|
-
}
|
|
122
|
-
});
|
|
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");
|
|
123
39
|
}
|
|
124
40
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
lines: [],
|
|
134
|
-
};
|
|
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
|
+
);
|
|
135
49
|
}
|
|
136
50
|
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
} else if (current && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
|
|
148
|
-
current.lines.push(line);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (current) hunks.push(current);
|
|
152
|
-
if (hunks.length === 0) {
|
|
153
|
-
throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
|
|
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;
|
|
154
61
|
}
|
|
155
|
-
return hunks;
|
|
156
62
|
}
|
|
157
63
|
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
if (matchAt(nominal)) return nominal;
|
|
168
|
-
const maxDelta = Math.max(fileLines.length, 100);
|
|
169
|
-
for (let delta = 1; delta <= maxDelta; delta++) {
|
|
170
|
-
if (matchAt(nominal + delta)) return nominal + delta;
|
|
171
|
-
if (matchAt(nominal - delta)) return nominal - delta;
|
|
172
|
-
}
|
|
173
|
-
return -1;
|
|
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
|
+
);
|
|
174
71
|
}
|
|
175
72
|
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const hunks = parsePatchHunks(patchText);
|
|
182
|
-
let fileLines = originalText.replace(/\r\n/g, "\n").split("\n");
|
|
183
|
-
const hasTrailingNewline = originalText.endsWith("\n");
|
|
184
|
-
let offsetShift = 0;
|
|
185
|
-
|
|
186
|
-
for (let h = 0; h < hunks.length; h++) {
|
|
187
|
-
const hunk = hunks[h];
|
|
188
|
-
const expectedOld = [];
|
|
189
|
-
const newLines = [];
|
|
190
|
-
|
|
191
|
-
for (const hLine of hunk.lines) {
|
|
192
|
-
if (hLine.startsWith("-")) {
|
|
193
|
-
expectedOld.push(hLine.slice(1));
|
|
194
|
-
} else if (hLine.startsWith("+")) {
|
|
195
|
-
newLines.push(hLine.slice(1));
|
|
196
|
-
} else {
|
|
197
|
-
const val = hLine.startsWith(" ") ? hLine.slice(1) : "";
|
|
198
|
-
expectedOld.push(val);
|
|
199
|
-
newLines.push(val);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (expectedOld.length !== hunk.oldLength || newLines.length !== hunk.newLength) {
|
|
204
|
-
throw new Error(`patch hunk ${h + 1} length does not match its header`);
|
|
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");
|
|
205
78
|
}
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
throw new Error(`patch hunk ${h + 1} rejected at line ${hunk.oldStart}: context did not match`);
|
|
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)`);
|
|
211
83
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
offsetShift += (matchIdx - nominal) + (newLines.length - expectedOld.length);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
let resultText = fileLines.join("\n");
|
|
218
|
-
if (hasTrailingNewline && !resultText.endsWith("\n")) resultText += "\n";
|
|
219
|
-
return { resultText, hunkCount: hunks.length };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const VFS_CACHE_MAX = 1024;
|
|
223
|
-
|
|
224
|
-
class CausalVfs {
|
|
225
|
-
constructor() {
|
|
226
|
-
this.cache = new Map();
|
|
227
|
-
this.overlays = [];
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
setCache(target, content) {
|
|
231
|
-
if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
|
|
232
|
-
const oldest = this.cache.keys().next().value;
|
|
233
|
-
if (oldest !== undefined) this.cache.delete(oldest);
|
|
234
|
-
}
|
|
235
|
-
this.cache.set(target, content);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
getOverlay(target) {
|
|
239
|
-
for (let i = this.overlays.length - 1; i >= 0; i--) {
|
|
240
|
-
if (this.overlays[i].has(target)) return this.overlays[i].get(target);
|
|
241
|
-
}
|
|
242
|
-
return undefined;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
getOverlayPaths() {
|
|
246
|
-
const paths = new Set();
|
|
247
|
-
for (const overlay of this.overlays) {
|
|
248
|
-
for (const target of overlay.keys()) paths.add(target);
|
|
249
|
-
}
|
|
250
|
-
return [...paths];
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async read(target) {
|
|
254
|
-
const overlay = this.getOverlay(target);
|
|
255
|
-
if (overlay !== undefined) return overlay;
|
|
256
|
-
|
|
257
|
-
const cached = this.cache.get(target);
|
|
258
|
-
if (cached !== undefined) return cached;
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
const text = await fs.readFile(target, "utf8");
|
|
262
|
-
this.setCache(target, text);
|
|
263
|
-
return text;
|
|
264
|
-
} catch (err) {
|
|
265
|
-
if (err.code === "EISDIR") {
|
|
266
|
-
throw new Error(`read path is a directory, not a file: ${target}`);
|
|
267
|
-
}
|
|
268
|
-
throw err;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async write(target, content) {
|
|
273
|
-
if (this.overlays.length > 0) {
|
|
274
|
-
this.overlays[this.overlays.length - 1].set(target, content);
|
|
275
|
-
return { speculative: true };
|
|
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},…]`);
|
|
276
86
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
} catch (err) {
|
|
284
|
-
if (err.code !== "ENOENT") throw err;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
288
|
-
await fs.writeFile(target, content, "utf8");
|
|
289
|
-
this.setCache(target, content);
|
|
290
|
-
return { speculative: false };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
begin() {
|
|
294
|
-
this.overlays.push(new Map());
|
|
295
|
-
return this.overlays.length;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async commit() {
|
|
299
|
-
if (this.overlays.length === 0) return { committed: 0, depth: 0 };
|
|
300
|
-
const top = this.overlays.pop();
|
|
301
|
-
if (this.overlays.length > 0) {
|
|
302
|
-
const parent = this.overlays[this.overlays.length - 1];
|
|
303
|
-
for (const [k, v] of top.entries()) parent.set(k, v);
|
|
304
|
-
return { committed: top.size, depth: this.overlays.length };
|
|
305
|
-
}
|
|
306
|
-
for (const [filePath, fileContent] of top.entries()) {
|
|
307
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
308
|
-
await fs.writeFile(filePath, fileContent, "utf8");
|
|
309
|
-
this.setCache(filePath, fileContent);
|
|
310
|
-
}
|
|
311
|
-
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}`);
|
|
312
92
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
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);
|
|
318
97
|
}
|
|
98
|
+
return { updated, matches };
|
|
99
|
+
}
|
|
319
100
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
await fs.
|
|
328
|
-
|
|
329
|
-
this.setCache(filePath, fileContent);
|
|
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;
|
|
330
110
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
invalidateCache() {
|
|
336
|
-
this.cache.clear();
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
clear() {
|
|
340
|
-
this.invalidateCache();
|
|
341
|
-
this.overlays.length = 0;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
getCacheSize() {
|
|
345
|
-
return this.cache.size;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
getOverlayDepth() {
|
|
349
|
-
return this.overlays.length;
|
|
350
|
-
}
|
|
111
|
+
} catch {}
|
|
112
|
+
const sizeSuffix = size ? `, ${size} bytes` : "";
|
|
113
|
+
return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
|
|
351
114
|
}
|
|
352
115
|
|
|
353
116
|
function createNativeAdapters(getCwd, vfs, config) {
|
|
@@ -359,57 +122,20 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
359
122
|
const results = await Promise.all(
|
|
360
123
|
targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
|
|
361
124
|
);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
batch: true,
|
|
365
|
-
items: results.map((r) => r.value),
|
|
366
|
-
});
|
|
125
|
+
const items = results.map((r) => r.content[0].text);
|
|
126
|
+
return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
|
|
367
127
|
}
|
|
368
128
|
|
|
369
|
-
|
|
370
|
-
isString(targetParam) &&
|
|
371
|
-
(targetParam.includes("/") ||
|
|
372
|
-
targetParam.includes("\\") ||
|
|
373
|
-
targetParam.startsWith(".") ||
|
|
374
|
-
(!/\s/.test(targetParam) && path.extname(targetParam).length > 0));
|
|
375
|
-
|
|
376
|
-
if (looksLikePath) {
|
|
129
|
+
if (looksLikePath(targetParam)) {
|
|
377
130
|
const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const lines = text.split("\n");
|
|
381
|
-
const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
|
|
382
|
-
const startIndex = offset - 1;
|
|
383
|
-
const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
|
|
384
|
-
text = lines.slice(startIndex, startIndex + limit).join("\n");
|
|
385
|
-
}
|
|
386
|
-
return textResult(text, { path: targetPath });
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
let isExistingFile = false;
|
|
390
|
-
let targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
391
|
-
const overlay = vfs.getOverlay(targetPath);
|
|
392
|
-
if (overlay !== undefined || vfs.cache.has(targetPath)) {
|
|
393
|
-
isExistingFile = true;
|
|
394
|
-
} else {
|
|
395
|
-
try {
|
|
396
|
-
const st = await fs.stat(targetPath);
|
|
397
|
-
isExistingFile = !st.isDirectory();
|
|
398
|
-
} catch (err) {
|
|
399
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
400
|
-
}
|
|
131
|
+
const text = await vfs.read(targetPath);
|
|
132
|
+
return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
|
|
401
133
|
}
|
|
402
134
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
|
|
408
|
-
const startIndex = offset - 1;
|
|
409
|
-
const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
|
|
410
|
-
text = lines.slice(startIndex, startIndex + limit).join("\n");
|
|
411
|
-
}
|
|
412
|
-
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 });
|
|
413
139
|
}
|
|
414
140
|
|
|
415
141
|
if (isString(targetParam) && targetParam.trim()) {
|
|
@@ -424,17 +150,9 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
424
150
|
} catch {}
|
|
425
151
|
}
|
|
426
152
|
|
|
427
|
-
targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (isNumber(params?.offset) || isNumber(params?.limit)) {
|
|
431
|
-
const lines = text.split("\n");
|
|
432
|
-
const offset = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
|
|
433
|
-
const startIndex = offset - 1;
|
|
434
|
-
const limit = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : lines.length;
|
|
435
|
-
text = lines.slice(startIndex, startIndex + limit).join("\n");
|
|
436
|
-
}
|
|
437
|
-
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 });
|
|
438
156
|
}
|
|
439
157
|
|
|
440
158
|
return {
|
|
@@ -458,11 +176,7 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
458
176
|
const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
|
|
459
177
|
if (signal?.aborted) throw new Error("aborted");
|
|
460
178
|
|
|
461
|
-
|
|
462
|
-
isString(params?.patch) ||
|
|
463
|
-
(params?.newText === undefined && isString(params?.oldText) && (params.oldText.includes("@@ -") || params.oldText.startsWith("---")));
|
|
464
|
-
|
|
465
|
-
if (isPatchMode) {
|
|
179
|
+
if (patchModeOf(params)) {
|
|
466
180
|
const patchContent = params.patch || params.oldText;
|
|
467
181
|
const original = await vfs.read(target);
|
|
468
182
|
const { resultText, hunkCount } = applyPatchToText(original, patchContent);
|
|
@@ -480,31 +194,8 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
480
194
|
const requestedEdits = Array.isArray(params?.edits)
|
|
481
195
|
? params.edits
|
|
482
196
|
: [{ oldText: params?.oldText, newText: params?.newText }];
|
|
483
|
-
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
484
|
-
|
|
485
197
|
const content = await vfs.read(target);
|
|
486
|
-
const matches =
|
|
487
|
-
if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
|
|
488
|
-
throw new Error("edit requires non-empty oldText");
|
|
489
|
-
}
|
|
490
|
-
if (!isString(replacement?.newText)) throw new Error("edit requires newText");
|
|
491
|
-
const index = content.indexOf(replacement.oldText);
|
|
492
|
-
if (index < 0) throw new Error(`edit target not found in ${target}`);
|
|
493
|
-
if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
|
|
494
|
-
throw new Error(`edit target is not unique in ${target}`);
|
|
495
|
-
}
|
|
496
|
-
return { ...replacement, index, end: index + replacement.oldText.length };
|
|
497
|
-
});
|
|
498
|
-
matches.sort((a, b) => a.index - b.index);
|
|
499
|
-
for (let i = 1; i < matches.length; i++) {
|
|
500
|
-
if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
let updated = content;
|
|
504
|
-
for (let i = matches.length - 1; i >= 0; i--) {
|
|
505
|
-
const match = matches[i];
|
|
506
|
-
updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
|
|
507
|
-
}
|
|
198
|
+
const { updated, matches } = applyReplacements(target, content, requestedEdits);
|
|
508
199
|
const { speculative } = await vfs.write(target, updated);
|
|
509
200
|
const diff =
|
|
510
201
|
matches.length === 1
|
|
@@ -576,12 +267,13 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
576
267
|
},
|
|
577
268
|
async bash(params, signal) {
|
|
578
269
|
const cwd = getCwd();
|
|
579
|
-
const command = String(params?.command ?? "").trim();
|
|
270
|
+
const command = unwrapIfFullyQuoted(String(params?.command ?? "").trim());
|
|
580
271
|
if (!command) throw new Error("bash requires command");
|
|
581
272
|
const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
|
|
582
273
|
|
|
583
|
-
|
|
584
|
-
|
|
274
|
+
// Always shell. Splitting on spaces treated `git status` / quoted `exec("git status")`
|
|
275
|
+
// as a single binary name (`bash: git status: command not found`).
|
|
276
|
+
const argv = ["bash", "-c", command];
|
|
585
277
|
|
|
586
278
|
const transactionBarrier = await vfs.prepareExternalMutation("bash");
|
|
587
279
|
let res;
|
|
@@ -595,7 +287,8 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
595
287
|
} finally {
|
|
596
288
|
vfs.invalidateCache();
|
|
597
289
|
}
|
|
598
|
-
const
|
|
290
|
+
const { stdout, stderr } = res;
|
|
291
|
+
const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
|
|
599
292
|
return {
|
|
600
293
|
content: [{ type: "text", text }],
|
|
601
294
|
details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
|
|
@@ -658,17 +351,7 @@ function createNativeAdapters(getCwd, vfs, config) {
|
|
|
658
351
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
659
352
|
const lines = [];
|
|
660
353
|
for (const entry of entries) {
|
|
661
|
-
|
|
662
|
-
const isSym = entry.isSymbolicLink();
|
|
663
|
-
const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
|
|
664
|
-
let size = 0;
|
|
665
|
-
try {
|
|
666
|
-
if (!isDir && !isSym) {
|
|
667
|
-
const st = await fs.stat(path.join(dirPath, entry.name));
|
|
668
|
-
size = st.size;
|
|
669
|
-
}
|
|
670
|
-
} catch {}
|
|
671
|
-
lines.push(`${entry.name}${isDir ? "/" : ""} (${typeLabel}${size ? `, ${size} bytes` : ""})`);
|
|
354
|
+
lines.push(await formatLsEntry(dirPath, entry));
|
|
672
355
|
}
|
|
673
356
|
return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
|
|
674
357
|
},
|
|
@@ -710,6 +393,8 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
710
393
|
function resetCallBudget() {
|
|
711
394
|
callCount = 0;
|
|
712
395
|
trace = [];
|
|
396
|
+
// Files may change between programs (editor, git); never serve a stale run.
|
|
397
|
+
vfs.invalidateCache();
|
|
713
398
|
}
|
|
714
399
|
|
|
715
400
|
function getTrace() {
|
|
@@ -759,15 +444,19 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
759
444
|
} catch {}
|
|
760
445
|
}
|
|
761
446
|
|
|
762
|
-
|
|
447
|
+
function checkCallBudget(name) {
|
|
763
448
|
const maxCalls = config.maxBridgeCalls ?? 256;
|
|
764
449
|
callCount += 1;
|
|
765
450
|
if (callCount > maxCalls) {
|
|
766
|
-
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
|
+
);
|
|
767
454
|
}
|
|
768
455
|
if (activeSignal?.aborted) throw new Error("aborted");
|
|
769
456
|
if (!isString(name) || !name) throw new Error("tool name required");
|
|
457
|
+
}
|
|
770
458
|
|
|
459
|
+
function assertCallableTarget(name) {
|
|
771
460
|
// Never re-enter supernova or other excluded composition tools via the bridge.
|
|
772
461
|
const excluded = new Set(config.excludeTools || []);
|
|
773
462
|
if (name === "supernova" || excluded.has(name)) {
|
|
@@ -775,6 +464,30 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
775
464
|
`nova.call("${name}") is blocked (excluded / non-reentrant). Use nova.search/describe for discovery, or call a concrete host tool.`,
|
|
776
465
|
);
|
|
777
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);
|
|
778
491
|
|
|
779
492
|
const record = { name, args: args || {}, time: Date.now() };
|
|
780
493
|
trace.push(record);
|
|
@@ -783,46 +496,36 @@ export function createHostBridge({ pi, config, getCwd }) {
|
|
|
783
496
|
try {
|
|
784
497
|
const exec = executors.get(name);
|
|
785
498
|
if (exec) {
|
|
786
|
-
|
|
787
|
-
if (name === "write" && isString(args?.path) && isString(args?.content)) {
|
|
788
|
-
const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
|
|
789
|
-
let previous = "";
|
|
790
|
-
try {
|
|
791
|
-
previous = await vfs.read(target);
|
|
792
|
-
} catch (error) {
|
|
793
|
-
if (error?.code !== "ENOENT") throw error;
|
|
794
|
-
}
|
|
795
|
-
fallbackDiff = buildWriteDiff(target, previous, args.content);
|
|
796
|
-
}
|
|
499
|
+
const fallbackDiff = await writeFallbackDiff(name, args);
|
|
797
500
|
if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
|
|
798
501
|
const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
|
|
799
|
-
|
|
800
|
-
record.ok = res?.isError !== true && res?.details?.ok !== false;
|
|
801
|
-
if (diff && record.ok) record.diff = diff;
|
|
802
|
-
notifyCall(record);
|
|
502
|
+
completeRecord(record, res, fallbackDiff);
|
|
803
503
|
return res;
|
|
804
504
|
}
|
|
805
505
|
|
|
806
506
|
const native = natives[name];
|
|
807
507
|
if (native) {
|
|
808
508
|
const res = await native(args || {}, activeSignal);
|
|
809
|
-
|
|
810
|
-
record.ok = res?.isError !== true && res?.details?.ok !== false;
|
|
811
|
-
if (diff && record.ok) record.diff = diff;
|
|
812
|
-
notifyCall(record);
|
|
509
|
+
completeRecord(record, res);
|
|
813
510
|
return res;
|
|
814
511
|
}
|
|
815
512
|
|
|
816
|
-
throw new Error(
|
|
817
|
-
`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(", ")}`,
|
|
818
|
-
);
|
|
513
|
+
throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
|
|
819
514
|
} catch (error) {
|
|
820
515
|
record.ok = false;
|
|
516
|
+
record.ms = Date.now() - record.time;
|
|
821
517
|
notifyCall(record);
|
|
822
518
|
throw error;
|
|
823
519
|
}
|
|
824
520
|
}
|
|
825
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
|
+
|
|
826
529
|
async function call(name, args) {
|
|
827
530
|
if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
|
|
828
531
|
const raw = await invokeRaw(name, args);
|