pi-supernova 0.0.7 → 0.0.11

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/host-bridge.js CHANGED
@@ -1,13 +1,18 @@
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 { selectEvidence } from "./evidence.js";
12
+ import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
13
+ import { CausalVfs } from "./vfs.js";
14
+ import { applyPatchToText } from "./patch.js";
15
+ import { resolveWorkspacePath, runCommand, clearPathCache } from "./workspace.js";
11
16
 
12
17
  function textResult(text, details) {
13
18
  return {
@@ -27,341 +32,144 @@ function unwrapIfFullyQuoted(s) {
27
32
  return inner;
28
33
  }
29
34
 
30
- let cachedCwd = null;
31
- let cachedResolvedCwd = null;
32
-
33
- function getResolvedCwd(cwd) {
34
- if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
35
- cachedCwd = cwd;
36
- cachedResolvedCwd = path.resolve(cwd);
37
- return cachedResolvedCwd;
35
+ function sliceLines(text, offset, limit) {
36
+ if (!isNumber(offset) && !isNumber(limit)) return text;
37
+ const lines = text.split("\n");
38
+ const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
39
+ const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
40
+ return lines.slice(startIndex, startIndex + count).join("\n");
38
41
  }
39
42
 
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;
43
+ function looksLikePath(target) {
44
+ return (
45
+ isString(target) &&
46
+ (target.includes("/") ||
47
+ target.includes("\\") ||
48
+ target.startsWith(".") ||
49
+ (!/\s/.test(target) && path.extname(target).length > 0))
50
+ );
72
51
  }
73
52
 
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
- });
134
- }
135
-
136
- function parseHunkHeader(line) {
137
- const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
138
- if (!match) return null;
139
- return {
140
- oldStart: parseInt(match[1], 10),
141
- oldLength: match[2] !== undefined ? parseInt(match[2], 10) : 1,
142
- newStart: parseInt(match[3], 10),
143
- newLength: match[4] !== undefined ? parseInt(match[4], 10) : 1,
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;
167
- }
168
-
169
- function findHunkMatch(fileLines, expectedOld, nominal) {
170
- const matchAt = (idx) => {
171
- if (idx < 0 || idx + expectedOld.length > fileLines.length) return false;
172
- for (let j = 0; j < expectedOld.length; j++) {
173
- if (fileLines[idx + j] !== expectedOld[j]) return false;
174
- }
175
- return true;
176
- };
177
-
178
- if (matchAt(nominal)) return nominal;
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;
53
+ async function probeExistingFile(cwd, targetParam, vfs) {
54
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
55
+ if (vfs.getOverlay(targetPath) !== undefined || vfs.cache.has(targetPath)) return targetPath;
56
+ try {
57
+ const st = await fs.stat(targetPath);
58
+ if (st.isDirectory()) throw new Error(`read path is a directory, not a file: ${targetPath} (use ls)`);
59
+ return targetPath;
60
+ } catch (err) {
61
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
62
+ return null;
183
63
  }
184
- return -1;
185
64
  }
186
65
 
187
- export function applyPatchToText(originalText, patchText) {
188
- if (!isString(patchText) || !patchText.trim()) {
189
- throw new Error("apply_patch requires non-empty patch");
190
- }
191
-
192
- const hunks = parsePatchHunks(patchText);
193
- let fileLines = originalText.replace(/\r\n/g, "\n").split("\n");
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 };
66
+ function patchModeOf(params) {
67
+ return (
68
+ isString(params?.patch) ||
69
+ (params?.newText === undefined &&
70
+ isString(params?.oldText) &&
71
+ (params.oldText.includes("@@ -") || params.oldText.startsWith("---")))
72
+ );
231
73
  }
232
74
 
233
- const VFS_CACHE_MAX = 1024;
234
-
235
- class CausalVfs {
236
- constructor() {
237
- this.cache = new Map();
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);
75
+ function applyReplacements(target, content, requestedEdits) {
76
+ if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
77
+ const matches = requestedEdits.map((replacement) => {
78
+ if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
79
+ throw new Error("edit requires non-empty oldText");
245
80
  }
246
- this.cache.set(target, content);
247
- }
248
-
249
- getOverlay(target) {
250
- for (let i = this.overlays.length - 1; i >= 0; i--) {
251
- if (this.overlays[i].has(target)) return this.overlays[i].get(target);
81
+ if (!isString(replacement?.newText)) throw new Error("edit requires newText");
82
+ const index = content.indexOf(replacement.oldText);
83
+ if (index < 0) {
84
+ 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
85
  }
253
- return undefined;
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);
86
+ if (content.indexOf(replacement.oldText, index + replacement.oldText.length) >= 0) {
87
+ throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
260
88
  }
261
- return [...paths];
262
- }
263
-
264
- async read(target) {
265
- const overlay = this.getOverlay(target);
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 };
89
+ return { ...replacement, index, end: index + replacement.oldText.length };
90
+ });
91
+ matches.sort((a, b) => a.index - b.index);
92
+ for (let i = 1; i < matches.length; i++) {
93
+ if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
323
94
  }
324
-
325
- rollback() {
326
- if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
327
- const top = this.overlays.pop();
328
- return { rolledBack: top.size, depth: this.overlays.length };
95
+ let updated = content;
96
+ for (let i = matches.length - 1; i >= 0; i--) {
97
+ const match = matches[i];
98
+ updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
329
99
  }
100
+ return { updated, matches };
101
+ }
330
102
 
331
- async prepareExternalMutation(name) {
332
- if (this.overlays.length > 1) {
333
- throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
334
- }
335
- if (this.overlays.length === 0) return false;
336
- const pending = this.overlays[0];
337
- for (const [filePath, fileContent] of pending.entries()) {
338
- await fs.mkdir(path.dirname(filePath), { recursive: true });
339
- await fs.writeFile(filePath, fileContent, "utf8");
340
- this.setCache(filePath, fileContent);
103
+ async function formatLsEntry(dirPath, entry) {
104
+ const isDir = entry.isDirectory();
105
+ const isSym = entry.isSymbolicLink();
106
+ const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
107
+ let size = 0;
108
+ try {
109
+ if (!isDir && !isSym) {
110
+ const st = await fs.stat(path.join(dirPath, entry.name));
111
+ size = st.size;
341
112
  }
342
- this.overlays[0] = new Map();
343
- return pending.size > 0;
344
- }
113
+ } catch {}
114
+ const sizeSuffix = size ? `, ${size} bytes` : "";
115
+ return `${entry.name}${isDir ? "/" : ""} (${typeLabel}${sizeSuffix})`;
116
+ }
345
117
 
346
- invalidateCache() {
347
- this.cache.clear();
348
- }
118
+ function rgGrepArgs(pattern, params, searchPath) {
119
+ const args = ["--line-number", "--no-heading", "--color", "never"];
120
+ if (params?.caseSensitive !== true) args.push("--ignore-case");
121
+ if (params?.glob) args.push("--glob", String(params.glob));
122
+ args.push("--", pattern, searchPath);
123
+ return args;
124
+ }
349
125
 
350
- clear() {
351
- this.invalidateCache();
352
- this.overlays.length = 0;
353
- }
126
+ /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
127
+ async function listWithTools(searchDir, pattern, cwd, signal) {
128
+ const args = ["--files"];
129
+ if (pattern) args.push("-g", pattern);
130
+ const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
131
+ if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
132
+ const findArgs = [searchDir];
133
+ if (pattern) findArgs.push("-name", pattern);
134
+ const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
135
+ return textResult(findRes.stdout, { via: "find" });
136
+ }
354
137
 
355
- getCacheSize() {
356
- return this.cache.size;
357
- }
138
+ /** rg-compatible grep served from the index; null when the pattern or tree needs real rg. */
139
+ async function grepIndexed(index, pattern, params, searchPath, cwd) {
140
+ let regex;
141
+ try {
142
+ regex = new RegExp(pattern, params?.caseSensitive === true ? "" : "i");
143
+ } catch {
144
+ return null;
145
+ }
146
+ let files = await index.files(searchPath);
147
+ if (!index.canScan(files)) return null;
148
+ if (params?.glob) {
149
+ const matcher = globToRegExp(String(params.glob));
150
+ files = files.filter((f) => matcher.test(path.relative(cwd, f).split(path.sep).join("/")));
151
+ }
152
+ const rows = index.grep(files, regex, cwd);
153
+ return rows.length ? rows.join("\n") + "\n" : "";
154
+ }
358
155
 
359
- getOverlayDepth() {
360
- return this.overlays.length;
361
- }
156
+ /** rg --files [-g pattern] served from the index; null when the tree is too large. */
157
+ async function listIndexed(index, root, cwd, pattern) {
158
+ const files = await index.files(root);
159
+ if (!index.canScan(files)) return null;
160
+ const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
161
+ if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
162
+ let matcher;
163
+ try {
164
+ matcher = globToRegExp(pattern);
165
+ } catch {
166
+ return null;
167
+ }
168
+ const hits = rel.filter((f) => matcher.test(f));
169
+ return hits.length ? hits.join("\n") + "\n" : "";
362
170
  }
363
171
 
364
- function createNativeAdapters(getCwd, vfs, config) {
172
+ function createNativeAdapters(getCwd, vfs, config, index) {
365
173
  async function readAdapter(params, signal) {
366
174
  const cwd = getCwd();
367
175
  const targetParam = params?.path ?? params?.target;
@@ -370,57 +178,20 @@ function createNativeAdapters(getCwd, vfs, config) {
370
178
  const results = await Promise.all(
371
179
  targetParam.map((p) => readAdapter({ path: p, offset: params?.offset, limit: params?.limit }, signal)),
372
180
  );
373
- return textResult(results.map((r) => r.value).join("\n---\n"), {
374
- count: results.length,
375
- batch: true,
376
- items: results.map((r) => r.value),
377
- });
181
+ const items = results.map((r) => r.content[0].text);
182
+ return textResult(items.join("\n---\n"), { count: results.length, batch: true, items });
378
183
  }
379
184
 
380
- const looksLikePath =
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) {
185
+ if (looksLikePath(targetParam)) {
388
186
  const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
389
- let text = await vfs.read(targetPath);
390
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
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
- }
187
+ const text = await vfs.read(targetPath);
188
+ return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
412
189
  }
413
190
 
414
- if (isExistingFile) {
415
- let text = await vfs.read(targetPath);
416
- if (isNumber(params?.offset) || isNumber(params?.limit)) {
417
- const lines = text.split("\n");
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 });
191
+ const existing = await probeExistingFile(cwd, targetParam, vfs);
192
+ if (existing) {
193
+ const text = await vfs.read(existing);
194
+ return textResult(sliceLines(text, params?.offset, params?.limit), { path: existing });
424
195
  }
425
196
 
426
197
  if (isString(targetParam) && targetParam.trim()) {
@@ -428,24 +199,17 @@ function createNativeAdapters(getCwd, vfs, config) {
428
199
  const snapRes = await executeSnap({
429
200
  query: targetParam,
430
201
  searchDir: cwd,
431
- vfs,
432
- runCommand: (argv, opts) => runCommand(argv, { cwd, signal, ...opts }),
202
+ index,
203
+ overlayText: (p) => vfs.getOverlay(p),
204
+ pendingPaths: vfs.getOverlayPaths(),
433
205
  });
434
206
  return textResult(JSON.stringify(snapRes, null, 2), { ...snapRes, isSnap: true });
435
207
  } catch {}
436
208
  }
437
209
 
438
- targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
439
-
440
- let text = await vfs.read(targetPath);
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 });
210
+ const targetPath = await resolveWorkspacePath(cwd, targetParam, "read", false);
211
+ const text = await vfs.read(targetPath);
212
+ return textResult(sliceLines(text, params?.offset, params?.limit), { path: targetPath });
449
213
  }
450
214
 
451
215
  return {
@@ -469,11 +233,7 @@ function createNativeAdapters(getCwd, vfs, config) {
469
233
  const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
470
234
  if (signal?.aborted) throw new Error("aborted");
471
235
 
472
- const isPatchMode =
473
- isString(params?.patch) ||
474
- (params?.newText === undefined && isString(params?.oldText) && (params.oldText.includes("@@ -") || params.oldText.startsWith("---")));
475
-
476
- if (isPatchMode) {
236
+ if (patchModeOf(params)) {
477
237
  const patchContent = params.patch || params.oldText;
478
238
  const original = await vfs.read(target);
479
239
  const { resultText, hunkCount } = applyPatchToText(original, patchContent);
@@ -491,31 +251,8 @@ function createNativeAdapters(getCwd, vfs, config) {
491
251
  const requestedEdits = Array.isArray(params?.edits)
492
252
  ? params.edits
493
253
  : [{ oldText: params?.oldText, newText: params?.newText }];
494
- if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
495
-
496
254
  const content = await vfs.read(target);
497
- const matches = requestedEdits.map((replacement) => {
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
- }
255
+ const { updated, matches } = applyReplacements(target, content, requestedEdits);
519
256
  const { speculative } = await vfs.write(target, updated);
520
257
  const diff =
521
258
  matches.length === 1
@@ -563,19 +300,25 @@ function createNativeAdapters(getCwd, vfs, config) {
563
300
  const res = await executeSnap({
564
301
  query: params.query,
565
302
  searchDir: snapTarget,
303
+ root: cwd,
566
304
  includeHidden,
567
- vfs: {
568
- read: async (candidate) => {
569
- // Jail each snap candidate (symlink files must not escape the workspace).
570
- const jailed = await resolveWorkspacePath(cwd, candidate, "snap", false);
571
- return vfs.read(jailed);
572
- },
573
- },
574
- runCommand: (argv, opts) => runCommand(argv, { cwd: snapTarget, signal, ...opts }),
305
+ index,
306
+ overlayText: (p) => vfs.getOverlay(p),
575
307
  pendingPaths: vfs.getOverlayPaths(),
576
308
  });
577
309
  return textResult(JSON.stringify(res, null, 2), res);
578
310
  },
311
+ async evidence(params, signal) {
312
+ const cwd = getCwd();
313
+ if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
314
+ if (signal?.aborted) throw new Error("aborted");
315
+ const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
316
+ const options = {};
317
+ if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
318
+ if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
319
+ const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), options });
320
+ return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
321
+ },
579
322
  async surface(params, signal) {
580
323
  const cwd = getCwd();
581
324
  const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
@@ -606,8 +349,10 @@ function createNativeAdapters(getCwd, vfs, config) {
606
349
  });
607
350
  } finally {
608
351
  vfs.invalidateCache();
352
+ index.invalidate();
609
353
  }
610
- const text = [res.stdout, res.stderr].filter(Boolean).join("\n");
354
+ const { stdout, stderr } = res;
355
+ const text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
611
356
  return {
612
357
  content: [{ type: "text", text }],
613
358
  details: { exitCode: res.exitCode, outputTruncated: res.outputTruncated, transactionBarrier },
@@ -619,11 +364,9 @@ function createNativeAdapters(getCwd, vfs, config) {
619
364
  const pattern = String(params?.pattern || "");
620
365
  if (!pattern) throw new Error("grep requires pattern");
621
366
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
622
- const args = ["--line-number", "--no-heading", "--color", "never"];
623
- if (params?.caseSensitive !== true) args.push("--ignore-case");
624
- if (params?.glob) args.push("--glob", String(params.glob));
625
- args.push("--", pattern, searchPath);
626
- const res = await runCommand(["rg", ...args], { cwd, timeoutMs: 30_000, signal });
367
+ const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
368
+ if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
369
+ const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
627
370
  if (res.exitCode !== 0 && res.exitCode !== 1) {
628
371
  throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
629
372
  }
@@ -633,6 +376,8 @@ function createNativeAdapters(getCwd, vfs, config) {
633
376
  const cwd = getCwd();
634
377
  const pattern = String(params?.pattern || "");
635
378
  if (!pattern) throw new Error("glob requires pattern");
379
+ const indexed = await listIndexed(index, cwd, cwd, pattern);
380
+ if (indexed !== null) return textResult(indexed, { via: "index" });
636
381
  const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
637
382
  () => null,
638
383
  );
@@ -652,16 +397,10 @@ function createNativeAdapters(getCwd, vfs, config) {
652
397
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
653
398
  const pattern = params?.pattern || params?.glob;
654
399
  if (signal?.aborted) throw new Error("aborted");
655
- const args = ["--files"];
656
- if (pattern) args.push("-g", String(pattern));
657
- const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
658
- if (res && (res.exitCode === 0 || res.exitCode === 1)) {
659
- return textResult(res.stdout, { via: "rg" });
660
- }
661
- const findArgs = [searchDir];
662
- if (pattern) findArgs.push("-name", String(pattern));
663
- const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
664
- return textResult(findRes.stdout, { via: "find" });
400
+ const globPattern = pattern ? String(pattern) : null;
401
+ const indexed = await listIndexed(index, searchDir, cwd, globPattern);
402
+ if (indexed !== null) return textResult(indexed, { via: "index" });
403
+ return listWithTools(searchDir, globPattern, cwd, signal);
665
404
  },
666
405
  async ls(params, signal) {
667
406
  const cwd = getCwd();
@@ -670,17 +409,7 @@ function createNativeAdapters(getCwd, vfs, config) {
670
409
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
671
410
  const lines = [];
672
411
  for (const entry of entries) {
673
- const isDir = entry.isDirectory();
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` : ""})`);
412
+ lines.push(await formatLsEntry(dirPath, entry));
684
413
  }
685
414
  return textResult(lines.join("\n"), { path: dirPath, count: entries.length });
686
415
  },
@@ -688,9 +417,10 @@ function createNativeAdapters(getCwd, vfs, config) {
688
417
  }
689
418
 
690
419
  export function createHostBridge({ pi, config, getCwd }) {
691
- const vfs = new CausalVfs();
420
+ const index = new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
421
+ const vfs = new CausalVfs(() => index.invalidate());
692
422
  const executors = new Map();
693
- const natives = createNativeAdapters(getCwd, vfs, config);
423
+ const natives = createNativeAdapters(getCwd, vfs, config, index);
694
424
  let callCount = 0;
695
425
  let activeCtx = null;
696
426
  let activeSignal = undefined;
@@ -722,6 +452,9 @@ export function createHostBridge({ pi, config, getCwd }) {
722
452
  function resetCallBudget() {
723
453
  callCount = 0;
724
454
  trace = [];
455
+ // Files may change between programs (editor, git); never serve a stale run.
456
+ vfs.invalidateCache();
457
+ clearPathCache();
725
458
  }
726
459
 
727
460
  function getTrace() {
@@ -771,15 +504,19 @@ export function createHostBridge({ pi, config, getCwd }) {
771
504
  } catch {}
772
505
  }
773
506
 
774
- async function invokeRaw(name, args) {
507
+ function checkCallBudget(name) {
775
508
  const maxCalls = config.maxBridgeCalls ?? 256;
776
509
  callCount += 1;
777
510
  if (callCount > maxCalls) {
778
- throw new Error(`supernova host call budget exceeded (${maxCalls})`);
511
+ throw new Error(
512
+ `host call budget exceeded (${maxCalls} calls per program): batch with read([paths]) or nova.callMany, or split the work across programs`,
513
+ );
779
514
  }
780
515
  if (activeSignal?.aborted) throw new Error("aborted");
781
516
  if (!isString(name) || !name) throw new Error("tool name required");
517
+ }
782
518
 
519
+ function assertCallableTarget(name) {
783
520
  // Never re-enter supernova or other excluded composition tools via the bridge.
784
521
  const excluded = new Set(config.excludeTools || []);
785
522
  if (name === "supernova" || excluded.has(name)) {
@@ -787,6 +524,30 @@ export function createHostBridge({ pi, config, getCwd }) {
787
524
  `nova.call("${name}") is blocked (excluded / non-reentrant). Use nova.search/describe for discovery, or call a concrete host tool.`,
788
525
  );
789
526
  }
527
+ }
528
+
529
+ async function writeFallbackDiff(name, args) {
530
+ if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
531
+ const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
532
+ let previous = "";
533
+ try {
534
+ previous = await vfs.read(target);
535
+ } catch (error) {
536
+ if (error?.code !== "ENOENT") throw error;
537
+ }
538
+ return buildWriteDiff(target, previous, args.content);
539
+ }
540
+
541
+ function completeRecord(record, res, fallbackDiff) {
542
+ const diff = resultDiff(res) || fallbackDiff;
543
+ finishRecord(record, res);
544
+ if (diff && record.ok) record.diff = diff;
545
+ notifyCall(record);
546
+ }
547
+
548
+ async function invokeRaw(name, args) {
549
+ checkCallBudget(name);
550
+ assertCallableTarget(name);
790
551
 
791
552
  const record = { name, args: args || {}, time: Date.now() };
792
553
  trace.push(record);
@@ -795,46 +556,37 @@ export function createHostBridge({ pi, config, getCwd }) {
795
556
  try {
796
557
  const exec = executors.get(name);
797
558
  if (exec) {
798
- let fallbackDiff;
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
- }
559
+ const fallbackDiff = await writeFallbackDiff(name, args);
809
560
  if (isMutatingTool(name, config)) await vfs.prepareExternalMutation(name);
810
561
  const res = await exec(`supernova:${name}:${callCount}`, args || {}, activeSignal, undefined, activeCtx);
811
- const diff = resultDiff(res) || fallbackDiff;
812
- record.ok = res?.isError !== true && res?.details?.ok !== false;
813
- if (diff && record.ok) record.diff = diff;
814
- notifyCall(record);
562
+ completeRecord(record, res, fallbackDiff);
815
563
  return res;
816
564
  }
817
565
 
818
566
  const native = natives[name];
819
567
  if (native) {
820
568
  const res = await native(args || {}, activeSignal);
821
- const diff = resultDiff(res);
822
- record.ok = res?.isError !== true && res?.details?.ok !== false;
823
- if (diff && record.ok) record.diff = diff;
824
- notifyCall(record);
569
+ completeRecord(record, res);
825
570
  return res;
826
571
  }
827
572
 
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
- );
573
+ throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
831
574
  } catch (error) {
832
575
  record.ok = false;
576
+ record.ms = Date.now() - record.time;
577
+ record.error = error instanceof Error ? error.message : String(error);
833
578
  notifyCall(record);
834
579
  throw error;
835
580
  }
836
581
  }
837
582
 
583
+ function finishRecord(record, res) {
584
+ record.ms = Date.now() - record.time;
585
+ record.ok = res?.isError !== true && res?.details?.ok !== false;
586
+ const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
587
+ if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
588
+ }
589
+
838
590
  async function call(name, args) {
839
591
  if (!isString(name) || !name) throw new Error("nova.call requires a tool name");
840
592
  const raw = await invokeRaw(name, args);