pi-readseek 0.3.18 → 0.3.20
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/package.json +2 -2
- package/src/edit.ts +425 -409
- package/src/grep.ts +336 -474
- package/src/read.ts +358 -356
- package/src/readseek-value.ts +10 -2
- package/src/refs.ts +9 -37
- package/src/sg.ts +9 -39
- package/src/tui-render-utils.ts +55 -1
package/src/read.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { ExtensionAPI, ToolRenderResultOptions, AgentToolResult } from "@ea
|
|
|
2
2
|
import {
|
|
3
3
|
createReadTool,
|
|
4
4
|
truncateHead,
|
|
5
|
-
formatSize,
|
|
6
5
|
DEFAULT_MAX_BYTES,
|
|
7
6
|
DEFAULT_MAX_LINES,
|
|
8
7
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -47,6 +46,356 @@ interface ReadToolOptions {
|
|
|
47
46
|
onSuccessfulRead?: (absolutePath: string) => void;
|
|
48
47
|
}
|
|
49
48
|
|
|
49
|
+
export interface ExecuteReadOptions {
|
|
50
|
+
toolCallId: string;
|
|
51
|
+
params: unknown;
|
|
52
|
+
signal: AbortSignal | undefined;
|
|
53
|
+
onUpdate: any;
|
|
54
|
+
cwd: string;
|
|
55
|
+
onSuccessfulRead?: (absolutePath: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolResult<any>> {
|
|
59
|
+
const { toolCallId, params, signal, onUpdate, cwd, onSuccessfulRead } = opts;
|
|
60
|
+
await ensureHashInit();
|
|
61
|
+
const rawParams = params as ReadParams;
|
|
62
|
+
const offset = coerceObviousBase10Int(rawParams.offset, "offset");
|
|
63
|
+
if (!offset.ok) {
|
|
64
|
+
return buildToolErrorResult("read", "invalid-offset", offset.message, { path: rawParams.path });
|
|
65
|
+
}
|
|
66
|
+
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
67
|
+
if (!limit.ok) {
|
|
68
|
+
return buildToolErrorResult("read", "invalid-limit", limit.message, { path: rawParams.path });
|
|
69
|
+
}
|
|
70
|
+
if (limit.value !== undefined && limit.value < 1) {
|
|
71
|
+
const message = `Invalid limit: expected a positive integer, received ${limit.value}.`;
|
|
72
|
+
return buildToolErrorResult("read", "invalid-limit", message, { path: rawParams.path });
|
|
73
|
+
}
|
|
74
|
+
if (offset.value !== undefined && offset.value < 1) {
|
|
75
|
+
const message = `Invalid offset: expected a positive integer, received ${offset.value}.`;
|
|
76
|
+
return buildToolErrorResult("read", "invalid-offset", message, { path: rawParams.path });
|
|
77
|
+
}
|
|
78
|
+
const p = {
|
|
79
|
+
...rawParams,
|
|
80
|
+
offset: offset.value,
|
|
81
|
+
limit: limit.value,
|
|
82
|
+
};
|
|
83
|
+
if (rawParams.symbol !== undefined) {
|
|
84
|
+
const trimmedSymbol = typeof rawParams.symbol === "string" ? rawParams.symbol.trim() : "";
|
|
85
|
+
if (trimmedSymbol.length === 0) {
|
|
86
|
+
const message = "Invalid symbol: expected a non-empty string.";
|
|
87
|
+
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
88
|
+
}
|
|
89
|
+
p.symbol = trimmedSymbol;
|
|
90
|
+
}
|
|
91
|
+
const rawPath = p.path.replace(/^@/, "");
|
|
92
|
+
const absolutePath = resolveToCwd(rawPath, cwd);
|
|
93
|
+
const succeed = <T extends AgentToolResult<any>>(result: T): T => {
|
|
94
|
+
const isError = (result as { isError?: boolean }).isError;
|
|
95
|
+
if (!isError) {
|
|
96
|
+
onSuccessfulRead?.(absolutePath);
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
throwIfAborted(signal);
|
|
102
|
+
if (p.symbol && (p.offset !== undefined || p.limit !== undefined)) {
|
|
103
|
+
const message = "Cannot combine symbol with offset/limit. Use one or the other.";
|
|
104
|
+
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
105
|
+
}
|
|
106
|
+
if (p.bundle && !p.symbol) {
|
|
107
|
+
const message = 'Cannot use bundle without symbol. Use read({ path, symbol, bundle: "local" }).';
|
|
108
|
+
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
109
|
+
}
|
|
110
|
+
if (p.bundle && p.map) {
|
|
111
|
+
const message = "Cannot combine bundle with map. Use one or the other.";
|
|
112
|
+
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
113
|
+
}
|
|
114
|
+
if (p.map && p.symbol) {
|
|
115
|
+
const message = "Cannot combine map with symbol. Use one or the other.";
|
|
116
|
+
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
117
|
+
}
|
|
118
|
+
// Delegate images to the built-in read tool
|
|
119
|
+
throwIfAborted(signal);
|
|
120
|
+
const ext = rawPath.split(".").pop()?.toLowerCase() ?? "";
|
|
121
|
+
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
|
122
|
+
const builtinRead = createReadTool(cwd);
|
|
123
|
+
return succeed(await builtinRead.execute(toolCallId, p, signal, onUpdate));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
throwIfAborted(signal);
|
|
127
|
+
let rawBuffer: Buffer;
|
|
128
|
+
try {
|
|
129
|
+
rawBuffer = await fsReadFile(absolutePath);
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
const code = err?.code;
|
|
132
|
+
if (code === "EISDIR") {
|
|
133
|
+
const message = `Path is a directory: ${rawPath}. Use ls to inspect directories.`;
|
|
134
|
+
return buildToolErrorResult("read", "path-is-directory", message, { path: rawParams.path, hint: `Use ls(${JSON.stringify(rawPath)}) to inspect directories.` });
|
|
135
|
+
}
|
|
136
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
137
|
+
const message = `Permission denied — cannot access: ${rawPath}`;
|
|
138
|
+
return buildToolErrorResult("read", "permission-denied", message, { path: rawParams.path });
|
|
139
|
+
}
|
|
140
|
+
if (code === "ENOENT") {
|
|
141
|
+
const message = `File not found: ${rawPath}`;
|
|
142
|
+
return buildToolErrorResult("read", "file-not-found", message, { path: rawParams.path });
|
|
143
|
+
}
|
|
144
|
+
const message = `File not readable: ${rawPath}${err?.message ? ` — ${err.message}` : ""}`;
|
|
145
|
+
return buildToolErrorResult("read", "fs-error", message, { path: rawParams.path, details: { fsCode: code, fsMessage: err?.message } });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isSupportedImageBuffer(rawBuffer)) {
|
|
149
|
+
const builtinRead = createReadTool(cwd);
|
|
150
|
+
return succeed(await builtinRead.execute(toolCallId, p, signal, onUpdate));
|
|
151
|
+
}
|
|
152
|
+
const hasBinaryContent = looksLikeBinary(rawBuffer);
|
|
153
|
+
throwIfAborted(signal);
|
|
154
|
+
const normalized = normalizeToLF(stripBom(rawBuffer.toString("utf-8")).text);
|
|
155
|
+
const allLines = splitReadseekLines(normalized);
|
|
156
|
+
const total = allLines.length;
|
|
157
|
+
const structuredWarnings: ReadseekWarning[] = [];
|
|
158
|
+
let startLine = p.offset !== undefined ? p.offset : 1;
|
|
159
|
+
let endIdx = p.limit !== undefined ? Math.min(startLine - 1 + p.limit, total) : total;
|
|
160
|
+
if (p.offset !== undefined && startLine > total) {
|
|
161
|
+
const message = `[offset ${p.offset} is past end of file (${total} lines)]`;
|
|
162
|
+
return buildToolErrorResult("read", "offset-past-end", message, { path: rawParams.path });
|
|
163
|
+
}
|
|
164
|
+
let symbolMatch: SymbolMatch | undefined;
|
|
165
|
+
let symbolFileMap: Awaited<ReturnType<typeof getOrGenerateMap>> | null = null;
|
|
166
|
+
let symbolWarning: string | undefined;
|
|
167
|
+
let bundleMetadata:
|
|
168
|
+
| {
|
|
169
|
+
mode: "local";
|
|
170
|
+
applied: boolean;
|
|
171
|
+
localSupport: Array<{
|
|
172
|
+
symbol: {
|
|
173
|
+
query: string;
|
|
174
|
+
name: string;
|
|
175
|
+
kind: string;
|
|
176
|
+
parentName?: string;
|
|
177
|
+
startLine: number;
|
|
178
|
+
endLine: number;
|
|
179
|
+
};
|
|
180
|
+
lines: string[];
|
|
181
|
+
}>;
|
|
182
|
+
warnings: ReadseekWarning[];
|
|
183
|
+
}
|
|
184
|
+
| null = null;
|
|
185
|
+
if (p.symbol) {
|
|
186
|
+
symbolFileMap = await getOrGenerateMap(absolutePath);
|
|
187
|
+
if (!symbolFileMap) {
|
|
188
|
+
const extLabel = ext || "unknown";
|
|
189
|
+
symbolWarning = `[Warning: symbol lookup not available for .${extLabel} files — showing full file]\n\n`;
|
|
190
|
+
} else {
|
|
191
|
+
const lookup = findSymbol(symbolFileMap, p.symbol);
|
|
192
|
+
if (lookup.type === "ambiguous") {
|
|
193
|
+
return succeed({
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: formatAmbiguous(p.symbol, lookup.candidates),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
isError: false,
|
|
201
|
+
details: {},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (lookup.type === "not-found") {
|
|
205
|
+
symbolWarning = `${formatNotFound(p.symbol, symbolFileMap)}\n\n`;
|
|
206
|
+
}
|
|
207
|
+
if (lookup.type === "found") {
|
|
208
|
+
startLine = Math.max(1, lookup.symbol.startLine);
|
|
209
|
+
endIdx = Math.min(total, lookup.symbol.endLine);
|
|
210
|
+
symbolMatch = lookup.symbol;
|
|
211
|
+
}
|
|
212
|
+
if (lookup.type === "fuzzy") {
|
|
213
|
+
startLine = Math.max(1, lookup.symbol.startLine);
|
|
214
|
+
endIdx = Math.min(total, lookup.symbol.endLine);
|
|
215
|
+
symbolMatch = lookup.symbol;
|
|
216
|
+
|
|
217
|
+
const tierLabel = lookup.tier === "camelCase" ? "camelCase word boundary" : "substring";
|
|
218
|
+
const otherNames = lookup.otherCandidates.map((c) => `\`${c.name}\``).join(", ");
|
|
219
|
+
const confirmHint = `read({ symbol: "${lookup.symbol.name}" }) or ${lookup.symbol.name}@${lookup.symbol.startLine} to select by start line`;
|
|
220
|
+
const lines = [
|
|
221
|
+
`[Symbol '${p.symbol}' not exact-matched. Closest match: \`${lookup.symbol.name}\` (${lookup.symbol.kind}, lines ${lookup.symbol.startLine}-${lookup.symbol.endLine}) via ${tierLabel}.`,
|
|
222
|
+
];
|
|
223
|
+
if (otherNames) lines.push(` Other candidates: ${otherNames}.`);
|
|
224
|
+
lines.push(` To confirm: ${confirmHint}.]`);
|
|
225
|
+
const bannerText = lines.join("\n");
|
|
226
|
+
structuredWarnings.push(
|
|
227
|
+
buildReadseekWarning("fuzzy-symbol-match", bannerText, {
|
|
228
|
+
tier: lookup.tier,
|
|
229
|
+
symbol: lookup.symbol,
|
|
230
|
+
otherCandidates: lookup.otherCandidates,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (p.bundle === "local") {
|
|
238
|
+
if (!symbolFileMap) {
|
|
239
|
+
const extLabel = ext || "unknown";
|
|
240
|
+
const warning = buildReadseekWarning(
|
|
241
|
+
"bundle-unmappable",
|
|
242
|
+
`[Warning: local bundle unavailable because symbol mapping is not available for .${extLabel} files — showing plain symbol read]`,
|
|
243
|
+
);
|
|
244
|
+
structuredWarnings.push(warning);
|
|
245
|
+
bundleMetadata = {
|
|
246
|
+
mode: "local",
|
|
247
|
+
applied: false,
|
|
248
|
+
localSupport: [],
|
|
249
|
+
warnings: [warning],
|
|
250
|
+
};
|
|
251
|
+
} else if (!symbolMatch) {
|
|
252
|
+
bundleMetadata = {
|
|
253
|
+
mode: "local",
|
|
254
|
+
applied: false,
|
|
255
|
+
localSupport: [],
|
|
256
|
+
warnings: [],
|
|
257
|
+
};
|
|
258
|
+
} else {
|
|
259
|
+
const bundle = buildLocalBundle(symbolFileMap, symbolMatch, allLines);
|
|
260
|
+
if (!bundle) {
|
|
261
|
+
const warning = buildReadseekWarning(
|
|
262
|
+
"bundle-context-unavailable",
|
|
263
|
+
`[Warning: local bundle context could not be determined for symbol '${symbolMatch.name}' — showing plain symbol read]`,
|
|
264
|
+
);
|
|
265
|
+
structuredWarnings.push(warning);
|
|
266
|
+
bundleMetadata = {
|
|
267
|
+
mode: "local",
|
|
268
|
+
applied: false,
|
|
269
|
+
localSupport: [],
|
|
270
|
+
warnings: [warning],
|
|
271
|
+
};
|
|
272
|
+
} else {
|
|
273
|
+
bundleMetadata = {
|
|
274
|
+
mode: "local",
|
|
275
|
+
applied: true,
|
|
276
|
+
localSupport: bundle.support.map((item) => ({
|
|
277
|
+
symbol: {
|
|
278
|
+
query: item.symbol.name,
|
|
279
|
+
name: item.symbol.name,
|
|
280
|
+
kind: item.symbol.kind,
|
|
281
|
+
parentName: item.symbol.parentName,
|
|
282
|
+
startLine: item.symbol.startLine,
|
|
283
|
+
endLine: item.symbol.endLine,
|
|
284
|
+
},
|
|
285
|
+
lines: item.lines,
|
|
286
|
+
})),
|
|
287
|
+
warnings: [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
throwIfAborted(signal);
|
|
294
|
+
let readseekOutput: Awaited<ReturnType<typeof readseekRead>>;
|
|
295
|
+
try {
|
|
296
|
+
readseekOutput = total === 0
|
|
297
|
+
? await readseekRead(absolutePath)
|
|
298
|
+
: await readseekRead(absolutePath, startLine, endIdx);
|
|
299
|
+
} catch (err: any) {
|
|
300
|
+
const detail = err?.message ? ` — ${err.message}` : "";
|
|
301
|
+
const message = `readseek failed while reading ${rawPath}${detail}`;
|
|
302
|
+
return buildToolErrorResult("read", "readseek-error", message, { path: rawParams.path, hint: "Ensure @jarkkojs/readseek and its npm platform package are installed.", details: { message: err?.message } });
|
|
303
|
+
}
|
|
304
|
+
const expectedLineCount = Math.max(0, endIdx - startLine + 1);
|
|
305
|
+
const invalidLine = readseekOutput.hashlines.find((line, index) => line.line !== startLine + index);
|
|
306
|
+
if (readseekOutput.hashlines.length !== expectedLineCount || invalidLine) {
|
|
307
|
+
const message = invalidLine
|
|
308
|
+
? `readseek returned non-sequential line ${invalidLine.line} for requested range ${startLine}-${endIdx}`
|
|
309
|
+
: `readseek returned ${readseekOutput.hashlines.length} lines for requested range ${startLine}-${endIdx} (${expectedLineCount} expected)`;
|
|
310
|
+
return buildToolErrorResult("read", "readseek-output-mismatch", message, { path: rawParams.path });
|
|
311
|
+
}
|
|
312
|
+
const readseekLines: ReadseekLine[] = readseekOutput.hashlines.map((line) => ({
|
|
313
|
+
line: line.line,
|
|
314
|
+
hash: line.hash,
|
|
315
|
+
anchor: `${line.line}:${line.hash}`,
|
|
316
|
+
raw: line.text,
|
|
317
|
+
display: escapeControlCharsForDisplay(line.text),
|
|
318
|
+
}));
|
|
319
|
+
const selected = readseekLines.map((line) => line.raw);
|
|
320
|
+
const formatted = renderReadseekLines(readseekLines);
|
|
321
|
+
|
|
322
|
+
const truncation = truncateHead(formatted, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
323
|
+
|
|
324
|
+
// Append structural map: on-demand (p.map) or auto on truncated full-file reads
|
|
325
|
+
const shouldAppendMap = !!p.map || (!!truncation.truncated && !p.offset && !p.limit && !symbolMatch);
|
|
326
|
+
let appendedMap = false;
|
|
327
|
+
let mapText: string | null = null;
|
|
328
|
+
if (shouldAppendMap) {
|
|
329
|
+
try {
|
|
330
|
+
const fileMap = await getOrGenerateMap(absolutePath);
|
|
331
|
+
if (fileMap) {
|
|
332
|
+
const formattedMap = formatFileMapWithBudget(fileMap);
|
|
333
|
+
mapText = formattedMap;
|
|
334
|
+
appendedMap = true;
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
// Map formatting failed — still return hashlined content without map
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (symbolWarning) {
|
|
342
|
+
structuredWarnings.push(buildReadseekWarning("symbol-warning", symbolWarning.trim()));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (hasBinaryContent) {
|
|
346
|
+
const warning = "[Warning: file appears to be binary — output may be garbled]";
|
|
347
|
+
structuredWarnings.push(buildReadseekWarning("binary-content", warning));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (hasBareCarriageReturn(rawBuffer.toString("utf-8"))) {
|
|
351
|
+
const warning = "[Warning: file contains bare CR (\\r) line endings — line numbering may be inconsistent with grep and other tools]";
|
|
352
|
+
structuredWarnings.push(buildReadseekWarning("bare-cr", warning));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const readOutput = buildReadOutput({
|
|
356
|
+
path: absolutePath,
|
|
357
|
+
startLine,
|
|
358
|
+
endLine: endIdx,
|
|
359
|
+
totalLines: total,
|
|
360
|
+
selectedLines: selected,
|
|
361
|
+
lines: readseekLines,
|
|
362
|
+
warnings: structuredWarnings,
|
|
363
|
+
truncation: truncation.truncated
|
|
364
|
+
? {
|
|
365
|
+
outputLines: truncation.outputLines,
|
|
366
|
+
totalLines: total,
|
|
367
|
+
outputBytes: truncation.outputBytes,
|
|
368
|
+
totalBytes: truncation.totalBytes,
|
|
369
|
+
}
|
|
370
|
+
: null,
|
|
371
|
+
continuation: !truncation.truncated && endIdx < total ? { nextOffset: endIdx + 1 } : null,
|
|
372
|
+
symbol: symbolMatch
|
|
373
|
+
? {
|
|
374
|
+
query: p.symbol ?? symbolMatch.name,
|
|
375
|
+
name: symbolMatch.name,
|
|
376
|
+
kind: symbolMatch.kind,
|
|
377
|
+
parentName: symbolMatch.parentName,
|
|
378
|
+
startLine: symbolMatch.startLine,
|
|
379
|
+
endLine: symbolMatch.endLine,
|
|
380
|
+
}
|
|
381
|
+
: null,
|
|
382
|
+
map: {
|
|
383
|
+
requested: !!p.map,
|
|
384
|
+
appended: appendedMap,
|
|
385
|
+
text: mapText,
|
|
386
|
+
},
|
|
387
|
+
...(bundleMetadata ? { bundle: bundleMetadata } : {}),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return succeed({
|
|
391
|
+
content: [{ type: "text", text: readOutput.text }],
|
|
392
|
+
details: {
|
|
393
|
+
truncation: truncation.truncated ? truncation : undefined,
|
|
394
|
+
readseekValue: readOutput.readseekValue,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
50
399
|
function splitReadseekLines(text: string): string[] {
|
|
51
400
|
if (text.length === 0) return [];
|
|
52
401
|
const withoutTrailingTerminator = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
@@ -92,361 +441,14 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
|
|
|
92
441
|
),
|
|
93
442
|
}),
|
|
94
443
|
ptc: toolConfig,
|
|
95
|
-
async execute(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (!limit.ok) {
|
|
104
|
-
return buildToolErrorResult("read", "invalid-limit", limit.message, { path: rawParams.path });
|
|
105
|
-
}
|
|
106
|
-
if (limit.value !== undefined && limit.value < 1) {
|
|
107
|
-
const message = `Invalid limit: expected a positive integer, received ${limit.value}.`;
|
|
108
|
-
return buildToolErrorResult("read", "invalid-limit", message, { path: rawParams.path });
|
|
109
|
-
}
|
|
110
|
-
if (offset.value !== undefined && offset.value < 1) {
|
|
111
|
-
const message = `Invalid offset: expected a positive integer, received ${offset.value}.`;
|
|
112
|
-
return buildToolErrorResult("read", "invalid-offset", message, { path: rawParams.path });
|
|
113
|
-
}
|
|
114
|
-
const p = {
|
|
115
|
-
...rawParams,
|
|
116
|
-
offset: offset.value,
|
|
117
|
-
limit: limit.value,
|
|
118
|
-
};
|
|
119
|
-
if (rawParams.symbol !== undefined) {
|
|
120
|
-
const trimmedSymbol = typeof rawParams.symbol === "string" ? rawParams.symbol.trim() : "";
|
|
121
|
-
if (trimmedSymbol.length === 0) {
|
|
122
|
-
const message = "Invalid symbol: expected a non-empty string.";
|
|
123
|
-
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
124
|
-
}
|
|
125
|
-
p.symbol = trimmedSymbol;
|
|
126
|
-
}
|
|
127
|
-
const rawPath = p.path.replace(/^@/, "");
|
|
128
|
-
const absolutePath = resolveToCwd(rawPath, ctx.cwd);
|
|
129
|
-
const succeed = <T extends AgentToolResult<any>>(result: T): T => {
|
|
130
|
-
const isError = (result as { isError?: boolean }).isError;
|
|
131
|
-
if (!isError) {
|
|
132
|
-
options.onSuccessfulRead?.(absolutePath);
|
|
133
|
-
}
|
|
134
|
-
return result;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
throwIfAborted(signal);
|
|
138
|
-
if (p.symbol && (p.offset !== undefined || p.limit !== undefined)) {
|
|
139
|
-
const message = "Cannot combine symbol with offset/limit. Use one or the other.";
|
|
140
|
-
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
141
|
-
}
|
|
142
|
-
if (p.bundle && !p.symbol) {
|
|
143
|
-
const message = 'Cannot use bundle without symbol. Use read({ path, symbol, bundle: "local" }).';
|
|
144
|
-
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
145
|
-
}
|
|
146
|
-
if (p.bundle && p.map) {
|
|
147
|
-
const message = "Cannot combine bundle with map. Use one or the other.";
|
|
148
|
-
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
149
|
-
}
|
|
150
|
-
if (p.map && p.symbol) {
|
|
151
|
-
const message = "Cannot combine map with symbol. Use one or the other.";
|
|
152
|
-
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
153
|
-
}
|
|
154
|
-
// Delegate images to the built-in read tool
|
|
155
|
-
throwIfAborted(signal);
|
|
156
|
-
const ext = rawPath.split(".").pop()?.toLowerCase() ?? "";
|
|
157
|
-
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
|
158
|
-
const builtinRead = createReadTool(ctx.cwd);
|
|
159
|
-
return succeed(await builtinRead.execute(_toolCallId, p, signal, _onUpdate));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
throwIfAborted(signal);
|
|
163
|
-
let rawBuffer: Buffer;
|
|
164
|
-
try {
|
|
165
|
-
rawBuffer = await fsReadFile(absolutePath);
|
|
166
|
-
} catch (err: any) {
|
|
167
|
-
const code = err?.code;
|
|
168
|
-
if (code === "EISDIR") {
|
|
169
|
-
const message = `Path is a directory: ${rawPath}. Use ls to inspect directories.`;
|
|
170
|
-
return buildToolErrorResult("read", "path-is-directory", message, { path: rawParams.path, hint: `Use ls(${JSON.stringify(rawPath)}) to inspect directories.` });
|
|
171
|
-
}
|
|
172
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
173
|
-
const message = `Permission denied — cannot access: ${rawPath}`;
|
|
174
|
-
return buildToolErrorResult("read", "permission-denied", message, { path: rawParams.path });
|
|
175
|
-
}
|
|
176
|
-
if (code === "ENOENT") {
|
|
177
|
-
const message = `File not found: ${rawPath}`;
|
|
178
|
-
return buildToolErrorResult("read", "file-not-found", message, { path: rawParams.path });
|
|
179
|
-
}
|
|
180
|
-
const message = `File not readable: ${rawPath}${err?.message ? ` — ${err.message}` : ""}`;
|
|
181
|
-
return buildToolErrorResult("read", "fs-error", message, { path: rawParams.path, details: { fsCode: code, fsMessage: err?.message } });
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (isSupportedImageBuffer(rawBuffer)) {
|
|
185
|
-
const builtinRead = createReadTool(ctx.cwd);
|
|
186
|
-
return succeed(await builtinRead.execute(_toolCallId, p, signal, _onUpdate));
|
|
187
|
-
}
|
|
188
|
-
const hasBinaryContent = looksLikeBinary(rawBuffer);
|
|
189
|
-
throwIfAborted(signal);
|
|
190
|
-
const normalized = normalizeToLF(stripBom(rawBuffer.toString("utf-8")).text);
|
|
191
|
-
const allLines = splitReadseekLines(normalized);
|
|
192
|
-
const total = allLines.length;
|
|
193
|
-
const structuredWarnings: ReadseekWarning[] = [];
|
|
194
|
-
let startLine = p.offset !== undefined ? p.offset : 1;
|
|
195
|
-
let endIdx = p.limit !== undefined ? Math.min(startLine - 1 + p.limit, total) : total;
|
|
196
|
-
if (p.offset !== undefined && startLine > total) {
|
|
197
|
-
const message = `[offset ${p.offset} is past end of file (${total} lines)]`;
|
|
198
|
-
return buildToolErrorResult("read", "offset-past-end", message, { path: rawParams.path });
|
|
199
|
-
}
|
|
200
|
-
let symbolMatch: SymbolMatch | undefined;
|
|
201
|
-
let symbolFileMap: Awaited<ReturnType<typeof getOrGenerateMap>> | null = null;
|
|
202
|
-
let symbolWarning: string | undefined;
|
|
203
|
-
let bundleMetadata:
|
|
204
|
-
| {
|
|
205
|
-
mode: "local";
|
|
206
|
-
applied: boolean;
|
|
207
|
-
localSupport: Array<{
|
|
208
|
-
symbol: {
|
|
209
|
-
query: string;
|
|
210
|
-
name: string;
|
|
211
|
-
kind: string;
|
|
212
|
-
parentName?: string;
|
|
213
|
-
startLine: number;
|
|
214
|
-
endLine: number;
|
|
215
|
-
};
|
|
216
|
-
lines: string[];
|
|
217
|
-
}>;
|
|
218
|
-
warnings: ReadseekWarning[];
|
|
219
|
-
}
|
|
220
|
-
| null = null;
|
|
221
|
-
if (p.symbol) {
|
|
222
|
-
symbolFileMap = await getOrGenerateMap(absolutePath);
|
|
223
|
-
if (!symbolFileMap) {
|
|
224
|
-
const extLabel = ext || "unknown";
|
|
225
|
-
symbolWarning = `[Warning: symbol lookup not available for .${extLabel} files — showing full file]\n\n`;
|
|
226
|
-
} else {
|
|
227
|
-
const lookup = findSymbol(symbolFileMap, p.symbol);
|
|
228
|
-
if (lookup.type === "ambiguous") {
|
|
229
|
-
return succeed({
|
|
230
|
-
content: [
|
|
231
|
-
{
|
|
232
|
-
type: "text",
|
|
233
|
-
text: formatAmbiguous(p.symbol, lookup.candidates),
|
|
234
|
-
},
|
|
235
|
-
],
|
|
236
|
-
isError: false,
|
|
237
|
-
details: {},
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
if (lookup.type === "not-found") {
|
|
241
|
-
symbolWarning = `${formatNotFound(p.symbol, symbolFileMap)}\n\n`;
|
|
242
|
-
}
|
|
243
|
-
if (lookup.type === "found") {
|
|
244
|
-
startLine = Math.max(1, lookup.symbol.startLine);
|
|
245
|
-
endIdx = Math.min(total, lookup.symbol.endLine);
|
|
246
|
-
symbolMatch = lookup.symbol;
|
|
247
|
-
}
|
|
248
|
-
if (lookup.type === "fuzzy") {
|
|
249
|
-
startLine = Math.max(1, lookup.symbol.startLine);
|
|
250
|
-
endIdx = Math.min(total, lookup.symbol.endLine);
|
|
251
|
-
symbolMatch = lookup.symbol;
|
|
252
|
-
|
|
253
|
-
const tierLabel = lookup.tier === "camelCase" ? "camelCase word boundary" : "substring";
|
|
254
|
-
const otherNames = lookup.otherCandidates.map((c) => `\`${c.name}\``).join(", ");
|
|
255
|
-
const confirmHint = `read({ symbol: "${lookup.symbol.name}" }) or ${lookup.symbol.name}@${lookup.symbol.startLine} to select by start line`;
|
|
256
|
-
const lines = [
|
|
257
|
-
`[Symbol '${p.symbol}' not exact-matched. Closest match: \`${lookup.symbol.name}\` (${lookup.symbol.kind}, lines ${lookup.symbol.startLine}-${lookup.symbol.endLine}) via ${tierLabel}.`,
|
|
258
|
-
];
|
|
259
|
-
if (otherNames) lines.push(` Other candidates: ${otherNames}.`);
|
|
260
|
-
lines.push(` To confirm: ${confirmHint}.]`);
|
|
261
|
-
const bannerText = lines.join("\n");
|
|
262
|
-
structuredWarnings.push(
|
|
263
|
-
buildReadseekWarning("fuzzy-symbol-match", bannerText, {
|
|
264
|
-
tier: lookup.tier,
|
|
265
|
-
symbol: lookup.symbol,
|
|
266
|
-
otherCandidates: lookup.otherCandidates,
|
|
267
|
-
}),
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (p.bundle === "local") {
|
|
274
|
-
if (!symbolFileMap) {
|
|
275
|
-
const extLabel = ext || "unknown";
|
|
276
|
-
const warning = buildReadseekWarning(
|
|
277
|
-
"bundle-unmappable",
|
|
278
|
-
`[Warning: local bundle unavailable because symbol mapping is not available for .${extLabel} files — showing plain symbol read]`,
|
|
279
|
-
);
|
|
280
|
-
structuredWarnings.push(warning);
|
|
281
|
-
bundleMetadata = {
|
|
282
|
-
mode: "local",
|
|
283
|
-
applied: false,
|
|
284
|
-
localSupport: [],
|
|
285
|
-
warnings: [warning],
|
|
286
|
-
};
|
|
287
|
-
} else if (!symbolMatch) {
|
|
288
|
-
bundleMetadata = {
|
|
289
|
-
mode: "local",
|
|
290
|
-
applied: false,
|
|
291
|
-
localSupport: [],
|
|
292
|
-
warnings: [],
|
|
293
|
-
};
|
|
294
|
-
} else {
|
|
295
|
-
const bundle = buildLocalBundle(symbolFileMap, symbolMatch, allLines);
|
|
296
|
-
if (!bundle) {
|
|
297
|
-
const warning = buildReadseekWarning(
|
|
298
|
-
"bundle-context-unavailable",
|
|
299
|
-
`[Warning: local bundle context could not be determined for symbol '${symbolMatch.name}' — showing plain symbol read]`,
|
|
300
|
-
);
|
|
301
|
-
structuredWarnings.push(warning);
|
|
302
|
-
bundleMetadata = {
|
|
303
|
-
mode: "local",
|
|
304
|
-
applied: false,
|
|
305
|
-
localSupport: [],
|
|
306
|
-
warnings: [warning],
|
|
307
|
-
};
|
|
308
|
-
} else {
|
|
309
|
-
bundleMetadata = {
|
|
310
|
-
mode: "local",
|
|
311
|
-
applied: true,
|
|
312
|
-
localSupport: bundle.support.map((item) => ({
|
|
313
|
-
symbol: {
|
|
314
|
-
query: item.symbol.name,
|
|
315
|
-
name: item.symbol.name,
|
|
316
|
-
kind: item.symbol.kind,
|
|
317
|
-
parentName: item.symbol.parentName,
|
|
318
|
-
startLine: item.symbol.startLine,
|
|
319
|
-
endLine: item.symbol.endLine,
|
|
320
|
-
},
|
|
321
|
-
lines: item.lines,
|
|
322
|
-
})),
|
|
323
|
-
warnings: [],
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
throwIfAborted(signal);
|
|
330
|
-
let readseekOutput: Awaited<ReturnType<typeof readseekRead>>;
|
|
331
|
-
try {
|
|
332
|
-
readseekOutput = total === 0
|
|
333
|
-
? await readseekRead(absolutePath)
|
|
334
|
-
: await readseekRead(absolutePath, startLine, endIdx);
|
|
335
|
-
} catch (err: any) {
|
|
336
|
-
const detail = err?.message ? ` — ${err.message}` : "";
|
|
337
|
-
const message = `readseek failed while reading ${rawPath}${detail}`;
|
|
338
|
-
return buildToolErrorResult("read", "readseek-error", message, { path: rawParams.path, hint: "Ensure @jarkkojs/readseek and its npm platform package are installed.", details: { message: err?.message } });
|
|
339
|
-
}
|
|
340
|
-
const expectedLineCount = Math.max(0, endIdx - startLine + 1);
|
|
341
|
-
const invalidLine = readseekOutput.hashlines.find((line, index) => line.line !== startLine + index);
|
|
342
|
-
if (readseekOutput.hashlines.length !== expectedLineCount || invalidLine) {
|
|
343
|
-
const message = invalidLine
|
|
344
|
-
? `readseek returned non-sequential line ${invalidLine.line} for requested range ${startLine}-${endIdx}`
|
|
345
|
-
: `readseek returned ${readseekOutput.hashlines.length} lines for requested range ${startLine}-${endIdx} (${expectedLineCount} expected)`;
|
|
346
|
-
return buildToolErrorResult("read", "readseek-output-mismatch", message, { path: rawParams.path });
|
|
347
|
-
}
|
|
348
|
-
const readseekLines: ReadseekLine[] = readseekOutput.hashlines.map((line) => ({
|
|
349
|
-
line: line.line,
|
|
350
|
-
hash: line.hash,
|
|
351
|
-
anchor: `${line.line}:${line.hash}`,
|
|
352
|
-
raw: line.text,
|
|
353
|
-
display: escapeControlCharsForDisplay(line.text),
|
|
354
|
-
}));
|
|
355
|
-
const selected = readseekLines.map((line) => line.raw);
|
|
356
|
-
const formatted = renderReadseekLines(readseekLines);
|
|
357
|
-
|
|
358
|
-
const truncation = truncateHead(formatted, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
359
|
-
let text = truncation.content;
|
|
360
|
-
|
|
361
|
-
if (truncation.truncated) {
|
|
362
|
-
text += `\n\n[Output truncated: showing ${truncation.outputLines} of ${total} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Use offset=${startLine + truncation.outputLines} to continue.]`;
|
|
363
|
-
} else if (endIdx < total) {
|
|
364
|
-
text += `\n\n[Showing lines ${startLine}-${endIdx} of ${total}. Use offset=${endIdx + 1} to continue.]`;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Append structural map: on-demand (p.map) or auto on truncated full-file reads
|
|
368
|
-
const shouldAppendMap =
|
|
369
|
-
!!p.map ||
|
|
370
|
-
(!!truncation.truncated && !p.offset && !p.limit && !symbolMatch);
|
|
371
|
-
let appendedMap = false;
|
|
372
|
-
let mapText: string | null = null;
|
|
373
|
-
if (shouldAppendMap) {
|
|
374
|
-
try {
|
|
375
|
-
const fileMap = await getOrGenerateMap(absolutePath);
|
|
376
|
-
if (fileMap) {
|
|
377
|
-
const formattedMap = formatFileMapWithBudget(fileMap);
|
|
378
|
-
text += "\n\n" + formattedMap;
|
|
379
|
-
mapText = formattedMap;
|
|
380
|
-
appendedMap = true;
|
|
381
|
-
}
|
|
382
|
-
} catch {
|
|
383
|
-
// Map formatting failed — still return hashlined content without map
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (p.symbol && symbolMatch) {
|
|
388
|
-
const parentInfo = symbolMatch.parentName ? ` in ${symbolMatch.parentName}` : "";
|
|
389
|
-
text = `[Symbol: ${symbolMatch.name} (${symbolMatch.kind})${parentInfo}, lines ${symbolMatch.startLine}-${symbolMatch.endLine} of ${total}]\n\n${text}`;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (symbolWarning) {
|
|
393
|
-
structuredWarnings.push(buildReadseekWarning("symbol-warning", symbolWarning.trim()));
|
|
394
|
-
text = symbolWarning + text;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (hasBinaryContent) {
|
|
398
|
-
const warning = "[Warning: file appears to be binary — output may be garbled]";
|
|
399
|
-
structuredWarnings.push(buildReadseekWarning("binary-content", warning));
|
|
400
|
-
text = `${warning}\n\n${text}`;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (hasBareCarriageReturn(rawBuffer.toString("utf-8"))) {
|
|
404
|
-
const warning = "[Warning: file contains bare CR (\\r) line endings — line numbering may be inconsistent with grep and other tools]";
|
|
405
|
-
structuredWarnings.push(buildReadseekWarning("bare-cr", warning));
|
|
406
|
-
text = `${warning}\n\n${text}`;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const readOutput = buildReadOutput({
|
|
410
|
-
path: absolutePath,
|
|
411
|
-
startLine,
|
|
412
|
-
endLine: endIdx,
|
|
413
|
-
totalLines: total,
|
|
414
|
-
selectedLines: selected,
|
|
415
|
-
lines: readseekLines,
|
|
416
|
-
warnings: structuredWarnings,
|
|
417
|
-
truncation: truncation.truncated
|
|
418
|
-
? {
|
|
419
|
-
outputLines: truncation.outputLines,
|
|
420
|
-
totalLines: total,
|
|
421
|
-
outputBytes: truncation.outputBytes,
|
|
422
|
-
totalBytes: truncation.totalBytes,
|
|
423
|
-
}
|
|
424
|
-
: null,
|
|
425
|
-
continuation: !truncation.truncated && endIdx < total ? { nextOffset: endIdx + 1 } : null,
|
|
426
|
-
symbol: symbolMatch
|
|
427
|
-
? {
|
|
428
|
-
query: p.symbol ?? symbolMatch.name,
|
|
429
|
-
name: symbolMatch.name,
|
|
430
|
-
kind: symbolMatch.kind,
|
|
431
|
-
parentName: symbolMatch.parentName,
|
|
432
|
-
startLine: symbolMatch.startLine,
|
|
433
|
-
endLine: symbolMatch.endLine,
|
|
434
|
-
}
|
|
435
|
-
: null,
|
|
436
|
-
map: {
|
|
437
|
-
requested: !!p.map,
|
|
438
|
-
appended: appendedMap,
|
|
439
|
-
text: mapText,
|
|
440
|
-
},
|
|
441
|
-
...(bundleMetadata ? { bundle: bundleMetadata } : {}),
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
return succeed({
|
|
445
|
-
content: [{ type: "text", text: readOutput.text }],
|
|
446
|
-
details: {
|
|
447
|
-
truncation: truncation.truncated ? truncation : undefined,
|
|
448
|
-
readseekValue: readOutput.readseekValue,
|
|
449
|
-
},
|
|
444
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
445
|
+
return executeRead({
|
|
446
|
+
toolCallId,
|
|
447
|
+
params,
|
|
448
|
+
signal,
|
|
449
|
+
onUpdate,
|
|
450
|
+
cwd: ctx.cwd,
|
|
451
|
+
onSuccessfulRead: options.onSuccessfulRead,
|
|
450
452
|
});
|
|
451
453
|
},
|
|
452
454
|
renderCall(args: any, theme: any, ...rest: any[]) {
|