pi-supernova 0.10.1 → 0.11.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.
Files changed (81) hide show
  1. package/README.md +376 -221
  2. package/docs/CHANGELOG.md +107 -5
  3. package/docs/TOKEN_COSTS.md +149 -324
  4. package/index.js +40 -11
  5. package/package.json +4 -3
  6. package/src/adapters/bash.js +40 -10
  7. package/src/adapters/edit.js +77 -23
  8. package/src/adapters/errors.js +4 -0
  9. package/src/adapters/index.js +1 -0
  10. package/src/adapters/list.js +6 -3
  11. package/src/adapters/read-focus.js +6 -0
  12. package/src/adapters/read-image.js +17 -9
  13. package/src/adapters/read-indexed.js +72 -0
  14. package/src/adapters/read-text.js +19 -2
  15. package/src/adapters/read.js +57 -13
  16. package/src/adapters/refs.js +17 -7
  17. package/src/adapters/write.js +14 -21
  18. package/src/bridge/host-bridge.js +152 -39
  19. package/src/bridge/invoke.js +3 -1
  20. package/src/bridge/tool-registry.js +132 -16
  21. package/src/context/evidence-graph.js +1 -0
  22. package/src/context/evidence-rank.js +1 -0
  23. package/src/context/evidence.js +4 -2
  24. package/src/context/fuzzy.js +31 -8
  25. package/src/context/outline.js +33 -9
  26. package/src/context/query.js +7 -2
  27. package/src/context/repo-index.js +8 -3
  28. package/src/context/search-files.js +20 -0
  29. package/src/context/search.js +25 -10
  30. package/src/context/snap-search.js +28 -6
  31. package/src/context/snap.js +8 -3
  32. package/src/context/source-entry.js +64 -28
  33. package/src/context/spans.js +1 -0
  34. package/src/contract/bash.js +16 -7
  35. package/src/contract/edit.js +26 -7
  36. package/src/contract/program.js +2 -1
  37. package/src/contract/read.js +46 -46
  38. package/src/fs/background.js +10 -2
  39. package/src/fs/check.js +844 -46
  40. package/src/fs/commit.js +89 -15
  41. package/src/fs/diff.js +12 -9
  42. package/src/fs/directory.js +14 -4
  43. package/src/fs/file-io.js +28 -7
  44. package/src/fs/glob.js +93 -11
  45. package/src/fs/json-read.js +3 -2
  46. package/src/fs/json-size.js +2 -1
  47. package/src/fs/lines.js +49 -2
  48. package/src/fs/patch.js +26 -10
  49. package/src/fs/process-tree.js +19 -1
  50. package/src/fs/read-window.js +36 -20
  51. package/src/fs/source-window.js +5 -10
  52. package/src/fs/text-ops.js +39 -19
  53. package/src/fs/vfs.js +296 -41
  54. package/src/fs/workspace.js +24 -6
  55. package/src/output/bottleneck.js +50 -25
  56. package/src/output/final.js +20 -0
  57. package/src/output/format.js +97 -36
  58. package/src/output/outcome.js +25 -9
  59. package/src/runtime/batch-input.js +17 -3
  60. package/src/runtime/guest-api.js +32 -7
  61. package/src/runtime/guest-worker.js +22 -4
  62. package/src/runtime/parallel.js +9 -49
  63. package/src/runtime/program-batch.js +53 -28
  64. package/src/runtime/program-file.js +2 -1
  65. package/src/runtime/program.js +94 -41
  66. package/src/runtime/reference.js +10 -9
  67. package/src/runtime/runtime.js +86 -17
  68. package/src/runtime/worker-pool.js +10 -1
  69. package/src/shared/decode.js +9 -8
  70. package/src/shared/image-worker.js +7 -0
  71. package/src/shared/image.js +58 -15
  72. package/src/shared/png.js +18 -0
  73. package/src/shared/result.js +30 -9
  74. package/src/shared/syntax-context.js +7 -1
  75. package/src/shared/utf8.js +11 -13
  76. package/src/ui/host-render.js +4 -0
  77. package/src/ui/omp-frame.js +2 -0
  78. package/src/ui/progress.js +2 -1
  79. package/src/ui/render-measure.js +11 -0
  80. package/src/ui/render.js +92 -31
  81. package/src/ui/trace.js +8 -5
package/src/fs/patch.js CHANGED
@@ -78,8 +78,8 @@ function splitFile(text) {
78
78
  });
79
79
  }
80
80
 
81
- function findHunkMatch(fileLines, expectedOld, nominal, hunk, oldStart) {
82
- const matchAt = index => index >= 0 && index + expectedOld.length <= fileLines.length
81
+ function findHunkMatch(fileLines, expectedOld, nominal, hunk, oldStart, floor) {
82
+ const matchAt = index => index >= floor && index + expectedOld.length <= fileLines.length
83
83
  && expectedOld.every((line, i) => fileLines[index + i].text === line);
84
84
 
85
85
  if (matchAt(nominal)) return { index: nominal, relocated: 0 };
@@ -102,8 +102,11 @@ function findHunkMatch(fileLines, expectedOld, nominal, hunk, oldStart) {
102
102
  return { index: -1, relocated: 0 };
103
103
  }
104
104
 
105
- function hunkLineText(line) {
106
- return line.slice(1).replace(/\r$/, "");
105
+ function hunkLineText(hunk, i) {
106
+ const text = hunk.lines[i].slice(1);
107
+
108
+ // Without an original newline, a trailing CR is payload, not a CRLF ending.
109
+ return hunk.noNewline.includes(i) ? text : text.replace(/\r$/, "");
107
110
  }
108
111
 
109
112
  function applyHunkLines(hunk, fileLines, matchIndex, ending) {
@@ -114,7 +117,7 @@ function applyHunkLines(hunk, fileLines, matchIndex, ending) {
114
117
  const line = hunk.lines[i];
115
118
 
116
119
  if (line[0] === "+") {
117
- replacement.push({ text: hunkLineText(line), ending: hunk.noNewline.includes(i) ? "" : ending });
120
+ replacement.push({ text: hunkLineText(hunk, i), ending: hunk.noNewline.includes(i) ? "" : ending });
118
121
  } else {
119
122
  const original = fileLines[oldIndex++];
120
123
 
@@ -127,23 +130,33 @@ function applyHunkLines(hunk, fileLines, matchIndex, ending) {
127
130
  return replacement;
128
131
  }
129
132
 
130
- function applyOneHunk(hunk, h, fileLines, offsetShift, relocationShift, ending) {
131
- const expectedOld = hunk.lines.filter(line => line[0] !== "+").map(hunkLineText);
133
+ function applyOneHunk(hunk, h, fileLines, offsetShift, relocationShift, ending, floor) {
134
+ const expectedOld = hunk.lines.flatMap((line, i) => line[0] !== "+" ? [hunkLineText(hunk, i)] : []);
132
135
  const newCount = hunk.lines.filter(line => line[0] !== "-").length;
133
136
 
134
137
  if (expectedOld.length !== hunk.oldLength || newCount !== hunk.newLength) throw new Error("patch hunk " + (h + 1) + " length does not match its header");
135
138
  // The new coordinate also handles BSD diff's -1,0 header at file start.
136
139
  const nominal = hunk.oldLength === 0 ? hunk.newStart - 1 + relocationShift : hunk.oldStart - 1 + offsetShift;
137
- const match = findHunkMatch(fileLines, expectedOld, nominal, h + 1, hunk.oldStart);
140
+ const match = findHunkMatch(fileLines, expectedOld, nominal, h + 1, hunk.oldStart, floor);
138
141
 
139
142
  if (match.index < 0) throw new Error("patch hunk " + (h + 1) + " rejected at line " + hunk.oldStart + ": context did not match");
140
143
  const replacement = applyHunkLines(hunk, fileLines, match.index, ending);
141
- fileLines.splice(match.index, expectedOld.length, ...replacement);
144
+ const tailEnd = fileLines.length;
145
+ const length = tailEnd + replacement.length - expectedOld.length;
146
+
147
+ // Valid hunks can exceed the engine's argument-count limit. Move the tail
148
+ // in place rather than passing every replacement line as a splice argument.
149
+ if (length > tailEnd) fileLines.length = length;
150
+ fileLines.copyWithin(match.index + replacement.length, match.index + expectedOld.length, tailEnd);
151
+ fileLines.length = length;
152
+
153
+ for (let i = 0; i < replacement.length; i++) fileLines[match.index + i] = replacement[i];
142
154
 
143
155
  return {
144
156
  relocationShift: relocationShift + match.relocated,
145
157
  offsetShift: offsetShift + match.relocated + replacement.length - expectedOld.length,
146
158
  relocated: match.relocated,
159
+ floor: match.index + replacement.length,
147
160
  };
148
161
  }
149
162
 
@@ -155,11 +168,14 @@ export function applyPatchToText(originalText, patchText) {
155
168
  const relocations = [];
156
169
  let offsetShift = 0;
157
170
  let relocationShift = 0;
171
+ // Later hunks cannot reinterpret already-published output as original input.
172
+ let floor = 0;
158
173
 
159
174
  for (let h = 0; h < hunks.length; h++) {
160
- const applied = applyOneHunk(hunks[h], h, fileLines, offsetShift, relocationShift, ending);
175
+ const applied = applyOneHunk(hunks[h], h, fileLines, offsetShift, relocationShift, ending, floor);
161
176
  offsetShift = applied.offsetShift;
162
177
  relocationShift = applied.relocationShift;
178
+ floor = applied.floor;
163
179
 
164
180
  if (applied.relocated !== 0) relocations.push({ hunk: h + 1, offset: applied.relocated });
165
181
  }
@@ -54,6 +54,24 @@ function signalGroups(job, signal) {
54
54
  }
55
55
  }
56
56
 
57
+ function waitForRetirement(job) {
58
+ if (job.processClosed) return delay(10);
59
+
60
+ // A child normally closes its pipes just after exit. Wake on that event rather
61
+ // than charging every successful command a polling interval. Keep a fallback
62
+ // for stuck pipes; group disappearance is still checked by the caller.
63
+ return new Promise(resolve => {
64
+ const done = () => {
65
+ clearTimeout(timer);
66
+ job.child.off("close", done);
67
+ resolve();
68
+ };
69
+
70
+ const timer = setTimeout(done, 10);
71
+ job.child.once("close", done);
72
+ });
73
+ }
74
+
57
75
  // Both foreground and background commands own their POSIX groups until they
58
76
  // disappear, not merely until the leader exits or inherited output pipes close.
59
77
  // Callers update processExited/processClosed from the real child events and may
@@ -85,7 +103,7 @@ export async function retireProcessTree(job) {
85
103
  if (Date.now() >= deadline) throw new Error("owned process group or output pipes did not close" + (job.signalError ? ": " + job.signalError : ""));
86
104
 
87
105
  if (process.platform !== "win32") signalGroups(job, "SIGKILL");
88
- await delay(10);
106
+ await waitForRetirement(job);
89
107
  }
90
108
 
91
109
  if (discoveryError) throw new Error("descendant discovery failed: " + discoveryError.message);
@@ -16,15 +16,18 @@ function advanceLines(bytes, start, remaining) {
16
16
  return { offset: start, remaining };
17
17
  }
18
18
 
19
- async function scanWindow(file, stat, startLine, lineCount, maxBytes, signal) {
20
- const scan = { parts: [], collected: 0, startByte: undefined, endByte: undefined };
21
- let skip = startLine - 1, take = lineCount ?? Infinity, position = 0;
22
-
23
- for await (const chunk of fileChunks(file, signal, stat.size)) {
19
+ async function scanWindow(file, startLine, lineCount, maxBytes, signal) {
20
+ const scan = { parts: [], collected: 0, startByte: undefined, endByte: undefined, readBytes: 0, eof: false };
21
+ // Empty windows need an emptiness probe, not a seek to the requested line.
22
+ let skip = lineCount === 0 ? 0 : startLine - 1, take = lineCount ?? Infinity;
23
+
24
+ // Regular virtual files can report zero, undersized or oversized stat lengths.
25
+ // Only the read stream establishes EOF and whether a selected window is whole.
26
+ for await (const chunk of fileChunks(file, signal, Infinity, lineCount === 0 ? 1 : undefined)) {
24
27
  const head = advanceLines(chunk, 0, skip);
25
28
  skip = head.remaining;
26
- const start = position;
27
- position += chunk.length;
29
+ const start = scan.readBytes;
30
+ scan.readBytes += chunk.length;
28
31
 
29
32
  if (skip) continue;
30
33
  scan.startByte ??= start + head.offset;
@@ -39,9 +42,11 @@ async function scanWindow(file, stat, startLine, lineCount, maxBytes, signal) {
39
42
  scan.collected += end - head.offset;
40
43
  }
41
44
 
42
- if (take === 0 || scan.collected > maxBytes) break;
45
+ if (scan.collected > maxBytes || take === 0 && tail.offset < chunk.length) return scan;
43
46
  }
44
47
 
48
+ scan.eof = true;
49
+
45
50
  return scan;
46
51
  }
47
52
 
@@ -59,7 +64,7 @@ async function openReadFile(target) {
59
64
 
60
65
  export function createWindowReader(vfs) {
61
66
  return async function readWindow(target, startLine, lineCount, maxBytes, signal) {
62
- const overlay = vfs.getOverlay(target);
67
+ const overlay = await vfs.getOverlay(target);
63
68
 
64
69
  if (overlay !== undefined) {
65
70
  const window = overlayWindow(overlay,startLine,lineCount);
@@ -70,22 +75,33 @@ export function createWindowReader(vfs) {
70
75
 
71
76
  const file = await openReadFile(target);
72
77
 
78
+ let stat, result, wholeBytes;
79
+
73
80
  try {
74
- const stat = await file.stat();
81
+ stat = await file.stat();
75
82
 
76
83
  if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
77
84
 
78
- if (lineCount === 0) return { text: "", satisfied: true, whole: stat.size === 0 };
79
- const scan = await scanWindow(file, stat, startLine, lineCount, maxBytes, signal);
85
+ const scan = await scanWindow(file, startLine, lineCount, maxBytes, signal);
86
+
87
+ if (scan.startByte === undefined) {
88
+ result = { text: "", satisfied: true, whole: scan.eof && scan.readBytes === 0 };
89
+ } else {
90
+ if (scan.collected > maxBytes) return { text: "", satisfied: false, whole: false };
91
+ const bytes = Buffer.concat(scan.parts, scan.collected);
92
+ const end = scan.startByte + scan.collected;
93
+ const eof = scan.eof;
94
+ const text = eof ? decodeUtf8Strict(bytes, target) : decodeUtf8Window(bytes, target);
95
+ const whole = startLine === 1 && scan.startByte === 0 && eof;
96
+ // The digest-reuse contract requires bytes agreeing with observed size.
97
+ wholeBytes = whole && bytes.length === stat.size ? bytes : undefined;
98
+ result = { text, satisfied: end >= scan.endByte || eof, whole };
99
+ }
100
+ } finally { await file.close(); }
80
101
 
81
- if (scan.startByte === undefined) return { text: "", satisfied: true, whole: stat.size === 0 };
82
- const bytes = Buffer.concat(scan.parts, scan.collected);
83
- const end = scan.startByte + scan.collected;
84
- const eof = end >= stat.size;
85
- const text = eof ? decodeUtf8Strict(bytes, target) : decodeUtf8Window(bytes);
86
- await vfs.recordExpected(target, stat);
102
+ // An open read handle can prevent Windows from restoring a rollback backup.
103
+ await vfs.recordExpected(target, stat, wholeBytes);
87
104
 
88
- return { text, satisfied: end >= scan.endByte || eof, whole: startLine === 1 && scan.startByte === 0 && eof };
89
- } finally { await file.close(); }
105
+ return result;
90
106
  };
91
107
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { relativeSlash } from "./workspace.js";
4
+ import { sameFileVersion, readLimitedBytes } from "./file-io.js";
4
5
 
5
6
  export 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;
6
7
 
@@ -15,18 +16,11 @@ async function readBoundedFile(real, rootPrefix, signal) {
15
16
  const stat = await handle.stat();
16
17
 
17
18
  if (!real.startsWith(rootPrefix) || !stat.isFile() || stat.size > 1024 * 1024) return null;
18
- const buffer = Buffer.alloc(Math.min(stat.size, 1024 * 1024));
19
- let offset = 0;
19
+ const buffer = await readLimitedBytes(handle, stat, 1024 * 1024, "diagnostic input", signal);
20
20
 
21
- while (offset < buffer.length) {
22
- const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
21
+ if (!sameFileVersion(stat, await handle.stat())) return null;
23
22
 
24
- if (bytesRead <= 0) break;
25
- offset += bytesRead;
26
- signal?.throwIfAborted();
27
- }
28
-
29
- return buffer.subarray(0, offset).toString("utf8");
23
+ return buffer.toString("utf8");
30
24
  } finally { await handle.close(); }
31
25
  }
32
26
 
@@ -71,6 +65,7 @@ export async function sourceForReferences(cwd, commandCwd, output, signal, ledge
71
65
  const key = m[1] + ":" + m[2];
72
66
 
73
67
  if (seen.has(key)) continue;
68
+
74
69
  if (seen.size >= 4) break;
75
70
  seen.add(key);
76
71
  const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]), signal, ledger);
@@ -1,9 +1,15 @@
1
1
  import { fileChunks } from "./file-io.js";
2
+
2
3
  export {MAX_DIRECTORY_ENTRIES,formatDirectoryEntry,formatLsEntry} from './directory.js';
4
+
3
5
  export {textResult,resultDiff} from '../shared/result.js';
6
+
4
7
  import {sliceLinesRaw,lineNumberAt,formatNumberedLine,numberedPreview,lineStartIndex,lineEndIndex,lineTextRange,contentLineInfo} from './lines.js';
8
+
5
9
  export * from './lines.js';
10
+
6
11
  export {jsonStringLength,maxJsonStringPrefix} from './json-size.js';
12
+
7
13
  import * as fs from "node:fs/promises";
8
14
  import * as path from "node:path";
9
15
  import { homedir } from "node:os";
@@ -27,13 +33,13 @@ export function normalizeReadWindow(params) {
27
33
  const limit = readLineParam(params.limit, "limit");
28
34
 
29
35
  if (offset !== undefined) normalized.offset = Math.max(1, offset);
36
+
30
37
  if (limit !== undefined) normalized.limit = Math.max(0, limit);
31
38
 
32
39
  return normalized;
33
40
  }
34
41
 
35
42
  export function resolveReadPath(cwd, target) {
36
- if (!isString(target) || !target.trim()) throw new Error("read requires path");
37
43
  const input = assertFilesystemPath(target, "read");
38
44
 
39
45
  return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
@@ -42,8 +48,9 @@ export function resolveReadPath(cwd, target) {
42
48
  export async function probeExistingPath(cwd, targetParam, vfs) {
43
49
  const targetPath = resolveReadPath(cwd, targetParam);
44
50
 
45
- if (vfs.getOverlay(targetPath) !== undefined) {
46
- const overlay = vfs.getOverlay(targetPath);
51
+ const overlay = await vfs.getOverlay(targetPath);
52
+
53
+ if (overlay !== undefined) {
47
54
 
48
55
  return { path: targetPath, directory: false, size: Buffer.byteLength(overlay, "utf8"), overlay };
49
56
  }
@@ -55,7 +62,7 @@ export async function probeExistingPath(cwd, targetParam, vfs) {
55
62
  } catch (err) {
56
63
  if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
57
64
 
58
- if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
65
+ if ((await vfs.getOverlayPaths(targetPath)).some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
59
66
 
60
67
  return null;
61
68
  }
@@ -74,7 +81,7 @@ function duplicateEditError(target, content, index, second) {
74
81
  return 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, lineAt(content, a)) + "\n" + formatNumberedLine(b, lineAt(content, b)));
75
82
  }
76
83
 
77
- /** Exact bytes at the closest guess: a byte-for-byte miss is usually indentation drift. */
84
+ /** Show nearby exact bytes without guessing why the multi-line match failed. */
78
85
  function nearMissPreview(content, oldText) {
79
86
  const first = String(oldText).split("\n").find(line => line.trim().length > 0);
80
87
 
@@ -87,12 +94,13 @@ function nearMissPreview(content, oldText) {
87
94
  const from = lineStartIndex(content, start);
88
95
  const shown = content.slice(from, lineEndIndex(content, from, Math.min(5, line - start + 3))).replace(/\r?\n$/, "");
89
96
 
90
- return "first oldText line matches line " + line + " only after trimming; exact bytes there:\n"
97
+ return "oldText anchor near line " + line + "; exact bytes there:\n"
91
98
  + shown.split("\n").map((text, index) => formatNumberedLine(start + index, text)).join("\n");
92
99
  }
100
+
93
101
  function matchReplacement(target, content, replacement) {
94
102
  if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
95
- throw new Error("edit requires non-empty oldText");
103
+ throw new Error("edit requires non-empty oldText; to insert, include adjacent existing text in oldText and repeat it in newText");
96
104
  }
97
105
 
98
106
  if (!isString(replacement?.newText)) throw new Error("edit requires newText");
@@ -103,6 +111,7 @@ function matchReplacement(target, content, replacement) {
103
111
  if (index < 0) {
104
112
  throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + (nearMissPreview(content, oldText) ?? numberedPreview(content)));
105
113
  }
114
+
106
115
  const second = content.indexOf(oldText, index + 1);
107
116
 
108
117
  if (second >= 0) throw duplicateEditError(target, content, index, second);
@@ -110,31 +119,38 @@ function matchReplacement(target, content, replacement) {
110
119
  return { ...replacement, oldText, newText, index, end: index + oldText.length };
111
120
  }
112
121
 
113
- function assertNoOverlap(target, matches) {
122
+ function assertNoOverlap(target, content, matches) {
114
123
  for (let i = 1; i < matches.length; i++) {
115
- if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
124
+ if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}: edits ${matches[i - 1].editNumber} and ${matches[i].editNumber} overlap at line ${lineNumberAt(content, matches[i].index)}; combine overlapping replacements or apply them sequentially against updated text`);
116
125
  }
117
126
  }
118
127
 
119
128
  export function applyReplacements(target, content, requestedEdits) {
120
129
  if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
130
+
121
131
  const matches = requestedEdits.map((replacement, index) => {
122
- try { return matchReplacement(target, content, replacement); }
132
+ try { return { ...matchReplacement(target, content, replacement), editNumber: index + 1 }; }
123
133
  catch (error) {
124
134
  // Name the failing entry: a multi-edit miss is otherwise a guessing game.
125
135
  throw requestedEdits.length === 1 ? error : new Error("edit " + (index + 1) + " of " + requestedEdits.length + ": " + error.message);
126
136
  }
127
137
  });
138
+
128
139
  matches.sort((a, b) => a.index - b.index);
129
- assertNoOverlap(target, matches);
130
- let updated = content;
140
+ assertNoOverlap(target, content, matches);
141
+ const parts = [];
142
+ let from = 0;
131
143
 
132
- for (let i = matches.length - 1; i >= 0; i--) {
133
- const match = matches[i];
134
- updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
144
+ // All coordinates refer to the original. Assemble once instead of copying
145
+ // the growing document for every replacement; inserted text is never rematched.
146
+ for (const match of matches) {
147
+ parts.push(content.slice(from, match.index), match.newText);
148
+ from = match.end;
135
149
  }
136
150
 
137
- return { updated, matches };
151
+ parts.push(content.slice(from));
152
+
153
+ return { updated: parts.join(""), matches };
138
154
  }
139
155
 
140
156
  export function shiftDiffLines(diff, delta) {
@@ -162,6 +178,7 @@ function withLineEnding(insert, ending) {
162
178
 
163
179
  function viewInsertText(content, startIndex, endIndex, newText) {
164
180
  const hasSuffix = endIndex < content.length;
181
+
165
182
  // A view replaces whole source lines. Preserve the separator before following
166
183
  // lines, but let an explicit trailing newline change a no-trailing-newline EOF.
167
184
  if (hasSuffix) return content.slice(0, startIndex) + withLineEnding(newText, suffixSeparator(content, endIndex)) + content.slice(endIndex);
@@ -193,7 +210,9 @@ export function applyViewReplace(target, content, start, end, oldText, newText)
193
210
  }
194
211
 
195
212
  export const WRITE_DIFF_MAX_READ_BYTES = 512 * 1024;
213
+
196
214
  export const WRITE_APPEND_MAX_READ_BYTES = 64 * 1024 * 1024;
215
+
197
216
  export const QUICK_CHECK_MAX_CHARS = 2 * 1024 * 1024;
198
217
 
199
218
  async function snapshotLargeFile(vfs, target, overlay, signal) {
@@ -230,14 +249,14 @@ async function existingStat(target) {
230
249
  /** Prior body (or line count) for a write receipt / CAS, without always materializing huge files. */
231
250
  export async function writeSnapshot(vfs, target, signal) {
232
251
  const stat = await existingStat(target);
233
- const overlay = vfs.getOverlay(target);
252
+ const overlay = await vfs.getOverlay(target);
234
253
  const existingBytes = overlay !== undefined ? Buffer.byteLength(overlay, "utf8") : stat?.size;
235
254
 
236
255
  if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
237
- return { previous: "", removedLines: await snapshotLargeFile(vfs, target, overlay, signal), overlay, existingBytes };
256
+ return { previous: "", removedLines: await snapshotLargeFile(vfs, target, overlay, signal) };
238
257
  }
239
258
 
240
- return { previous: await snapshotSmallFile(vfs, target, overlay), removedLines: undefined, overlay, existingBytes };
259
+ return { previous: await snapshotSmallFile(vfs, target, overlay), removedLines: undefined };
241
260
  }
242
261
 
243
262
  export async function countContentLines(target, signal) {
@@ -280,6 +299,7 @@ export function boundedEditDiff(target, original, matches) {
280
299
  const nextStart = start + shift;
281
300
 
282
301
  for (let i = 0; i < oldInfo.preview.length; i++) lines.push({ type: "remove", lineNum: start + i, newLineNum: nextStart + i, text: oldInfo.preview[i] });
302
+
283
303
  for (let i = 0; i < newInfo.preview.length; i++) lines.push({ type: "add", lineNum: nextStart + i, newLineNum: nextStart + i, text: newInfo.preview[i] });
284
304
 
285
305
  shift += newInfo.newlines - oldInfo.newlines;