pi-readseek 0.1.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.
- package/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- package/src/write.ts +532 -0
package/src/read.ts
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolRenderResultOptions, AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
createReadTool,
|
|
4
|
+
truncateHead,
|
|
5
|
+
formatSize,
|
|
6
|
+
DEFAULT_MAX_BYTES,
|
|
7
|
+
DEFAULT_MAX_LINES,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Type } from "@sinclair/typebox";
|
|
10
|
+
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
11
|
+
import { readFile as fsReadFile } from "fs/promises";
|
|
12
|
+
import { normalizeToLF, stripBom, hasBareCarriageReturn } from "./edit-diff.js";
|
|
13
|
+
import { ensureHashInit, escapeControlCharsForDisplay } from "./hashline.js";
|
|
14
|
+
import { buildReadseekError, buildReadseekWarning, renderReadseekLines, type ReadseekLine, type ReadseekWarning } from "./readseek-value.js";
|
|
15
|
+
import { looksLikeBinary } from "./binary-detect.js";
|
|
16
|
+
import { resolveToCwd } from "./path-utils.js";
|
|
17
|
+
import { throwIfAborted } from "./runtime.js";
|
|
18
|
+
import { getOrGenerateMap } from "./map-cache.js";
|
|
19
|
+
import { formatFileMapWithBudget } from "./readseek/formatter.js";
|
|
20
|
+
import { findSymbol, type SymbolMatch } from "./readseek/symbol-lookup.js";
|
|
21
|
+
import { formatAmbiguous, formatNotFound } from "./readseek/symbol-error-format.js";
|
|
22
|
+
import { buildReadOutput } from "./read-output.js";
|
|
23
|
+
import { buildReadRehydrateDescriptor } from "./context-hygiene.js";
|
|
24
|
+
import { buildLocalBundle } from "./read-local-bundle.js";
|
|
25
|
+
import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
26
|
+
import { readseekRead } from "./readseek-client.js";
|
|
27
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
28
|
+
import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
|
|
29
|
+
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
|
|
30
|
+
|
|
31
|
+
const READ_PROMPT_METADATA = defineToolPromptMetadata({
|
|
32
|
+
promptUrl: new URL("../prompts/read.md", import.meta.url),
|
|
33
|
+
promptSnippet: "Read text files or images; text reads include hashline anchors and optional maps/symbol lookup",
|
|
34
|
+
promptGuidelines: [
|
|
35
|
+
"Use read instead of bash cat/head/tail/sed for file inspection.",
|
|
36
|
+
"Use read for images/screenshots; supported images return attachments like stock pi read.",
|
|
37
|
+
"Use read offset/limit, symbol, or map to keep large files focused.",
|
|
38
|
+
"Use read anchors as fresh inputs for edit.",
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
interface ReadParams {
|
|
43
|
+
path: string;
|
|
44
|
+
offset?: number | string;
|
|
45
|
+
limit?: number | string;
|
|
46
|
+
symbol?: string;
|
|
47
|
+
map?: boolean;
|
|
48
|
+
bundle?: "local";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ReadToolOptions {
|
|
52
|
+
onSuccessfulRead?: (absolutePath: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
56
|
+
|
|
57
|
+
function startsWithBytes(buffer: Buffer, bytes: number[]): boolean {
|
|
58
|
+
return buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function startsWithAscii(buffer: Buffer, offset: number, text: string): boolean {
|
|
62
|
+
if (buffer.length < offset + text.length) return false;
|
|
63
|
+
for (let index = 0; index < text.length; index++) {
|
|
64
|
+
if (buffer[offset + index] !== text.charCodeAt(index)) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readUint32BE(buffer: Buffer, offset: number): number {
|
|
70
|
+
return (
|
|
71
|
+
((buffer[offset] ?? 0) * 0x1000000) +
|
|
72
|
+
((buffer[offset + 1] ?? 0) << 16) +
|
|
73
|
+
((buffer[offset + 2] ?? 0) << 8) +
|
|
74
|
+
(buffer[offset + 3] ?? 0)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isPng(buffer: Buffer): boolean {
|
|
79
|
+
return buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isAnimatedPng(buffer: Buffer): boolean {
|
|
83
|
+
let offset = PNG_SIGNATURE.length;
|
|
84
|
+
while (offset + 8 <= buffer.length) {
|
|
85
|
+
const chunkLength = readUint32BE(buffer, offset);
|
|
86
|
+
const chunkTypeOffset = offset + 4;
|
|
87
|
+
if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true;
|
|
88
|
+
if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false;
|
|
89
|
+
const nextOffset = offset + 8 + chunkLength + 4;
|
|
90
|
+
if (nextOffset <= offset || nextOffset > buffer.length) return false;
|
|
91
|
+
offset = nextOffset;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isSupportedImageBuffer(buffer: Buffer): boolean {
|
|
97
|
+
if (startsWithBytes(buffer, [0xff, 0xd8, 0xff])) return buffer[3] !== 0xf7;
|
|
98
|
+
if (startsWithBytes(buffer, PNG_SIGNATURE)) return isPng(buffer) && !isAnimatedPng(buffer);
|
|
99
|
+
if (startsWithAscii(buffer, 0, "GIF")) return true;
|
|
100
|
+
return startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}) {
|
|
105
|
+
const toolConfig = {
|
|
106
|
+
callable: true,
|
|
107
|
+
enabled: true,
|
|
108
|
+
policy: "read-only" as const,
|
|
109
|
+
readOnly: true,
|
|
110
|
+
pythonName: "read",
|
|
111
|
+
defaultExposure: "safe-by-default" as const,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const tool = {
|
|
115
|
+
name: "read",
|
|
116
|
+
label: "Read",
|
|
117
|
+
description: READ_PROMPT_METADATA.description,
|
|
118
|
+
promptSnippet: READ_PROMPT_METADATA.promptSnippet,
|
|
119
|
+
promptGuidelines: READ_PROMPT_METADATA.promptGuidelines,
|
|
120
|
+
parameters: Type.Object({
|
|
121
|
+
path: Type.String({ description: "File path" }),
|
|
122
|
+
offset: Type.Optional(
|
|
123
|
+
Type.Union([
|
|
124
|
+
Type.Number({ description: "Start line (1-indexed)" }),
|
|
125
|
+
Type.String({ description: "Start line (1-indexed)" }),
|
|
126
|
+
]),
|
|
127
|
+
),
|
|
128
|
+
limit: Type.Optional(
|
|
129
|
+
Type.Union([
|
|
130
|
+
Type.Number({ description: "Max lines" }),
|
|
131
|
+
Type.String({ description: "Max lines" }),
|
|
132
|
+
]),
|
|
133
|
+
),
|
|
134
|
+
symbol: Type.Optional(Type.String({ description: "Symbol name to read" })),
|
|
135
|
+
map: Type.Optional(Type.Boolean({ description: "Append structural map" })),
|
|
136
|
+
bundle: Type.Optional(
|
|
137
|
+
Type.Literal("local", {
|
|
138
|
+
description: "Include same-file local support",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
}),
|
|
142
|
+
ptc: toolConfig,
|
|
143
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
144
|
+
await ensureHashInit();
|
|
145
|
+
const rawParams = params as ReadParams;
|
|
146
|
+
const offset = coerceObviousBase10Int(rawParams.offset, "offset");
|
|
147
|
+
if (!offset.ok) {
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: "text", text: offset.message }],
|
|
150
|
+
isError: true,
|
|
151
|
+
details: {
|
|
152
|
+
readseekValue: {
|
|
153
|
+
tool: "read",
|
|
154
|
+
ok: false,
|
|
155
|
+
path: rawParams.path,
|
|
156
|
+
error: buildReadseekError("invalid-offset", offset.message),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
162
|
+
if (!limit.ok) {
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: "text", text: limit.message }],
|
|
165
|
+
isError: true,
|
|
166
|
+
details: {
|
|
167
|
+
readseekValue: {
|
|
168
|
+
tool: "read",
|
|
169
|
+
ok: false,
|
|
170
|
+
path: rawParams.path,
|
|
171
|
+
error: buildReadseekError("invalid-limit", limit.message),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (limit.value !== undefined && limit.value < 1) {
|
|
177
|
+
const message = `Invalid limit: expected a positive integer, received ${limit.value}.`;
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: "text", text: message }],
|
|
180
|
+
isError: true,
|
|
181
|
+
details: {
|
|
182
|
+
readseekValue: {
|
|
183
|
+
tool: "read",
|
|
184
|
+
ok: false,
|
|
185
|
+
path: rawParams.path,
|
|
186
|
+
error: buildReadseekError("invalid-limit", message),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (offset.value !== undefined && offset.value < 1) {
|
|
192
|
+
const message = `Invalid offset: expected a positive integer, received ${offset.value}.`;
|
|
193
|
+
return {
|
|
194
|
+
content: [{ type: "text", text: message }],
|
|
195
|
+
isError: true,
|
|
196
|
+
details: {
|
|
197
|
+
readseekValue: {
|
|
198
|
+
tool: "read",
|
|
199
|
+
ok: false,
|
|
200
|
+
path: rawParams.path,
|
|
201
|
+
error: buildReadseekError("invalid-offset", message),
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const p = {
|
|
207
|
+
...rawParams,
|
|
208
|
+
offset: offset.value,
|
|
209
|
+
limit: limit.value,
|
|
210
|
+
};
|
|
211
|
+
if (rawParams.symbol !== undefined) {
|
|
212
|
+
const trimmedSymbol = typeof rawParams.symbol === "string" ? rawParams.symbol.trim() : "";
|
|
213
|
+
if (trimmedSymbol.length === 0) {
|
|
214
|
+
const message = "Invalid symbol: expected a non-empty string.";
|
|
215
|
+
return {
|
|
216
|
+
content: [{ type: "text", text: message }],
|
|
217
|
+
isError: true,
|
|
218
|
+
details: {
|
|
219
|
+
readseekValue: {
|
|
220
|
+
tool: "read",
|
|
221
|
+
ok: false,
|
|
222
|
+
path: rawParams.path,
|
|
223
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
p.symbol = trimmedSymbol;
|
|
229
|
+
}
|
|
230
|
+
const rawPath = p.path.replace(/^@/, "");
|
|
231
|
+
const absolutePath = resolveToCwd(rawPath, ctx.cwd);
|
|
232
|
+
const succeed = <T extends AgentToolResult<any>>(result: T): T => {
|
|
233
|
+
const isError = (result as { isError?: boolean }).isError;
|
|
234
|
+
if (!isError) {
|
|
235
|
+
options.onSuccessfulRead?.(absolutePath);
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
throwIfAborted(signal);
|
|
241
|
+
if (p.symbol && (p.offset !== undefined || p.limit !== undefined)) {
|
|
242
|
+
const message = "Cannot combine symbol with offset/limit. Use one or the other.";
|
|
243
|
+
return {
|
|
244
|
+
content: [{ type: "text", text: message }],
|
|
245
|
+
isError: true,
|
|
246
|
+
details: {
|
|
247
|
+
readseekValue: {
|
|
248
|
+
tool: "read",
|
|
249
|
+
ok: false,
|
|
250
|
+
path: rawParams.path,
|
|
251
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (p.bundle && !p.symbol) {
|
|
257
|
+
const message = 'Cannot use bundle without symbol. Use read({ path, symbol, bundle: "local" }).';
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: "text", text: message }],
|
|
260
|
+
isError: true,
|
|
261
|
+
details: {
|
|
262
|
+
readseekValue: {
|
|
263
|
+
tool: "read",
|
|
264
|
+
ok: false,
|
|
265
|
+
path: rawParams.path,
|
|
266
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (p.bundle && p.map) {
|
|
272
|
+
const message = "Cannot combine bundle with map. Use one or the other.";
|
|
273
|
+
return {
|
|
274
|
+
content: [{ type: "text", text: message }],
|
|
275
|
+
isError: true,
|
|
276
|
+
details: {
|
|
277
|
+
readseekValue: {
|
|
278
|
+
tool: "read",
|
|
279
|
+
ok: false,
|
|
280
|
+
path: rawParams.path,
|
|
281
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (p.map && p.symbol) {
|
|
287
|
+
const message = "Cannot combine map with symbol. Use one or the other.";
|
|
288
|
+
return {
|
|
289
|
+
content: [{ type: "text", text: message }],
|
|
290
|
+
isError: true,
|
|
291
|
+
details: {
|
|
292
|
+
readseekValue: {
|
|
293
|
+
tool: "read",
|
|
294
|
+
ok: false,
|
|
295
|
+
path: rawParams.path,
|
|
296
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Delegate images to the built-in read tool
|
|
302
|
+
throwIfAborted(signal);
|
|
303
|
+
const ext = rawPath.split(".").pop()?.toLowerCase() ?? "";
|
|
304
|
+
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
|
305
|
+
const builtinRead = createReadTool(ctx.cwd);
|
|
306
|
+
return succeed(await builtinRead.execute(_toolCallId, p, signal, _onUpdate));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
throwIfAborted(signal);
|
|
310
|
+
let rawBuffer: Buffer;
|
|
311
|
+
try {
|
|
312
|
+
rawBuffer = await fsReadFile(absolutePath);
|
|
313
|
+
} catch (err: any) {
|
|
314
|
+
const code = err?.code;
|
|
315
|
+
if (code === "EISDIR") {
|
|
316
|
+
const message = `Path is a directory: ${rawPath}. Use ls to inspect directories.`;
|
|
317
|
+
return {
|
|
318
|
+
content: [{ type: "text", text: message }],
|
|
319
|
+
isError: true,
|
|
320
|
+
details: {
|
|
321
|
+
readseekValue: {
|
|
322
|
+
tool: "read",
|
|
323
|
+
ok: false,
|
|
324
|
+
path: rawParams.path,
|
|
325
|
+
error: buildReadseekError(
|
|
326
|
+
"path-is-directory",
|
|
327
|
+
message,
|
|
328
|
+
`Use ls(${JSON.stringify(rawPath)}) to inspect directories.`,
|
|
329
|
+
),
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
335
|
+
const message = `Permission denied — cannot access: ${rawPath}`;
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: message }],
|
|
338
|
+
isError: true,
|
|
339
|
+
details: {
|
|
340
|
+
readseekValue: {
|
|
341
|
+
tool: "read",
|
|
342
|
+
ok: false,
|
|
343
|
+
path: rawParams.path,
|
|
344
|
+
error: buildReadseekError("permission-denied", message),
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (code === "ENOENT") {
|
|
350
|
+
const message = `File not found: ${rawPath}`;
|
|
351
|
+
return {
|
|
352
|
+
content: [{ type: "text", text: message }],
|
|
353
|
+
isError: true,
|
|
354
|
+
details: {
|
|
355
|
+
readseekValue: {
|
|
356
|
+
tool: "read",
|
|
357
|
+
ok: false,
|
|
358
|
+
path: rawParams.path,
|
|
359
|
+
error: buildReadseekError("file-not-found", message),
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const message = `File not readable: ${rawPath}${err?.message ? ` — ${err.message}` : ""}`;
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: message }],
|
|
367
|
+
isError: true,
|
|
368
|
+
details: {
|
|
369
|
+
readseekValue: {
|
|
370
|
+
tool: "read",
|
|
371
|
+
ok: false,
|
|
372
|
+
path: rawParams.path,
|
|
373
|
+
error: buildReadseekError("fs-error", message, undefined, {
|
|
374
|
+
fsCode: code,
|
|
375
|
+
fsMessage: err?.message,
|
|
376
|
+
}),
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (isSupportedImageBuffer(rawBuffer)) {
|
|
383
|
+
const builtinRead = createReadTool(ctx.cwd);
|
|
384
|
+
return succeed(await builtinRead.execute(_toolCallId, p, signal, _onUpdate));
|
|
385
|
+
}
|
|
386
|
+
const hasBinaryContent = looksLikeBinary(rawBuffer);
|
|
387
|
+
throwIfAborted(signal);
|
|
388
|
+
const normalized = normalizeToLF(stripBom(rawBuffer.toString("utf-8")).text);
|
|
389
|
+
const allLines = normalized.split("\n");
|
|
390
|
+
const total = allLines.length;
|
|
391
|
+
const structuredWarnings: ReadseekWarning[] = [];
|
|
392
|
+
let startLine = p.offset !== undefined ? p.offset : 1;
|
|
393
|
+
let endIdx = p.limit !== undefined ? Math.min(startLine - 1 + p.limit, total) : total;
|
|
394
|
+
if (p.offset !== undefined && startLine > total) {
|
|
395
|
+
const message = `[offset ${p.offset} is past end of file (${total} lines)]`;
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: "text", text: message }],
|
|
398
|
+
isError: true,
|
|
399
|
+
details: {
|
|
400
|
+
readseekValue: {
|
|
401
|
+
tool: "read",
|
|
402
|
+
ok: false,
|
|
403
|
+
path: rawParams.path,
|
|
404
|
+
error: buildReadseekError("offset-past-end", message),
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
let symbolMatch: SymbolMatch | undefined;
|
|
410
|
+
let symbolFileMap: Awaited<ReturnType<typeof getOrGenerateMap>> | null = null;
|
|
411
|
+
let symbolWarning: string | undefined;
|
|
412
|
+
let bundleMetadata:
|
|
413
|
+
| {
|
|
414
|
+
mode: "local";
|
|
415
|
+
applied: boolean;
|
|
416
|
+
localSupport: Array<{
|
|
417
|
+
symbol: {
|
|
418
|
+
query: string;
|
|
419
|
+
name: string;
|
|
420
|
+
kind: string;
|
|
421
|
+
parentName?: string;
|
|
422
|
+
startLine: number;
|
|
423
|
+
endLine: number;
|
|
424
|
+
};
|
|
425
|
+
lines: string[];
|
|
426
|
+
}>;
|
|
427
|
+
warnings: ReadseekWarning[];
|
|
428
|
+
}
|
|
429
|
+
| null = null;
|
|
430
|
+
if (p.symbol) {
|
|
431
|
+
symbolFileMap = await getOrGenerateMap(absolutePath);
|
|
432
|
+
if (!symbolFileMap) {
|
|
433
|
+
const extLabel = ext || "unknown";
|
|
434
|
+
symbolWarning = `[Warning: symbol lookup not available for .${extLabel} files — showing full file]\n\n`;
|
|
435
|
+
} else {
|
|
436
|
+
const lookup = findSymbol(symbolFileMap, p.symbol);
|
|
437
|
+
if (lookup.type === "ambiguous") {
|
|
438
|
+
return succeed({
|
|
439
|
+
content: [
|
|
440
|
+
{
|
|
441
|
+
type: "text",
|
|
442
|
+
text: formatAmbiguous(p.symbol, lookup.candidates),
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
isError: false,
|
|
446
|
+
details: {},
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (lookup.type === "not-found") {
|
|
450
|
+
symbolWarning = `${formatNotFound(p.symbol, symbolFileMap)}\n\n`;
|
|
451
|
+
}
|
|
452
|
+
if (lookup.type === "found") {
|
|
453
|
+
startLine = Math.max(1, lookup.symbol.startLine);
|
|
454
|
+
endIdx = Math.min(total, lookup.symbol.endLine);
|
|
455
|
+
symbolMatch = lookup.symbol;
|
|
456
|
+
}
|
|
457
|
+
if (lookup.type === "fuzzy") {
|
|
458
|
+
startLine = Math.max(1, lookup.symbol.startLine);
|
|
459
|
+
endIdx = Math.min(total, lookup.symbol.endLine);
|
|
460
|
+
symbolMatch = lookup.symbol;
|
|
461
|
+
|
|
462
|
+
const tierLabel = lookup.tier === "camelCase" ? "camelCase word boundary" : "substring";
|
|
463
|
+
const otherNames = lookup.otherCandidates.map((c) => `\`${c.name}\``).join(", ");
|
|
464
|
+
const confirmHint = `read({ symbol: "${lookup.symbol.name}" }) or ${lookup.symbol.name}@${lookup.symbol.startLine} to select by start line`;
|
|
465
|
+
const lines = [
|
|
466
|
+
`[Symbol '${p.symbol}' not exact-matched. Closest match: \`${lookup.symbol.name}\` (${lookup.symbol.kind}, lines ${lookup.symbol.startLine}-${lookup.symbol.endLine}) via ${tierLabel}.`,
|
|
467
|
+
];
|
|
468
|
+
if (otherNames) lines.push(` Other candidates: ${otherNames}.`);
|
|
469
|
+
lines.push(` To confirm: ${confirmHint}.]`);
|
|
470
|
+
const bannerText = lines.join("\n");
|
|
471
|
+
structuredWarnings.push(
|
|
472
|
+
buildReadseekWarning("fuzzy-symbol-match", bannerText, {
|
|
473
|
+
tier: lookup.tier,
|
|
474
|
+
symbol: lookup.symbol,
|
|
475
|
+
otherCandidates: lookup.otherCandidates,
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (p.bundle === "local") {
|
|
483
|
+
if (!symbolFileMap) {
|
|
484
|
+
const extLabel = ext || "unknown";
|
|
485
|
+
const warning = buildReadseekWarning(
|
|
486
|
+
"bundle-unmappable",
|
|
487
|
+
`[Warning: local bundle unavailable because symbol mapping is not available for .${extLabel} files — showing plain symbol read]`,
|
|
488
|
+
);
|
|
489
|
+
structuredWarnings.push(warning);
|
|
490
|
+
bundleMetadata = {
|
|
491
|
+
mode: "local",
|
|
492
|
+
applied: false,
|
|
493
|
+
localSupport: [],
|
|
494
|
+
warnings: [warning],
|
|
495
|
+
};
|
|
496
|
+
} else if (!symbolMatch) {
|
|
497
|
+
bundleMetadata = {
|
|
498
|
+
mode: "local",
|
|
499
|
+
applied: false,
|
|
500
|
+
localSupport: [],
|
|
501
|
+
warnings: [],
|
|
502
|
+
};
|
|
503
|
+
} else {
|
|
504
|
+
const bundle = buildLocalBundle(symbolFileMap, symbolMatch, allLines);
|
|
505
|
+
if (!bundle) {
|
|
506
|
+
const warning = buildReadseekWarning(
|
|
507
|
+
"bundle-context-unavailable",
|
|
508
|
+
`[Warning: local bundle context could not be determined for symbol '${symbolMatch.name}' — showing plain symbol read]`,
|
|
509
|
+
);
|
|
510
|
+
structuredWarnings.push(warning);
|
|
511
|
+
bundleMetadata = {
|
|
512
|
+
mode: "local",
|
|
513
|
+
applied: false,
|
|
514
|
+
localSupport: [],
|
|
515
|
+
warnings: [warning],
|
|
516
|
+
};
|
|
517
|
+
} else {
|
|
518
|
+
bundleMetadata = {
|
|
519
|
+
mode: "local",
|
|
520
|
+
applied: true,
|
|
521
|
+
localSupport: bundle.support.map((item) => ({
|
|
522
|
+
symbol: {
|
|
523
|
+
query: item.symbol.name,
|
|
524
|
+
name: item.symbol.name,
|
|
525
|
+
kind: item.symbol.kind,
|
|
526
|
+
parentName: item.symbol.parentName,
|
|
527
|
+
startLine: item.symbol.startLine,
|
|
528
|
+
endLine: item.symbol.endLine,
|
|
529
|
+
},
|
|
530
|
+
lines: item.lines,
|
|
531
|
+
})),
|
|
532
|
+
warnings: [],
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
throwIfAborted(signal);
|
|
539
|
+
let readseekOutput: Awaited<ReturnType<typeof readseekRead>>;
|
|
540
|
+
try {
|
|
541
|
+
readseekOutput = await readseekRead(absolutePath, startLine, endIdx);
|
|
542
|
+
} catch (err: any) {
|
|
543
|
+
const detail = err?.message ? ` — ${err.message}` : "";
|
|
544
|
+
const message = `readseek failed while reading ${rawPath}${detail}`;
|
|
545
|
+
return {
|
|
546
|
+
content: [{ type: "text", text: message }],
|
|
547
|
+
isError: true,
|
|
548
|
+
details: {
|
|
549
|
+
readseekValue: {
|
|
550
|
+
tool: "read",
|
|
551
|
+
ok: false,
|
|
552
|
+
path: rawParams.path,
|
|
553
|
+
error: buildReadseekError(
|
|
554
|
+
"readseek-error",
|
|
555
|
+
message,
|
|
556
|
+
"Ensure @jarkkojs/readseek and its npm platform package are installed.",
|
|
557
|
+
{ message: err?.message },
|
|
558
|
+
),
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
const expectedLineCount = Math.max(0, endIdx - startLine + 1);
|
|
564
|
+
const invalidLine = readseekOutput.hashlines.find((line, index) => line.line !== startLine + index);
|
|
565
|
+
if (readseekOutput.hashlines.length !== expectedLineCount || invalidLine) {
|
|
566
|
+
const message = invalidLine
|
|
567
|
+
? `readseek returned non-sequential line ${invalidLine.line} for requested range ${startLine}-${endIdx}`
|
|
568
|
+
: `readseek returned ${readseekOutput.hashlines.length} lines for requested range ${startLine}-${endIdx} (${expectedLineCount} expected)`;
|
|
569
|
+
return {
|
|
570
|
+
content: [{ type: "text", text: message }],
|
|
571
|
+
isError: true,
|
|
572
|
+
details: {
|
|
573
|
+
readseekValue: {
|
|
574
|
+
tool: "read",
|
|
575
|
+
ok: false,
|
|
576
|
+
path: rawParams.path,
|
|
577
|
+
error: buildReadseekError("readseek-output-mismatch", message),
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const readseekLines: ReadseekLine[] = readseekOutput.hashlines.map((line) => ({
|
|
583
|
+
line: line.line,
|
|
584
|
+
hash: line.hash,
|
|
585
|
+
anchor: `${line.line}:${line.hash}`,
|
|
586
|
+
raw: line.text,
|
|
587
|
+
display: escapeControlCharsForDisplay(line.text),
|
|
588
|
+
}));
|
|
589
|
+
const selected = readseekLines.map((line) => line.raw);
|
|
590
|
+
const formatted = renderReadseekLines(readseekLines);
|
|
591
|
+
|
|
592
|
+
const truncation = truncateHead(formatted, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
593
|
+
let text = truncation.content;
|
|
594
|
+
|
|
595
|
+
if (truncation.truncated) {
|
|
596
|
+
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.]`;
|
|
597
|
+
} else if (endIdx < total) {
|
|
598
|
+
text += `\n\n[Showing lines ${startLine}-${endIdx} of ${total}. Use offset=${endIdx + 1} to continue.]`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Append structural map: on-demand (p.map) or auto on truncated full-file reads
|
|
602
|
+
const shouldAppendMap =
|
|
603
|
+
!!p.map ||
|
|
604
|
+
(!!truncation.truncated && !p.offset && !p.limit && !symbolMatch);
|
|
605
|
+
let appendedMap = false;
|
|
606
|
+
let mapText: string | null = null;
|
|
607
|
+
if (shouldAppendMap) {
|
|
608
|
+
try {
|
|
609
|
+
const fileMap = await getOrGenerateMap(absolutePath);
|
|
610
|
+
if (fileMap) {
|
|
611
|
+
const formattedMap = formatFileMapWithBudget(fileMap);
|
|
612
|
+
text += "\n\n" + formattedMap;
|
|
613
|
+
mapText = formattedMap;
|
|
614
|
+
appendedMap = true;
|
|
615
|
+
}
|
|
616
|
+
} catch {
|
|
617
|
+
// Map formatting failed — still return hashlined content without map
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (p.symbol && symbolMatch) {
|
|
622
|
+
const parentInfo = symbolMatch.parentName ? ` in ${symbolMatch.parentName}` : "";
|
|
623
|
+
text = `[Symbol: ${symbolMatch.name} (${symbolMatch.kind})${parentInfo}, lines ${symbolMatch.startLine}-${symbolMatch.endLine} of ${total}]\n\n${text}`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (symbolWarning) {
|
|
627
|
+
structuredWarnings.push(buildReadseekWarning("symbol-warning", symbolWarning.trim()));
|
|
628
|
+
text = symbolWarning + text;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (hasBinaryContent) {
|
|
632
|
+
const warning = "[Warning: file appears to be binary — output may be garbled]";
|
|
633
|
+
structuredWarnings.push(buildReadseekWarning("binary-content", warning));
|
|
634
|
+
text = `${warning}\n\n${text}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (hasBareCarriageReturn(rawBuffer.toString("utf-8"))) {
|
|
638
|
+
const warning = "[Warning: file contains bare CR (\\r) line endings — line numbering may be inconsistent with grep and other tools]";
|
|
639
|
+
structuredWarnings.push(buildReadseekWarning("bare-cr", warning));
|
|
640
|
+
text = `${warning}\n\n${text}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const readOutput = buildReadOutput({
|
|
644
|
+
path: absolutePath,
|
|
645
|
+
startLine,
|
|
646
|
+
endLine: endIdx,
|
|
647
|
+
totalLines: total,
|
|
648
|
+
selectedLines: selected,
|
|
649
|
+
lines: readseekLines,
|
|
650
|
+
warnings: structuredWarnings,
|
|
651
|
+
truncation: truncation.truncated
|
|
652
|
+
? {
|
|
653
|
+
outputLines: truncation.outputLines,
|
|
654
|
+
totalLines: total,
|
|
655
|
+
outputBytes: truncation.outputBytes,
|
|
656
|
+
totalBytes: truncation.totalBytes,
|
|
657
|
+
}
|
|
658
|
+
: null,
|
|
659
|
+
continuation: !truncation.truncated && endIdx < total ? { nextOffset: endIdx + 1 } : null,
|
|
660
|
+
symbol: symbolMatch
|
|
661
|
+
? {
|
|
662
|
+
query: p.symbol ?? symbolMatch.name,
|
|
663
|
+
name: symbolMatch.name,
|
|
664
|
+
kind: symbolMatch.kind,
|
|
665
|
+
parentName: symbolMatch.parentName,
|
|
666
|
+
startLine: symbolMatch.startLine,
|
|
667
|
+
endLine: symbolMatch.endLine,
|
|
668
|
+
}
|
|
669
|
+
: null,
|
|
670
|
+
map: {
|
|
671
|
+
requested: !!p.map,
|
|
672
|
+
appended: appendedMap,
|
|
673
|
+
text: mapText,
|
|
674
|
+
},
|
|
675
|
+
...(bundleMetadata ? { bundle: bundleMetadata } : {}),
|
|
676
|
+
rehydrate: buildReadRehydrateDescriptor({
|
|
677
|
+
path: p.path,
|
|
678
|
+
offset: p.offset,
|
|
679
|
+
limit: p.limit,
|
|
680
|
+
symbol: p.symbol,
|
|
681
|
+
map: p.map,
|
|
682
|
+
bundle: p.bundle,
|
|
683
|
+
}),
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
return succeed({
|
|
687
|
+
content: [{ type: "text", text: readOutput.text }],
|
|
688
|
+
details: {
|
|
689
|
+
truncation: truncation.truncated ? truncation : undefined,
|
|
690
|
+
readseekValue: readOutput.readseekValue,
|
|
691
|
+
contextHygiene: readOutput.contextHygiene,
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
},
|
|
695
|
+
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
696
|
+
const context = rest[0] ?? {};
|
|
697
|
+
const cwd = context.cwd ?? process.cwd();
|
|
698
|
+
const { path: filePath, suffix } = formatReadCallText(args);
|
|
699
|
+
const rangeSuffix = typeof args?.offset === "number" && typeof args?.limit === "number" && args.offset > 0 && args.limit > 0
|
|
700
|
+
? `:${args.offset}-${args.offset + args.limit - 1}`
|
|
701
|
+
: "";
|
|
702
|
+
let text = renderToolLabel(theme, "read");
|
|
703
|
+
if (filePath) {
|
|
704
|
+
text += ` ${linkToolPath(theme.fg("accent", `${filePath}${rangeSuffix}`), filePath, cwd)}`;
|
|
705
|
+
} else {
|
|
706
|
+
text += ` ${theme.fg("toolOutput", "...")}`;
|
|
707
|
+
}
|
|
708
|
+
if (!rangeSuffix && suffix) text += ` ${theme.fg("dim", suffix)}`;
|
|
709
|
+
return new Text(clampLineToWidth(text, context.width), 0, 0);
|
|
710
|
+
},
|
|
711
|
+
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
712
|
+
const context: { isPartial?: boolean; isError?: boolean; expanded?: boolean; cwd?: string; width?: number } = rest[0] ?? options ?? {};
|
|
713
|
+
const isPartial = context.isPartial ?? (options as any)?.isPartial ?? false;
|
|
714
|
+
const isError = context.isError ?? false;
|
|
715
|
+
const expanded = isRendererExpanded(options as any, context as any);
|
|
716
|
+
const width = context.width ?? (options as any)?.width;
|
|
717
|
+
if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending read")], width).join("\n"), 0, 0);
|
|
718
|
+
|
|
719
|
+
const content = result.content?.[0];
|
|
720
|
+
const textContent = content?.type === "text" ? content.text : "";
|
|
721
|
+
if (isError || result.isError) {
|
|
722
|
+
const firstLine = textContent.split("\n")[0] || "Error";
|
|
723
|
+
const errorText = expanded ? (textContent || firstLine) : firstLine;
|
|
724
|
+
return new Text(clampLinesToWidth([summaryLine(errorText)], width).join("\n"), 0, 0);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const readseekValue = (result.details as any)?.readseekValue as { range: { startLine: number; endLine: number; totalLines: number }; truncation: any; symbol: any; map: any; warnings: ReadseekWarning[] } | undefined;
|
|
728
|
+
if (!readseekValue) {
|
|
729
|
+
const lines = textContent.split("\n").filter(Boolean).length || textContent.split("\n").length;
|
|
730
|
+
return new Text(summaryLine(`loaded ${lines} ${lines === 1 ? "line" : "lines"}`, { hidden: !!textContent && !expanded }), 0, 0);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const info = formatReadResultText({ range: readseekValue.range, truncation: readseekValue.truncation, symbol: readseekValue.symbol, map: readseekValue.map, warnings: readseekValue.warnings });
|
|
734
|
+
const visibleLines = info.truncated && readseekValue.truncation ? readseekValue.truncation.outputLines : readseekValue.range.endLine - readseekValue.range.startLine + 1;
|
|
735
|
+
const loadedWord = visibleLines === 1 ? "line" : "lines";
|
|
736
|
+
const summaryParts: string[] = [info.truncated ? `loaded ${visibleLines} of ${readseekValue.truncation?.totalLines ?? readseekValue.range.totalLines} ${loadedWord} (truncated)` : `loaded ${visibleLines} ${loadedWord}`];
|
|
737
|
+
if (info.symbolBadge) summaryParts.push(info.symbolBadge);
|
|
738
|
+
for (const badge of info.badges) summaryParts.push(badge);
|
|
739
|
+
const summary = summaryParts.join(" • ");
|
|
740
|
+
let text = summaryLine(summary, { hidden: !!textContent && !expanded });
|
|
741
|
+
if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidth(textContent, width);
|
|
742
|
+
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
743
|
+
},
|
|
744
|
+
} satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
|
|
745
|
+
|
|
746
|
+
pi.registerTool(tool);
|
|
747
|
+
return tool;
|
|
748
|
+
}
|