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
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isString, isNumber } from "../shared/decode.js";
|
|
4
|
+
import { extractStructuralSurface } from "../context/surface.js";
|
|
5
|
+
import { pickSpan } from "../context/spans.js";
|
|
6
|
+
import { executeSnap, tokenizeQuery, stem } from "../context/snap.js";
|
|
7
|
+
import { selectEvidence } from "../context/evidence.js";
|
|
8
|
+
import { WorkspaceIndex } from "../context/repo-index.js";
|
|
9
|
+
import { outlineFile } from "../context/outline.js";
|
|
10
|
+
import { MAX_JSON_BYTES, jsonProjector } from "../fs/json-read.js";
|
|
11
|
+
import { normalizeRead, classifyRead, needsProbe, SESSION_URI, buildJsonRouting, buildSelectionRouting, routingText } from "../contract/read.js";
|
|
12
|
+
import { resolveWorkspacePath, runCommand, relativeSlash } from "../fs/workspace.js";
|
|
13
|
+
import {
|
|
14
|
+
textResult, sliceLinesRawInfo, sliceLinesRaw,
|
|
15
|
+
normalizeReadWindow, resolveReadPath, probeExistingPath,
|
|
16
|
+
sourceLines, lineStartIndex, lineTextRange, contentLineInfo,
|
|
17
|
+
formatDirectoryEntry, formatLsEntry, MAX_DIRECTORY_ENTRIES,
|
|
18
|
+
jsonStringLength, maxJsonStringPrefix,
|
|
19
|
+
} from "../fs/text-ops.js";
|
|
20
|
+
import { imageTooLarge, missingFile, IMAGE_MAX_BYTES, LARGE_FILE_BYTES, ABOUT_TOKEN_MAX, IMAGE_MIME, RAW_JSON_CHARS, RAW_SOURCE_CHARS, RAW_SOURCE_LINES, ROUTING_MAX_CHARS } from "./errors.js";
|
|
21
|
+
import { outlineOptions, recordOutlineOrigins, createReferenceFinder } from "./refs.js";
|
|
22
|
+
|
|
23
|
+
export function createRead(ctx) {
|
|
24
|
+
const { getCwd, vfs, config, index, ledger, hooks, reads } = ctx;
|
|
25
|
+
const referenceFinder = createReferenceFinder(index, vfs);
|
|
26
|
+
async function sourceRead(query, searchDir, signal, params = {}) {
|
|
27
|
+
params = { ...params, resolve: params.resolve !== false };
|
|
28
|
+
const cwd = getCwd();
|
|
29
|
+
|
|
30
|
+
const includeHidden = path.relative(cwd, searchDir).split(path.sep)
|
|
31
|
+
.some(segment => segment.startsWith(".") && segment.length > 1);
|
|
32
|
+
|
|
33
|
+
const result = await executeSnap({ query, searchDir, root: cwd, includeHidden,
|
|
34
|
+
pathContext: { frecency: index.frecency, currentFile: index.lastTouched },
|
|
35
|
+
overlayText: p => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), signal });
|
|
36
|
+
|
|
37
|
+
return openSource(result, params, signal, undefined, query);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function sourceIsBounded(target, query, params) {
|
|
41
|
+
if (!isString(query) || params.complete === true) return false;
|
|
42
|
+
const overlay = vfs.getOverlay(target);
|
|
43
|
+
|
|
44
|
+
if (overlay !== undefined) return Buffer.byteLength(overlay, "utf8") > 512 * 1024;
|
|
45
|
+
try { return (await fs.stat(target)).size > 512 * 1024; } catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function openSource(result, params, signal, resolvedPath, query) {
|
|
49
|
+
const cwd = getCwd();
|
|
50
|
+
|
|
51
|
+
if (result.status !== "found") return textResult(JSON.stringify(result), { isSnap: true });
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
const target = resolvedPath ?? path.resolve(cwd, result.path);
|
|
54
|
+
const bounded = await sourceIsBounded(target, query, params);
|
|
55
|
+
|
|
56
|
+
const opened = await readFile(target, bounded
|
|
57
|
+
? { ...params, about: undefined, offset: Math.max(1, result.line - 4), limit: params.limit ?? 120 }
|
|
58
|
+
: { ...params, about: undefined }, result.line, result.path, bounded ? undefined : query, signal);
|
|
59
|
+
const block = opened.content?.[0];
|
|
60
|
+
|
|
61
|
+
if (block?.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
|
|
62
|
+
|
|
63
|
+
return openedSource(result, params, block, opened.details);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function openedSource(result, params, block, details) {
|
|
67
|
+
const { firstLine, lastLine, sourceChars, nextOffset, complete } = details;
|
|
68
|
+
|
|
69
|
+
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 }), { ...details, isSnap: true });
|
|
70
|
+
|
|
71
|
+
return foundSource(result, params, block, details, firstLine, lastLine, sourceChars, nextOffset, complete);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function foundSource(result, params, block, details, firstLine, lastLine, sourceChars, nextOffset, complete) {
|
|
75
|
+
const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
|
|
76
|
+
text: block.text.slice(0, sourceChars), complete, nextOffset };
|
|
77
|
+
|
|
78
|
+
return textResult(params.resolve ? JSON.stringify(source) : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
|
|
79
|
+
{ ...details, isSnap: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function overlayDirRows(dirPath, rows) {
|
|
83
|
+
let truncated = false;
|
|
84
|
+
|
|
85
|
+
for (const file of vfs.getOverlayPaths()) {
|
|
86
|
+
if (rows.size >= MAX_DIRECTORY_ENTRIES) return true;
|
|
87
|
+
const relative = path.relative(dirPath, file);
|
|
88
|
+
|
|
89
|
+
if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) continue;
|
|
90
|
+
const [name, child] = relative.split(path.sep);
|
|
91
|
+
rows.set(name, child === undefined
|
|
92
|
+
? formatDirectoryEntry(name, "file", Buffer.byteLength(vfs.getOverlay(file), "utf8"))
|
|
93
|
+
: formatDirectoryEntry(name, "dir"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return truncated;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function diskDirRows(dirPath, rows, signal) {
|
|
100
|
+
let entries;
|
|
101
|
+
|
|
102
|
+
try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch (error) {
|
|
103
|
+
if (error.code !== "ENOENT" || rows.size === 0) throw error;
|
|
104
|
+
entries = [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < entries.length; i++) {
|
|
108
|
+
if ((i & 127) === 0) signal?.throwIfAborted();
|
|
109
|
+
if (rows.size >= MAX_DIRECTORY_ENTRIES) return true;
|
|
110
|
+
if (!rows.has(entries[i].name)) rows.set(entries[i].name, await formatLsEntry(dirPath, entries[i]));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readDirectory(dirPath, signal) {
|
|
117
|
+
signal?.throwIfAborted();
|
|
118
|
+
const rows = new Map();
|
|
119
|
+
let truncated = overlayDirRows(dirPath, rows);
|
|
120
|
+
truncated = await diskDirRows(dirPath, rows, signal) || truncated;
|
|
121
|
+
|
|
122
|
+
const values = [...rows.values()];
|
|
123
|
+
const text = values.join("\n") + (truncated ? "\n[directory listing truncated at " + MAX_DIRECTORY_ENTRIES + " entries]" : "");
|
|
124
|
+
|
|
125
|
+
return textResult(text, { path: dirPath, directory: true, count: rows.size, entries: values, outputTruncated: truncated });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function overlayWindow(overlay, startLine, lineCount) {
|
|
129
|
+
const window = sliceLinesRawInfo(overlay, startLine, lineCount);
|
|
130
|
+
const satisfied = lineCount === undefined || lineCount === 0 || window.text === "" || window.count >= lineCount || window.eof;
|
|
131
|
+
|
|
132
|
+
return { text: window.text, satisfied, whole: window.whole };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function skipToStart(scan, bytesRead, linesSeen, startLine) {
|
|
136
|
+
let begin = 0;
|
|
137
|
+
|
|
138
|
+
while (begin < bytesRead && linesSeen < startLine - 1) if (scan[begin++] === 10) linesSeen++;
|
|
139
|
+
|
|
140
|
+
return { begin, linesSeen, started: linesSeen >= startLine - 1 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function clipWanted(scan, begin, bytesRead, linesSeen, wantedLines) {
|
|
144
|
+
let end = bytesRead;
|
|
145
|
+
let done = false;
|
|
146
|
+
let doneAt = -1;
|
|
147
|
+
|
|
148
|
+
if (wantedLines === Infinity) return { end, linesSeen, done, doneAt };
|
|
149
|
+
|
|
150
|
+
for (let i = begin; i < bytesRead; i++) {
|
|
151
|
+
if (scan[i] !== 10) continue;
|
|
152
|
+
linesSeen++;
|
|
153
|
+
if (linesSeen !== wantedLines) continue;
|
|
154
|
+
end = i + 1;
|
|
155
|
+
done = true;
|
|
156
|
+
doneAt = end;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { end, linesSeen, done, doneAt };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function consumeScanChunk(scan, bytesRead, state) {
|
|
164
|
+
let { linesSeen, started, startByte, position, startLine, wantedLines, maxBytes, collected, parts } = state;
|
|
165
|
+
let begin = 0;
|
|
166
|
+
let done = false;
|
|
167
|
+
let doneByte = -1;
|
|
168
|
+
|
|
169
|
+
if (!started) {
|
|
170
|
+
const skip = skipToStart(scan, bytesRead, linesSeen, startLine);
|
|
171
|
+
linesSeen = skip.linesSeen;
|
|
172
|
+
if (!skip.started) return { ...state, linesSeen, skip: true, done: false, doneByte: -1 };
|
|
173
|
+
started = true;
|
|
174
|
+
begin = skip.begin;
|
|
175
|
+
startByte = position + begin;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const clip = clipWanted(scan, begin, bytesRead, linesSeen, wantedLines);
|
|
179
|
+
linesSeen = clip.linesSeen;
|
|
180
|
+
if (clip.done) { done = true; doneByte = position + clip.doneAt; }
|
|
181
|
+
collected = takeScanSlice(scan, begin, clip, position, startByte, maxBytes, collected, parts);
|
|
182
|
+
|
|
183
|
+
return { linesSeen, started, startByte, done, doneByte, collected, parts, skip: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function takeScanSlice(scan, begin, clip, position, startByte, maxBytes, collected, parts) {
|
|
187
|
+
const takeBegin = position === startByte ? begin : Math.max(0, startByte - position);
|
|
188
|
+
const takeEnd = Math.min(clip.end, takeBegin + Math.max(0, maxBytes + 1 - collected));
|
|
189
|
+
|
|
190
|
+
if (takeEnd > takeBegin) {
|
|
191
|
+
parts.push(Buffer.from(scan.subarray(takeBegin, takeEnd)));
|
|
192
|
+
collected += takeEnd - takeBegin;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return collected;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function scanFileWindow(file, stat, startLine, lineCount, maxBytes, signal) {
|
|
199
|
+
const wantedLines = lineCount === undefined ? Infinity : startLine + lineCount - 1;
|
|
200
|
+
const scan = Buffer.alloc(64 * 1024);
|
|
201
|
+
const parts = [];
|
|
202
|
+
let position = 0;
|
|
203
|
+
let linesSeen = 0;
|
|
204
|
+
let started = startLine === 1;
|
|
205
|
+
let startByte = started ? 0 : -1;
|
|
206
|
+
let done = false;
|
|
207
|
+
let doneByte = -1;
|
|
208
|
+
let collected = 0;
|
|
209
|
+
|
|
210
|
+
while (position < stat.size && !done && collected <= maxBytes) {
|
|
211
|
+
signal?.throwIfAborted();
|
|
212
|
+
const { bytesRead } = await file.read(scan, 0, scan.length, position);
|
|
213
|
+
|
|
214
|
+
if (bytesRead <= 0) break;
|
|
215
|
+
const chunk = consumeScanChunk(scan, bytesRead, { linesSeen, started, startByte, position, startLine, wantedLines, maxBytes, collected, parts });
|
|
216
|
+
linesSeen = chunk.linesSeen;
|
|
217
|
+
started = chunk.started;
|
|
218
|
+
startByte = chunk.startByte;
|
|
219
|
+
done = chunk.done;
|
|
220
|
+
doneByte = chunk.doneByte;
|
|
221
|
+
collected = chunk.collected;
|
|
222
|
+
position += bytesRead;
|
|
223
|
+
if (chunk.skip) continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { parts, collected, started, startByte, done, doneByte, position };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function openReadFile(targetPath) {
|
|
230
|
+
try { return await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error.code === "ENOENT") throw missingFile(targetPath);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function finishFileWindow(targetPath, stat, startLine, scan) {
|
|
238
|
+
const text = Buffer.concat(scan.parts, scan.collected).toString("utf8");
|
|
239
|
+
const satisfied = (scan.done && scan.startByte + scan.collected >= scan.doneByte) || scan.startByte + scan.collected >= stat.size;
|
|
240
|
+
const whole = startLine === 1 && scan.startByte === 0 && scan.startByte + scan.collected >= stat.size;
|
|
241
|
+
await vfs.recordExpected(targetPath, stat);
|
|
242
|
+
|
|
243
|
+
return { text, satisfied, whole };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function emptyWindow(targetPath, stat) {
|
|
247
|
+
return { text: "", satisfied: true, whole: stat.size === 0 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function readWindow(targetPath, startLine, lineCount, maxBytes, signal) {
|
|
251
|
+
const overlay = vfs.getOverlay(targetPath);
|
|
252
|
+
|
|
253
|
+
if (overlay !== undefined) return overlayWindow(overlay, startLine, lineCount);
|
|
254
|
+
const file = await openReadFile(targetPath);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const stat = await file.stat();
|
|
258
|
+
|
|
259
|
+
if (!stat.isFile()) throw new Error("read requires a regular file: " + targetPath);
|
|
260
|
+
if (lineCount === 0) return emptyWindow(targetPath, stat);
|
|
261
|
+
const scan = await scanFileWindow(file, stat, startLine, lineCount, maxBytes, signal);
|
|
262
|
+
|
|
263
|
+
if (!scan.started) return emptyWindow(targetPath, stat);
|
|
264
|
+
|
|
265
|
+
return finishFileWindow(targetPath, stat, startLine, scan);
|
|
266
|
+
} finally {
|
|
267
|
+
await file.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function readAdapter(params, signal) {
|
|
272
|
+
signal?.throwIfAborted();
|
|
273
|
+
params = normalizeRead(normalizeReadWindow(params));
|
|
274
|
+
const cwd = getCwd();
|
|
275
|
+
const targetParam = params.path;
|
|
276
|
+
|
|
277
|
+
if (Array.isArray(targetParam)) {
|
|
278
|
+
|
|
279
|
+
const results = await Promise.all(targetParam.map(async p => {
|
|
280
|
+
try {
|
|
281
|
+
const res = await readAdapter({ ...params, path: p, target: undefined }, signal);
|
|
282
|
+
const block = res.content?.[0];
|
|
283
|
+
const item = block?.type === "image" ? block
|
|
284
|
+
: res.details?.directory === true && Array.isArray(res.details.entries) ? res.details.entries
|
|
285
|
+
: block?.text ?? "";
|
|
286
|
+
|
|
287
|
+
return { text: item };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
signal?.throwIfAborted();
|
|
290
|
+
|
|
291
|
+
return { text: `[read error: ${p}] ${error.message}`, error: { path: p, message: error.message } };
|
|
292
|
+
}
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
signal?.throwIfAborted();
|
|
296
|
+
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) });
|
|
297
|
+
response.isError = params._independent !== true && results.some(r => r.error);
|
|
298
|
+
|
|
299
|
+
return response;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return reads.schedule("read", () => readSingle(params, cwd, targetParam, signal), signal);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function sessionUriParts(uri) {
|
|
306
|
+
const match = /^(agent|artifact):\/\/([^/?#]+)$/i.exec(uri);
|
|
307
|
+
|
|
308
|
+
if (!match) throw new Error("session resource reads support bare agent://<id> and artifact://<number>; use offset/limit for pagination");
|
|
309
|
+
const kind = match[1].toLowerCase();
|
|
310
|
+
const id = decodeURIComponent(match[2]);
|
|
311
|
+
|
|
312
|
+
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");
|
|
313
|
+
|
|
314
|
+
return { kind, id };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function findArtifactFile(root, id, uri, signal) {
|
|
318
|
+
const matches = [];
|
|
319
|
+
let count = 0;
|
|
320
|
+
|
|
321
|
+
for await (const entry of await fs.opendir(root)) {
|
|
322
|
+
signal?.throwIfAborted();
|
|
323
|
+
|
|
324
|
+
if (++count > 4096) throw new Error("session artifact lookup exceeded its directory budget");
|
|
325
|
+
|
|
326
|
+
if (entry.name.startsWith(id + ".") && !entry.isDirectory()) matches.push(entry.name);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (matches.length !== 1) throw new Error(matches.length ? "ambiguous session artifact: " + uri : "session artifact not found: " + uri);
|
|
330
|
+
|
|
331
|
+
return matches[0];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function resolveSessionResource(uri, signal) {
|
|
335
|
+
const { kind, id } = sessionUriParts(uri);
|
|
336
|
+
const dir = hooks.artifactsDir?.();
|
|
337
|
+
|
|
338
|
+
if (!isString(dir) || !dir) throw new Error("this host session does not expose an artifacts directory for " + uri);
|
|
339
|
+
signal?.throwIfAborted();
|
|
340
|
+
const root = await fs.realpath(dir);
|
|
341
|
+
const file = kind === "artifact" ? await findArtifactFile(root, id, uri, signal) : id + ".md";
|
|
342
|
+
const target = await fs.realpath(path.join(root, file));
|
|
343
|
+
|
|
344
|
+
if (!target.startsWith(root + path.sep)) throw new Error("session resource escapes its artifacts directory");
|
|
345
|
+
|
|
346
|
+
if (!(await fs.stat(target)).isFile()) throw new Error("session resource is not a file: " + uri);
|
|
347
|
+
signal?.throwIfAborted();
|
|
348
|
+
|
|
349
|
+
return target;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function readSingle(params, cwd, targetParam, signal) {
|
|
353
|
+
params = normalizeRead({ ...params, path: targetParam });
|
|
354
|
+
const existing = needsProbe(params) ? await probeExistingPath(cwd, params.path, vfs) : null;
|
|
355
|
+
const cls = classifyRead(params, existing);
|
|
356
|
+
const relOf = hit => relativeSlash(cwd, hit.path);
|
|
357
|
+
const snapScope = (scoped, hit) => hit?.directory ? hit.path : scoped ? resolveReadPath(cwd, params.path) : cwd;
|
|
358
|
+
const kinds = {
|
|
359
|
+
session: async () => {
|
|
360
|
+
const target = await resolveSessionResource(params.path, signal);
|
|
361
|
+
|
|
362
|
+
return params.resolve
|
|
363
|
+
? openSource({ status: "found", path: params.path, line: params.offset ?? 1 }, params, signal, target)
|
|
364
|
+
: readFile(target, params, undefined, params.path, undefined, signal);
|
|
365
|
+
},
|
|
366
|
+
evidence: () => evidence({ ...params, query: cls.query, path: cls.scope }, signal),
|
|
367
|
+
snap: () => sourceRead(cls.query, snapScope(cls.scoped, cls.existing), signal, params),
|
|
368
|
+
outline: () => surface({ path: params.path }, signal),
|
|
369
|
+
focus: () => focusAbout({ rel: relOf(cls.existing), about: cls.about, overlay: cls.existing.overlay, targetPath: cls.existing.path, signal }),
|
|
370
|
+
open: () => openSource({ status: "found", path: relOf(cls.existing), line: params.offset ?? 1 }, params, signal),
|
|
371
|
+
file: () => readFile(cls.existing ? cls.existing.path : resolveReadPath(cwd, params.path), params, undefined, undefined, undefined, signal),
|
|
372
|
+
dir: () => readDirectory(cls.existing.path, signal),
|
|
373
|
+
missing: () => textResult(JSON.stringify({ status: "not_found", path: null, line: null, signature: "", confidence: 0, context: [] }), { isSnap: true }),
|
|
374
|
+
};
|
|
375
|
+
const run = kinds[cls.kind];
|
|
376
|
+
|
|
377
|
+
if (!run) throw new Error("unhandled read kind: " + cls.kind);
|
|
378
|
+
|
|
379
|
+
return run();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function aboutStems(about, requireStem) {
|
|
383
|
+
const tokens = tokenizeQuery(about).tokens;
|
|
384
|
+
|
|
385
|
+
if (tokens.length > 16) throw new Error("about is too broad; use at most 16 keywords");
|
|
386
|
+
const stems = [...new Set(tokens.map(token => stem(token).slice(0, 128)))];
|
|
387
|
+
|
|
388
|
+
if (requireStem && !stems.length) throw new Error("about needs at least one searchable keyword");
|
|
389
|
+
|
|
390
|
+
return stems;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function overlayHits(overlay, stems, signal) {
|
|
394
|
+
const hits = [];
|
|
395
|
+
let line = 1;
|
|
396
|
+
let start = 0;
|
|
397
|
+
|
|
398
|
+
while (start <= overlay.length) {
|
|
399
|
+
signal?.throwIfAborted();
|
|
400
|
+
const newline = overlay.indexOf("\n", start);
|
|
401
|
+
const end = newline < 0 ? overlay.length : newline + 1;
|
|
402
|
+
const row = overlay.slice(start, newline < 0 ? end : newline).replace(/\r$/, "").toLowerCase();
|
|
403
|
+
|
|
404
|
+
if (stems.some(st => row.includes(st))) {
|
|
405
|
+
hits.push(line);
|
|
406
|
+
if (hits.length >= 200) break;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (end === overlay.length) break;
|
|
410
|
+
start = end;
|
|
411
|
+
line++;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { hits, lineCount: line };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function overlayWindows(rel, overlay, hits, lineCount, budget) {
|
|
418
|
+
const out = [];
|
|
419
|
+
let cursor = 1;
|
|
420
|
+
let used = 0;
|
|
421
|
+
let truncated = hits.length >= 200;
|
|
422
|
+
|
|
423
|
+
for (const hit of hits) {
|
|
424
|
+
const from = Math.max(cursor, hit - 3);
|
|
425
|
+
const first = lineStartIndex(overlay, from);
|
|
426
|
+
const last = lineTextRange(overlay, Math.min(hit + 3, lineCount)).end;
|
|
427
|
+
const body = overlay.slice(first, last);
|
|
428
|
+
|
|
429
|
+
if (used + body.length > budget) { truncated = true; break; }
|
|
430
|
+
if (out.length && from > cursor) out.push("...");
|
|
431
|
+
out.push(`// ${rel}:${from}\n${body}`);
|
|
432
|
+
used += body.length;
|
|
433
|
+
cursor = Math.max(cursor, hit + 4);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { out, truncated };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function focusDisk(rel, about, targetPath, stems, budget, signal) {
|
|
440
|
+
const args = ["rg", "--fixed-strings", "--ignore-case", "--line-number", "--before-context", "3", "--after-context", "3"];
|
|
441
|
+
|
|
442
|
+
for (const token of stems) args.push("-e", token);
|
|
443
|
+
args.push("--", targetPath);
|
|
444
|
+
const observed = await fs.stat(targetPath);
|
|
445
|
+
const res = await runCommand(args, { cwd: path.dirname(targetPath), timeoutMs: 15000, maxOutputChars: budget, signal });
|
|
446
|
+
|
|
447
|
+
if (res.exitCode === 0 || res.exitCode === 1) await vfs.recordExpected(targetPath, observed);
|
|
448
|
+
if (res.exitCode === 1) return textResult("// " + rel + " · no matching text\n", { path: targetPath, outputTruncated: false, complete: false });
|
|
449
|
+
if (res.exitCode !== 0) throw new Error(res.stderr.trim() || `rg exited ${res.exitCode}`);
|
|
450
|
+
const marker = res.outputTruncated ? "\n[focused read truncated; narrow about or use read(path, line, count)]" : "";
|
|
451
|
+
|
|
452
|
+
return textResult("// " + rel + " · focused text windows (not a complete file); read(path, line, count) for raw text\n" + res.stdout + marker,
|
|
453
|
+
{ path: targetPath, outputTruncated: res.outputTruncated, complete: false });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function focusAbout({ rel, about, overlay, targetPath, signal }) {
|
|
457
|
+
const stems = aboutStems(about, overlay === undefined);
|
|
458
|
+
const budget = readBudget(false);
|
|
459
|
+
|
|
460
|
+
if (overlay === undefined) return focusDisk(rel, about, targetPath, stems, budget, signal);
|
|
461
|
+
const { hits, lineCount } = overlayHits(overlay, stems, signal);
|
|
462
|
+
const { out, truncated } = overlayWindows(rel, overlay, hits, lineCount, budget);
|
|
463
|
+
|
|
464
|
+
if (!out.length) return textResult("// " + rel + (hits.length
|
|
465
|
+
? " · matching text exceeds view budget; first match at line " + hits[0] + "; use read(path, line, count)\n"
|
|
466
|
+
: " · no matching staged text\n"), { path: targetPath, outputTruncated: truncated, complete: false });
|
|
467
|
+
const marker = truncated ? "\n[focused read truncated; narrow about or use read(path, line, count)]" : "";
|
|
468
|
+
|
|
469
|
+
return textResult("// " + rel + " · focused staged text windows (not a complete file)\n" + out.join("\n") + marker, { path: targetPath, outputTruncated: truncated, complete: false });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function readBudget(resolve) {
|
|
473
|
+
return Math.max(1, Math.min(config.maxCallResultChars ?? 65536, config.maxReturnChars ?? 32000) - (resolve ? 1024 : 256));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function encodeJsonParts(project, document, rel, selectors, budget) {
|
|
477
|
+
const parts = [];
|
|
478
|
+
let remaining = budget;
|
|
479
|
+
let index = 0;
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
for (const value of project(document)) {
|
|
483
|
+
const encoded = JSON.stringify(value);
|
|
484
|
+
|
|
485
|
+
if (encoded.length > remaining) {
|
|
486
|
+
const routed = routingText(buildSelectionRouting(rel, selectors[index] ?? "selector", value, encoded.length));
|
|
487
|
+
remaining = Math.max(0, remaining - routed.length);
|
|
488
|
+
parts.push(routed);
|
|
489
|
+
index++;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
remaining -= encoded.length;
|
|
494
|
+
parts.push(encoded);
|
|
495
|
+
index++;
|
|
496
|
+
}
|
|
497
|
+
} catch (error) {
|
|
498
|
+
throw new Error("JSON selection failed for " + rel + " (" + selectors.join(", ") + "): " + (error instanceof Error ? error.message : String(error)));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return parts;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function projectJson(rel, targetPath, params) {
|
|
505
|
+
const project = jsonProjector(params.json);
|
|
506
|
+
const text = await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES, label: "JSON input" });
|
|
507
|
+
let document;
|
|
508
|
+
|
|
509
|
+
try { document = JSON.parse(text); }
|
|
510
|
+
catch { throw new Error("invalid JSON in " + rel + "; the entire document must parse before projection"); }
|
|
511
|
+
|
|
512
|
+
const many = Array.isArray(params.json);
|
|
513
|
+
const selectors = many ? params.json.map(String) : [params.json === true ? "." : String(params.json)];
|
|
514
|
+
const parts = encodeJsonParts(project, document, rel, selectors, readBudget(false) - (many ? params.json.length + 1 : 0));
|
|
515
|
+
|
|
516
|
+
return textResult(many ? "[" + parts.join(",") + "]" : parts[0], { path: targetPath, json: true, complete: true });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function readImage(rel, targetPath, mime, signal) {
|
|
520
|
+
const staged = vfs.getOverlay(targetPath);
|
|
521
|
+
|
|
522
|
+
if (staged !== undefined) {
|
|
523
|
+
const size = Buffer.byteLength(staged, "utf8");
|
|
524
|
+
|
|
525
|
+
if (size > IMAGE_MAX_BYTES) throw imageTooLarge(rel, size);
|
|
526
|
+
|
|
527
|
+
return Buffer.from(staged);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let file;
|
|
531
|
+
|
|
532
|
+
try { file = await fs.open(targetPath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0)); }
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (error.code === "ENOENT") throw missingFile(targetPath);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const stat = await file.stat();
|
|
540
|
+
|
|
541
|
+
if (!stat.isFile()) throw new Error("image read requires a regular file: " + targetPath);
|
|
542
|
+
if (stat.size > IMAGE_MAX_BYTES) throw imageTooLarge(rel, stat.size);
|
|
543
|
+
const bytes = await file.readFile({ signal });
|
|
544
|
+
await vfs.recordExpected(targetPath, stat);
|
|
545
|
+
|
|
546
|
+
return bytes;
|
|
547
|
+
} finally { await file.close(); }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function loadText(targetPath, params, query, budget, signal) {
|
|
551
|
+
const canWindow = params.complete !== true && !isString(params?.about) && !isString(query);
|
|
552
|
+
|
|
553
|
+
if (!canWindow) {
|
|
554
|
+
return { text: await vfs.read(targetPath, { maxBytes: 64 * 1024 * 1024 }), windowed: false, windowSatisfied: true, windowWhole: false };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const startLine = isNumber(params?.offset) ? Math.max(1, Math.floor(params.offset)) : 1;
|
|
558
|
+
const lineCount = isNumber(params?.limit) ? Math.max(0, Math.floor(params.limit)) : undefined;
|
|
559
|
+
const window = await readWindow(targetPath, startLine, lineCount, budget * 4 + 1024, signal);
|
|
560
|
+
|
|
561
|
+
return { text: window.text, windowed: true, windowSatisfied: window.satisfied, windowWhole: window.whole === true };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function maybeOutline(cwd, rel, targetPath, text, params, entry) {
|
|
565
|
+
if (!isString(params?.about)) return null;
|
|
566
|
+
const outline = entry && outlineFile(entry, rel, params.about, outlineOptions(params, await referenceFinder(cwd, targetPath), config));
|
|
567
|
+
|
|
568
|
+
if (!outline) return null;
|
|
569
|
+
recordOutlineOrigins(ledger, rel, outline.text);
|
|
570
|
+
|
|
571
|
+
return textResult(outline.text, { path: targetPath, outline: true, expanded: outline.expanded, declarations: outline.declarations });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function resolveSpan(entry, sourceLine, query, params) {
|
|
575
|
+
if (!params.resolve || !isString(query) || !entry) return { offset: params?.offset, limit: params?.limit, viewComplete: undefined };
|
|
576
|
+
const span = pickSpan(WorkspaceIndex.spansOf(entry), { line: sourceLine, name: query });
|
|
577
|
+
|
|
578
|
+
if (!span) return { offset: params?.offset, limit: params?.limit, viewComplete: undefined };
|
|
579
|
+
const spanLines = span.end - span.start + 1;
|
|
580
|
+
|
|
581
|
+
return { offset: span.start, limit: isNumber(params.limit) ? Math.min(params.limit, spanLines) : spanLines, viewComplete: (isNumber(params.limit) ? Math.min(params.limit, spanLines) : spanLines) >= spanLines };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function jsonFits(sliced, budget) {
|
|
585
|
+
return jsonStringLength(sliced) <= budget;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function jsonRoutingResult(rel, targetPath, text) {
|
|
589
|
+
const routing = routingText(buildJsonRouting(rel, text));
|
|
590
|
+
|
|
591
|
+
if (routing.length > ROUTING_MAX_CHARS) throw new Error("routing response exceeds its budget");
|
|
592
|
+
|
|
593
|
+
return textResult(routing, { path: targetPath, routed: true, complete: false });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function routeWindowedJson(rel, targetPath) {
|
|
597
|
+
try {
|
|
598
|
+
return jsonRoutingResult(rel, targetPath, await vfs.read(targetPath, { maxBytes: MAX_JSON_BYTES, label: "JSON input" }));
|
|
599
|
+
} catch { return null; }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function clipToBudget(rel, targetPath, sliced, firstLine, budget, explicit, params) {
|
|
603
|
+
if (!explicit && !params.resolve && path.extname(targetPath).toLowerCase() === ".json") {
|
|
604
|
+
const routed = await routeWindowedJson(rel, targetPath);
|
|
605
|
+
|
|
606
|
+
if (routed) return routed;
|
|
607
|
+
|
|
608
|
+
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");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const cap = params.resolve ? maxJsonStringPrefix(sliced, budget - 160) : budget - 160;
|
|
612
|
+
|
|
613
|
+
const end = sliced.lastIndexOf("\n", cap);
|
|
614
|
+
|
|
615
|
+
if (end < 0) throw new Error(`line ${firstLine} exceeds the read budget; use bash to inspect a bounded substring`);
|
|
616
|
+
const body = sliced.slice(0, end + 1);
|
|
617
|
+
const next = firstLine + body.split("\n").length - 1;
|
|
618
|
+
ledger.recordOrigin(rel, firstLine, sourceLines(body), explicit);
|
|
619
|
+
|
|
620
|
+
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 });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function assertAbout(params) {
|
|
624
|
+
if (isString(params?.about) && tokenizeQuery(params.about).tokens.length > ABOUT_TOKEN_MAX) {
|
|
625
|
+
throw new Error("about is too broad; use at most 16 keywords");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function rawReadSelected(params) {
|
|
630
|
+
return params.json !== undefined || isString(params.about) || params.outline === true || params.complete === true
|
|
631
|
+
|| isNumber(params.offset) || isNumber(params.limit) || params.resolve === true || params.evidence === true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function checkRawSize(rel, targetPath, loaded, params) {
|
|
635
|
+
if (rawReadSelected(params)) return null;
|
|
636
|
+
if (SESSION_URI.test(rel)) return null;
|
|
637
|
+
if (loaded.windowed && loaded.windowWhole !== true) return null;
|
|
638
|
+
const n = loaded.text.length;
|
|
639
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
640
|
+
|
|
641
|
+
if (ext === ".json" && n > RAW_JSON_CHARS) {
|
|
642
|
+
try { return jsonRoutingResult(rel, targetPath, loaded.text); }
|
|
643
|
+
catch { throw new Error("raw JSON read of " + rel + " is " + n + " chars; use json:\".field\" (or .length), offset/limit, or complete:true"); }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const lines = contentLineInfo(loaded.text).count;
|
|
647
|
+
|
|
648
|
+
if (n > RAW_SOURCE_CHARS || lines > RAW_SOURCE_LINES) {
|
|
649
|
+
throw new Error("raw read of " + rel + " is " + lines + " lines; use about, offset/limit, or complete:true");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function maybeImage(rel, targetPath, signal) {
|
|
656
|
+
const mime = IMAGE_MIME[path.extname(targetPath).toLowerCase()];
|
|
657
|
+
|
|
658
|
+
if (!mime) return null;
|
|
659
|
+
const bytes = await readImage(rel, targetPath, mime, signal);
|
|
660
|
+
|
|
661
|
+
if (bytes.length > IMAGE_MAX_BYTES) throw imageTooLarge(rel, bytes.length);
|
|
662
|
+
|
|
663
|
+
return { content: [{ type: "image", mimeType: mime, data: bytes.toString("base64") }], details: { path: targetPath } };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function viewOffset(span, sourceLine, loaded, budget) {
|
|
667
|
+
if (span.offset !== undefined) return span.offset;
|
|
668
|
+
if (sourceLine && loaded.text.length > budget) return Math.max(1, sourceLine - 2);
|
|
669
|
+
|
|
670
|
+
return 1;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function viewOverBudget(sliced, loaded, budget, params) {
|
|
674
|
+
return sliced.length > budget || (params.resolve && !jsonFits(sliced, budget)) || (loaded.windowed && !loaded.windowSatisfied);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function fileView(loaded, span, sourceLine, budget, params) {
|
|
678
|
+
const offset = viewOffset(span, sourceLine, loaded, budget);
|
|
679
|
+
const firstLine = isNumber(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
680
|
+
const sliced = loaded.windowed ? loaded.text : sliceLinesRaw(loaded.text, offset, span.limit);
|
|
681
|
+
|
|
682
|
+
return { firstLine, sliced, overBudget: viewOverBudget(sliced, loaded, budget, params) };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function assertComplete(rel, sliced, loaded, budget, params) {
|
|
686
|
+
if (params.complete === true && (sliced !== loaded.text || sliced.length > budget || (params.resolve && !jsonFits(sliced, budget)))) {
|
|
687
|
+
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`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function textFileResult(rel, targetPath, loaded, span, view, explicit) {
|
|
692
|
+
const slicedLines = view.sliced.length <= LARGE_FILE_BYTES ? sourceLines(view.sliced) : null;
|
|
693
|
+
|
|
694
|
+
if (slicedLines) ledger.recordOrigin(rel, view.firstLine, slicedLines, explicit);
|
|
695
|
+
|
|
696
|
+
return textResult(view.sliced, { path: targetPath, firstLine: view.firstLine, lastLine: view.firstLine + (slicedLines?.length ?? contentLineInfo(view.sliced).count) - 1, sourceChars: view.sliced.length, complete: loaded.windowed ? loaded.windowWhole : view.sliced === loaded.text, viewComplete: span.viewComplete });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function readTextFile(targetPath, params, sourceLine, rel, query, signal) {
|
|
700
|
+
const explicit = isNumber(params?.offset) || isNumber(params?.limit);
|
|
701
|
+
const budget = readBudget(params.resolve);
|
|
702
|
+
const loaded = await loadText(targetPath, params, query, budget, signal);
|
|
703
|
+
const routed = checkRawSize(rel, targetPath, loaded, params);
|
|
704
|
+
|
|
705
|
+
if (routed) return routed;
|
|
706
|
+
index.touch(rel);
|
|
707
|
+
const needsIndex = isString(params?.about) || (params.resolve && isString(query));
|
|
708
|
+
const entry = needsIndex ? WorkspaceIndex.fromText(targetPath, loaded.text) : null;
|
|
709
|
+
const outlined = await maybeOutline(getCwd(), rel, targetPath, loaded.text, params, entry);
|
|
710
|
+
|
|
711
|
+
if (outlined) return outlined;
|
|
712
|
+
const span = resolveSpan(entry, sourceLine, query, params);
|
|
713
|
+
const view = fileView(loaded, span, sourceLine, budget, params);
|
|
714
|
+
assertComplete(rel, view.sliced, loaded, budget, params);
|
|
715
|
+
|
|
716
|
+
if (view.overBudget) return await clipToBudget(rel, targetPath, view.sliced, view.firstLine, budget, explicit, params);
|
|
717
|
+
|
|
718
|
+
return textFileResult(rel, targetPath, loaded, span, view, explicit);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function readFile(targetPath, params, sourceLine, displayPath, query, signal) {
|
|
722
|
+
const rel = displayPath ?? relativeSlash(getCwd(), targetPath);
|
|
723
|
+
assertAbout(params);
|
|
724
|
+
|
|
725
|
+
if (params.json !== undefined) return projectJson(rel, targetPath, params);
|
|
726
|
+
const image = await maybeImage(rel, targetPath, signal);
|
|
727
|
+
|
|
728
|
+
if (image) return image;
|
|
729
|
+
|
|
730
|
+
return readTextFile(targetPath, params, sourceLine, rel, query, signal);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function snap(params, signal) {
|
|
734
|
+
const cwd = getCwd();
|
|
735
|
+
|
|
736
|
+
if (!isString(params?.query) || !params.query.trim()) {
|
|
737
|
+
throw new Error("snap requires query");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
741
|
+
const snapTarget = params?.path ? await resolveWorkspacePath(cwd, params.path, "snap", true) : cwd;
|
|
742
|
+
const relativeRoot = path.relative(cwd, snapTarget);
|
|
743
|
+
|
|
744
|
+
const includeHidden = Boolean(params?.path) && relativeRoot
|
|
745
|
+
.split(path.sep)
|
|
746
|
+
.some((segment) => segment.startsWith(".") && segment.length > 1);
|
|
747
|
+
|
|
748
|
+
const res = await executeSnap({
|
|
749
|
+
query: params.query,
|
|
750
|
+
searchDir: snapTarget,
|
|
751
|
+
root: cwd,
|
|
752
|
+
includeHidden,
|
|
753
|
+
overlayText: (p) => vfs.getOverlay(p),
|
|
754
|
+
pendingPaths: vfs.getOverlayPaths(),
|
|
755
|
+
signal,
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
return textResult(JSON.stringify(res), res);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function evidenceOptions(params) {
|
|
762
|
+
const options = {};
|
|
763
|
+
|
|
764
|
+
if (Number.isInteger(params?.k) && params.k > 0) options.k = Math.min(params.k, 20);
|
|
765
|
+
|
|
766
|
+
if (Number.isInteger(params?.maxChars) && params.maxChars > 0) options.maxChars = Math.min(params.maxChars, config.maxCallResultChars ?? 65536);
|
|
767
|
+
|
|
768
|
+
return options;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function evidence(params, signal) {
|
|
772
|
+
const cwd = getCwd();
|
|
773
|
+
|
|
774
|
+
if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
|
|
775
|
+
if (tokenizeQuery(params.query).tokens.length > 16) throw new Error("evidence query is too broad; use at most 16 keywords");
|
|
776
|
+
|
|
777
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
778
|
+
const searchDir = params?.path ? await resolveWorkspacePath(cwd, params.path, "evidence", true) : cwd;
|
|
779
|
+
const res = await selectEvidence({ query: params.query, root: cwd, searchDir, index, overlayText: (p) => vfs.getOverlay(p), pendingPaths: vfs.getOverlayPaths(), options: evidenceOptions(params) });
|
|
780
|
+
|
|
781
|
+
for (const span of res.spans) ledger.recordOrigin(span.path, span.lines[0], span.text.split("\n"));
|
|
782
|
+
|
|
783
|
+
return textResult(JSON.stringify(res), { route: res.route, count: res.spans.length });
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function surface(params, signal) {
|
|
787
|
+
const cwd = getCwd();
|
|
788
|
+
const target = await resolveWorkspacePath(cwd, params?.path, "surface", false);
|
|
789
|
+
|
|
790
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
791
|
+
const text = await vfs.read(target, { maxBytes: 2 * 1024 * 1024 });
|
|
792
|
+
const ext = path.extname(target);
|
|
793
|
+
const outline = extractStructuralSurface(text, ext);
|
|
794
|
+
|
|
795
|
+
return textResult(JSON.stringify(outline), { path: target, count: outline.items.length });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return {
|
|
799
|
+
read: readAdapter,
|
|
800
|
+
readDirectory,
|
|
801
|
+
snap,
|
|
802
|
+
evidence,
|
|
803
|
+
surface,
|
|
804
|
+
};
|
|
805
|
+
}
|