pi-supernova 0.8.1 → 0.9.0

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