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/index.js CHANGED
@@ -10,6 +10,7 @@ import { REFERENCE } from "./src/runtime/reference.js";
10
10
  import { errorMessage, isString } from "./src/shared/decode.js";
11
11
  import { loadConfig } from "./src/config/config.js";
12
12
  import { createHostBridge } from "./src/bridge/host-bridge.js";
13
+ import { NATIVE_NAMES } from "./src/bridge/native-tools.js";
13
14
 
14
15
  import { runGuestProgram, warmGuestWorker, stopWarmGuestWorker } from "./src/runtime/runtime.js";
15
16
  import { renderSupernovaCall, renderSupernovaResult } from "./src/ui/render.js";
@@ -65,14 +66,12 @@ export function registerCodeMode(pi) {
65
66
  function makeNovaApi(runBridge, cancel) {
66
67
  return {
67
68
  call: (name, args, onItem) => runBridge.call(name, args, onItem),
68
- callMany: (calls) => runBridge.callMany(calls),
69
69
  speculateBegin: () => runBridge.barrier(() => runBridge.beginSpeculation()),
70
70
  speculateCommit: () => runBridge.barrier(() => runBridge.commitSpeculation()),
71
71
  speculateRollback: () => runBridge.barrier(() => runBridge.rollbackSpeculation()),
72
- names: () => ["read", "edit", "write", "bash"],
72
+ names: () => NATIVE_NAMES,
73
73
  describeMemory: () => runBridge.describeMemory?.() ?? null,
74
74
  batchRead: runBridge.supportsBatchRead(),
75
- nativeArgv: runBridge.supportsNativeArgv?.() === true,
76
75
  cancel,
77
76
  };
78
77
  }
@@ -102,16 +101,18 @@ export function registerCodeMode(pi) {
102
101
  }
103
102
 
104
103
  async function runAndCommit(params, runCwd, runBridge, abortRun, runController, budget) {
104
+ const started = performance.now();
105
+ const timeoutMs = params?.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs);
105
106
  refreshCatalog(runBridge);
106
107
  runBridge.beginSpeculation();
107
108
 
108
- const outcome = await runGuestProgram({
109
+ let outcome = await runGuestProgram({
109
110
  code: params?.code,
110
111
  file: params?.file,
111
112
  cwd: runCwd,
112
113
  data: params?.data,
113
114
  nova: makeNovaApi(runBridge, abortRun),
114
- config: { ...config, maxLogLines: Math.max(0,config.maxLogLines-(budget?.logLines ?? 0)), timeoutMs: params?.timeoutMs === undefined ? config.timeoutMs : Number(params.timeoutMs) },
115
+ config: { ...config, maxLogLines: Math.max(0,config.maxLogLines-(budget?.logLines ?? 0)), timeoutMs },
115
116
  signal: runController.signal,
116
117
  onTimeout: abortRun,
117
118
  });
@@ -119,14 +120,35 @@ export function registerCodeMode(pi) {
119
120
  runBridge.close();
120
121
 
121
122
  if (outcome.ok) {
122
- if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished edit checkpoint; await it before returning");
123
- await runBridge.commitSpeculation();
123
+ try {
124
+ if (runBridge.getOverlayDepth() !== 1) throw new Error("program ended with an unfinished edit checkpoint; await it before returning");
125
+ await commitBeforeDeadline(runBridge, runController, timeoutMs, started);
126
+ } catch (error) {
127
+ abortRun();
128
+ outcome = {ok:false,error:errorMessage(error),logs:outcome.logs,logTruncated:outcome.logTruncated};
129
+ }
124
130
  }
125
- else while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
131
+
132
+ if (!outcome.ok) while (runBridge.getOverlayDepth()) runBridge.rollbackSpeculation();
133
+ outcome.wallMs = Math.round(performance.now() - started);
126
134
 
127
135
  return outcome;
128
136
  }
129
137
 
138
+ async function commitBeforeDeadline(runBridge, controller, requestedTimeout, started) {
139
+ // The guest has finished, but its public invocation still owns the queued
140
+ // and on-disk commit. Carry the same admitted timeout through this phase.
141
+ const timeout = Math.max(1, Math.min(2147483647, Math.floor(requestedTimeout)));
142
+ const remaining = timeout - (performance.now() - started);
143
+ const expire = () => controller.abort(new Error("program timed out after " + Math.round(performance.now()-started) + "ms during final commit (timeoutMs=" + timeout + ")"));
144
+ const timer = remaining > 0 ? setTimeout(expire, remaining) : undefined;
145
+
146
+ if (remaining <= 0) expire();
147
+
148
+ try { await runBridge.commitSpeculation(); }
149
+ finally { clearTimeout(timer); }
150
+ }
151
+
130
152
  function scheduleWarm(runController) {
131
153
  cancelWarmTimer();
132
154
 
@@ -160,9 +182,9 @@ export function registerCodeMode(pi) {
160
182
  const visible = runBridge.ledger.dedupe(bounded, call);
161
183
 
162
184
  const response = result(visible, {
163
- ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
185
+ ok: outcome.ok, partial: !outcome.ok && outcome.mutations.committed > 0, error: outcome.error, wallMs: outcome.wallMs,
164
186
  returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
165
- logs: outcome.logs, result: outcome.result, trace, mutations: outcome.mutations,
187
+ logs: outcome.logs, result: outcome.result, trace, mutations: outcome.mutations, savedPaths: outcome.savedPaths,
166
188
  });
167
189
 
168
190
  if (outcome.images?.length) response.content.push(...outcome.images);
@@ -172,6 +194,7 @@ export function registerCodeMode(pi) {
172
194
 
173
195
  pi.registerTool({
174
196
  name: "supernova",
197
+ exposure: "model-only",
175
198
  label: "Supernova",
176
199
  description: TOOL_DESCRIPTION,
177
200
  promptSnippet: "read, write, edit, bash",
@@ -230,6 +253,12 @@ export function registerCodeMode(pi) {
230
253
  },
231
254
  });
232
255
 
256
+ // Pi treats resolved execute() calls as successful, ignoring result.isError.
257
+ // Keep partial batch results/images intact and correct the final host status.
258
+ pi.on("tool_result", event => {
259
+ if (event.toolName === "supernova" && event.details?.ok === false) return { isError: true };
260
+ });
261
+
233
262
  // This is a pre-conversion observation, not a final-payload retention proof.
234
263
  // Shipping seenWindow:0 must not subscribe: a no-op listener still runs on every
235
264
  // provider context event. Opt-in windows register here.
@@ -263,7 +292,7 @@ export function registerCodeMode(pi) {
263
292
  handler: async (_args, ctx) => {
264
293
  bridge.bindCallContext(ctx);
265
294
  refreshCatalog();
266
- const commands = ["read", "edit", "write", "bash"].filter(bridge.isCallable);
295
+ const commands = NATIVE_NAMES.filter(bridge.isCallable);
267
296
 
268
297
  const lines = [
269
298
  `Supernova CodeMode: ${commands.join(", ")}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "CodeMode for Pi and OMP: read, edit, write and bash, with transactional files, source views and shared-input program batches.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -25,7 +25,8 @@
25
25
  "files": [
26
26
  "index.js",
27
27
  "src/",
28
- "docs/",
28
+ "docs/CHANGELOG.md",
29
+ "docs/TOKEN_COSTS.md",
29
30
  "README.md",
30
31
  "LICENSE"
31
32
  ],
@@ -34,7 +35,7 @@
34
35
  "test:hosts": "node tests/hosts/verify.mjs",
35
36
  "measure": "node tests/efficiency/measure.mjs",
36
37
  "test:tokens": "node tests/efficiency/tokens.mjs",
37
- "prepublishOnly": "npm test && node ../../scripts/preflight.mjs"
38
+ "prepublishOnly": "node ../../scripts/preflight.mjs"
38
39
  },
39
40
  "pi": {
40
41
  "extensions": [
@@ -1,5 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { canonicalNewPath } from "../fs/commit.js";
2
4
  import { readResult } from "../shared/result.js";
5
+ import { truncateChars } from "../output/format.js";
6
+
7
+ const BASH_CAPTURE_CHARS = 2 * 1024 * 1024;
3
8
 
4
9
  import { normalizeBash } from "../contract/bash.js";
5
10
  import { sourceForReferences } from "../fs/source-window.js";
@@ -20,7 +25,7 @@ export function createBash(ctx) {
20
25
  const command = String(params.command);
21
26
  const literal = Array.isArray(params.args);
22
27
  const argv = literal ? [command, ...params.args] : ["bash", "-c", command];
23
- const targetCwd = await commandCwd(params, cwd);
28
+ const targetCwd = await commandCwd(params, cwd, vfs);
24
29
 
25
30
  const transactionBarrier = await vfs.prepareExternalMutation("bash");
26
31
  let res;
@@ -32,7 +37,7 @@ export function createBash(ctx) {
32
37
  commandLabel: command,
33
38
  timeoutMs: params?.timeoutMs === undefined ? config.timeoutMs : params.timeoutMs,
34
39
  signal,
35
- maxOutputChars: config.maxCallResultChars,
40
+ maxOutputChars: BASH_CAPTURE_CHARS,
36
41
  });
37
42
  } catch (error) {
38
43
  if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message, signal, ledger);
@@ -44,6 +49,8 @@ export function createBash(ctx) {
44
49
  hooks.workspaceChanged();
45
50
  }
46
51
 
52
+ if (res.outputTruncated) throw new Error("bash output exceeds " + BASH_CAPTURE_CHARS + " character capture limit; redirect output to a file and read it with complete:true or json");
53
+
47
54
  const { stdout, stderr } = res;
48
55
  let text = combineBashText(stdout, stderr);
49
56
 
@@ -55,11 +62,14 @@ export function createBash(ctx) {
55
62
  text += await sourceForReferences(cwd, targetCwd, text, signal, ledger);
56
63
  }
57
64
 
58
- return {
59
- content: [{ type: "text", text }],
60
- details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
61
- isError: res.exitCode !== 0,
62
- };
65
+ // Computation owns the complete captured value; traces/model output get
66
+ // only a bounded preview. Never feed a clipped JSON string to the guest.
67
+ const result = readResult(text, { exitCode: res.exitCode, signal: res.signal, outputTruncated: false, transactionBarrier },
68
+ truncateChars(text, config.maxCallResultChars, "command output").text);
69
+
70
+ result.isError = res.exitCode !== 0;
71
+
72
+ return result;
63
73
  }
64
74
 
65
75
  async function background(params, signal) {
@@ -71,7 +81,7 @@ export function createBash(ctx) {
71
81
 
72
82
  if (params.background === true) {
73
83
  await manager.validateStart(params, hooks.terminalGeneration);
74
- targetCwd = await commandCwd(params, getCwd());
84
+ targetCwd = await commandCwd(params, getCwd(), vfs);
75
85
  } else manager.validateControl(params, owner, hooks.terminalGeneration);
76
86
  const transactionBarrier = mutating ? await vfs.prepareExternalMutation("bash") : false;
77
87
 
@@ -97,13 +107,33 @@ export function createBash(ctx) {
97
107
  return { bash };
98
108
  }
99
109
 
100
- async function commandCwd(params, cwd) {
110
+ // Admit only directories that the pending file set will materialize. Checking
111
+ // before the barrier keeps invalid cwd requests from committing unrelated files.
112
+ async function stagedDirectory(target, vfs) {
113
+ const pending = await vfs.getOverlayPaths();
114
+
115
+ if (!pending.length) return false;
116
+ const directory = await canonicalNewPath(target);
117
+ let found = false;
118
+
119
+ for (const logicalPath of pending) {
120
+ const file = await canonicalNewPath(logicalPath);
121
+
122
+ if (file === directory) return false;
123
+
124
+ if (file.startsWith(directory + path.sep)) found = true;
125
+ }
126
+
127
+ return found;
128
+ }
129
+
130
+ async function commandCwd(params, cwd, vfs) {
101
131
  const target = params.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
102
132
 
103
133
  if (params.cwd !== undefined) {
104
134
  const stat = await fs.stat(target).catch(() => null);
105
135
 
106
- if (!stat?.isDirectory()) throw new Error("bash cwd is not a directory: " + params.cwd);
136
+ if (!stat?.isDirectory() && (stat || !await stagedDirectory(target, vfs))) throw new Error("bash cwd is not a directory: " + params.cwd);
107
137
  }
108
138
 
109
139
  return target;
@@ -7,17 +7,48 @@ import { applyPatchToText } from "../fs/patch.js";
7
7
  import { resolveWorkspacePath, relativeSlash } from "../fs/workspace.js";
8
8
  import { referencesForNames } from "../context/search.js";
9
9
  import {
10
- textResult, sourceLines, lineTextRange, applyReplacements, applyViewReplace,
11
- shiftDiffLines, contentLineInfo, boundedEditDiff, QUICK_CHECK_MAX_CHARS,
10
+ textResult, sourceLines, applyReplacements, applyViewReplace,
11
+ shiftDiffLines, contentLineInfo, boundedEditDiff, QUICK_CHECK_MAX_CHARS, lineStartIndex, lineEndIndex,
12
12
  } from "../fs/text-ops.js";
13
13
 
14
14
  export function createEdit(ctx) {
15
15
  const { getCwd, vfs, index, ledger } = ctx;
16
- function lineAt(updated, newLines, n) {
17
- if (newLines) return newLines[n - 1] ?? "";
18
- const { start, end } = lineTextRange(updated, n);
19
16
 
20
- return updated.slice(start, end).replace(/\r?\n$/, "");
17
+ async function editTargetPath(cwd, inputPath, command) {
18
+ try {
19
+ // Windows can report ENOENT for an oversized component instead of ENAMETOOLONG.
20
+ if (process.platform === "win32" && isString(inputPath) && inputPath.split(/[\\/]/).some(part => part.length > 255)) {
21
+ throw Object.assign(new Error("path component exceeds the filesystem limit"), { code: "ENAMETOOLONG" });
22
+ }
23
+
24
+ return await resolveWorkspacePath(cwd, inputPath, command, false);
25
+ }
26
+ catch (error) {
27
+ if (error?.code !== "ENAMETOOLONG") throw error;
28
+
29
+ // A plain read value mistaken for a path can contain an entire source
30
+ // file. Keep the errno, but do not reflect that value in the diagnostic.
31
+ const failure = new Error(command + " path exceeds the filesystem limit; expected a filename. Ordinary read(path) returns text; use edit(path,oldText,newText), or read(path,{resolve:true}) for an editable view");
32
+
33
+ failure.code = error.code;
34
+
35
+ throw failure;
36
+ }
37
+ }
38
+
39
+ function editBlockLines(updated, newLines, start, last) {
40
+ if (newLines) return newLines.slice(start - 1, last);
41
+ const lines = [];
42
+ let from = lineStartIndex(updated, start);
43
+
44
+ // Consecutive preview lines share one prefix scan, even in large files.
45
+ for (let line = start; line <= last; line++) {
46
+ const end = lineEndIndex(updated, from, 1);
47
+ lines.push(updated.slice(from, end).replace(/\r?\n$/, ""));
48
+ from = end;
49
+ }
50
+
51
+ return lines;
21
52
  }
22
53
 
23
54
  function spanEditRange(span, lineCount) {
@@ -39,6 +70,7 @@ export function createEdit(ctx) {
39
70
 
40
71
  if (explicit) return explicit;
41
72
  const ranges = [];
73
+
42
74
  const positions = diff.lines.filter(row => row.type !== "context")
43
75
  .map(row => Math.min(lineCount, row.newLineNum ?? row.lineNum)).sort((a, b) => a - b);
44
76
 
@@ -53,7 +85,7 @@ export function createEdit(ctx) {
53
85
 
54
86
  for (const { start, end } of ranges) {
55
87
  const last = Math.min(end, start + perRange - 1);
56
- const lines = Array.from({ length: Math.max(0, last - start + 1) }, (_, i) => lineAt(updated, newLines, start + i));
88
+ const lines = editBlockLines(updated, newLines, start, last);
57
89
  ledger.recordOrigin(rel, start, lines);
58
90
  blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
59
91
 
@@ -80,21 +112,34 @@ export function createEdit(ctx) {
80
112
  return out;
81
113
  }
82
114
 
83
- function lineText(text, lines, number) {
84
- if (lines) return lines[number - 1] ?? "";
85
- const { start, end } = lineTextRange(text, number);
115
+ function lineReader(text) {
116
+ let line = 1, start = 0;
117
+
118
+ // Diff rows share a forward cursor per version, not a fresh prefix scan
119
+ // per row or a second array of every line. Backward coordinates rewind it.
120
+ return number => {
121
+ const nextLine = Math.max(1, number);
122
+
123
+ if (nextLine < line) { line = 1; start = 0; }
86
124
 
87
- return text.slice(start, end).replace(/\r?\n$/, "");
125
+ start = lineEndIndex(text, start, nextLine - line);
126
+ line = nextLine;
127
+ const end = lineEndIndex(text, start, 1);
128
+
129
+ return text.slice(start, end).replace(/\r?\n$/, "");
130
+ };
88
131
  }
89
132
 
90
- function nameAtDiffLine(l, original, updated, oldLines, newLines, canMapOwners, target, spans) {
133
+ function nameAtDiffLine(l, original, updated, readOldLine, readNewLine, canMapOwners, target, spans) {
91
134
  const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
92
135
  const source = l.type === "remove" ? original : updated;
93
- const cached = l.type === "remove" ? oldLines : newLines;
94
- const name = declaredName(lineText(source, cached, number));
136
+ const readLine = l.type === "remove" ? readOldLine : readNewLine;
137
+ const name = declaredName(readLine(number));
95
138
 
96
139
  if (name) return name;
140
+
97
141
  if (!canMapOwners) return;
142
+
98
143
  if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, source)));
99
144
 
100
145
  return spans.get(l.type).find(span => span.start <= number && number <= span.end)?.name;
@@ -102,16 +147,19 @@ export function createEdit(ctx) {
102
147
 
103
148
  function collectChangedNames(target, original, updated, diff) {
104
149
  const canMapOwners = original.length <= 512 * 1024 && updated.length <= 512 * 1024;
105
- const oldLines = canMapOwners ? original.split("\n") : null;
106
- const newLines = canMapOwners ? updated.split("\n") : null;
150
+ const readOldLine = lineReader(original);
151
+ const readNewLine = lineReader(updated);
107
152
  const names = new Set();
108
153
  const spans = new Map();
109
154
 
110
155
  for (const l of diff.lines) {
111
156
  if (l.type === "context") continue;
112
- const name = nameAtDiffLine(l, original, updated, oldLines, newLines, canMapOwners, target, spans);
157
+ const name = nameAtDiffLine(l, original, updated, readOldLine, readNewLine, canMapOwners, target, spans);
158
+
159
+ // The generic declaration heuristic reads "const auto" as a binding.
160
+ // In C++ it is a type keyword, not a symbol to search across the repo.
161
+ if (name && !(name === "auto" && /\.(?:cc|cpp|h|hpp)$/i.test(target))) names.add(name);
113
162
 
114
- if (name) names.add(name);
115
163
  if (names.size >= 3) break;
116
164
  }
117
165
 
@@ -137,8 +185,10 @@ export function createEdit(ctx) {
137
185
  if (names.size === 0) return "";
138
186
 
139
187
  try {
188
+ const excludePath = path.resolve(cwd, path.relative(await vfs.resolvePath(cwd), await vfs.resolvePath(target)));
189
+
140
190
  const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
141
- excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
191
+ excludePath, overlayText: file => vfs.getOverlay(file), pendingPaths: await vfs.getOverlayPaths(cwd), signal });
142
192
 
143
193
  return formatNameRefs(references, incomplete);
144
194
  } catch (error) {
@@ -159,9 +209,11 @@ export function createEdit(ctx) {
159
209
  async function applyViewEdit(cwd, target, content, params, signal) {
160
210
  const viewText = String(params.viewText);
161
211
  const nextText = String(params.newText);
212
+
162
213
  const windowNext = isString(params.oldText)
163
214
  ? applyReplacements(target, viewText, [{ oldText: String(params.oldText), newText: nextText }]).updated
164
215
  : nextText;
216
+
165
217
  const { updated } = applyViewReplace(target, content, params.viewStart, params.viewEnd, viewText, windowNext);
166
218
  const diffFrom = isString(params.oldText) ? String(params.oldText) : viewText;
167
219
  const diff = shiftDiffLines(buildEditDiff(target, viewText, diffFrom, isString(params.oldText) ? nextText : windowNext), params.viewStart - 1);
@@ -172,6 +224,7 @@ export function createEdit(ctx) {
172
224
 
173
225
  function diffForMatches(target, content, updated, matches) {
174
226
  if (content.length > 512 * 1024 || updated.length > 512 * 1024) return boundedEditDiff(target, content, matches);
227
+
175
228
  if (matches.length === 1) return buildEditDiff(target, content, matches[0].oldText, matches[0].newText);
176
229
 
177
230
  return buildMultiEditDiff(target, content, matches);
@@ -179,10 +232,10 @@ export function createEdit(ctx) {
179
232
 
180
233
  async function edit(params, signal) {
181
234
  const cwd = getCwd();
182
- const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
235
+ const target = await editTargetPath(cwd, params?.path, "edit");
183
236
 
184
237
  if (signal?.aborted) throw new Error("aborted");
185
- const content = await vfs.read(target, { maxBytes: 64 * 1024 * 1024 });
238
+ const content = await vfs.read(target, { maxBytes: 64 * 1024 * 1024, forWrite: true });
186
239
 
187
240
  if (isNumber(params?.viewStart) && isNumber(params?.viewEnd) && isString(params?.viewText) && isString(params?.newText)) {
188
241
  return applyViewEdit(cwd, target, content, params, signal);
@@ -210,7 +263,7 @@ export function createEdit(ctx) {
210
263
 
211
264
  async function readPatchOriginal(target) {
212
265
  try {
213
- return await vfs.read(target, { maxBytes: 64 * 1024 * 1024, preserveRead: true });
266
+ return await vfs.read(target, { maxBytes: 64 * 1024 * 1024, forWrite: true });
214
267
  } catch (error) {
215
268
  if (error?.code !== "ENOENT") throw error;
216
269
 
@@ -220,7 +273,7 @@ export function createEdit(ctx) {
220
273
 
221
274
  async function apply_patch(params, signal) {
222
275
  const cwd = getCwd();
223
- const target = await resolveWorkspacePath(cwd, patchInputPath(params), "apply_patch", false);
276
+ const target = await editTargetPath(cwd, patchInputPath(params), "apply_patch");
224
277
 
225
278
  if (!isString(params?.patch) || !params.patch.trim()) {
226
279
  throw new Error("apply_patch requires patch");
@@ -228,6 +281,7 @@ export function createEdit(ctx) {
228
281
 
229
282
  if (signal?.aborted) throw new Error("aborted");
230
283
  const original = await readPatchOriginal(target);
284
+
231
285
  if (original.length > 2 * 1024 * 1024) throw new Error("apply_patch input exceeds 2 MiB; use edit() for targeted replacements");
232
286
  const { resultText, hunkCount, relocations } = applyPatchToText(original, params.patch);
233
287
  const { speculative } = await vfs.write(target, resultText);
@@ -1,8 +1,11 @@
1
1
  export const IMAGE_MAX_BYTES = 20 * 1024 * 1024;
2
+
2
3
  export const LARGE_FILE_BYTES = 512 * 1024;
4
+
3
5
  export const TEXT_MAX_BYTES = 64 * 1024 * 1024;
4
6
 
5
7
  export const ABOUT_TOKEN_MAX = 16;
8
+
6
9
  export const IMAGE_MIME = {
7
10
  ".png": "image/png",
8
11
  ".jpg": "image/jpeg",
@@ -19,5 +22,6 @@ export function imageTooLarge(rel, size) {
19
22
  export function missingFile(targetPath) {
20
23
  const error = new Error("no such file: " + targetPath + " (locate it with read using a directory path or source question; use Promise.allSettled for optional reads to retain successful siblings)");
21
24
  error.code = "ENOENT";
25
+
22
26
  return error;
23
27
  }
@@ -14,6 +14,7 @@ export function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks)
14
14
  const bash = createBash(ctx);
15
15
  const list = createList(ctx);
16
16
  hooks.summarizeEdit = edit.editSummary;
17
+
17
18
  return {
18
19
  read: read.read,
19
20
  write: write.write,
@@ -48,7 +48,7 @@ export function createList(ctx) {
48
48
  if (signal?.aborted) throw new Error("aborted");
49
49
  const searchDir = await resolveListDir(op, params, cwd);
50
50
  const globPattern = globPatternOf(op, params);
51
- const pending = vfs.getOverlayPaths();
51
+ const pending = await vfs.getOverlayPaths(searchDir);
52
52
  const cached = await listFromCache(searchDir, cwd, globPattern, pending);
53
53
 
54
54
  if (cached !== null) return cached;
@@ -72,9 +72,10 @@ export function createList(ctx) {
72
72
 
73
73
  if (!pattern) throw new Error("grep requires pattern");
74
74
  const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
75
- const indexed = await grepIndexed(index, pattern, params, searchPath, cwd, file => vfs.getOverlay(file), vfs.getOverlayPaths());
75
+ const indexed = await grepIndexed(index, pattern, params, searchPath, cwd, file => vfs.getOverlay(file), await vfs.getOverlayPaths(searchPath));
76
76
 
77
77
  if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
78
+
78
79
  // Large tree: real rg keeps its own output format.
79
80
  return grepWithRg(pattern, params, searchPath, cwd, signal);
80
81
  }
@@ -82,13 +83,15 @@ export function createList(ctx) {
82
83
  async function glob(params, signal) {
83
84
  return listFiles(params, signal, "glob", getCwd());
84
85
  }
86
+
85
87
  async function find(params, signal) {
86
88
  return listFiles(params, signal, "find", getCwd());
87
89
  }
90
+
88
91
  async function ls(params, signal) {
89
92
  const cwd = getCwd();
90
93
  const dirPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "ls", true) : cwd;
91
- const pending = vfs.getOverlay(dirPath);
94
+ const pending = await vfs.getOverlay(dirPath);
92
95
 
93
96
  if (pending !== undefined) return fileListing(dirPath, Buffer.byteLength(pending, "utf8"));
94
97
  const stat = await fs.stat(dirPath).catch(() => null);
@@ -4,6 +4,7 @@ import {tokenizeQuery,stem} from '../context/snap.js';
4
4
  import {runCommand} from '../fs/workspace.js';
5
5
  import {lineStartIndex,lineTextRange} from '../fs/lines.js';
6
6
  import {textResult} from '../shared/result.js';
7
+
7
8
  export function createFocusedReader(vfs, readBudget) {
8
9
  function aboutStems(about, requireStem) {
9
10
  const tokens = tokenizeQuery(about).tokens;
@@ -29,6 +30,7 @@ export function createFocusedReader(vfs, readBudget) {
29
30
 
30
31
  if (stems.some(st => row.includes(st))) {
31
32
  hits.push(line);
33
+
32
34
  if (hits.length >= 200) break;
33
35
  }
34
36
 
@@ -53,6 +55,7 @@ export function createFocusedReader(vfs, readBudget) {
53
55
  const body = overlay.slice(first, last);
54
56
 
55
57
  if (used + body.length > budget) { truncated = true; break; }
58
+
56
59
  if (out.length && from > cursor) out.push("...");
57
60
  out.push(`// ${rel}:${from}\n${body}`);
58
61
  used += body.length;
@@ -71,7 +74,9 @@ export function createFocusedReader(vfs, readBudget) {
71
74
  const res = await runCommand(args, { cwd: path.dirname(targetPath), timeoutMs: 15000, maxOutputChars: budget, signal });
72
75
 
73
76
  if (res.exitCode === 0 || res.exitCode === 1) await vfs.recordExpected(targetPath, observed);
77
+
74
78
  if (res.exitCode === 1) return textResult("// " + rel + " · no matching text\n", { path: targetPath, outputTruncated: false, complete: false });
79
+
75
80
  if (res.exitCode !== 0) throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
76
81
  const marker = res.outputTruncated ? "\n[focused read truncated; narrow about or use read(path, line, count)]" : "";
77
82
 
@@ -94,5 +99,6 @@ export function createFocusedReader(vfs, readBudget) {
94
99
 
95
100
  return textResult("// " + rel + " · focused staged text windows (not a complete file)\n" + out.join("\n") + marker, { path: targetPath, outputTruncated: truncated, complete: false });
96
101
  }
102
+
97
103
  return focusAbout;
98
104
  }
@@ -2,10 +2,12 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import {assertModelImageMime} from '../shared/decode.js';
4
4
  import {validateImageBytes} from '../shared/image.js';
5
+ import {readLimitedBytes} from '../fs/file-io.js';
5
6
  import {IMAGE_MIME,IMAGE_MAX_BYTES,imageTooLarge,missingFile} from './errors.js';
7
+
6
8
  export function createImageReader(vfs) {
7
- async function readImage(rel, targetPath, mime, signal) {
8
- const staged = vfs.getOverlay(targetPath);
9
+ async function readImage(rel, targetPath, signal) {
10
+ const staged = await vfs.getOverlay(targetPath);
9
11
 
10
12
  if (staged !== undefined) {
11
13
  const size = Buffer.byteLength(staged, "utf8");
@@ -23,16 +25,22 @@ export function createImageReader(vfs) {
23
25
  throw error;
24
26
  }
25
27
 
28
+ let stat, bytes;
29
+
26
30
  try {
27
- const stat = await file.stat();
31
+ stat = await file.stat();
28
32
 
29
33
  if (!stat.isFile()) throw new Error("image read requires a regular file: " + targetPath);
30
- if (stat.size > IMAGE_MAX_BYTES) throw imageTooLarge(rel, stat.size);
31
- const bytes = await file.readFile({ signal });
32
- await vfs.recordExpected(targetPath, stat);
33
34
 
34
- return bytes;
35
+ if (stat.size > IMAGE_MAX_BYTES) throw imageTooLarge(rel, stat.size);
36
+ bytes = await readLimitedBytes(file, stat, IMAGE_MAX_BYTES, "image", signal,
37
+ () => new Error("image read limit exceeded: " + rel + "; maximum " + IMAGE_MAX_BYTES + " bytes (20 MiB); resize or select fewer/smaller images"));
35
38
  } finally { await file.close(); }
39
+
40
+ // Recovery must be able to replace the destination before we await it.
41
+ await vfs.recordExpected(targetPath, stat, bytes);
42
+
43
+ return bytes;
36
44
  }
37
45
 
38
46
  async function maybeImage(rel, targetPath, signal) {
@@ -40,12 +48,12 @@ export function createImageReader(vfs) {
40
48
 
41
49
  if (!mime) return null;
42
50
  assertModelImageMime(mime);
43
- const bytes = await readImage(rel, targetPath, mime, signal);
51
+ const bytes = await readImage(rel, targetPath, signal);
44
52
 
45
- if (bytes.length > IMAGE_MAX_BYTES) throw imageTooLarge(rel, bytes.length);
46
53
  await validateImageBytes(bytes,mime,rel,signal);
47
54
 
48
55
  return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
49
56
  }
57
+
50
58
  return maybeImage;
51
59
  }