pi-readseek 0.3.19 → 0.3.21

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