pi-supernova 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -3
- package/docs/CHANGELOG.md +114 -0
- package/docs/TOKEN_COSTS.md +13 -5
- package/index.js +120 -79
- package/package.json +1 -1
- package/src/adapters/bash.js +73 -0
- package/src/adapters/edit.js +249 -0
- package/src/adapters/errors.js +31 -0
- package/src/adapters/index.js +31 -0
- package/src/adapters/list.js +102 -0
- package/src/adapters/read.js +805 -0
- package/src/adapters/refs.js +41 -0
- package/src/adapters/write.js +96 -0
- package/src/bridge/catalog.js +28 -222
- package/src/bridge/host-bridge.js +113 -1668
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -198
- package/src/context/evidence.js +140 -76
- package/src/context/fuzzy.js +42 -24
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +23 -18
- package/src/context/repo-index.js +206 -170
- package/src/context/search.js +157 -77
- package/src/context/snap.js +240 -136
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +14 -5
- package/src/contract/bash.js +31 -0
- package/src/contract/edit.js +95 -0
- package/src/contract/read.js +220 -0
- package/src/fs/check.js +12 -8
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +94 -50
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +205 -175
- package/src/fs/workspace.js +119 -108
- package/src/output/bottleneck.js +195 -116
- package/src/output/format.js +101 -67
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +296 -292
- package/src/runtime/parallel.js +97 -64
- package/src/runtime/program-batch.js +178 -69
- package/src/runtime/reference.js +15 -14
- package/src/runtime/runtime.js +327 -187
- package/src/shared/decode.js +58 -36
- package/src/ui/omp-frame.js +59 -42
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +241 -145
|
@@ -1,1567 +1,19 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
|
|
4
|
+
import { truncateChars } from "../output/format.js";
|
|
5
|
+
import { isString, isFunction, isObject } from "../shared/decode.js";
|
|
6
|
+
import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
|
|
7
|
+
import { unknownToolMessage } from "./catalog.js";
|
|
8
|
+
import { toolIsCallable, resolveInvokeTarget } from "./invoke.js";
|
|
9
|
+
import { buildWriteDiff } from "../fs/diff.js";
|
|
10
|
+
import { WorkspaceIndex } from "../context/repo-index.js";
|
|
11
|
+
import { SeenLedger } from "../context/ledger.js";
|
|
12
|
+
import { CausalVfs } from "../fs/vfs.js";
|
|
13
|
+
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from "../fs/workspace.js";
|
|
14
|
+
import { createNativeAdapters } from "../adapters/index.js";
|
|
15
|
+
import { resultDiff, boundedWriteDiff, writeSnapshot } from "../fs/text-ops.js";
|
|
1
16
|
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { packageHostResult, hostResultFailed } from "../output/bottleneck.js";
|
|
6
|
-
import { truncateChars } from "../output/format.js";
|
|
7
|
-
import { isString, isNumber, isFunction, isObject, looksLikePath } from "../shared/decode.js";
|
|
8
|
-
import { isMutatingTool, runParallelWave, createNativeScheduler } from "../runtime/parallel.js";
|
|
9
|
-
import { unknownToolMessage } from "./catalog.js";
|
|
10
|
-
import { extractStructuralSurface } from "../context/surface.js";
|
|
11
|
-
import { pickSpan } from "../context/spans.js";
|
|
12
|
-
import { buildEditDiff, buildMultiEditDiff, buildPatchDiff, buildWriteDiff } from "../fs/diff.js";
|
|
13
|
-
import { executeSnap, tokenizeQuery, stem } from "../context/snap.js";
|
|
14
|
-
import { selectEvidence } from "../context/evidence.js";
|
|
15
|
-
import { WorkspaceIndex } from "../context/repo-index.js";
|
|
16
|
-
import { outlineFile } from "../context/outline.js";
|
|
17
|
-
import { SeenLedger } from "../context/ledger.js";
|
|
18
|
-
import { quickCheck } from "../fs/check.js";
|
|
19
|
-
import { declaredName } from "../context/repo-index.js";
|
|
20
|
-
import { CausalVfs } from "../fs/vfs.js";
|
|
21
|
-
import { MAX_JSON_BYTES, jsonProjector, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
22
|
-
import { applyPatchToText } from "../fs/patch.js";
|
|
23
|
-
import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash, assertFilesystemPath } from "../fs/workspace.js";
|
|
24
|
-
import { fuzzyFind, grepIndexed, listIndexed, listWithTools, rgGrepArgs, referencesForNames } from "../context/search.js";
|
|
25
|
-
|
|
26
|
-
function textResult(text, details) {
|
|
27
|
-
return {
|
|
28
|
-
content: [{ type: "text", text: String(text ?? "") }],
|
|
29
|
-
details: details || {},
|
|
30
|
-
};
|
|
31
|
-
}
|
|
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
|
-
|
|
47
|
-
/** Unwrap a single matching quote pair around the whole string (`'git status'`). */
|
|
48
|
-
function unwrapIfFullyQuoted(s) {
|
|
49
|
-
if (s.length < 2) return s;
|
|
50
|
-
const q = s[0];
|
|
51
|
-
|
|
52
|
-
if (q !== "'" && q !== '"') return s;
|
|
53
|
-
|
|
54
|
-
if (s[s.length - 1] !== q) return s;
|
|
55
|
-
const inner = s.slice(1, -1);
|
|
56
|
-
|
|
57
|
-
if (inner.includes(q)) return s;
|
|
58
|
-
|
|
59
|
-
return inner;
|
|
60
|
-
}
|
|
61
|
-
|
|
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
|
-
|
|
70
|
-
const startIndex = (isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1) - 1;
|
|
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"));
|
|
84
|
-
|
|
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 };
|
|
88
|
-
}
|
|
89
|
-
|
|
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;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function resolveReadPath(cwd, target) {
|
|
117
|
-
if (!isString(target) || !target.trim()) throw new Error("read requires path");
|
|
118
|
-
const input = assertFilesystemPath(target, "read");
|
|
119
|
-
|
|
120
|
-
return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function probeExistingPath(cwd, targetParam, vfs) {
|
|
124
|
-
const targetPath = resolveReadPath(cwd, targetParam);
|
|
125
|
-
|
|
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
|
-
}
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
const st = await fs.stat(targetPath);
|
|
134
|
-
|
|
135
|
-
return { path: targetPath, directory: st.isDirectory(), size: st.size };
|
|
136
|
-
} catch (err) {
|
|
137
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
138
|
-
|
|
139
|
-
if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
|
|
140
|
-
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
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
|
-
|
|
179
|
-
function applyReplacements(target, content, requestedEdits) {
|
|
180
|
-
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
181
|
-
|
|
182
|
-
const matches = requestedEdits.map((replacement) => {
|
|
183
|
-
if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
|
|
184
|
-
throw new Error("edit requires non-empty oldText");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (!isString(replacement?.newText)) throw new Error("edit requires newText");
|
|
188
|
-
const oldText = String(replacement.oldText);
|
|
189
|
-
const newText = String(replacement.newText);
|
|
190
|
-
const index = content.indexOf(oldText);
|
|
191
|
-
|
|
192
|
-
if (index < 0) {
|
|
193
|
-
throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + numberedPreview(content));
|
|
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
|
-
};
|
|
205
|
-
|
|
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)));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return { ...replacement, oldText, newText, index, end: index + oldText.length };
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
matches.sort((a, b) => a.index - b.index);
|
|
213
|
-
|
|
214
|
-
for (let i = 1; i < matches.length; i++) {
|
|
215
|
-
if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
let updated = content;
|
|
219
|
-
|
|
220
|
-
for (let i = matches.length - 1; i >= 0; i--) {
|
|
221
|
-
const match = matches[i];
|
|
222
|
-
updated = updated.slice(0, match.index) + match.newText + updated.slice(match.end);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return { updated, matches };
|
|
226
|
-
}
|
|
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
|
-
|
|
384
|
-
function formatDirectoryEntry(name, type, size = 0) {
|
|
385
|
-
const sizeSuffix = size ? `, ${size} bytes` : "";
|
|
386
|
-
|
|
387
|
-
return `${name}${type === "dir" ? "/" : ""} (${type}${sizeSuffix})`;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
async function formatLsEntry(dirPath, entry) {
|
|
391
|
-
const isDir = entry.isDirectory();
|
|
392
|
-
const isSym = entry.isSymbolicLink();
|
|
393
|
-
const typeLabel = isDir ? "dir" : isSym ? "sym" : "file";
|
|
394
|
-
let size = 0;
|
|
395
|
-
|
|
396
|
-
try {
|
|
397
|
-
if (!isDir && !isSym) {
|
|
398
|
-
const st = await fs.stat(path.join(dirPath, entry.name));
|
|
399
|
-
size = st.size;
|
|
400
|
-
}
|
|
401
|
-
} catch {}
|
|
402
|
-
|
|
403
|
-
return formatDirectoryEntry(entry.name, typeLabel, size);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function createNativeAdapters(getCwd, vfs, config, index, ledger, hooks) {
|
|
407
|
-
const reads = createNativeScheduler();
|
|
408
|
-
|
|
409
|
-
async function sourceRead(query, searchDir, signal, params = {}) {
|
|
410
|
-
params = { ...params, resolve: params.resolve !== false };
|
|
411
|
-
const cwd = getCwd();
|
|
412
|
-
|
|
413
|
-
const includeHidden = path.relative(cwd, searchDir).split(path.sep)
|
|
414
|
-
.some(segment => segment.startsWith(".") && segment.length > 1);
|
|
415
|
-
|
|
416
|
-
const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
|
|
417
|
-
pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
|
|
418
|
-
overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
419
|
-
|
|
420
|
-
return openSource(result, params, signal, undefined, query);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
async function openSource(result, params, signal, resolvedPath, query) {
|
|
424
|
-
const cwd = getCwd();
|
|
425
|
-
|
|
426
|
-
if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
|
|
427
|
-
signal?.throwIfAborted();
|
|
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);
|
|
433
|
-
|
|
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];
|
|
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 });
|
|
447
|
-
const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
|
|
448
|
-
text: block.text.slice(0, sourceChars), complete, nextOffset };
|
|
449
|
-
|
|
450
|
-
return textResult(params.resolve ? JSON.stringify(source) : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
|
|
451
|
-
{ ...opened.details, isSnap: true });
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
async function readDirectory(dirPath, signal) {
|
|
455
|
-
signal?.throwIfAborted();
|
|
456
|
-
const rows = new Map();
|
|
457
|
-
let truncated = false;
|
|
458
|
-
|
|
459
|
-
for (const file of vfs.getOverlayPaths()) {
|
|
460
|
-
if (rows.size >= MAX_DIRECTORY_ENTRIES) { truncated = true; break; }
|
|
461
|
-
const relative = path.relative(dirPath, file);
|
|
462
|
-
|
|
463
|
-
if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
|
|
464
|
-
const [name, child] = relative.split(path.sep);
|
|
465
|
-
rows.set(name, child === undefined
|
|
466
|
-
? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
|
|
467
|
-
: formatDirectoryEntry(name, "dir"));
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
let entries;
|
|
471
|
-
|
|
472
|
-
try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
|
|
473
|
-
if (error.code !== "ENOENT" || rows.size === 0) throw error;
|
|
474
|
-
entries = [];
|
|
475
|
-
}
|
|
476
|
-
|
|
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
|
-
}
|
|
482
|
-
|
|
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
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
async function readAdapter(params, signal) {
|
|
598
|
-
signal?.throwIfAborted();
|
|
599
|
-
params = normalizeReadWindow(params);
|
|
600
|
-
if (!isObject(params)) throw new Error("read requires an options object");
|
|
601
|
-
const cwd = getCwd();
|
|
602
|
-
const targetParam = params?.path ?? params?.target;
|
|
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
|
-
|
|
606
|
-
if (Array.isArray(targetParam)) {
|
|
607
|
-
if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
608
|
-
|
|
609
|
-
for (const p of targetParam) if (!isString(p) || !p.trim()) throw new Error("read paths must be non-empty strings");
|
|
610
|
-
|
|
611
|
-
const results = await Promise.all(targetParam.map(async p => {
|
|
612
|
-
try {
|
|
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 ?? "";
|
|
618
|
-
|
|
619
|
-
return { text: item };
|
|
620
|
-
} catch (error) {
|
|
621
|
-
signal?.throwIfAborted();
|
|
622
|
-
|
|
623
|
-
return { text: `[read error: ${p}] ${error.message}`, error: { path: p, message: error.message } };
|
|
624
|
-
}
|
|
625
|
-
}));
|
|
626
|
-
|
|
627
|
-
signal?.throwIfAborted();
|
|
628
|
-
const response = textResult("", { count: results.length, batch: true, independent: params._independent === true, items: results.map(r => r.text), itemErrors: results.map(r => r.error?.message ?? null), errors: results.filter(r => r.error).map(r => r.error) });
|
|
629
|
-
response.isError = params._independent !== true && results.some(r => r.error);
|
|
630
|
-
|
|
631
|
-
return response;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
async function resolveSessionResource(uri, signal) {
|
|
638
|
-
const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
|
|
639
|
-
|
|
640
|
-
if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
|
|
641
|
-
const kind = match[1].toLowerCase();
|
|
642
|
-
const id = decodeURIComponent(match[2]);
|
|
643
|
-
|
|
644
|
-
if (!id || id === "." || id === ".." || (/[/\\]/u.test(id) || Array.from(id).some(char => char.charCodeAt(0) < 32)) || (kind === "artifact" && !/^\d+$/.test(id))) throw new Error("invalid session resource ID");
|
|
645
|
-
const dir = hooks.artifactsDir?.();
|
|
646
|
-
|
|
647
|
-
if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
|
|
648
|
-
signal?.throwIfAborted();
|
|
649
|
-
const root = await fs.realpath(dir);
|
|
650
|
-
let file = id + ".md";
|
|
651
|
-
|
|
652
|
-
if (kind === "artifact") {
|
|
653
|
-
const matches = [];
|
|
654
|
-
let count = 0;
|
|
655
|
-
|
|
656
|
-
for await (const entry of await fs.opendir(root)) {
|
|
657
|
-
signal?.throwIfAborted();
|
|
658
|
-
|
|
659
|
-
if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
|
|
660
|
-
|
|
661
|
-
if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
|
|
665
|
-
file = matches[0];
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const target = await fs.realpath(path.join(root, file));
|
|
669
|
-
|
|
670
|
-
if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
|
|
671
|
-
|
|
672
|
-
if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
|
|
673
|
-
signal?.throwIfAborted();
|
|
674
|
-
|
|
675
|
-
return target;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
async function readSingle(params, cwd, targetParam, signal) {
|
|
679
|
-
params = sessionJsonArgs({ ...params, path: targetParam });
|
|
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");
|
|
692
|
-
targetParam = params.path;
|
|
693
|
-
|
|
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");
|
|
696
|
-
const target = await resolveSessionResource(targetParam, signal);
|
|
697
|
-
|
|
698
|
-
return params.resolve
|
|
699
|
-
? openSource({status:"found",path:targetParam,line:params.offset ?? 1}, params, signal, target)
|
|
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);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
if (isString(params?.query)) {
|
|
711
|
-
const scope = targetParam && targetParam !== params.query ? resolveReadPath(cwd, targetParam) : cwd;
|
|
712
|
-
|
|
713
|
-
return sourceRead(params.query, scope, signal, params);
|
|
714
|
-
}
|
|
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
|
-
|
|
724
|
-
const existing = await probeExistingPath(cwd, targetParam, vfs);
|
|
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
|
-
|
|
730
|
-
if (existing) {
|
|
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
|
-
}
|
|
741
|
-
|
|
742
|
-
if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
|
|
743
|
-
|
|
744
|
-
return isString(params?.about) ? sourceRead(params.about, existing.path, signal, params) : readDirectory(existing.path, signal);
|
|
745
|
-
}
|
|
746
|
-
|
|
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
|
-
}
|
|
755
|
-
const targetPath = resolveReadPath(cwd, targetParam);
|
|
756
|
-
|
|
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 });
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
/** Plain text, a line window, or (with `about`) a relevance-folded outline of the whole file. */
|
|
840
|
-
async function readFile(targetPath, params, sourceLine, displayPath, query, signal) {
|
|
841
|
-
const cwd = getCwd();
|
|
842
|
-
const rel = displayPath ?? relativeSlash(cwd, targetPath);
|
|
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
|
-
|
|
848
|
-
if (params.json !== undefined) {
|
|
849
|
-
const project = jsonProjector(params.json);
|
|
850
|
-
const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES, label: "JSON input" });
|
|
851
|
-
let document;
|
|
852
|
-
|
|
853
|
-
try { document = JSON.parse(text); }
|
|
854
|
-
catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
|
|
855
|
-
|
|
856
|
-
const many = Array.isArray(params.json);
|
|
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;
|
|
860
|
-
const parts = [];
|
|
861
|
-
|
|
862
|
-
try {
|
|
863
|
-
let index = 0;
|
|
864
|
-
|
|
865
|
-
for (const value of project(document)) {
|
|
866
|
-
const encoded = JSON.stringify(value);
|
|
867
|
-
|
|
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)));
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
return textResult(many ? "[" + parts.join(",") + "]" : parts[0], { path: targetPath, json: true, complete: true });
|
|
882
|
-
}
|
|
883
|
-
|
|
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()];
|
|
885
|
-
|
|
886
|
-
if (mime) {
|
|
887
|
-
const staged = vfs.getOverlay(targetPath);
|
|
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");
|
|
920
|
-
|
|
921
|
-
return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
|
|
922
|
-
}
|
|
923
|
-
|
|
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
|
-
|
|
945
|
-
index.touch(rel);
|
|
946
|
-
|
|
947
|
-
if (isString(params?.about)) {
|
|
948
|
-
const entry = WorkspaceIndex.fromText(targetPath, text);
|
|
949
|
-
const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath)));
|
|
950
|
-
|
|
951
|
-
if (outline) {
|
|
952
|
-
recordOutlineOrigins(rel, outline.text);
|
|
953
|
-
|
|
954
|
-
return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
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;
|
|
975
|
-
const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
976
|
-
const sliced = windowed ? text : sliceLinesRaw(text, offset, limit);
|
|
977
|
-
|
|
978
|
-
if (params.complete === true && (sliced !== text || sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget))) {
|
|
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`);
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
if (sliced.length > budget || (params.resolve && JSON.stringify(sliced).length > budget) || (windowed && !windowSatisfied)) {
|
|
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");
|
|
984
|
-
let cap = budget - 160;
|
|
985
|
-
|
|
986
|
-
if (params.resolve) {
|
|
987
|
-
// Budget the actual JSON string, not a pessimistic fixed escape multiplier.
|
|
988
|
-
let low = 0, high = Math.max(0, cap);
|
|
989
|
-
|
|
990
|
-
while (low < high) {
|
|
991
|
-
const mid = Math.ceil((low + high) / 2);
|
|
992
|
-
|
|
993
|
-
if (JSON.stringify(sliced.slice(0, mid)).length <= budget - 160) low = mid;
|
|
994
|
-
else high = mid - 1;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
cap = low;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
const end = sliced.lastIndexOf("\n", cap);
|
|
1001
|
-
|
|
1002
|
-
if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
|
|
1003
|
-
const body = sliced.slice(0, end + 1);
|
|
1004
|
-
const next = firstLine + body.split("\n").length - 1;
|
|
1005
|
-
ledger.recordOrigin(rel, firstLine, sourceLines(body), explicit);
|
|
1006
|
-
|
|
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 });
|
|
1008
|
-
}
|
|
1009
|
-
|
|
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);
|
|
1014
|
-
|
|
1015
|
-
return textResult(sliced, { path: targetPath, firstLine, lastLine: firstLine + slicedLineCount - 1, sourceChars: sliced.length, complete: windowed ? windowWhole : sliced === text, viewComplete });
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
/**
|
|
1019
|
-
* The edit result answers the follow-ups a model would otherwise spend turns on: the post-edit
|
|
1020
|
-
* lines with numbers, a quick structural check, and bounded lexical reference hints.
|
|
1021
|
-
* These do not replace tests or semantic caller resolution.
|
|
1022
|
-
*/
|
|
1023
|
-
async function editSummary(cwd, target, original, updated, diff, signal, span) {
|
|
1024
|
-
const rel = relativeSlash(cwd, target);
|
|
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
|
-
};
|
|
1034
|
-
const ranges = [];
|
|
1035
|
-
|
|
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);
|
|
1043
|
-
|
|
1044
|
-
for (const line of positions) {
|
|
1045
|
-
const start = Math.max(1, line - 2), end = Math.min(lineCount, line + 2);
|
|
1046
|
-
|
|
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
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
const blocks = [];
|
|
1053
|
-
const perRange = Math.max(1, Math.floor(40 / Math.max(1, ranges.length)));
|
|
1054
|
-
|
|
1055
|
-
for (const { start, end } of ranges) {
|
|
1056
|
-
const last = Math.min(end, start + perRange - 1);
|
|
1057
|
-
const lines = Array.from({ length: Math.max(0, last - start + 1) }, (_, i) => lineAt(start + i));
|
|
1058
|
-
ledger.recordOrigin(rel, start, lines);
|
|
1059
|
-
blocks.push("edited " + rel + ":" + start + "-" + last + "\n" + lines.map((line, i) => String(start + i).padStart(5) + " " + line).join("\n"));
|
|
1060
|
-
|
|
1061
|
-
if (last < end) blocks.push("[continue with read({path:" + JSON.stringify(rel) + ",offset:" + (last + 1) + ",limit:" + (end - last) + "})]");
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
let out = blocks.join("\n");
|
|
1065
|
-
const check = updated.length <= QUICK_CHECK_MAX_CHARS ? quickCheck(updated, path.extname(target)) : null;
|
|
1066
|
-
|
|
1067
|
-
if (check && !check.ok) out += `\ncheck: ${check.message}`;
|
|
1068
|
-
const refs = await changedDeclarationRefs(cwd, target, original, updated, diff, signal);
|
|
1069
|
-
|
|
1070
|
-
if (refs) out += `\n${refs}`;
|
|
1071
|
-
|
|
1072
|
-
return out;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
async function changedDeclarationRefs(cwd, target, original, updated, diff, signal) {
|
|
1076
|
-
// Diff rows carry the replaced fragments; declarations live on whole file lines.
|
|
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
|
-
};
|
|
1087
|
-
const names = new Set();
|
|
1088
|
-
const spans = new Map();
|
|
1089
|
-
|
|
1090
|
-
for (const l of diff.lines) {
|
|
1091
|
-
if (l.type === "context") continue;
|
|
1092
|
-
const number = l.type === "remove" ? l.lineNum : l.newLineNum ?? l.lineNum;
|
|
1093
|
-
const name = declaredName(changedLine(l.type === "remove" ? original : updated, l.type === "remove" ? oldLines : newLines, number));
|
|
1094
|
-
|
|
1095
|
-
if (name) names.add(name);
|
|
1096
|
-
else if (canMapOwners) {
|
|
1097
|
-
if (!spans.has(l.type)) spans.set(l.type, WorkspaceIndex.spansOf(WorkspaceIndex.fromText(target, l.type === "remove" ? original : updated)));
|
|
1098
|
-
const owner = spans.get(l.type).find(span => span.start <= number && number <= span.end);
|
|
1099
|
-
|
|
1100
|
-
if (owner?.name) names.add(owner.name);
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
if (names.size >= 3) break;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
if (names.size === 0) return "";
|
|
1107
|
-
|
|
1108
|
-
try {
|
|
1109
|
-
const { references, incomplete } = await referencesForNames({ root: cwd, names: [...names].slice(0, 3),
|
|
1110
|
-
excludePath: target, overlayText: file => vfs.getOverlay(file), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
1111
|
-
|
|
1112
|
-
const parts = [];
|
|
1113
|
-
|
|
1114
|
-
for (const [name, refs] of references) {
|
|
1115
|
-
if (refs.length) parts.push(name + " also referenced in " + refs.slice(0, 6).join(", ") + (refs.length > 6 ? " (more matches)" : ""));
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
if (incomplete) parts.push("references incomplete: search budget reached");
|
|
1119
|
-
|
|
1120
|
-
return parts.join("\n");
|
|
1121
|
-
} catch (error) {
|
|
1122
|
-
signal?.throwIfAborted();
|
|
1123
|
-
|
|
1124
|
-
return "references unavailable: " + error.message;
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
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;
|
|
1129
|
-
|
|
1130
|
-
/** Fresh bounded source window for a diagnostic; no index warmup or stale cached bodies. */
|
|
1131
|
-
async function sourceWindow(cwd, commandCwd, file, lineNo, signal) {
|
|
1132
|
-
const candidate = path.resolve(commandCwd, file);
|
|
1133
|
-
let text, rel;
|
|
1134
|
-
|
|
1135
|
-
try {
|
|
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;
|
|
1139
|
-
|
|
1140
|
-
if (!candidate.startsWith(cwdPrefix) && !candidate.startsWith(rootPrefix)) return null;
|
|
1141
|
-
const real = await fs.realpath(candidate);
|
|
1142
|
-
const handle = await fs.open(real, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
1143
|
-
|
|
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(); }
|
|
1161
|
-
rel = relativeSlash(root, real);
|
|
1162
|
-
} catch { return null; }
|
|
1163
|
-
|
|
1164
|
-
const raw = text.split("\n");
|
|
1165
|
-
|
|
1166
|
-
if (lineNo < 1 || lineNo > raw.length) return null;
|
|
1167
|
-
const start = Math.max(1, lineNo - 2);
|
|
1168
|
-
const rows = [];
|
|
1169
|
-
|
|
1170
|
-
for (let l = start; l <= Math.min(raw.length, lineNo + 2); l++) rows.push((l === lineNo ? "►" : " ") + String(l).padStart(4) + " " + raw[l - 1]);
|
|
1171
|
-
ledger.recordOrigin(rel, start, rows);
|
|
1172
|
-
|
|
1173
|
-
return rel + ":" + lineNo + "\n" + rows.join("\n");
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
/** A failing command names path:line; the model wants those lines next. Attach them (≤4 sites). */
|
|
1177
|
-
async function sourceForReferences(cwd, commandCwd, output, signal) {
|
|
1178
|
-
const seen = new Set();
|
|
1179
|
-
const blocks = [];
|
|
1180
|
-
|
|
1181
|
-
for (const m of output.matchAll(SOURCE_REF)) {
|
|
1182
|
-
const key = m[1] + ":" + m[2];
|
|
1183
|
-
|
|
1184
|
-
if (seen.has(key)) continue;
|
|
1185
|
-
|
|
1186
|
-
if (seen.size >= 4) break;
|
|
1187
|
-
seen.add(key);
|
|
1188
|
-
const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]), signal);
|
|
1189
|
-
|
|
1190
|
-
if (block) blocks.push(block);
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
return blocks.length ? "\n--- source\n" + blocks.join("\n") : "";
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
function outlineOptions(params, references) {
|
|
1197
|
-
const options = { references };
|
|
1198
|
-
|
|
1199
|
-
if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
|
|
1200
|
-
|
|
1201
|
-
return options;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
/** Outline lines carry their own line numbers (" 330 text"); provenance follows them. */
|
|
1205
|
-
function recordOutlineOrigins(rel, outlineText) {
|
|
1206
|
-
for (const line of outlineText.split("\n")) {
|
|
1207
|
-
const m = /^\s*(\d+) (.*)$/.exec(line);
|
|
1208
|
-
|
|
1209
|
-
if (m && !/ … \d+ lines$/.test(line)) ledger.recordOrigin(rel, Number(m[1]), [line]);
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
/** Where else a name appears (declaration line excluded), for outlines and edit results. */
|
|
1214
|
-
async function referenceFinder(cwd, targetPath) {
|
|
1215
|
-
let files;
|
|
1216
|
-
|
|
1217
|
-
try { files = [...new Set([...await index.files(cwd), ...vfs.getOverlayPaths()])]; }
|
|
1218
|
-
catch { return () => []; }
|
|
1219
|
-
|
|
1220
|
-
if (!index.canScan(files)) return () => [];
|
|
1221
|
-
|
|
1222
|
-
return (name, excludeLine) => {
|
|
1223
|
-
if (!name || name.length < 3) return [];
|
|
1224
|
-
const escaped = name.replace(/[$]/g, (c) => "\\" + c);
|
|
1225
|
-
const regex = new RegExp("\\b" + escaped + "\\b");
|
|
1226
|
-
|
|
1227
|
-
return index
|
|
1228
|
-
.grepRows(files, regex, cwd, file => vfs.getOverlay(file))
|
|
1229
|
-
.filter((r) => !(r.line === excludeLine && r.rel === relativeSlash(cwd, targetPath)))
|
|
1230
|
-
.map((r) => r.rel + ":" + r.line);
|
|
1231
|
-
};
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
hooks.summarizeEdit = editSummary;
|
|
1235
|
-
|
|
1236
|
-
const adapters = {
|
|
1237
|
-
read: readAdapter,
|
|
1238
|
-
async write(params, signal) {
|
|
1239
|
-
const cwd = getCwd();
|
|
1240
|
-
const target = await resolveWorkspacePath(cwd, params?.path, "write", false);
|
|
1241
|
-
|
|
1242
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
1243
|
-
|
|
1244
|
-
if (!isString(params?.content)) throw new Error("write requires string content");
|
|
1245
|
-
|
|
1246
|
-
if (params.append !== undefined && params.append !== true && params.append !== false) throw new Error("write append must be a boolean");
|
|
1247
|
-
if (params.allowReadArtifacts !== undefined && typeof params.allowReadArtifacts !== "boolean") throw new Error("write allowReadArtifacts must be a boolean");
|
|
1248
|
-
let content = String(params.content);
|
|
1249
|
-
|
|
1250
|
-
if (params.allowReadArtifacts !== true && /\[read truncated;|…\[[^\]\n]*truncated[^\]\n]*\]…/u.test(content)) {
|
|
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");
|
|
1252
|
-
}
|
|
1253
|
-
|
|
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
|
-
}
|
|
1284
|
-
|
|
1285
|
-
const { speculative } = await vfs.write(target, content);
|
|
1286
|
-
index.touch(relativeSlash(cwd, target));
|
|
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);
|
|
1290
|
-
const tag = speculative ? " (speculative)" : "";
|
|
1291
|
-
const check = content.length <= QUICK_CHECK_MAX_CHARS ? quickCheck(content, path.extname(target)) : null;
|
|
1292
|
-
const warning = check && !check.ok ? "\ncheck: " + check.message : "";
|
|
1293
|
-
|
|
1294
|
-
return textResult(`wrote ${target}${tag}${warning}`, { path: target, speculative, diff });
|
|
1295
|
-
},
|
|
1296
|
-
async edit(params, signal) {
|
|
1297
|
-
const cwd = getCwd();
|
|
1298
|
-
const target = await resolveWorkspacePath(cwd, params?.path, "edit", false);
|
|
1299
|
-
|
|
1300
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
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
|
-
|
|
1324
|
-
const requestedEdits = Array.isArray(params?.edits)
|
|
1325
|
-
? params.edits
|
|
1326
|
-
: [{ oldText: params?.oldText, newText: params?.newText }];
|
|
1327
|
-
const { updated, matches } = applyReplacements(target, content, requestedEdits);
|
|
1328
|
-
const { speculative } = await vfs.write(target, updated);
|
|
1329
|
-
index.touch(relativeSlash(cwd, target));
|
|
1330
|
-
|
|
1331
|
-
const diff = content.length > 512 * 1024 || updated.length > 512 * 1024
|
|
1332
|
-
? boundedEditDiff(target, content, matches)
|
|
1333
|
-
: matches.length === 1
|
|
1334
|
-
? buildEditDiff(target, content, matches[0].oldText, matches[0].newText)
|
|
1335
|
-
: buildMultiEditDiff(target, content, matches);
|
|
1336
|
-
|
|
1337
|
-
const summary = await editSummary(cwd, target, content, updated, diff, signal);
|
|
1338
|
-
|
|
1339
|
-
return textResult(summary, { path: target, speculative, diff });
|
|
1340
|
-
},
|
|
1341
|
-
async apply_patch(params, signal) {
|
|
1342
|
-
const cwd = getCwd();
|
|
1343
|
-
let inputPath = params?.path;
|
|
1344
|
-
|
|
1345
|
-
if (!inputPath && isString(params?.patch)) {
|
|
1346
|
-
for (const match of params.patch.matchAll(/^(?:---|\+\+\+)\s+([^\t\n]+)/gm)) {
|
|
1347
|
-
const candidate = match[1].trim().replace(/^[ab]\//, "");
|
|
1348
|
-
|
|
1349
|
-
if (candidate !== "/dev/null") { inputPath = candidate; break; }
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
const target = await resolveWorkspacePath(cwd, inputPath, "apply_patch", false);
|
|
1354
|
-
|
|
1355
|
-
if (!isString(params?.patch) || !params.patch.trim()) {
|
|
1356
|
-
throw new Error("apply_patch requires patch");
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
1360
|
-
|
|
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");
|
|
1370
|
-
const { resultText, hunkCount } = applyPatchToText(original, params.patch);
|
|
1371
|
-
const { speculative } = await vfs.write(target, resultText);
|
|
1372
|
-
const diff = buildPatchDiff(target, params.patch);
|
|
1373
|
-
index.touch(relativeSlash(cwd, target));
|
|
1374
|
-
const summary = await editSummary(cwd, target, original, resultText, diff, signal);
|
|
1375
|
-
|
|
1376
|
-
return textResult(summary, {
|
|
1377
|
-
path: target,
|
|
1378
|
-
hunks: hunkCount,
|
|
1379
|
-
speculative,
|
|
1380
|
-
diff,
|
|
1381
|
-
});
|
|
1382
|
-
},
|
|
1383
|
-
async snap(params, signal) {
|
|
1384
|
-
const cwd = getCwd();
|
|
1385
|
-
|
|
1386
|
-
if (!isString(params?.query) || !params.query.trim()) {
|
|
1387
|
-
throw new Error("snap requires query");
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
1391
|
-
const snapTarget = params?.path ? await resolveWorkspacePath(cwd, params.path, "snap", true) : cwd;
|
|
1392
|
-
const relativeRoot = path.relative(cwd, snapTarget);
|
|
1393
|
-
|
|
1394
|
-
const includeHidden = Boolean(params?.path) && relativeRoot
|
|
1395
|
-
.split(path.sep)
|
|
1396
|
-
.some((segment) => segment.startsWith(".") && segment.length > 1);
|
|
1397
|
-
|
|
1398
|
-
const res = await executeSnap({
|
|
1399
|
-
query: params.query,
|
|
1400
|
-
searchDir: snapTarget,
|
|
1401
|
-
root: cwd,
|
|
1402
|
-
includeHidden,
|
|
1403
|
-
overlayText: (p) => vfs.getOverlay(p),
|
|
1404
|
-
pendingPaths: vfs.getOverlayPaths(),
|
|
1405
|
-
signal,
|
|
1406
|
-
});
|
|
1407
|
-
|
|
1408
|
-
return textResult(JSON.stringify(res, null, 2), res);
|
|
1409
|
-
},
|
|
1410
|
-
async evidence(params, signal) {
|
|
1411
|
-
const cwd = getCwd();
|
|
1412
|
-
|
|
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");
|
|
1415
|
-
|
|
1416
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
1417
|
-
const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
|
|
1418
|
-
const options = {};
|
|
1419
|
-
|
|
1420
|
-
if (Number.isInteger(params?.k) && params.k > 0) options.k = Math.min(params.k, 20);
|
|
1421
|
-
|
|
1422
|
-
if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
|
|
1423
|
-
const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options });
|
|
1424
|
-
|
|
1425
|
-
for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
|
|
1426
|
-
|
|
1427
|
-
return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
|
|
1428
|
-
},
|
|
1429
|
-
async surface(params, signal) {
|
|
1430
|
-
const cwd = getCwd();
|
|
1431
|
-
const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
|
|
1432
|
-
|
|
1433
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
1434
|
-
const text = await vfs.read(target, { maxBytes: 2 * 1024 * 1024 });
|
|
1435
|
-
const ext = path.extname(target);
|
|
1436
|
-
const outline = extractStructuralSurface(text, ext);
|
|
1437
|
-
|
|
1438
|
-
return textResult(JSON.stringify(outline, null, 2), { path: target, count: outline.items.length });
|
|
1439
|
-
},
|
|
1440
|
-
async bash(params, signal) {
|
|
1441
|
-
const cwd = getCwd();
|
|
1442
|
-
const literal = Array.isArray(params?.args) && process.platform !== "win32" && params.args.length === Object.keys(params.args).length && params.args.every(isString);
|
|
1443
|
-
|
|
1444
|
-
if (params?.command !== undefined && !isString(params.command)) throw new Error("bash command must be a string");
|
|
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");
|
|
1446
|
-
const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
|
|
1447
|
-
|
|
1448
|
-
if (!command.trim()) throw new Error("bash requires command");
|
|
1449
|
-
const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
|
|
1450
|
-
|
|
1451
|
-
// String commands keep shell semantics; literal argv needs no quoting or shell startup.
|
|
1452
|
-
const argv = literal ? [command, ...params.args] : ["bash", "-c", command];
|
|
1453
|
-
|
|
1454
|
-
const transactionBarrier = await vfs.prepareExternalMutation("bash");
|
|
1455
|
-
let res;
|
|
1456
|
-
|
|
1457
|
-
try {
|
|
1458
|
-
res = await runCommand(argv, {
|
|
1459
|
-
cwd: targetCwd,
|
|
1460
|
-
env: hooks.commandEnv(),
|
|
1461
|
-
commandLabel: literal ? command : undefined,
|
|
1462
|
-
timeoutMs: params?.timeoutMs,
|
|
1463
|
-
signal,
|
|
1464
|
-
maxOutputChars: config.maxCallResultChars,
|
|
1465
|
-
});
|
|
1466
|
-
} catch (error) {
|
|
1467
|
-
if (!signal?.aborted) error.message += await sourceForReferences(cwd, targetCwd, error.message, signal);
|
|
1468
|
-
throw error;
|
|
1469
|
-
} finally {
|
|
1470
|
-
vfs.invalidateCache();
|
|
1471
|
-
index.invalidate();
|
|
1472
|
-
clearPathCache();
|
|
1473
|
-
hooks.workspaceChanged();
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
const { stdout, stderr } = res;
|
|
1477
|
-
let text = stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
|
|
1478
|
-
|
|
1479
|
-
if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text, signal);
|
|
1480
|
-
|
|
1481
|
-
return {
|
|
1482
|
-
content: [{ type: "text", text }],
|
|
1483
|
-
details: { exitCode: res.exitCode, signal: res.signal, outputTruncated: res.outputTruncated, transactionBarrier },
|
|
1484
|
-
isError: res.exitCode !== 0,
|
|
1485
|
-
};
|
|
1486
|
-
},
|
|
1487
|
-
async grep(params, signal) {
|
|
1488
|
-
const cwd = getCwd();
|
|
1489
|
-
const pattern = String(params?.pattern || "");
|
|
1490
|
-
|
|
1491
|
-
if (!pattern) throw new Error("grep requires pattern");
|
|
1492
|
-
const searchPath = params?.path ? await resolveWorkspacePath(cwd, params.path, "grep", true) : cwd;
|
|
1493
|
-
const indexed = await grepIndexed(index, pattern, params, searchPath, cwd, file => vfs.getOverlay(file), vfs.getOverlayPaths());
|
|
1494
|
-
|
|
1495
|
-
if (indexed !== null) return textResult(indexed, { exitCode: indexed ? 0 : 1, via: "index" });
|
|
1496
|
-
// Large tree: real rg keeps its own output format.
|
|
1497
|
-
const res = await runCommand(["rg", ...rgGrepArgs(pattern, params, searchPath)], { cwd, timeoutMs: 30_000, signal });
|
|
1498
|
-
|
|
1499
|
-
if (res.exitCode !== 0 && res.exitCode !== 1) {
|
|
1500
|
-
throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
return textResult(res.stdout, { exitCode: res.exitCode });
|
|
1504
|
-
},
|
|
1505
|
-
async glob(params, signal) {
|
|
1506
|
-
const cwd = getCwd();
|
|
1507
|
-
const pattern = String(params?.pattern || "");
|
|
1508
|
-
|
|
1509
|
-
if (!pattern) throw new Error("glob requires pattern");
|
|
1510
|
-
const fuzzy = await fuzzyFind(index, cwd, cwd, pattern, 20, vfs.getOverlayPaths());
|
|
1511
|
-
|
|
1512
|
-
if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
|
|
1513
|
-
const indexed = await listIndexed(index, cwd, cwd, pattern, vfs.getOverlayPaths());
|
|
1514
|
-
|
|
1515
|
-
if (indexed !== null) return textResult(indexed, { via: "index" });
|
|
1516
|
-
|
|
1517
|
-
return listWithTools(cwd, pattern, cwd, signal, vfs.getOverlayPaths());
|
|
1518
|
-
},
|
|
1519
|
-
async find(params, signal) {
|
|
1520
|
-
const cwd = getCwd();
|
|
1521
|
-
const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "find", true) : cwd;
|
|
1522
|
-
const pattern = params?.pattern || params?.glob;
|
|
1523
|
-
|
|
1524
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
1525
|
-
const globPattern = pattern ? String(pattern) : null;
|
|
1526
|
-
const fuzzy = await fuzzyFind(index, searchDir, cwd, globPattern, 20, vfs.getOverlayPaths());
|
|
1527
|
-
|
|
1528
|
-
if (fuzzy !== null) return textResult(fuzzy, { via: "fuzzy" });
|
|
1529
|
-
const indexed = await listIndexed(index, searchDir, cwd, globPattern, vfs.getOverlayPaths());
|
|
1530
|
-
|
|
1531
|
-
if (indexed !== null) return textResult(indexed, { via: "index" });
|
|
1532
|
-
|
|
1533
|
-
return listWithTools(searchDir, globPattern, cwd, signal, vfs.getOverlayPaths());
|
|
1534
|
-
},
|
|
1535
|
-
async ls(params, signal) {
|
|
1536
|
-
const cwd = getCwd();
|
|
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
|
-
}
|
|
1552
|
-
|
|
1553
|
-
return readDirectory(dirPath, signal);
|
|
1554
|
-
},
|
|
1555
|
-
};
|
|
1556
|
-
|
|
1557
|
-
return adapters;
|
|
1558
|
-
}
|
|
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
|
-
*/
|
|
1565
17
|
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
|
|
1566
18
|
const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
1567
19
|
const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
|
|
@@ -1620,17 +72,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1620
72
|
return env;
|
|
1621
73
|
};
|
|
1622
74
|
|
|
1623
|
-
|
|
75
|
+
function captureHostTool(tool, excluded) {
|
|
76
|
+
return tool && isString(tool.name) && isFunction(tool.execute) && tool.name !== "supernova" && !excluded.has(tool.name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function wrapHostRegister() {
|
|
80
|
+
if (registry || !pi || !isFunction(pi.registerTool)) return;
|
|
1624
81
|
const original = pi.registerTool.bind(pi);
|
|
1625
82
|
const excluded = new Set(config.excludeTools || []);
|
|
1626
83
|
pi.registerTool = (tool) => {
|
|
1627
|
-
if (
|
|
1628
|
-
tool &&
|
|
1629
|
-
isString(tool.name) &&
|
|
1630
|
-
isFunction(tool.execute) &&
|
|
1631
|
-
tool.name !== "supernova" &&
|
|
1632
|
-
!excluded.has(tool.name)
|
|
1633
|
-
) {
|
|
84
|
+
if (captureHostTool(tool, excluded)) {
|
|
1634
85
|
executors.set(tool.name, tool.execute.bind(tool));
|
|
1635
86
|
definitions.set(tool.name, tool);
|
|
1636
87
|
}
|
|
@@ -1639,6 +90,8 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1639
90
|
};
|
|
1640
91
|
}
|
|
1641
92
|
|
|
93
|
+
wrapHostRegister();
|
|
94
|
+
|
|
1642
95
|
function bindCallContext(ctx, signal) {
|
|
1643
96
|
activeCtx = ctx || null;
|
|
1644
97
|
const sessionId = ctx?.sessionManager?.getSessionId?.();
|
|
@@ -1671,32 +124,35 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1671
124
|
catch { return undefined; }
|
|
1672
125
|
}
|
|
1673
126
|
|
|
1674
|
-
function
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
127
|
+
function callableEnv() {
|
|
128
|
+
return {
|
|
129
|
+
excluded: new Set(config.excludeTools || []),
|
|
130
|
+
hostSession,
|
|
131
|
+
natives,
|
|
132
|
+
executors,
|
|
133
|
+
sessionInvalid: () => hostSession && (hostSession.isDisposed || hostSession.sessionManager?.getSessionId?.() !== boundSessionId),
|
|
134
|
+
nativeOwned: name => Object.hasOwn(natives, name) && !executors.has(name)
|
|
135
|
+
&& (!hostSession || !definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"),
|
|
136
|
+
evalAllows: name => {
|
|
137
|
+
if (!evalToolNames().includes(name) && definitions.has(name)) return false;
|
|
1678
138
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
if (nativeOwned) return true;
|
|
1684
|
-
|
|
1685
|
-
if (hostSession) {
|
|
1686
|
-
const evalNames = evalToolNames();
|
|
1687
|
-
|
|
1688
|
-
if (!evalNames.includes(name) && definitions.has(name)) return false;
|
|
1689
|
-
|
|
1690
|
-
return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
|
|
1691
|
-
}
|
|
139
|
+
return !!hostTool(name) || (Object.hasOwn(natives, name) && (!definitions.has(name) || definitions.get(name).sourceInfo?.source === "builtin"));
|
|
140
|
+
},
|
|
141
|
+
listed: name => {
|
|
142
|
+
let activeTools;
|
|
1692
143
|
|
|
1693
|
-
|
|
144
|
+
try { activeTools = isFunction(pi?.getActiveTools) ? pi.getActiveTools() : undefined; } catch {}
|
|
1694
145
|
|
|
1695
|
-
|
|
146
|
+
if (definitions.has(name) && Array.isArray(activeTools) && !activeTools.includes(name)) return false;
|
|
1696
147
|
|
|
1697
|
-
|
|
148
|
+
return executors.has(name) || Object.hasOwn(natives, name);
|
|
149
|
+
},
|
|
150
|
+
hostTool,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
1698
153
|
|
|
1699
|
-
|
|
154
|
+
function isCallable(name) {
|
|
155
|
+
return toolIsCallable(name, callableEnv());
|
|
1700
156
|
}
|
|
1701
157
|
|
|
1702
158
|
function refreshTools() {
|
|
@@ -1725,7 +181,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1725
181
|
callCount = 0;
|
|
1726
182
|
trace = [];
|
|
1727
183
|
// Files may change between programs (editor, git); never serve a stale run.
|
|
1728
|
-
vfs.
|
|
184
|
+
vfs.invalidateObserved();
|
|
1729
185
|
clearPathCache();
|
|
1730
186
|
}
|
|
1731
187
|
|
|
@@ -1757,8 +213,15 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1757
213
|
} catch {}
|
|
1758
214
|
}
|
|
1759
215
|
|
|
1760
|
-
function
|
|
216
|
+
function assertRunOpen(name) {
|
|
1761
217
|
if (closed) throw new Error("program is already complete");
|
|
218
|
+
|
|
219
|
+
if (activeSignal?.aborted) throw new Error("aborted");
|
|
220
|
+
|
|
221
|
+
if (!isString(name) || !name) throw new Error("tool name required");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function chargeCallBudget() {
|
|
1762
225
|
const maxCalls = config.maxBridgeCalls ?? 256;
|
|
1763
226
|
|
|
1764
227
|
if (budget && ++budget.calls > maxCalls) throw new Error("host call budget exceeded (" + maxCalls + " calls per program batch): split the batch");
|
|
@@ -1769,10 +232,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1769
232
|
`host call budget exceeded (${maxCalls} calls per program): split the work across programs`,
|
|
1770
233
|
);
|
|
1771
234
|
}
|
|
1772
|
-
|
|
1773
|
-
if (activeSignal?.aborted) throw new Error("aborted");
|
|
1774
|
-
|
|
1775
|
-
if (!isString(name) || !name) throw new Error("tool name required");
|
|
1776
235
|
}
|
|
1777
236
|
|
|
1778
237
|
function assertCallableTarget(name) {
|
|
@@ -1789,32 +248,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1789
248
|
async function writeFallbackDiff(name, args) {
|
|
1790
249
|
if (name !== "write" || !isString(args?.path) || !isString(args?.content)) return undefined;
|
|
1791
250
|
const target = await resolveWorkspacePath(getCwd(), args.path, "write", false);
|
|
1792
|
-
|
|
1793
|
-
let removedLines;
|
|
1794
|
-
let stat;
|
|
1795
|
-
|
|
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
|
-
}
|
|
1817
|
-
}
|
|
251
|
+
const { previous, removedLines } = await writeSnapshot(vfs, target, activeSignal);
|
|
1818
252
|
|
|
1819
253
|
return removedLines === undefined ? buildWriteDiff(target, previous, args.content) : boundedWriteDiff(target, args.content, removedLines);
|
|
1820
254
|
}
|
|
@@ -1835,6 +269,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1835
269
|
const value = args[key];
|
|
1836
270
|
|
|
1837
271
|
if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
|
|
272
|
+
// eslint-disable-next-line anti-slop/no-runtime-typeof -- display-only label: the value is already handled, this names its kind for the trace.
|
|
1838
273
|
else if (Array.isArray(value)) out[key] = value.slice(0, 128).map(item => isString(item) ? truncateChars(item, 240, "trace").text : typeof item);
|
|
1839
274
|
}
|
|
1840
275
|
|
|
@@ -1845,65 +280,70 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1845
280
|
return out;
|
|
1846
281
|
}
|
|
1847
282
|
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
283
|
+
function assertOwnedOverride(name, args) {
|
|
284
|
+
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");
|
|
285
|
+
if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
|
|
286
|
+
}
|
|
1852
287
|
|
|
1853
|
-
|
|
288
|
+
async function invokeOverride(target, name, args, record, callId) {
|
|
289
|
+
assertOwnedOverride(name, args);
|
|
290
|
+
const fallbackDiff = await writeFallbackDiff(name, args);
|
|
291
|
+
const mutating = isMutatingTool(name, config, args, definitions.get(name));
|
|
1854
292
|
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
notifyCall(record);
|
|
293
|
+
if (mutating) await vfs.prepareExternalMutation(name);
|
|
294
|
+
if (activeSignal?.aborted || closed) throw new Error("aborted");
|
|
295
|
+
if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
|
|
1859
296
|
|
|
1860
297
|
try {
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
// Explicit overrides own all reads. Options, even false-valued ones,
|
|
1865
|
-
// must not silently bypass the host executor.
|
|
1866
|
-
if (exec && !argvOwned) {
|
|
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");
|
|
298
|
+
const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated
|
|
299
|
+
? { ...activeCtx, settings: hostSession.settings, toolNames: evalToolNames(), autoApprove: false }
|
|
300
|
+
: activeCtx);
|
|
1868
301
|
|
|
1869
|
-
|
|
1870
|
-
const fallbackDiff = await writeFallbackDiff(name, args);
|
|
1871
|
-
const mutating = isMutatingTool(name, config, args, definitions.get(name));
|
|
302
|
+
completeRecord(record, res, fallbackDiff);
|
|
1872
303
|
|
|
1873
|
-
|
|
304
|
+
return res;
|
|
305
|
+
} finally {
|
|
306
|
+
if (mutating) { vfs.invalidateObserved(); index.invalidate(); clearPathCache(); notifyWorkspaceChanged(); }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
1874
309
|
|
|
1875
|
-
|
|
310
|
+
async function invokeNative(target, args, record) {
|
|
311
|
+
const res = await target.native(target.argvOwned ? { ...args, args: args.args.map(String) } : args || {}, activeSignal);
|
|
312
|
+
completeRecord(record, res);
|
|
1876
313
|
|
|
1877
|
-
|
|
314
|
+
return res;
|
|
315
|
+
}
|
|
1878
316
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
317
|
+
function failRecord(record, error) {
|
|
318
|
+
record.ok = false;
|
|
319
|
+
record.ms = Date.now() - record.time;
|
|
320
|
+
record.error = error instanceof Error ? error.message : String(error);
|
|
321
|
+
notifyCall(record);
|
|
322
|
+
}
|
|
1883
323
|
|
|
1884
|
-
|
|
324
|
+
async function invokeRaw(name, args) {
|
|
325
|
+
assertRunOpen(name);
|
|
326
|
+
const callId = ++sharedRegistry.callSeq;
|
|
327
|
+
assertCallableTarget(name);
|
|
1885
328
|
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
329
|
+
if (!isCallable(name)) throw new Error(unknownToolMessage(name, [...definitions.keys(), ...Object.keys(natives)].filter(isCallable)));
|
|
330
|
+
// Refused calls are free: only charge the budget once a target will run.
|
|
331
|
+
chargeCallBudget();
|
|
1891
332
|
|
|
1892
|
-
|
|
333
|
+
const command = { apply_patch: "edit", surface: "read", evidence: "read", snap: "read" }[name] ?? name;
|
|
334
|
+
const record = { name: command, adapter: name, args: traceArgs(args), time: Date.now() };
|
|
335
|
+
trace.push(record);
|
|
336
|
+
notifyCall(record);
|
|
1893
337
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
completeRecord(record, res);
|
|
338
|
+
try {
|
|
339
|
+
const target = resolveInvokeTarget(name, args, { hostTool, hostSession, executors, natives });
|
|
1897
340
|
|
|
1898
|
-
|
|
1899
|
-
|
|
341
|
+
if (target.kind === "override") return await invokeOverride(target, name, args, record, callId);
|
|
342
|
+
if (target.kind === "native") return await invokeNative(target, args, record);
|
|
1900
343
|
|
|
1901
344
|
throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
|
|
1902
345
|
} catch (error) {
|
|
1903
|
-
record
|
|
1904
|
-
record.ms = Date.now() - record.time;
|
|
1905
|
-
record.error = error instanceof Error ? error.message : String(error);
|
|
1906
|
-
notifyCall(record);
|
|
346
|
+
failRecord(record, error);
|
|
1907
347
|
throw error;
|
|
1908
348
|
}
|
|
1909
349
|
}
|
|
@@ -1967,7 +407,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1967
407
|
// Windows command shims need shell handling; preserve the existing route there.
|
|
1968
408
|
supportsNativeArgv: () => process.platform !== "win32" && !hostTool("bash") && !executors.has("bash"),
|
|
1969
409
|
summarizeEdit: (target, before, after, diff) => hooks.summarizeEdit(getCwd(), target, before, after, diff),
|
|
1970
|
-
invalidateFiles() { vfs.
|
|
410
|
+
invalidateFiles() { vfs.invalidateObserved(); index.invalidate(); clearPathCache(); },
|
|
411
|
+
describeMemory() {
|
|
412
|
+
const overlays = vfs.describeOverlays();
|
|
413
|
+
|
|
414
|
+
// No body cache: reads always hit disk, so retained VFS bytes are always zero.
|
|
415
|
+
return { vfsCacheBytes: 0, indexBytes: index.getEntryBytes(), overlayFiles: overlays.files, overlayBytes: overlays.bytes };
|
|
416
|
+
},
|
|
1971
417
|
fileOperations: {
|
|
1972
418
|
access: (target, mode) => fs.access(target, mode),
|
|
1973
419
|
readFile: async target => Buffer.from(await vfs.read(target), "utf8"),
|
|
@@ -1992,7 +438,6 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
1992
438
|
beginSpeculation,
|
|
1993
439
|
commitSpeculation,
|
|
1994
440
|
rollbackSpeculation,
|
|
1995
|
-
getVfsCacheSize: () => vfs.getCacheSize(),
|
|
1996
441
|
getOverlayDepth: () => vfs.getOverlayDepth(),
|
|
1997
442
|
call,
|
|
1998
443
|
callMany,
|