pi-supernova 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,14 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
6
- import { isString, isNumber, isFunction, isObject } from "../shared/decode.js";
6
+ import { truncateChars } from "../output/format.js";
7
+ import { isString, isNumber, isFunction, isObject, looksLikePath } from "../shared/decode.js";
7
8
  import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
8
9
  import { unknownToolMessage } from "./catalog.js";
9
10
  import { extractStructuralSurface } from "../context/surface.js";
11
+ import { pickSpan } from "../context/spans.js";
10
12
  import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
11
- import { executeSnap } from "../context/snap.js";
13
+ import { executeSnap, tokenizeQuery, stem } from "../context/snap.js";
12
14
  import { selectEvidence } from "../context/evidence.js";
13
15
  import { WorkspaceIndex } from "../context/repo-index.js";
14
16
  import { outlineFile } from "../context/outline.js";
@@ -18,7 +20,7 @@ import { declaredName } from "../context/repo-index.js";
18
20
  import { CausalVfs } from "../fs/vfs.js";
19
21
  import { MAX_JSON_BYTES, jsonProjector, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
20
22
  import { applyPatchToText } from "../fs/patch.js";
21
- import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
23
+ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash, assertFilesystemPath } from "../fs/workspace.js";
22
24
  import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
23
25
 
24
26
  function textResult(text, details) {
@@ -28,6 +30,20 @@ function textResult(text, details) {
28
30
  };
29
31
  }
30
32
 
33
+ function resultDiff(response) {
34
+ let details = response?.details;
35
+
36
+ if (isString(details)) {
37
+ try {
38
+ details = JSON.parse(details);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ return isObject(details) ? details.diff : undefined;
45
+ }
46
+
31
47
  /** Unwrap a single matching quote pair around the whole string (`'git status'`). */
32
48
  function unwrapIfFullyQuoted(s) {
33
49
  if (s.length < 2) return s;
@@ -43,28 +59,63 @@ function unwrapIfFullyQuoted(s) {
43
59
  return inner;
44
60
  }
45
61
 
46
- function sliceLines(text, offset, limit) {
47
- if (!isNumber(offset) && !isNumber(limit)) return text;
48
- const lines = text.split("\n");
62
+ function sliceLinesRawInfo(text, offset, limit) {
63
+ const logical = contentLineInfo(text).count;
64
+ const totalLines = text === "" ? 1 : logical + (text.endsWith("\n") ? 1 : 0);
65
+
66
+ if (!isNumber(offset) && !isNumber(limit)) {
67
+ return { text, end: totalLines, total: totalLines, count: totalLines, eof: true, whole: true };
68
+ }
69
+
49
70
  const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
50
- const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : lines.length;
71
+ const count = isNumber(limit) ? Math.max(0, Math.floor(limit)) : totalLines;
72
+
73
+ if (count === 0 || startIndex >= totalLines) {
74
+ const emptyFile = totalLines === 1 && text === "";
75
+
76
+ return { text: "", end: totalLines, total: totalLines, count: 0, eof: true, whole: emptyFile };
77
+ }
78
+
79
+ const endExclusive = Math.min(totalLines, startIndex + count);
80
+ const start = lineStartIndex(text, startIndex + 1);
81
+ const end = lineEndIndex(text, start, endExclusive - startIndex);
82
+ let selected = text.slice(start, end);
83
+ const eof = endExclusive >= totalLines || (endExclusive === totalLines - 1 && text.endsWith("\n"));
51
84
 
52
- return lines.slice(startIndex, startIndex + count).join("\n");
85
+ if (endExclusive < totalLines && !selected.endsWith("\n")) selected += "\n";
86
+
87
+ return { text: selected, end: endExclusive, total: totalLines, count: endExclusive - startIndex, eof, whole: startIndex === 0 && eof };
53
88
  }
54
89
 
55
- function looksLikePath(target) {
56
- return (
57
- isString(target) &&
58
- (target.includes("/") ||
59
- target.includes("\\") ||
60
- target.startsWith(".") ||
61
- (!/\s/.test(target) && path.extname(target).length > 0))
62
- );
90
+ /** Read-window slicing preserves the selected lines' own line ending. */
91
+ function sliceLinesRaw(text, offset, limit) {
92
+ return sliceLinesRawInfo(text, offset, limit).text;
93
+ }
94
+
95
+ function readLineParam(value, name) {
96
+ if (value === undefined) return undefined;
97
+ const number = isNumber(value) ? value : isString(value) && value.trim() !== "" ? Number(value) : NaN;
98
+
99
+ if (!Number.isFinite(number)) throw new Error("read " + name + " must be a finite number");
100
+
101
+ return Math.floor(number);
102
+ }
103
+
104
+ function normalizeReadWindow(params) {
105
+ if (!isObject(params)) return params;
106
+ const normalized = { ...params };
107
+ const offset = readLineParam(params.offset, "offset");
108
+ const limit = readLineParam(params.limit, "limit");
109
+
110
+ if (offset !== undefined) normalized.offset = Math.max(1, offset);
111
+ if (limit !== undefined) normalized.limit = Math.max(0, limit);
112
+
113
+ return normalized;
63
114
  }
64
115
 
65
116
  function resolveReadPath(cwd, target) {
66
117
  if (!isString(target) || !target.trim()) throw new Error("read requires path");
67
- const input = target.trim();
118
+ const input = assertFilesystemPath(target, "read");
68
119
 
69
120
  return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
70
121
  }
@@ -72,12 +123,16 @@ function resolveReadPath(cwd, target) {
72
123
  async function probeExistingPath(cwd, targetParam, vfs) {
73
124
  const targetPath = resolveReadPath(cwd, targetParam);
74
125
 
75
- if (vfs.getOverlay(targetPath) !== undefined) return { path: targetPath, directory: false };
126
+ if (vfs.getOverlay(targetPath) !== undefined) {
127
+ const overlay = vfs.getOverlay(targetPath);
128
+
129
+ return { path: targetPath, directory: false, size: Buffer.byteLength(overlay, "utf8"), overlay };
130
+ }
76
131
 
77
132
  try {
78
133
  const st = await fs.stat(targetPath);
79
134
 
80
- return { path: targetPath, directory: st.isDirectory() };
135
+ return { path: targetPath, directory: st.isDirectory(), size: st.size };
81
136
  } catch (err) {
82
137
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
83
138
 
@@ -87,6 +142,40 @@ async function probeExistingPath(cwd, targetParam, vfs) {
87
142
  }
88
143
  }
89
144
 
145
+ const EDIT_PREVIEW_LINES = 16;
146
+
147
+ const MAX_DIRECTORY_ENTRIES = 10000;
148
+
149
+ function sourceLines(content) {
150
+ const raw = content.split("\n");
151
+
152
+ if (raw.at(-1) === "") raw.pop();
153
+
154
+ return raw;
155
+ }
156
+
157
+ function lineNumberAt(content, index) {
158
+ let line = 1;
159
+
160
+ for (let i = 0; i < index; i++) if (content.charCodeAt(i) === 10) line++;
161
+
162
+ return line;
163
+ }
164
+
165
+ function formatNumberedLine(n, text) {
166
+ return String(n).padStart(5) + " " + text;
167
+ }
168
+
169
+ function numberedPreview(content, cap = EDIT_PREVIEW_LINES) {
170
+ const { count, preview } = contentLineInfo(content, cap);
171
+
172
+ if (count === 0) return "0 lines";
173
+ const body = preview.map((line, i) => formatNumberedLine(i + 1, line)).join("\n");
174
+ const suffix = count + " lines total";
175
+
176
+ return body + "\n" + suffix;
177
+ }
178
+
90
179
  function applyReplacements(target, content, requestedEdits) {
91
180
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
92
181
 
@@ -96,17 +185,28 @@ function applyReplacements(target, content, requestedEdits) {
96
185
  }
97
186
 
98
187
  if (!isString(replacement?.newText)) throw new Error("edit requires newText");
99
- const index = content.indexOf(replacement.oldText);
188
+ const oldText = String(replacement.oldText);
189
+ const newText = String(replacement.newText);
190
+ const index = content.indexOf(oldText);
100
191
 
101
192
  if (index < 0) {
102
- throw new Error(`edit target not found in ${target}: oldText must match the file byte-for-byte (read() it first; check whitespace and quotes)`);
193
+ throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
103
194
  }
195
+ const second = content.indexOf(oldText, index + 1);
196
+
197
+ if (second >= 0) {
198
+ const a = lineNumberAt(content, index);
199
+ const b = lineNumberAt(content, second);
200
+ const lineText = n => {
201
+ const range = lineTextRange(content, n);
202
+
203
+ return content.slice(range.start, range.end).replace(/\r?\n$/, "");
204
+ };
104
205
 
105
- if (content.indexOf(replacement.oldText, index + 1) >= 0) {
106
- throw new Error(`edit target is not unique in ${target}: include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]`);
206
+ throw new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lineText(a)) + "\n" + formatNumberedLine(b, lineText(b)));
107
207
  }
108
208
 
109
- return { ...replacement, index, end: index + replacement.oldText.length };
209
+ return { ...replacement, oldText, newText, index, end: index + oldText.length };
110
210
  });
111
211
 
112
212
  matches.sort((a, b) => a.index - b.index);
@@ -125,6 +225,162 @@ function applyReplacements(target, content, requestedEdits) {
125
225
  return { updated, matches };
126
226
  }
127
227
 
228
+ function lineStartIndex(content, line) {
229
+ let index = 0;
230
+
231
+ for (let current = 1; current < line; current++) {
232
+ const next = content.indexOf("\n", index);
233
+
234
+ if (next < 0) return content.length;
235
+ index = next + 1;
236
+ }
237
+
238
+ return Math.min(index, content.length);
239
+ }
240
+
241
+ function lineEndIndex(content, startIndex, lineCount) {
242
+ let index = startIndex;
243
+
244
+ for (let i = 0; i < lineCount; i++) {
245
+ const next = content.indexOf("\n", index);
246
+
247
+ if (next < 0) return content.length;
248
+ index = next + 1;
249
+ }
250
+
251
+ return index;
252
+ }
253
+
254
+ function lineTextRange(content, line) {
255
+ const start = lineStartIndex(content, line);
256
+
257
+ return { start, end: lineEndIndex(content, start, 1) };
258
+ }
259
+
260
+ function shiftDiffLines(diff, delta) {
261
+ if (!diff || delta === 0) return diff;
262
+
263
+ return {
264
+ ...diff,
265
+ lines: diff.lines.map(line => ({
266
+ ...line,
267
+ lineNum: line.lineNum + delta,
268
+ newLineNum: line.newLineNum === undefined ? undefined : line.newLineNum + delta,
269
+ })),
270
+ };
271
+ }
272
+
273
+ function applyViewReplace(target, content, start, end, oldText, newText) {
274
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
275
+ throw new Error("edit requires a valid view range in " + target);
276
+ }
277
+
278
+ const current = sliceLinesRaw(content, start, end - start + 1);
279
+
280
+ if (current !== oldText) {
281
+ const shown = current.length ? current : content;
282
+
283
+ throw new Error("edit view is stale in " + target + ": lines " + start + "-" + end + " changed\n" + numberedPreview(shown));
284
+ }
285
+
286
+ const startIndex = lineStartIndex(content, start);
287
+ const endIndex = lineEndIndex(content, startIndex, Math.max(0, end - start + 1));
288
+ const hasSuffix = endIndex < content.length;
289
+ const separator = hasSuffix && content.slice(Math.max(0, endIndex - 2), endIndex) === "\r\n" ? "\r\n" : "\n";
290
+ const eofNewline = content.endsWith("\r\n") ? "\r\n" : "\n";
291
+ let insert = newText;
292
+
293
+ // A view replaces whole source lines. Preserve the separator before following
294
+ // lines, but let an explicit trailing newline change a no-trailing-newline EOF.
295
+ if (hasSuffix && insert !== "" && !insert.endsWith("\n")) insert += separator;
296
+ if (!hasSuffix && content.endsWith("\n") && insert !== "" && !insert.endsWith("\n")) insert += eofNewline;
297
+ const updated = content.slice(0, startIndex) + insert + content.slice(endIndex);
298
+
299
+ return { updated, oldText, newText };
300
+ }
301
+
302
+ const WRITE_DIFF_MAX_READ_BYTES = 512 * 1024;
303
+ const WRITE_APPEND_MAX_READ_BYTES = 64 * 1024 * 1024;
304
+ const QUICK_CHECK_MAX_CHARS = 2 * 1024 * 1024;
305
+
306
+ async function countContentLines(target, signal) {
307
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
308
+
309
+ try {
310
+ const stat = await file.stat();
311
+
312
+ if (!stat.isFile()) return null;
313
+ let newlines = 0;
314
+ let last = -1;
315
+ let total = 0;
316
+
317
+ for await (const chunk of file.createReadStream({ autoClose: false, signal })) {
318
+ for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) newlines++;
319
+ last = chunk.at(-1);
320
+ total += chunk.length;
321
+ }
322
+
323
+ return total === 0 ? 0 : newlines + (last === 10 ? 0 : 1);
324
+ } finally { await file.close(); }
325
+ }
326
+
327
+ function contentLineInfo(text, previewLimit = 0) {
328
+ if (text === "") return { count: 0, preview: [], newlines: 0 };
329
+ const preview = [];
330
+ let count = 0;
331
+ let start = 0;
332
+
333
+ while (start <= text.length) {
334
+ const newline = text.indexOf("\n", start);
335
+ const end = newline < 0 ? text.length : newline;
336
+
337
+ if (end === text.length && end === start && text.endsWith("\n")) break;
338
+ if (preview.length < previewLimit) preview.push(text.slice(start, end).replace(/\r$/, ""));
339
+ count++;
340
+ if (newline < 0) break;
341
+ start = newline + 1;
342
+ }
343
+
344
+ return { count, preview, newlines: count - Number(!text.endsWith("\n")) };
345
+ }
346
+
347
+ function boundedEditDiff(target, original, matches) {
348
+ const lines = [];
349
+ let shift = 0;
350
+ let added = 0;
351
+ let removed = 0;
352
+
353
+ for (const match of matches) {
354
+ const oldInfo = contentLineInfo(match.oldText, 32);
355
+ const newInfo = contentLineInfo(match.newText, 32);
356
+ const start = lineNumberAt(original, match.index);
357
+ const nextStart = start + shift;
358
+
359
+ removed += oldInfo.count;
360
+ added += newInfo.count;
361
+
362
+ for (let i = 0; i < oldInfo.preview.length; i++) lines.push({ type: "remove", lineNum: start + i, newLineNum: nextStart + i, text: oldInfo.preview[i] });
363
+ for (let i = 0; i < newInfo.preview.length; i++) lines.push({ type: "add", lineNum: nextStart + i, newLineNum: nextStart + i, text: newInfo.preview[i] });
364
+
365
+ shift += newInfo.newlines - oldInfo.newlines;
366
+ }
367
+
368
+ return { path: target, op: "edit", added, removed, lines };
369
+ }
370
+
371
+ function boundedWriteDiff(target, content, removed) {
372
+ const added = contentLineInfo(content, 64);
373
+
374
+ return {
375
+ path: target,
376
+ op: "write",
377
+ added: added.count,
378
+ removed: removed ?? 0,
379
+ displayLineCount: (removed ?? 0) + added.count,
380
+ lines: added.preview.map((text, i) => ({ type: "add", lineNum: i + 1, text })),
381
+ };
382
+ }
383
+
128
384
  function formatDirectoryEntry(name, type, size = 0) {
129
385
  const sizeSuffix = size ? `, ${size} bytes` : "";
130
386
 
@@ -151,6 +407,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
151
407
  const reads = createNativeScheduler();
152
408
 
153
409
  async function sourceRead(query, searchDir, signal, params = {}) {
410
+ params = { ...params, resolve: params.resolve !== false };
154
411
  const cwd = getCwd();
155
412
 
156
413
  const includeHidden = path.relative(cwd, searchDir).split(path.sep)
@@ -160,20 +417,33 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
160
417
  pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
161
418
  overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
162
419
 
163
- return openSource(result, params, signal);
420
+ return openSource(result, params, signal, undefined, query);
164
421
  }
165
422
 
166
- async function openSource(result, params, signal, resolvedPath) {
423
+ async function openSource(result, params, signal, resolvedPath, query) {
167
424
  const cwd = getCwd();
168
425
 
169
426
  if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
170
427
  signal?.throwIfAborted();
171
- const opened = await readFile(resolvedPath ?? path.resolve(cwd, result.path), { ...params, about: undefined }, result.line, result.path);
172
- const block = opened.content[0];
428
+ const target = resolvedPath ?? path.resolve(cwd, result.path);
429
+ let bounded = isString(query) && params.complete !== true;
430
+
431
+ if (bounded) {
432
+ const overlay = vfs.getOverlay(target);
173
433
 
174
- if (block.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
175
- const { firstLine, lastLine, sourceChars, nextOffset, complete } = opened.details;
434
+ if (overlay !== undefined) bounded = Buffer.byteLength(overlay, "utf8") > 512 * 1024;
435
+ else try { bounded = (await fs.stat(target)).size > 512 * 1024; } catch { bounded = false; }
436
+ }
437
+
438
+ const opened = await readFile(target, bounded
439
+ ? { ...params, about: undefined, offset: Math.max(1, result.line - 4), limit: params.limit ?? 120 }
440
+ : { ...params, about: undefined }, result.line, result.path, bounded ? undefined : query, signal);
441
+ const block = opened.content?.[0];
176
442
 
443
+ if (block?.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
444
+ const { firstLine, lastLine, sourceChars, nextOffset, complete, viewComplete } = opened.details;
445
+
446
+ if (lastLine < firstLine) return textResult(JSON.stringify({ status: "incomplete", path: result.path, line: result.line, signature: result.signature ?? "", confidence: result.confidence ?? 0, context: result.context ?? [], message: "offset is beyond the end of " + result.path }), { ...opened.details, isSnap: true });
177
447
  const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
178
448
  text: block.text.slice(0, sourceChars), complete, nextOffset };
179
449
 
@@ -184,8 +454,10 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
184
454
  async function readDirectory(dirPath, signal) {
185
455
  signal?.throwIfAborted();
186
456
  const rows = new Map();
457
+ let truncated = false;
187
458
 
188
459
  for (const file of vfs.getOverlayPaths()) {
460
+ if (rows.size >= MAX_DIRECTORY_ENTRIES) { truncated = true; break; }
189
461
  const relative = path.relative(dirPath, file);
190
462
 
191
463
  if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
@@ -202,16 +474,135 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
202
474
  entries = [];
203
475
  }
204
476
 
205
- for (const entry of entries) if (!rows.has(entry.name)) rows.set(entry.name, await formatLsEntry(dirPath, entry));
477
+ for (let i = 0; i < entries.length; i++) {
478
+ if ((i & 127) === 0) signal?.throwIfAborted();
479
+ if (rows.size >= MAX_DIRECTORY_ENTRIES) { truncated = true; break; }
480
+ if (!rows.has(entries[i].name)) rows.set(entries[i].name, await formatLsEntry(dirPath, entries[i]));
481
+ }
206
482
 
207
- return textResult([...rows.values()].join("\n"), { path: dirPath, count: rows.size });
483
+ const values = [...rows.values()];
484
+ const text = values.join("\n") + (truncated ? "\n[directory listing truncated at " + MAX_DIRECTORY_ENTRIES + " entries]" : "");
485
+
486
+ return textResult(text, { path: dirPath, directory: true, count: rows.size, entries: values, outputTruncated: truncated });
487
+ }
488
+
489
+ /** Read an explicit line window without materializing the whole file when possible. */
490
+ async function readWindow(targetPath, startLine, lineCount, maxBytes, signal) {
491
+ const overlay = vfs.getOverlay(targetPath);
492
+
493
+ if (overlay !== undefined) {
494
+ const window = sliceLinesRawInfo(overlay, startLine, lineCount);
495
+ const satisfied = lineCount === undefined || lineCount === 0 || window.text === "" || window.count >= lineCount || window.eof;
496
+
497
+ return { text: window.text, satisfied, whole: window.whole };
498
+ }
499
+
500
+ let file;
501
+
502
+ try { file = await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
503
+ catch (error) {
504
+ if (error.code === "ENOENT") {
505
+ const missing = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question)");
506
+ missing.code = "ENOENT";
507
+ throw missing;
508
+ }
509
+
510
+ throw error;
511
+ }
512
+
513
+ try {
514
+ const stat = await file.stat();
515
+
516
+ if (!stat.isFile()) throw new Error("read requires a regular file: " + targetPath);
517
+ if (lineCount === 0) {
518
+ if (stat.size === 0) vfs.setCache(targetPath, "");
519
+
520
+ return { text: "", satisfied: true, whole: stat.size === 0 };
521
+ }
522
+
523
+ const wantedLines = lineCount === undefined ? Infinity : startLine + lineCount - 1;
524
+ const scan = Buffer.alloc(64 * 1024);
525
+ const parts = [];
526
+ let position = 0;
527
+ let linesSeen = 0;
528
+ let started = startLine === 1;
529
+ let startByte = started ? 0 : -1;
530
+ let done = false;
531
+ let doneByte = -1;
532
+ let collected = 0;
533
+
534
+ while (position < stat.size && !done && collected <= maxBytes) {
535
+ signal?.throwIfAborted();
536
+ const { bytesRead } = await file.read(scan, 0, scan.length, position);
537
+
538
+ if (bytesRead <= 0) break;
539
+ let begin = 0;
540
+
541
+ if (!started) {
542
+ while (begin < bytesRead && linesSeen < startLine - 1) if (scan[begin++] === 10) linesSeen++;
543
+ if (linesSeen < startLine - 1) {
544
+ position += bytesRead;
545
+ continue;
546
+ }
547
+ started = true;
548
+ startByte = position + begin;
549
+ }
550
+
551
+ let end = bytesRead;
552
+
553
+ if (wantedLines !== Infinity) {
554
+ for (let i = begin; i < bytesRead; i++) {
555
+ if (scan[i] !== 10) continue;
556
+ linesSeen++;
557
+ if (linesSeen === wantedLines) {
558
+ end = i + 1;
559
+ done = true;
560
+ doneByte = position + end;
561
+ break;
562
+ }
563
+ }
564
+ }
565
+
566
+ const takeBegin = position === startByte ? begin : Math.max(0, startByte - position);
567
+ const takeEnd = Math.min(end, takeBegin + Math.max(0, maxBytes + 1 - collected));
568
+
569
+ if (takeEnd > takeBegin) {
570
+ parts.push(Buffer.from(scan.subarray(takeBegin, takeEnd)));
571
+ collected += takeEnd - takeBegin;
572
+ }
573
+
574
+ position += bytesRead;
575
+ }
576
+
577
+ if (!started) {
578
+ if (stat.size === 0) vfs.setCache(targetPath, "");
579
+
580
+ return { text: "", satisfied: true, whole: stat.size === 0 };
581
+ }
582
+ const text = Buffer.concat(parts, collected).toString("utf8");
583
+ const satisfied = (done && startByte + collected >= doneByte) || startByte + collected >= stat.size;
584
+ const whole = startLine === 1 && startByte === 0 && startByte + collected >= stat.size;
585
+
586
+ // Even a bounded window owns a full byte snapshot. The observed stat
587
+ // ties signing to the file version that supplied these window bytes.
588
+ await vfs.recordExpected(targetPath, stat);
589
+ if (whole) vfs.setCache(targetPath, text);
590
+
591
+ return { text, satisfied, whole };
592
+ } finally {
593
+ await file.close();
594
+ }
208
595
  }
209
596
 
210
597
  async function readAdapter(params, signal) {
211
598
  signal?.throwIfAborted();
599
+ params = normalizeReadWindow(params);
600
+ if (!isObject(params)) throw new Error("read requires an options object");
212
601
  const cwd = getCwd();
213
602
  const targetParam = params?.path ?? params?.target;
214
603
 
604
+ if (params?.path !== undefined && params?.target !== undefined && params.path !== params.target) throw new Error("read accepts either path or target, not both");
605
+
215
606
  if (Array.isArray(targetParam)) {
216
607
  if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
217
608
 
@@ -219,9 +610,13 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
219
610
 
220
611
  const results = await Promise.all(targetParam.map(async p => {
221
612
  try {
222
- const block = (await readAdapter({ ...params, path: p }, signal)).content[0];
613
+ const res = await readAdapter({ ...params, path: p, target: undefined }, signal);
614
+ const block = res.content?.[0];
615
+ const item = block?.type === "image" ? block
616
+ : res.details?.directory === true && Array.isArray(res.details.entries) ? res.details.entries
617
+ : block?.text ?? "";
223
618
 
224
- return { text: block.type === "image" ? block : block.text };
619
+ return { text: item };
225
620
  } catch (error) {
226
621
  signal?.throwIfAborted();
227
622
 
@@ -283,14 +678,33 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
283
678
  async function readSingle(params, cwd, targetParam, signal) {
284
679
  params = sessionJsonArgs({ ...params, path: targetParam });
285
680
  validateJsonRead(params);
681
+ for (const [key, value] of [["resolve", params.resolve], ["complete", params.complete], ["outline", params.outline], ["evidence", params.evidence]]) {
682
+ if (value !== undefined && typeof value !== "boolean") throw new Error("read " + key + " must be a boolean");
683
+ }
684
+
685
+ if (params.about !== undefined && !isString(params.about)) throw new Error("read about must be a string");
686
+ if (params.query !== undefined && !isString(params.query)) throw new Error("read query must be a string");
687
+ const focusModes = [params.about !== undefined, params.query !== undefined, params.outline === true].filter(Boolean).length;
688
+
689
+ if (focusModes > 1 || (params.outline === true && params.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
690
+ if (params.resolve === true && params.complete === true) throw new Error("read accepts either resolve or complete, not both");
691
+ if ((focusModes === 1 || params.evidence === true) && params.complete === true) throw new Error("complete:true requires a raw file read, not a source view");
286
692
  targetParam = params.path;
287
693
 
288
694
  if (isString(targetParam) && /^(?:agent|artifact):\/\//i.test(targetParam)) {
695
+ if (focusModes || params.evidence === true) throw new Error("session resources do not support about/query/outline/evidence views");
289
696
  const target = await resolveSessionResource(targetParam, signal);
290
697
 
291
698
  return params.resolve
292
699
  ? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
293
- : readFile(target, params, undefined, targetParam);
700
+ : readFile(target, params, undefined, targetParam, undefined, signal);
701
+ }
702
+
703
+ if (params.evidence === true) {
704
+ const query = params.about ?? params.query ?? targetParam;
705
+ const scope = targetParam !== query || looksLikePath(targetParam) ? targetParam : undefined;
706
+
707
+ return adapters.evidence({ ...params, query, path: scope }, signal);
294
708
  }
295
709
 
296
710
  if (isString(params?.query)) {
@@ -299,47 +713,169 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
299
713
  return sourceRead(params.query, scope, signal, params);
300
714
  }
301
715
 
716
+ if (params.outline === true) {
717
+ const targetPath = resolveReadPath(cwd, targetParam);
718
+ const text = await vfs.read(targetPath, { maxBytes: 2 * 1024 * 1024 });
719
+ const outline = extractStructuralSurface(text, path.extname(targetPath));
720
+
721
+ return textResult(JSON.stringify(outline, null, 2), { path: targetPath, count: outline.items.length });
722
+ }
723
+
302
724
  const existing = await probeExistingPath(cwd, targetParam, vfs);
303
725
 
726
+ if (params.resolve === true && isString(params.about) && existing && !existing.directory) {
727
+ throw new Error("resolve:true cannot combine with about on a file; use about for a focused outline or resolve for source text");
728
+ }
729
+
304
730
  if (existing) {
305
- if (!existing.directory) return params.resolve
306
- ? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
307
- : readFile(existing.path, params);
731
+ if (!existing.directory) {
732
+ if (isString(params.about) && existing.size > 512 * 1024) {
733
+ if (existing.overlay !== undefined) return focusedOverlayText(existing.path, relativeSlash(cwd, existing.path), existing.overlay, params.about, signal);
734
+ return focusedLargeFile(existing.path, relativeSlash(cwd, existing.path), params.about, signal);
735
+ }
736
+
737
+ return params.resolve
738
+ ? openSource({ status: "found", path: relativeSlash(cwd, existing.path), line: params.offset ?? 1 }, params, signal)
739
+ : readFile(existing.path, params, undefined, undefined, undefined, signal);
740
+ }
308
741
 
309
742
  if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
310
743
 
311
744
  return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
312
745
  }
313
746
 
314
- if (params.json === undefined && !looksLikePath(targetParam)) return sourceRead(targetParam, cwd, signal, params);
747
+ if (params.json === undefined && !looksLikePath(targetParam)) {
748
+ const scope = isString(params.about) ? resolveReadPath(cwd, targetParam) : cwd;
749
+
750
+ return sourceRead(isString(params.about) ? params.about : targetParam, scope, signal, params);
751
+ }
752
+ if (params.resolve === true && params.complete !== true) {
753
+ return textResult(JSON.stringify({ status: "not_found", path: null, line: null, signature: "", confidence: 0, context: [] }), { isSnap: true });
754
+ }
315
755
  const targetPath = resolveReadPath(cwd, targetParam);
316
756
 
317
- return readFile(targetPath, params);
757
+ return readFile(targetPath, params, undefined, undefined, undefined, signal);
758
+ }
759
+
760
+ /** Focus a huge file through rg instead of materializing it just to fold declarations. */
761
+ async function focusedLargeFile(targetPath, rel, about, signal) {
762
+ const tokens = tokenizeQuery(about).tokens;
763
+ const stems = [...new Set(tokens.map(token => stem(token).slice(0, 128)))];
764
+
765
+ if (tokens.length > 16) throw new Error("about is too broad; use at most 16 keywords");
766
+ if (!stems.length) throw new Error("about needs at least one searchable keyword");
767
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256);
768
+ const args = ["rg", "--fixed-strings", "--ignore-case", "--line-number", "--before-context", "3", "--after-context", "3"];
769
+
770
+ for (const token of stems) args.push("-e", token);
771
+ args.push("--", targetPath);
772
+ const observed = await fs.stat(targetPath);
773
+ const res = await runCommand(args, { cwd: path.dirname(targetPath), timeoutMs: 15000, maxOutputChars: budget, signal });
774
+
775
+ if (res.exitCode === 0 || res.exitCode === 1) await vfs.recordExpected(targetPath, observed);
776
+ if (res.exitCode === 1) return textResult("// " + rel + " · no matching text\n", { path: targetPath, outputTruncated: false, complete: false });
777
+ if (res.exitCode !== 0) throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
778
+ const marker = res.outputTruncated ? "\n[focused read truncated; narrow about or use read(path, line, count)]" : "";
779
+
780
+ return textResult("// " + rel + " · focused text windows (not a complete file); read(path, line, count) for raw text\n" + res.stdout + marker,
781
+ { path: targetPath, outputTruncated: res.outputTruncated, complete: false });
782
+ }
783
+
784
+ /** Focus a large staged file without materializing another bounded window of its whole text. */
785
+ function focusedOverlayText(targetPath, rel, overlay, about, signal) {
786
+ const { tokens } = tokenizeQuery(about);
787
+
788
+ if (tokens.length > 16) throw new Error("about is too broad; use at most 16 keywords");
789
+ const stems = tokens.map(token => stem(token).slice(0, 128));
790
+ const hits = [];
791
+ let line = 1;
792
+ let start = 0;
793
+
794
+ while (start <= overlay.length) {
795
+ signal?.throwIfAborted();
796
+ const newline = overlay.indexOf("\n", start);
797
+ const end = newline < 0 ? overlay.length : newline + 1;
798
+ const row = overlay.slice(start, newline < 0 ? end : newline).replace(/\r$/, "");
799
+
800
+ if (stems.some(st => row.toLowerCase().includes(st))) {
801
+ hits.push(line);
802
+ if (hits.length >= 200) break;
803
+ }
804
+
805
+ if (end === overlay.length) break;
806
+ start = end;
807
+ line++;
808
+ }
809
+
810
+ const out = [];
811
+ let cursor = 1;
812
+ let used = 0;
813
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256);
814
+ let truncated = hits.length >= 200;
815
+
816
+ for (const hit of hits) {
817
+ const from = Math.max(cursor, hit - 3);
818
+ const to = hit + 3;
819
+ const first = lineStartIndex(overlay, from);
820
+ const last = lineTextRange(overlay, Math.min(to, line)).end;
821
+ const body = overlay.slice(first, last);
822
+
823
+ if (used + body.length > budget) { truncated = true; break; }
824
+ if (out.length && from > cursor) out.push("...");
825
+ out.push(`// ${rel}:${from}\n${body}`);
826
+ used += body.length;
827
+ cursor = Math.max(cursor, to + 1);
828
+ }
829
+
830
+ if (!out.length) return textResult("// " + rel + (hits.length
831
+ ? " · matching text exceeds view budget; first match at line " + hits[0] + "; use read(path, line, count)\n"
832
+ : " · no matching staged text\n"), { path: targetPath, outputTruncated: truncated, complete: false });
833
+
834
+ const marker = truncated ? "\n[focused read truncated; narrow about or use read(path, line, count)]" : "";
835
+
836
+ return textResult("// " + rel + " · focused staged text windows (not a complete file)\n" + out.join("\n") + marker, { path: targetPath, outputTruncated: truncated, complete: false });
318
837
  }
319
838
 
320
839
  /** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
321
- async function readFile(targetPath, params, sourceLine, displayPath) {
840
+ async function readFile(targetPath, params, sourceLine, displayPath, query, signal) {
322
841
  const cwd = getCwd();
323
842
  const rel = displayPath ?? relativeSlash(cwd, targetPath);
324
843
 
844
+ if (isString(params?.about) && tokenizeQuery(params.about).tokens.length > 16) {
845
+ throw new Error("about is too broad; use at most 16 keywords");
846
+ }
847
+
325
848
  if (params.json !== undefined) {
326
849
  const project = jsonProjector(params.json);
327
- const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES });
850
+ const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES, label: "JSON input" });
328
851
  let document;
329
852
 
330
853
  try { document = JSON.parse(text); }
331
854
  catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
332
855
 
333
856
  const many = Array.isArray(params.json);
334
- let remaining = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256) - (many ? params.json.length + 1 : 0);
857
+ const selectors = many ? params.json.map(selector => String(selector)) : [params.json === true ? "." : String(params.json)];
858
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - 256) - (many ? params.json.length + 1 : 0);
859
+ let remaining = budget;
335
860
  const parts = [];
336
861
 
337
- for (const value of project(document)) {
338
- const encoded = JSON.stringify(value);
339
- remaining -= encoded.length;
862
+ try {
863
+ let index = 0;
864
+
865
+ for (const value of project(document)) {
866
+ const encoded = JSON.stringify(value);
340
867
 
341
- if (remaining < 0) throw new Error("JSON selection exceeds the read budget; select narrower fields or an array slice such as .items[0:10]");
342
- parts.push(encoded);
868
+ if (encoded.length > remaining) {
869
+ throw new Error("JSON selection exceeds the read budget for " + rel + " (" + (selectors[index] ?? "selector") + ": " + encoded.length + " chars, " + remaining + " remaining of " + budget + "); select narrower fields or an array slice such as .items[0:10]");
870
+ }
871
+
872
+ remaining -= encoded.length;
873
+ parts.push(encoded);
874
+ index++;
875
+ }
876
+ } catch (error) {
877
+ if (error instanceof Error && error.message.startsWith("JSON selection exceeds the read budget")) throw error;
878
+ throw new Error("JSON selection failed for " + rel + " (" + selectors.join(", ") + "): " + (error instanceof Error ? error.message : String(error)));
343
879
  }
344
880
 
345
881
  return textResult(many ? "[" + parts.join(",") + "]" : parts[0], { path: targetPath, json: true, complete: true });
@@ -348,14 +884,64 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
348
884
  const mime = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" }[path.extname(targetPath).toLowerCase()];
349
885
 
350
886
  if (mime) {
351
- if ((await fs.stat(targetPath)).size > 20 * 1024 * 1024) throw new Error("image exceeds 20 MiB; resize it before reading");
352
887
  const staged = vfs.getOverlay(targetPath);
353
- const bytes = staged === undefined ? await fs.readFile(targetPath) : Buffer.from(staged);
888
+ let bytes;
889
+
890
+ if (staged !== undefined) {
891
+ const size = Buffer.byteLength(staged, "utf8");
892
+
893
+ if (size > 20 * 1024 * 1024) throw new Error("image " + rel + " is " + size + " bytes (" + (size / 1024 / 1024).toFixed(1) + " MiB); the image read limit is 20971520 bytes (20 MiB); resize or select fewer/smaller images");
894
+ bytes = Buffer.from(staged);
895
+ } else {
896
+ let file;
897
+
898
+ try { file = await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
899
+ catch (error) {
900
+ if (error.code === "ENOENT") {
901
+ const missing = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question)");
902
+ missing.code = "ENOENT";
903
+ throw missing;
904
+ }
905
+
906
+ throw error;
907
+ }
908
+
909
+ try {
910
+ const stat = await file.stat();
911
+
912
+ if (!stat.isFile()) throw new Error("image read requires a regular file: " + targetPath);
913
+ if (stat.size > 20 * 1024 * 1024) throw new Error("image " + rel + " is " + stat.size + " bytes (" + (stat.size / 1024 / 1024).toFixed(1) + " MiB); the image read limit is 20971520 bytes (20 MiB); resize or select fewer/smaller images");
914
+ bytes = await file.readFile({ signal });
915
+ await vfs.recordExpected(targetPath, stat);
916
+ } finally { await file.close(); }
917
+ }
918
+
919
+ if (bytes.length > 20 * 1024 * 1024) throw new Error("image " + rel + " is " + bytes.length + " bytes (" + (bytes.length / 1024 / 1024).toFixed(1) + " MiB); the image read limit is 20971520 bytes (20 MiB); resize or select fewer/smaller images");
354
920
 
355
921
  return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
356
922
  }
357
923
 
358
- const text = await vfs.read(targetPath);
924
+ const explicit = isNumber(params?.offset) || isNumber(params?.limit);
925
+ const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
926
+ let text;
927
+ let windowed = false;
928
+ let windowSatisfied = true;
929
+ let windowWhole = false;
930
+
931
+ const canWindow = params.complete !== true && !isString(params?.about) && !isString(query);
932
+
933
+ if (canWindow) {
934
+ const startLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
935
+ const lineCount = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : undefined;
936
+ const window = await readWindow(targetPath, startLine, lineCount, budget * 4 + 1024, signal);
937
+ text = window.text;
938
+ windowed = true;
939
+ windowSatisfied = window.satisfied;
940
+ windowWhole = window.whole === true;
941
+ } else {
942
+ text = await vfs.read(targetPath, { maxBytes: 64 * 1024 * 1024 });
943
+ }
944
+
359
945
  index.touch(rel);
360
946
 
361
947
  if (isString(params?.about)) {
@@ -369,17 +955,31 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
369
955
  }
370
956
  }
371
957
 
372
- const explicit = isNumber(params?.offset) || isNumber(params?.limit);
373
- const budget = Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (params.resolve ? 1024 : 256));
374
- const offset = params?.offset ?? (sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1);
958
+ let offset = params?.offset;
959
+ let limit = params?.limit;
960
+ let viewComplete;
961
+
962
+ if (params.resolve && isString(query)) {
963
+ const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(targetPath, text));
964
+ const span = pickSpan(spans, { line: sourceLine, name: query });
965
+
966
+ if (span) {
967
+ const spanLines = span.end - span.start + 1;
968
+ offset = span.start;
969
+ limit = isNumber(params.limit) ? Math.min(params.limit, spanLines) : spanLines;
970
+ viewComplete = limit >= spanLines;
971
+ }
972
+ }
973
+
974
+ offset ??= sourceLine && text.length > budget ? Math.max(1, sourceLine - 2) : 1;
375
975
  const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
376
- const sliced = sliceLines(text, offset, params?.limit);
976
+ const sliced = windowed ? text : sliceLinesRaw(text, offset, limit);
377
977
 
378
978
  if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
379
979
  throw new Error(`incomplete read of ${rel}: complete:true requires the entire file within the read budget; use json:".field" for JSON reports, about for text selection, edit() for replacements, or reconstruct resolve:true source windows`);
380
980
  }
381
981
 
382
- if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget)) {
982
+ if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget) || (windowed && !windowSatisfied)) {
383
983
  if (!explicit && !params.resolve && path.extname(targetPath).toLowerCase() === ".json") throw new Error("incomplete JSON read of " + rel + "; use the json selector option to parse the whole document before projection, or explicit offset/limit for raw text windows");
384
984
  let cap = budget - 160;
385
985
 
@@ -402,13 +1002,17 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
402
1002
  if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
403
1003
  const body = sliced.slice(0, end + 1);
404
1004
  const next = firstLine + body.split("\n").length - 1;
1005
+ ledger.recordOrigin(rel, firstLine, sourceLines(body), explicit);
405
1006
 
406
- return textResult(body + `\n[read truncated; continue with read({path:${JSON.stringify(rel)}, offset:${next}})]`, { path: targetPath, outputTruncated: true, nextOffset: next, firstLine, lastLine: next - 1, sourceChars: body.length, complete: false });
1007
+ return textResult(body + `\n[read truncated; continue with read({path:${JSON.stringify(rel)}, offset:${next}})]`, { path: targetPath, outputTruncated: true, nextOffset: next, firstLine, lastLine: next - 1, sourceChars: body.length, complete: false, viewComplete: false });
407
1008
  }
408
1009
 
409
- ledger.recordOrigin(rel, firstLine, sliced.split("\n"), explicit);
1010
+ const slicedLines = sliced.length <= 512 * 1024 ? sourceLines(sliced) : null;
1011
+ const slicedLineCount = slicedLines?.length ?? contentLineInfo(sliced).count;
1012
+
1013
+ if (slicedLines) ledger.recordOrigin(rel, firstLine, slicedLines, explicit);
410
1014
 
411
- return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + sliced.split("\n").length - 1 - Number(sliced.endsWith("\n")), sourceChars: sliced.length, complete: sliced === text });
1015
+ return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + slicedLineCount - 1, sourceChars: sliced.length, complete: windowed ? windowWhole : sliced === text, viewComplete });
412
1016
  }
413
1017
 
414
1018
  /**
@@ -416,19 +1020,33 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
416
1020
  * lines with numbers, a quick structural check, and bounded lexical reference hints.
417
1021
  * These do not replace tests or semantic caller resolution.
418
1022
  */
419
- async function editSummary(cwd, target, original, updated, diff, signal) {
1023
+ async function editSummary(cwd, target, original, updated, diff, signal, span) {
420
1024
  const rel = relativeSlash(cwd, target);
421
- const newLines = updated.split("\n");
1025
+ const smallUpdated = updated.length <= 512 * 1024;
1026
+ const newLines = smallUpdated ? updated.split("\n") : null;
1027
+ const lineCount = newLines ? newLines.length : contentLineInfo(updated).count;
1028
+ const lineAt = n => {
1029
+ if (newLines) return newLines[n - 1] ?? "";
1030
+ const { start, end } = lineTextRange(updated, n);
1031
+
1032
+ return updated.slice(start, end).replace(/\r?\n$/, "");
1033
+ };
422
1034
  const ranges = [];
423
1035
 
424
- const positions = diff.lines.filter(row => row.type !== "context")
425
- .map(row => Math.min(newLines.length, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
1036
+ if (span && Number.isInteger(span.start) && Number.isInteger(span.end) && span.start >= 1 && span.end >= span.start) {
1037
+ const end = Math.min(lineCount, span.end);
1038
+
1039
+ if (span.start <= end) ranges.push({ start: span.start, end });
1040
+ } else {
1041
+ const positions = diff.lines.filter(row => row.type !== "context")
1042
+ .map(row => Math.min(lineCount, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
426
1043
 
427
- for (const line of positions) {
428
- const start = Math.max(1, line - 2), end = Math.min(newLines.length, line + 2);
1044
+ for (const line of positions) {
1045
+ const start = Math.max(1, line - 2), end = Math.min(lineCount, line + 2);
429
1046
 
430
- if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
431
- else ranges.push({ start, end });
1047
+ if (ranges.length && start <= ranges.at(-1).end + 1) ranges.at(-1).end = Math.max(ranges.at(-1).end, end);
1048
+ else ranges.push({ start, end });
1049
+ }
432
1050
  }
433
1051
 
434
1052
  const blocks = [];
@@ -436,7 +1054,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
436
1054
 
437
1055
  for (const { start, end } of ranges) {
438
1056
  const last = Math.min(end, start + perRange - 1);
439
- const lines = newLines.slice(start - 1, last);
1057
+ const lines = Array.from({ length: Math.max(0, last - start + 1) }, (_, i) => lineAt(start + i));
440
1058
  ledger.recordOrigin(rel, start, lines);
441
1059
  blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
442
1060
 
@@ -444,7 +1062,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
444
1062
  }
445
1063
 
446
1064
  let out = blocks.join("\n");
447
- const check = quickCheck(updated, path.extname(target));
1065
+ const check = updated.length <= QUICK_CHECK_MAX_CHARS ? quickCheck(updated, path.extname(target)) : null;
448
1066
 
449
1067
  if (check && !check.ok) out += `\ncheck: ${check.message}`;
450
1068
  const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
@@ -456,18 +1074,26 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
456
1074
 
457
1075
  async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
458
1076
  // Diff rows carry the replaced fragments; declarations live on whole file lines.
459
- const oldLines = original.split("\n");
460
- const newLines = updated.split("\n");
1077
+ // Large-file edits skip owner-span mapping: it duplicates source bodies and can exhaust the guest heap.
1078
+ const canMapOwners = original.length <= 512 * 1024 && updated.length <= 512 * 1024;
1079
+ const oldLines = canMapOwners ? original.split("\n") : null;
1080
+ const newLines = canMapOwners ? updated.split("\n") : null;
1081
+ const changedLine = (text, lines, number) => {
1082
+ if (lines) return lines[number - 1] ?? "";
1083
+ const { start, end } = lineTextRange(text, number);
1084
+
1085
+ return text.slice(start, end).replace(/\r?\n$/, "");
1086
+ };
461
1087
  const names = new Set();
462
1088
  const spans = new Map();
463
1089
 
464
1090
  for (const l of diff.lines) {
465
1091
  if (l.type === "context") continue;
466
1092
  const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
467
- const name = declaredName((l.type === "remove" ? oldLines : newLines)[number - 1] ?? "");
1093
+ const name = declaredName(changedLine(l.type === "remove" ? original : updated, l.type === "remove" ? oldLines : newLines, number));
468
1094
 
469
1095
  if (name) names.add(name);
470
- else {
1096
+ else if (canMapOwners) {
471
1097
  if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
472
1098
  const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
473
1099
 
@@ -502,18 +1128,36 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
502
1128
  const SOURCE_REF = /((?:\/|[A-Za-z]:[\\/])?(?:[\w.@-]+[\\/])*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
503
1129
 
504
1130
  /** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
505
- async function sourceWindow(cwd, commandCwd, file, lineNo) {
1131
+ async function sourceWindow(cwd, commandCwd, file, lineNo, signal) {
506
1132
  const candidate = path.resolve(commandCwd, file);
507
1133
  let text, rel;
508
1134
 
509
1135
  try {
510
1136
  const root = await fs.realpath(cwd);
1137
+ const cwdPrefix = path.resolve(cwd).endsWith(path.sep) ? path.resolve(cwd) : path.resolve(cwd) + path.sep;
1138
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
511
1139
 
512
- if (!candidate.startsWith(path.resolve(cwd) + path.sep) && !candidate.startsWith(root + path.sep)) return null;
1140
+ if (!candidate.startsWith(cwdPrefix) && !candidate.startsWith(rootPrefix)) return null;
513
1141
  const real = await fs.realpath(candidate);
1142
+ const handle = await fs.open(real, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
514
1143
 
515
- if (!real.startsWith(root + path.sep) || (await fs.stat(real)).size > 1024 * 1024) return null;
516
- text = await fs.readFile(real, "utf8");
1144
+ try {
1145
+ const stat = await handle.stat();
1146
+
1147
+ if (!real.startsWith(rootPrefix) || !stat.isFile() || stat.size > 1024 * 1024) return null;
1148
+ const buffer = Buffer.alloc(Math.min(stat.size, 1024 * 1024));
1149
+ let offset = 0;
1150
+
1151
+ while (offset < buffer.length) {
1152
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
1153
+
1154
+ if (bytesRead <= 0) break;
1155
+ offset += bytesRead;
1156
+ signal?.throwIfAborted();
1157
+ }
1158
+
1159
+ text = buffer.subarray(0, offset).toString("utf8");
1160
+ } finally { await handle.close(); }
517
1161
  rel = relativeSlash(root, real);
518
1162
  } catch { return null; }
519
1163
 
@@ -530,7 +1174,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
530
1174
  }
531
1175
 
532
1176
  /** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
533
- async function sourceForReferences(cwd, commandCwd, output) {
1177
+ async function sourceForReferences(cwd, commandCwd, output, signal) {
534
1178
  const seen = new Set();
535
1179
  const blocks = [];
536
1180
 
@@ -541,7 +1185,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
541
1185
 
542
1186
  if (seen.size >= 4) break;
543
1187
  seen.add(key);
544
- const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]));
1188
+ const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]), signal);
545
1189
 
546
1190
  if (block) blocks.push(block);
547
1191
  }
@@ -552,7 +1196,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
552
1196
  function outlineOptions(params, references) {
553
1197
  const options = { references };
554
1198
 
555
- if (params?.maxChars) options.maxChars = params.maxChars;
1199
+ if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
556
1200
 
557
1201
  return options;
558
1202
  }
@@ -568,7 +1212,10 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
568
1212
 
569
1213
  /** Where else a name appears (declaration line excluded), for outlines and edit results. */
570
1214
  async function referenceFinder(cwd, targetPath) {
571
- const files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])];
1215
+ let files;
1216
+
1217
+ try { files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])]; }
1218
+ catch { return () => []; }
572
1219
 
573
1220
  if (!index.canScan(files)) return () => [];
574
1221
 
@@ -586,7 +1233,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
586
1233
 
587
1234
  hooks.summarizeEdit = editSummary;
588
1235
 
589
- return {
1236
+ const adapters = {
590
1237
  read: readAdapter,
591
1238
  async write(params, signal) {
592
1239
  const cwd = getCwd();
@@ -597,24 +1244,51 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
597
1244
  if (!isString(params?.content)) throw new Error("write requires string content");
598
1245
 
599
1246
  if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
600
- let content = params.content;
1247
+ if (params.allowReadArtifacts !== undefined && typeof params.allowReadArtifacts !== "boolean") throw new Error("write allowReadArtifacts must be a boolean");
1248
+ let content = String(params.content);
601
1249
 
602
- if (params.allowReadArtifacts !== true && /\[read truncated;|…\[(?:host-result|output|value) truncated \d+ chars\]…/u.test(content)) {
1250
+ if (params.allowReadArtifacts !== true && /\[read truncated;|…\[[^\]\n]*truncated[^\]\n]*\]…/u.test(content)) {
603
1251
  throw new Error("refusing to write truncated read output; use edit() or reconstruct complete source windows. Set allowReadArtifacts:true only to intentionally write literal truncation-marker text");
604
1252
  }
605
1253
 
606
1254
  let prevText = "";
1255
+ let removedLines;
1256
+ let stat;
1257
+
1258
+ try { stat = await fs.stat(target); }
1259
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1260
+
1261
+ const overlay = vfs.getOverlay(target);
1262
+ const existingBytes = overlay !== undefined ? Buffer.byteLength(overlay, "utf8") : stat?.size;
1263
+
1264
+ if (params.append === true) {
1265
+ if (existingBytes > WRITE_APPEND_MAX_READ_BYTES) throw new Error("append input exceeds " + WRITE_APPEND_MAX_READ_BYTES + " bytes; stream it with bash redirection instead");
1266
+
1267
+ try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
1268
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1269
+ content = prevText + content;
1270
+ } else if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
1271
+ if (overlay === undefined) {
1272
+ // Keep the CAS baseline without materializing a huge old body just to draw a receipt.
1273
+ await vfs.captureExpected(target);
1274
+
1275
+ try { removedLines = await countContentLines(target, signal); }
1276
+ catch (error) { if (error.code !== "ENOENT") throw error; else removedLines = 0; }
1277
+ } else {
1278
+ removedLines = contentLineInfo(overlay).count;
1279
+ }
1280
+ } else {
1281
+ try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true }); }
1282
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1283
+ }
607
1284
 
608
- try {
609
- prevText = await vfs.read(target, { preserveRead: true });
610
- } catch (error) { if (error.code !== "ENOENT") throw error; }
611
-
612
- if (params.append === true) content = prevText + content;
613
1285
  const { speculative } = await vfs.write(target, content);
614
1286
  index.touch(relativeSlash(cwd, target));
615
- const diff = buildWriteDiff(target, prevText, content);
1287
+ const diff = removedLines === undefined && content.length <= WRITE_DIFF_MAX_READ_BYTES
1288
+ ? buildWriteDiff(target, prevText, content)
1289
+ : boundedWriteDiff(target, content, removedLines ?? contentLineInfo(prevText).count);
616
1290
  const tag = speculative ? " (speculative)" : "";
617
- const check = quickCheck(content, path.extname(target));
1291
+ const check = content.length <= QUICK_CHECK_MAX_CHARS ? quickCheck(content, path.extname(target)) : null;
618
1292
  const warning = check && !check.ok ? "\ncheck: " + check.message : "";
619
1293
 
620
1294
  return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
@@ -625,17 +1299,38 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
625
1299
 
626
1300
  if (signal?.aborted) throw new Error("aborted");
627
1301
 
1302
+ const content = await vfs.read(target, { maxBytes: 64 * 1024 * 1024 });
1303
+
1304
+ if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
1305
+ const viewText = String(params.viewText);
1306
+ const nextText = String(params.newText);
1307
+ const windowNext = isString(params.oldText)
1308
+ ? applyReplacements(target, viewText, [{ oldText: String(params.oldText), newText: nextText }]).updated
1309
+ : nextText;
1310
+ const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, viewText, windowNext);
1311
+ const { speculative } = await vfs.write(target, updated);
1312
+ index.touch(relativeSlash(cwd, target));
1313
+ const diffFrom = isString(params.oldText) ? String(params.oldText) : viewText;
1314
+ const diffTo = isString(params.oldText) ? nextText : windowNext;
1315
+ const localDiff = buildEditDiff(target, viewText, diffFrom, diffTo);
1316
+ const diff = shiftDiffLines(localDiff, params.viewStart - 1);
1317
+ const inserted = sourceLines(windowNext);
1318
+ const spanEnd = params.viewStart + Math.max(inserted.length, 1) - 1;
1319
+ const summary = await editSummary(cwd, target, content, updated, diff, signal, { start: params.viewStart, end: spanEnd });
1320
+
1321
+ return textResult(summary, { path: target, speculative, diff });
1322
+ }
1323
+
628
1324
  const requestedEdits = Array.isArray(params?.edits)
629
1325
  ? params.edits
630
1326
  : [{ oldText: params?.oldText, newText: params?.newText }];
631
-
632
- const content = await vfs.read(target);
633
1327
  const { updated, matches } = applyReplacements(target, content, requestedEdits);
634
1328
  const { speculative } = await vfs.write(target, updated);
635
1329
  index.touch(relativeSlash(cwd, target));
636
1330
 
637
- const diff =
638
- matches.length === 1
1331
+ const diff = content.length > 512 * 1024 || updated.length > 512 * 1024
1332
+ ? boundedEditDiff(target, content, matches)
1333
+ : matches.length === 1
639
1334
  ? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
640
1335
  : buildMultiEditDiff(target, content, matches);
641
1336
 
@@ -648,9 +1343,11 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
648
1343
  let inputPath = params?.path;
649
1344
 
650
1345
  if (!inputPath && isString(params?.patch)) {
651
- const headerMatch = /^\+\+\+\s+[ab]\/(.+)$/m.exec(params.patch) || /^---\s+[ab]\/(.+)$/m.exec(params.patch);
1346
+ for (const match of params.patch.matchAll(/^(?:---|\+\+\+)\s+([^\t\n]+)/gm)) {
1347
+ const candidate = match[1].trim().replace(/^[ab]\//, "");
652
1348
 
653
- if (headerMatch) inputPath = headerMatch[1].trim();
1349
+ if (candidate !== "/dev/null") { inputPath = candidate; break; }
1350
+ }
654
1351
  }
655
1352
 
656
1353
  const target = await resolveWorkspacePath(cwd, inputPath, "apply_patch", false);
@@ -661,7 +1358,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
661
1358
 
662
1359
  if (signal?.aborted) throw new Error("aborted");
663
1360
 
664
- const original = await vfs.read(target);
1361
+ let original;
1362
+
1363
+ try {
1364
+ original = await vfs.read(target, { maxBytes: 64 * 1024 * 1024, preserveRead: true });
1365
+ } catch (error) {
1366
+ if (error?.code !== "ENOENT") throw error;
1367
+ original = "";
1368
+ }
1369
+ if (original.length > 2 * 1024 * 1024) throw new Error("apply_patch input exceeds 2 MiB; use edit() for targeted replacements");
665
1370
  const { resultText, hunkCount } = applyPatchToText(original, params.patch);
666
1371
  const { speculative } = await vfs.write(target, resultText);
667
1372
  const diff = buildPatchDiff(target, params.patch);
@@ -706,14 +1411,15 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
706
1411
  const cwd = getCwd();
707
1412
 
708
1413
  if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
1414
+ if (tokenizeQuery(params.query).tokens.length > 16) throw new Error("evidence query is too broad; use at most 16 keywords");
709
1415
 
710
1416
  if (signal?.aborted) throw new Error("aborted");
711
1417
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
712
1418
  const options = {};
713
1419
 
714
- if (Number.isInteger(params?.k) && params.k > 0) options.k = params.k;
1420
+ if (Number.isInteger(params?.k) && params.k > 0) options.k = Math.min(params.k, 20);
715
1421
 
716
- if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = params.maxChars;
1422
+ if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
717
1423
  const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
718
1424
 
719
1425
  for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
@@ -725,7 +1431,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
725
1431
  const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
726
1432
 
727
1433
  if (signal?.aborted) throw new Error("aborted");
728
- const text = await vfs.read(target);
1434
+ const text = await vfs.read(target, { maxBytes: 2 * 1024 * 1024 });
729
1435
  const ext = path.extname(target);
730
1436
  const outline = extractStructuralSurface(text, ext);
731
1437
 
@@ -733,8 +1439,9 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
733
1439
  },
734
1440
  async bash(params, signal) {
735
1441
  const cwd = getCwd();
736
- const literal = params?._directArgv === true;
1442
+ const literal = Array.isArray(params?.args) && process.platform !== "win32" && params.args.length === Object.keys(params.args).length && params.args.every(isString);
737
1443
 
1444
+ if (params?.command !== undefined && !isString(params.command)) throw new Error("bash command must be a string");
738
1445
  if (literal && (!isString(params.command) || !Array.isArray(params.args) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
739
1446
  const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
740
1447
 
@@ -757,7 +1464,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
757
1464
  maxOutputChars: config.maxCallResultChars,
758
1465
  });
759
1466
  } catch (error) {
760
- if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message);
1467
+ if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message, signal);
761
1468
  throw error;
762
1469
  } finally {
763
1470
  vfs.invalidateCache();
@@ -769,7 +1476,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
769
1476
  const { stdout, stderr } = res;
770
1477
  let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
771
1478
 
772
- if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text);
1479
+ if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text, signal);
773
1480
 
774
1481
  return {
775
1482
  content: [{ type: "text", text }],
@@ -783,7 +1490,7 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
783
1490
 
784
1491
  if (!pattern) throw new Error("grep requires pattern");
785
1492
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
786
- const indexed = await grepIndexed(index, pattern, params, searchPath, cwd);
1493
+ const indexed = await grepIndexed(index, pattern, params, searchPath, cwd, file => vfs.getOverlay(file), vfs.getOverlayPaths());
787
1494
 
788
1495
  if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
789
1496
  // Large tree: real rg keeps its own output format.
@@ -800,30 +1507,14 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
800
1507
  const pattern = String(params?.pattern || "");
801
1508
 
802
1509
  if (!pattern) throw new Error("glob requires pattern");
803
- const fuzzy = await fuzzyFind(index, cwd, cwd, pattern);
1510
+ const fuzzy = await fuzzyFind(index, cwd, cwd, pattern, 20, vfs.getOverlayPaths());
804
1511
 
805
1512
  if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
806
- const indexed = await listIndexed(index, cwd, cwd, pattern);
1513
+ const indexed = await listIndexed(index, cwd, cwd, pattern, vfs.getOverlayPaths());
807
1514
 
808
1515
  if (indexed !== null) return textResult(indexed, { via: "index" });
809
1516
 
810
- const rg = await runCommand(["rg", "--files", "-g", pattern], { cwd, timeoutMs: 30_000, signal }).catch(
811
- () => null,
812
- );
813
-
814
- if (rg && (rg.exitCode === 0 || rg.exitCode === 1)) {
815
- return textResult(rg.stdout, { via: "rg" });
816
- }
817
-
818
- const findPattern = pattern.startsWith("./") ? pattern : `./${pattern}`;
819
-
820
- const fallback = await runCommand(["find", ".", "-type", "f", "-path", findPattern], {
821
- cwd,
822
- timeoutMs: 30_000,
823
- signal,
824
- });
825
-
826
- return textResult(fallback.stdout, { via: "find" });
1517
+ return listWithTools(cwd, pattern, cwd, signal, vfs.getOverlayPaths());
827
1518
  },
828
1519
  async find(params, signal) {
829
1520
  const cwd = getCwd();
@@ -832,24 +1523,45 @@ function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
832
1523
 
833
1524
  if (signal?.aborted) throw new Error("aborted");
834
1525
  const globPattern = pattern ? String(pattern) : null;
835
- const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern);
1526
+ const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern, 20, vfs.getOverlayPaths());
836
1527
 
837
1528
  if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
838
- const indexed = await listIndexed(index, searchDir, cwd, globPattern);
1529
+ const indexed = await listIndexed(index, searchDir, cwd, globPattern, vfs.getOverlayPaths());
839
1530
 
840
1531
  if (indexed !== null) return textResult(indexed, { via: "index" });
841
1532
 
842
- return listWithTools(searchDir, globPattern, cwd, signal);
1533
+ return listWithTools(searchDir, globPattern, cwd, signal, vfs.getOverlayPaths());
843
1534
  },
844
1535
  async ls(params, signal) {
845
1536
  const cwd = getCwd();
846
1537
  const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
1538
+ const pending = vfs.getOverlay(dirPath);
1539
+
1540
+ if (pending !== undefined) {
1541
+ const entry = formatDirectoryEntry(path.basename(dirPath), "file", Buffer.byteLength(pending, "utf8"));
1542
+
1543
+ return textResult(entry, { path: dirPath, directory: false, count: 1, entries: [entry] });
1544
+ }
1545
+ const stat = await fs.stat(dirPath).catch(() => null);
1546
+
1547
+ if (stat?.isFile()) {
1548
+ const entry = formatDirectoryEntry(path.basename(dirPath), "file", stat.size);
1549
+
1550
+ return textResult(entry, { path: dirPath, directory: false, count: 1, entries: [entry] });
1551
+ }
847
1552
 
848
1553
  return readDirectory(dirPath, signal);
849
1554
  },
850
1555
  };
1556
+
1557
+ return adapters;
851
1558
  }
852
1559
 
1560
+ /**
1561
+ * Fused INVOKE kernel. Guest RPC is the only caller; fuel is cwd + vfs + signal.
1562
+ * BIND stays downward (see tests/contracts/layers.test.mjs). Do not split this
1563
+ * closure into pass-through files that re-import each other.
1564
+ */
853
1565
  export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
854
1566
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
855
1567
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
@@ -932,13 +1644,22 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
932
1644
  const sessionId = ctx?.sessionManager?.getSessionId?.();
933
1645
  boundSessionId = sessionId;
934
1646
  const registry = pi?.pi?.AgentRegistry?.global?.();
935
- hostSession = sessionId && registry?.list
936
- ? registry.list().map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
1647
+ let sessions = [];
1648
+
1649
+ try { sessions = registry?.list?.() ?? []; } catch {}
1650
+ if (!Array.isArray(sessions)) sessions = [];
1651
+ hostSession = sessionId
1652
+ ? sessions.map(ref => ref.session).find(session => !session?.isDisposed && session?.sessionManager?.getSessionId?.() === sessionId) ?? null
937
1653
  : null;
938
1654
  activeSignal = signal;
939
1655
  vfs.signal = signal;
940
1656
  }
941
1657
 
1658
+ function evalToolNames() {
1659
+ try { return hostSession?.getEvalBridgeToolNames?.() ?? []; }
1660
+ catch { return []; }
1661
+ }
1662
+
942
1663
  function hostTool(name) {
943
1664
  if (!hostSession) return undefined;
944
1665
  const metadata = definitions.get(name);
@@ -946,13 +1667,14 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
946
1667
  // Keep Supernova's transactional adapters for ordinary built-ins. Respect overrides.
947
1668
  if (Object.hasOwn(natives, name) && metadata?.sourceInfo?.source === "builtin") return undefined;
948
1669
 
949
- return hostSession.getToolForEvalBridge?.(name);
1670
+ try { return hostSession.getToolForEvalBridge?.(name); }
1671
+ catch { return undefined; }
950
1672
  }
951
1673
 
952
1674
  function isCallable(name) {
953
1675
  if (name === "supernova" || (config.excludeTools ?? []).includes(name)) return false;
954
1676
 
955
- if (hostSession && (hostSession.isDisposed || hostSession.sessionManager.getSessionId() !== boundSessionId)) return false;
1677
+ if (hostSession && (hostSession.isDisposed || hostSession.sessionManager?.getSessionId?.() !== boundSessionId)) return false;
956
1678
 
957
1679
  // An internal adapter belongs to Supernova, not the host's visible tool list.
958
1680
  const nativeOwned = Object.hasOwn(natives, name) && !executors.has(name)
@@ -961,18 +1683,27 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
961
1683
  if (nativeOwned) return true;
962
1684
 
963
1685
  if (hostSession) {
964
- if (!hostSession.getEvalBridgeToolNames().includes(name) && definitions.has(name)) return false;
1686
+ const evalNames = evalToolNames();
1687
+
1688
+ if (!evalNames.includes(name) && definitions.has(name)) return false;
965
1689
 
966
1690
  return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
967
1691
  }
968
1692
 
969
- if (definitions.has(name) && isFunction(pi?.getActiveTools) && !pi.getActiveTools().includes(name)) return false;
1693
+ let activeTools;
1694
+
1695
+ try { activeTools = isFunction(pi?.getActiveTools) ? pi.getActiveTools() : undefined; } catch {}
1696
+
1697
+ if (definitions.has(name) && Array.isArray(activeTools) && !activeTools.includes(name)) return false;
970
1698
 
971
1699
  return executors.has(name) || Object.hasOwn(natives, name);
972
1700
  }
973
1701
 
974
1702
  function refreshTools() {
975
- const tools = pi?.getAllTools?.() ?? [];
1703
+ let listed = [];
1704
+
1705
+ try { listed = pi?.getAllTools?.() ?? []; } catch {}
1706
+ const tools = Array.isArray(listed) ? listed : [];
976
1707
 
977
1708
  for (const tool of tools) {
978
1709
  if (!isString(tool?.name)) continue;
@@ -1018,20 +1749,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1018
1749
  return vfs.rollback();
1019
1750
  }
1020
1751
 
1021
- function resultDiff(response) {
1022
- let details = response?.details;
1023
-
1024
- if (isString(details)) {
1025
- try {
1026
- details = JSON.parse(details);
1027
- } catch {
1028
- return undefined;
1029
- }
1030
- }
1031
-
1032
- return isObject(details) ? details.diff : undefined;
1033
- }
1034
-
1035
1752
  function notifyCall(record) {
1036
1753
  if (!callListener) return;
1037
1754
 
@@ -1073,14 +1790,33 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1073
1790
  if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
1074
1791
  const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
1075
1792
  let previous = "";
1793
+ let removedLines;
1794
+ let stat;
1076
1795
 
1077
- try {
1078
- previous = await vfs.read(target);
1079
- } catch (error) {
1080
- if (error?.code !== "ENOENT") throw error;
1796
+ try { stat = await fs.stat(target); }
1797
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1798
+
1799
+ const overlay = vfs.getOverlay(target);
1800
+ const existingBytes = overlay !== undefined ? Buffer.byteLength(overlay, "utf8") : stat?.size;
1801
+
1802
+ if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
1803
+ if (overlay === undefined) {
1804
+ await vfs.captureExpected(target);
1805
+
1806
+ try { removedLines = await countContentLines(target, activeSignal); }
1807
+ catch (error) { if (error.code !== "ENOENT") throw error; else removedLines = 0; }
1808
+ } else {
1809
+ removedLines = contentLineInfo(overlay).count;
1810
+ }
1811
+ } else {
1812
+ try {
1813
+ previous = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_DIFF_MAX_READ_BYTES, preserveRead: true });
1814
+ } catch (error) {
1815
+ if (error?.code !== "ENOENT") throw error;
1816
+ }
1081
1817
  }
1082
1818
 
1083
- return buildWriteDiff(target, previous, args.content);
1819
+ return removedLines === undefined ? buildWriteDiff(target, previous, args.content) : boundedWriteDiff(target, args.content, removedLines);
1084
1820
  }
1085
1821
 
1086
1822
  function completeRecord(record, res, fallbackDiff) {
@@ -1091,6 +1827,24 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1091
1827
  notifyCall(record);
1092
1828
  }
1093
1829
 
1830
+ function traceArgs(args) {
1831
+ if (!isObject(args)) return {};
1832
+ const out = {};
1833
+
1834
+ for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "op"]) {
1835
+ const value = args[key];
1836
+
1837
+ if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
1838
+ else if (Array.isArray(value)) out[key] = value.slice(0, 128).map(item => isString(item) ? truncateChars(item, 240, "trace").text : typeof item);
1839
+ }
1840
+
1841
+ if (isString(args.content)) out.content = args.content.length + " chars";
1842
+ if (Array.isArray(args.edits)) out.edits = args.edits.length + " edits";
1843
+ if (Array.isArray(args.args)) out.args = args.args.length + " argv";
1844
+
1845
+ return out;
1846
+ }
1847
+
1094
1848
  async function invokeRaw(name, args) {
1095
1849
  checkCallBudget(name);
1096
1850
  const callId = ++sharedRegistry.callSeq;
@@ -1099,15 +1853,17 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1099
1853
  if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
1100
1854
 
1101
1855
  const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
1102
- const record = { name: command, adapter: name, args: args || {}, time: Date.now() };
1856
+ const record = { name: command, adapter: name, args: traceArgs(args), time: Date.now() };
1103
1857
  trace.push(record);
1104
1858
  notifyCall(record);
1105
1859
 
1106
1860
  try {
1107
1861
  const delegated = hostTool(name);
1108
1862
  const exec = delegated ? delegated.execute.bind(delegated) : hostSession ? undefined : executors.get(name);
1109
-
1110
- if (exec) {
1863
+ const argvOwned = name === "bash" && Array.isArray(args?.args) && process.platform !== "win32" && args.args.length === Object.keys(args.args).length && args.args.every(isString);
1864
+ // Explicit overrides own all reads. Options, even false-valued ones,
1865
+ // must not silently bypass the host executor.
1866
+ if (exec && !argvOwned) {
1111
1867
  if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
1112
1868
 
1113
1869
  if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
@@ -1122,7 +1878,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1122
1878
 
1123
1879
  try {
1124
1880
  const res = await exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, delegated
1125
- ? { ...activeCtx, settings: hostSession.settings, toolNames: hostSession.getEvalBridgeToolNames(), autoApprove: false }
1881
+ ? { ...activeCtx, settings: hostSession.settings, toolNames: evalToolNames(), autoApprove: false }
1126
1882
  : activeCtx);
1127
1883
 
1128
1884
  completeRecord(record, res, fallbackDiff);
@@ -1136,7 +1892,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1136
1892
  const native = Object.hasOwn(natives, name) ? natives[name] : undefined;
1137
1893
 
1138
1894
  if (native) {
1139
- const res = await native(args || {}, activeSignal);
1895
+ const res = await native(argvOwned ? { ...(args || {}), args: args.args.map(String) } : args || {}, activeSignal);
1140
1896
  completeRecord(record, res);
1141
1897
 
1142
1898
  return res;
@@ -1158,6 +1914,11 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
1158
1914
  const exitCode = isObject(res?.details) ? res.details.exitCode : undefined;
1159
1915
 
1160
1916
  if (Number.isInteger(exitCode) && exitCode !== 0) record.exitCode = exitCode;
1917
+ const text = isObject(res) && Array.isArray(res.content)
1918
+ ? res.content.filter(part => part?.type === "text" && isString(part.text)).map(part => part.text).join("\n")
1919
+ : undefined;
1920
+
1921
+ if (text) record.resultText = truncateChars(text, 4096, "trace").text;
1161
1922
  }
1162
1923
 
1163
1924
  async function call(name, args) {