pi-supernova 0.10.2 → 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 +365 -90
  2. package/docs/CHANGELOG.md +89 -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 +38 -18
  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 +16 -4
  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 +69 -18
  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
@@ -0,0 +1,72 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import {isString} from '../shared/decode.js';
4
+
5
+ function contained(root, target) {
6
+ const rel = path.relative(root,target);
7
+
8
+ return rel !== '..' && !rel.startsWith('..'+path.sep) && !path.isAbsolute(rel);
9
+ }
10
+
11
+ function validHintPath(hint) {
12
+ return isString(hint?.path) && !!hint.path && hint.path.isWellFormed() && !hint.path.includes('\0');
13
+ }
14
+
15
+ function validHintRange(hint) {
16
+ return Number.isSafeInteger(hint.startLine) && Number.isSafeInteger(hint.endLine) &&
17
+ hint.startLine >= 1 && hint.endLine >= hint.startLine && hint.endLine-hint.startLine < 40;
18
+ }
19
+
20
+ function assertScope(root, scope, target, message) {
21
+ if (!contained(root,target) || !contained(scope,target)) throw new Error(message);
22
+ }
23
+
24
+ async function resolveHint(hint, cwd, scope, root, directory) {
25
+ if (!validHintPath(hint) || !validHintRange(hint)) throw new Error('invalid indexed context range');
26
+ let target = path.resolve(cwd,hint.path);
27
+
28
+ if (!contained(cwd,target) || !contained(scope,target)) {
29
+ // Providers may spell the same workspace with its realpath. Admit only
30
+ // that canonical scope, then restore the caller's logical path for reads.
31
+ assertScope(root,directory,target,'indexed context is outside the requested workspace scope');
32
+ target = path.resolve(scope,path.relative(directory,target));
33
+ }
34
+
35
+ const real = await fs.realpath(target);
36
+ assertScope(root,directory,real,'indexed context escapes workspace scope');
37
+
38
+ return {path:target,line:hint.startLine,endLine:hint.endLine};
39
+ }
40
+
41
+ function contextHints(response) {
42
+ const details = response?.details;
43
+
44
+ if (response?.isError || details?.code !== 'OK' || details.timedOut) throw new Error('indexed search incomplete or unavailable; retry or omit indexed:true');
45
+ const hints = details.contexts;
46
+
47
+ if (!Array.isArray(hints) || hints.length > 8) throw new Error('invalid indexed context metadata');
48
+
49
+ return {details,hints};
50
+ }
51
+
52
+ function sourceSelection(candidates, details, cwd) {
53
+ if (candidates.length === 1 && (!details.truncated || details.contextSelectionComplete === true)) return {status:'found',...candidates[0]};
54
+
55
+ return {status:candidates.length ? 'ambiguous' : 'not_found',path:null,line:null,context:candidates.map(c=>({...c,path:path.relative(cwd,c.path)})),message:'Narrow the indexed query or use an explicit path.'};
56
+ }
57
+
58
+ // Provider metadata is only a locator. The owned reader captures bytes and its
59
+ // CAS baseline itself; rendered text and sourceVersion are never trusted.
60
+ export async function indexedSource(hooks, query, cwd, scope, signal) {
61
+ if (!hooks.indexedSearch) throw new Error('indexed read requires a callable isearch');
62
+ signal?.throwIfAborted();
63
+ const response = await hooks.indexedSearch({query,path:path.relative(cwd,scope)||'.',withContext:true,limit:2,budgetChars:8000});
64
+ signal?.throwIfAborted();
65
+ const {details,hints} = contextHints(response);
66
+ const root = await fs.realpath(cwd), directory = await fs.realpath(scope);
67
+ const candidates = [];
68
+
69
+ for (const hint of hints) candidates.push(await resolveHint(hint,cwd,scope,root,directory));
70
+
71
+ return sourceSelection(candidates,details,cwd);
72
+ }
@@ -18,38 +18,51 @@ export function createTextReader(ctx, readWindow) {
18
18
  async function loadText(targetPath, params, query, signal) {
19
19
  const explicit = isNumber(params.offset) || isNumber(params.limit);
20
20
  const windowed = explicit && params.complete !== true && !isString(params.about) && !isString(query);
21
+
21
22
  if (!windowed) {
22
23
  // Whole reads hash the bytes already loaded. Do not read once for a window
23
24
  // and open/hash the entire file a second time just to return the same text.
24
25
  return {text:await vfs.read(targetPath,{maxBytes:TEXT_MAX_BYTES}),windowed:false};
25
26
  }
27
+
26
28
  const start = isNumber(params.offset) ? Math.max(1,Math.floor(params.offset)) : 1;
27
29
  const count = isNumber(params.limit) ? Math.max(0,Math.floor(params.limit)) : undefined;
28
30
  const window = await readWindow(targetPath,start,count,TEXT_MAX_BYTES,signal);
31
+
29
32
  if (!window.satisfied) throw new Error("read window exceeds " + TEXT_MAX_BYTES + " bytes: " + targetPath + "; request fewer lines or use a bounded parser through bash");
33
+
30
34
  return {text:window.text,windowed:true,windowWhole:window.whole};
31
35
  }
32
36
 
33
37
  async function maybeOutline(cwd, rel, targetPath, text, params, entry) {
34
38
  if (!isString(params.about)) return null;
35
- const outline = entry && outlineFile(entry,rel,params.about,outlineOptions(params,await referenceFinder(cwd,targetPath),config));
39
+ // Focused text has no declarations and cannot use lexical caller hints.
40
+ // Do not make this file-local read wait for unrelated workspace discovery.
41
+ const references = entry && WorkspaceIndex.spansOf(entry).length ? await referenceFinder(cwd,targetPath) : null;
42
+ const outline = entry && outlineFile(entry,rel,params.about,outlineOptions(params,references,config));
43
+
36
44
  if (!outline) return null;
37
45
  recordOutlineOrigins(ledger,rel,outline.text);
46
+
38
47
  return readResult(outline.text,{path:targetPath,outline:true,expanded:outline.expanded,declarations:outline.declarations});
39
48
  }
40
49
 
41
50
  function resolveSpan(entry, sourceLine, query, params) {
42
51
  if (!params.resolve || !isString(query) || !entry) return {offset:params.offset,limit:params.limit,viewComplete:undefined};
43
52
  const span=pickSpan(WorkspaceIndex.spansOf(entry),{line:sourceLine,name:query});
53
+
44
54
  if (!span) return {offset:params.offset,limit:params.limit,viewComplete:undefined};
45
55
  const spanLines=span.end-span.start+1;
46
56
  const limit=isNumber(params.limit) ? Math.min(params.limit,spanLines) : spanLines;
57
+
47
58
  return {offset:span.start,limit,viewComplete:limit>=spanLines};
48
59
  }
49
60
 
50
61
  function textFileResult(rel,targetPath,loaded,span,sliced,firstLine,explicit) {
51
- const lines=sliced.length<=LARGE_FILE_BYTES ? sourceLines(sliced) : null;
62
+ const lines=ledger.window!==0 && sliced.length<=LARGE_FILE_BYTES ? sourceLines(sliced) : null;
63
+
52
64
  if (lines) ledger.recordOrigin(rel,firstLine,lines,explicit);
65
+
53
66
  return readResult(sliced,{path:targetPath,firstLine,lastLine:firstLine+(lines?.length ?? contentLineInfo(sliced).count)-1,
54
67
  sourceChars:sliced.length,complete:loaded.windowed ? loaded.windowWhole : sliced===loaded.text,viewComplete:span.viewComplete});
55
68
  }
@@ -60,12 +73,16 @@ export function createTextReader(ctx, readWindow) {
60
73
  index.touch(rel);
61
74
  const entry=needsSourceIndex(params,query) ? WorkspaceIndex.fromText(targetPath,loaded.text) : null;
62
75
  const outlined=await maybeOutline(getCwd(),rel,targetPath,loaded.text,params,entry);
76
+
63
77
  if (outlined) return outlined;
64
78
  const span=resolveSpan(entry,sourceLine,query,params);
65
79
  const firstLine=isNumber(span.offset) ? Math.max(1,Math.floor(span.offset)) : 1;
66
80
  const sliced=loaded.windowed ? loaded.text : sliceLinesRaw(loaded.text,span.offset,span.limit);
81
+
67
82
  if (params.complete===true && sliced!==loaded.text) throw new Error("complete:true requires the whole file; remove offset/limit for " + rel);
83
+
68
84
  return textFileResult(rel,targetPath,loaded,span,sliced,firstLine,explicit);
69
85
  }
86
+
70
87
  return readTextFile;
71
88
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { isString } from "../shared/decode.js";
4
- import { readResult, asReadResult, READ_VALUE, READ_BYTES, MAX_READ_VALUE_BYTES } from "../shared/result.js";
4
+ import { readResult, asReadResult, READ_VALUE, READ_BYTES, READ_FILES, MAX_READ_VALUE_BYTES } from "../shared/result.js";
5
5
  import { extractStructuralSurface } from "../context/surface.js";
6
6
  import { executeSnap, tokenizeQuery } from "../context/snap.js";
7
7
  import { selectEvidence } from "../context/evidence.js";
@@ -16,6 +16,7 @@ import { projectJson } from "./read-json.js";
16
16
  import { createImageReader } from "./read-image.js";
17
17
  import { createTextReader } from "./read-text.js";
18
18
  import { createFocusedReader } from "./read-focus.js";
19
+ import { indexedSource } from "./read-indexed.js";
19
20
 
20
21
  export function createRead(ctx) {
21
22
  const { getCwd, vfs, config, index, ledger, hooks, reads } = ctx;
@@ -29,19 +30,30 @@ export function createRead(ctx) {
29
30
  params = { ...params, resolve: params.resolve !== false };
30
31
  const cwd = getCwd();
31
32
 
33
+ const pendingPaths = await vfs.getOverlayPaths(searchDir);
34
+
35
+ if (params.indexed && pendingPaths.length === 0) {
36
+ const hint = await indexedSource(hooks, query, cwd, searchDir, signal);
37
+
38
+ if (hint.status !== "found") return readResult(hint, {isSnap:true});
39
+ const opened = await readFile(hint.path, {resolve:true,offset:hint.line,limit:hint.endLine-hint.line+1}, hint.line, relativeSlash(cwd,hint.path), undefined, signal);
40
+
41
+ return openedSource({...hint,path:relativeSlash(cwd,hint.path)}, params, opened.content[0], opened.details);
42
+ }
43
+
32
44
  const includeHidden = path.relative(cwd, searchDir).split(path.sep)
33
45
  .some(segment => segment.startsWith(".") && segment.length > 1);
34
46
 
35
47
  const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
36
48
  pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
37
- overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
49
+ overlayText: p => vfs.getOverlay(p), pendingPaths, signal });
38
50
 
39
51
  return openSource(result, params, signal, undefined, query);
40
52
  }
41
53
 
42
54
  async function sourceIsBounded(target, query, params) {
43
55
  if (!isString(query) || params.complete === true) return false;
44
- const overlay = vfs.getOverlay(target);
56
+ const overlay = await vfs.getOverlay(target);
45
57
 
46
58
  if (overlay !== undefined) return Buffer.byteLength(overlay, "utf8") > 512 * 1024;
47
59
 
@@ -111,12 +123,13 @@ export function createRead(ctx) {
111
123
  raw.isError = true;
112
124
  }
113
125
 
126
+ for (const file of raw[READ_FILES] ?? []) state.files.add(file);
114
127
  state.sourcePaths[index] = raw.details?.sourcePath;
115
128
  await addBatchItem(state,index,raw,onItem);
116
129
  }
117
130
 
118
131
  async function readBatch(params, signal, onItem) {
119
- const state = {items:[],sourcePaths:[],errors:Array(params.path.length).fill(null),bytes:0,streamed:false};
132
+ const state = {items:[],sourcePaths:[],files:new Set(),errors:Array(params.path.length).fill(null),bytes:0,streamed:false};
120
133
 
121
134
  // Delivery/acknowledgement stays inside the same eight-operation scheduler
122
135
  // slot as I/O. A busy guest cannot cause unbounded host/message-queue buffering.
@@ -136,6 +149,8 @@ export function createRead(ctx) {
136
149
 
137
150
  response.isError = params._independent!==true && state.errors.some(Boolean);
138
151
 
152
+ if (!state.streamed) response[READ_FILES] = [...state.files];
153
+
139
154
  return response;
140
155
  }
141
156
 
@@ -152,19 +167,47 @@ export function createRead(ctx) {
152
167
  params = normalizeRead({ ...params, path: targetParam });
153
168
  const existing = needsProbe(params) ? await probeExistingPath(cwd, params.path, vfs) : null;
154
169
  const cls = classifyRead(params, existing);
170
+ const scope = await scopeForRead(params, cwd, cls, signal);
171
+ const staged = await vfs.getOverlay(scope) !== undefined;
172
+ const revision = await vfs.readRevision(scope);
173
+
174
+ try {
175
+ const value = await readSingleUnchecked(params, cwd, cls, scope, signal);
176
+
177
+ if (!staged) await vfs.assertReadCommitted(revision);
178
+ const source = value.details;
179
+
180
+ // Only the native text reader supplies these fields. JSON documents,
181
+ // directories, outlines and empty windows are not text-open receipts.
182
+ if (source?.sourceChars > 0 && isString(source.path) && path.isAbsolute(source.path) && await vfs.getOverlay(source.path) === undefined) value[READ_FILES] = [source.path];
183
+
184
+ return value;
185
+ } finally { vfs.releaseRead(revision); }
186
+ }
187
+
188
+ // Classify once so the observation guard follows the same path/scope as dispatch.
189
+ async function scopeForRead(params, cwd, cls, signal) {
190
+ if (cls.kind === "session") return resolveSessionResource(params.path, signal, hooks);
191
+
192
+ if (cls.kind === "snap") return cls.existing?.directory ? cls.existing.path : cls.scoped ? resolveReadPath(cwd, params.path) : cwd;
193
+
194
+ if (cls.kind === "evidence") return cls.scope ? resolveReadPath(cwd, cls.scope) : cwd;
195
+
196
+ return cls.existing?.path ?? (isString(params.path) ? resolveReadPath(cwd, params.path) : cwd);
197
+ }
198
+
199
+ async function readSingleUnchecked(params, cwd, cls, scope, signal) {
200
+ if (params.indexed && cls.kind !== "snap") throw new Error("indexed:true requires a source query");
155
201
  const relOf = hit => relativeSlash(cwd, hit.path);
156
- const snapScope = (scoped, hit) => hit?.directory ? hit.path : scoped ? resolveReadPath(cwd, params.path) : cwd;
157
202
 
158
203
  const kinds = {
159
204
  session: async () => {
160
- const target = await resolveSessionResource(params.path, signal, hooks);
161
-
162
205
  return params.resolve
163
- ? openSource({ status: "found", path: params.path, line: params.offset ?? 1 }, params, signal, target)
164
- : readFile(target, params, undefined, params.path, undefined, signal);
206
+ ? openSource({ status: "found", path: params.path, line: params.offset ?? 1 }, params, signal, scope)
207
+ : readFile(scope, params, undefined, params.path, undefined, signal);
165
208
  },
166
209
  evidence: () => evidence({ ...params, query: cls.query, path: cls.scope }, signal),
167
- snap: () => sourceRead(cls.query, snapScope(cls.scoped, cls.existing), signal, params),
210
+ snap: () => sourceRead(cls.query, scope, signal, params),
168
211
  outline: () => surface({ path: params.path }, signal),
169
212
  focus: () => focusAbout({ rel: relOf(cls.existing), about: cls.about, overlay: cls.existing.overlay, targetPath: cls.existing.path, signal }),
170
213
  open: () => openSource({ status: "found", path: relOf(cls.existing), line: params.offset ?? 1 }, params, signal),
@@ -223,7 +266,7 @@ export function createRead(ctx) {
223
266
  root: cwd,
224
267
  includeHidden,
225
268
  overlayText: (p) => vfs.getOverlay(p),
226
- pendingPaths: vfs.getOverlayPaths(),
269
+ pendingPaths: await vfs.getOverlayPaths(cwd),
227
270
  signal,
228
271
  });
229
272
 
@@ -249,7 +292,7 @@ export function createRead(ctx) {
249
292
 
250
293
  if (signal?.aborted) throw new Error("aborted");
251
294
  const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
252
- const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options: evidenceOptions(params) });
295
+ const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: await vfs.getOverlayPaths(searchDir), options: evidenceOptions(params) });
253
296
 
254
297
  for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
255
298
 
@@ -258,7 +301,8 @@ export function createRead(ctx) {
258
301
 
259
302
  async function surface(params, signal) {
260
303
  const cwd = getCwd();
261
- const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
304
+ // Outlines are read-only, like text reads; mutations still require workspace admission.
305
+ const target = resolveReadPath(cwd, params?.path);
262
306
 
263
307
  if (signal?.aborted) throw new Error("aborted");
264
308
  const text = await vfs.read(target, { maxBytes: 2 * 1024 * 1024 });
@@ -1,4 +1,5 @@
1
1
  import { relativeSlash } from "../fs/workspace.js";
2
+ import { overlaySnapshot } from "../context/search-files.js";
2
3
 
3
4
  export function outlineOptions(params, references, config) {
4
5
  const options = { references };
@@ -17,24 +18,33 @@ export function recordOutlineOrigins(ledger, rel, outlineText) {
17
18
  }
18
19
  }
19
20
 
20
- /** Where else a name appears (declaration line excluded), for outlines and edit results. */
21
+ /** Where else an expanded outline name appears, excluding its own declaration. */
21
22
  export function createReferenceFinder(index, vfs) {
22
23
  return async function referenceFinder(cwd, targetPath) {
23
24
  let files;
24
25
 
25
- try { files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])]; }
26
+ try { files = [...new Set([...await index.files(cwd), ...await vfs.getOverlayPaths(cwd)])]; }
26
27
  catch { return () => []; }
27
28
 
28
29
  if (!index.canScan(files)) return () => [];
30
+ const overlayText = await overlaySnapshot(files, file => vfs.getOverlay(file));
31
+ const targetRel = relativeSlash(await vfs.resolvePath(cwd), await vfs.resolvePath(targetPath));
29
32
 
30
- return (name, excludeLine) => {
33
+ // Each outline supplies its expanded names together. The row snapshot is
34
+ // call-local; the existing index still owns file validation and body caching.
35
+ let rows;
36
+
37
+ return (name, excludeLine, names) => {
31
38
  if (!name || name.length < 3) return [];
32
39
  const escaped = name.replace(/[$]/g, (c) => "\\" + c);
33
- const regex = new RegExp("\\b" + escaped + "\\b");
40
+ // JavaScript identifiers include $, which is not a regex word character.
41
+ const regex = new RegExp("(?<![\\w$])" + escaped + "(?![\\w$])");
42
+
43
+ rows ??= index.grepRows(files, new RegExp("(?<![\\w$])(?:" + names.filter(name => name.length >= 3)
44
+ .map(name => name.replaceAll("$", "\\$")).join("|") + ")(?![\\w$])"), cwd, overlayText);
34
45
 
35
- return index
36
- .grepRows(files, regex, cwd, file => vfs.getOverlay(file))
37
- .filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
46
+ return rows
47
+ .filter((r) => regex.test(r.text) && !(r.line === excludeLine && r.rel === targetRel))
38
48
  .map((r) => r.rel + ":" + r.line);
39
49
  };
40
50
  };
@@ -50,6 +50,7 @@ export function createWrite(ctx) {
50
50
  const unknown = Object.keys(params ?? {}).filter(key => !WRITE_OPTION_KEYS.includes(key));
51
51
 
52
52
  if (unknown.length) throw new Error("write does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are " + WRITE_OPTION_KEYS.join(", "));
53
+
53
54
  if (!isString(params?.content)) throw new Error("write requires string content");
54
55
  assertWriteAppendFlag(params.append);
55
56
  assertWriteArtifactsFlag(params.allowReadArtifacts);
@@ -62,21 +63,15 @@ export function createWrite(ctx) {
62
63
  return content;
63
64
  }
64
65
 
65
- async function applyAppend(target, content, snap) {
66
- const { overlay, existingBytes } = snap;
67
-
68
- 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");
69
- // Append needs the real content: the diff snapshot may be a lossy decode, and
70
- // concatenating that would silently corrupt a non-UTF-8 file.
71
- let prevText;
72
-
73
- try { prevText = overlay !== undefined ? overlay : await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, preserveRead: true }); }
66
+ async function readAppendBase(target) {
67
+ // Append bytes and receipt text share one strict, bounded read. The VFS
68
+ // supplies staged overlays and preserves any earlier read's CAS baseline.
69
+ try { return await vfs.read(target, { maxBytes: WRITE_APPEND_MAX_READ_BYTES, label: "append input", preserveRead: true }); }
74
70
  catch (error) {
75
71
  if (error.code !== "ENOENT") throw error;
76
- prevText = "";
77
- }
78
72
 
79
- return { content: prevText + content, prevText, removedLines: undefined };
73
+ return "";
74
+ }
80
75
  }
81
76
 
82
77
  async function write(params, signal) {
@@ -85,15 +80,13 @@ export function createWrite(ctx) {
85
80
 
86
81
  if (signal?.aborted) throw new Error("aborted");
87
82
  let content = assertWriteParams(params);
88
- const snap = await writeSnapshot(vfs, target, signal);
89
- let { previous: prevText, removedLines } = snap;
90
-
91
- if (params.append === true) {
92
- const appended = await applyAppend(target, content, snap);
93
- content = appended.content;
94
- prevText = appended.prevText;
95
- removedLines = appended.removedLines;
96
- }
83
+ const appending = params.append === true;
84
+
85
+ const { previous: prevText, removedLines } = appending
86
+ ? { previous: await readAppendBase(target) }
87
+ : await writeSnapshot(vfs, target, signal);
88
+
89
+ if (appending) content = prevText + content;
97
90
 
98
91
  const { speculative } = await vfs.write(target, content);
99
92
  index.touch(relativeSlash(cwd, target));