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/index.ts +11 -11
- package/package.json +1 -1
- package/src/edit.ts +427 -410
- package/src/grep.ts +338 -475
- package/src/read.ts +360 -357
- package/src/readseek-value.ts +10 -2
- package/src/refs.ts +88 -97
- package/src/session-anchors.ts +20 -0
- package/src/sg.ts +128 -139
- package/src/tool-types.ts +9 -0
- package/src/tui-render-utils.ts +55 -1
- package/src/write.ts +2 -1
package/src/grep.ts
CHANGED
|
@@ -6,9 +6,9 @@ import path from "path";
|
|
|
6
6
|
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
7
7
|
import { normalizeToLF, stripBom, hasBareCarriageReturn } from "./edit-diff.js";
|
|
8
8
|
import { looksLikeBinary } from "./binary-detect.js";
|
|
9
|
-
import { ensureHashInit,
|
|
10
|
-
import { buildReadseekLine, buildToolErrorResult } from "./readseek-value.js";
|
|
11
|
-
import { buildGrepOutput } from "./grep-output.js";
|
|
9
|
+
import { ensureHashInit, escapeControlCharsForDisplay } from "./hashline.js";
|
|
10
|
+
import { buildReadseekLine, buildToolErrorResult, type ReadseekLine } from "./readseek-value.js";
|
|
11
|
+
import { buildGrepOutput, type GrepOutputEntry, type GrepOutputGroup, type GrepOutputRecord, type GrepScopeWarning } from "./grep-output.js";
|
|
12
12
|
|
|
13
13
|
import { getOrGenerateMap } from "./map-cache.js";
|
|
14
14
|
import { scopeGrepGroupsToSymbols } from "./grep-symbol-scope.js";
|
|
@@ -18,6 +18,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
18
18
|
import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
|
|
19
19
|
import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
20
20
|
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
21
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
21
22
|
|
|
22
23
|
const GREP_PROMPT_METADATA = defineToolPromptMetadata({
|
|
23
24
|
promptUrl: new URL("../prompts/grep.md", import.meta.url),
|
|
@@ -99,177 +100,76 @@ function parseGrepOutputLine(line: string):
|
|
|
99
100
|
return null;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
raw: string;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export interface GrepIRFile {
|
|
108
|
-
path: string;
|
|
109
|
-
matchCount: number;
|
|
110
|
-
lines: GrepIRLine[];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface GrepIR {
|
|
114
|
-
totalMatches: number;
|
|
115
|
-
files: GrepIRFile[];
|
|
116
|
-
}
|
|
103
|
+
const GREP_TRUNCATION_THRESHOLD = 50;
|
|
104
|
+
const GREP_MAX_MATCHES_PER_FILE = 10;
|
|
117
105
|
|
|
118
|
-
|
|
119
|
-
path: string;
|
|
120
|
-
line: number;
|
|
121
|
-
hash: string;
|
|
122
|
-
anchor: string;
|
|
123
|
-
kind: "match" | "context";
|
|
124
|
-
raw: string;
|
|
125
|
-
display: string;
|
|
126
|
-
}
|
|
106
|
+
type GrepAnchoredEntry = Extract<GrepOutputEntry, { kind: "match" | "context" }>;
|
|
127
107
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Collapse context lines that duplicate a match line by line number (a match
|
|
110
|
+
* supersedes a context entry at the same line), then sort ascending and insert
|
|
111
|
+
* a `--` separator across each line-number gap.
|
|
112
|
+
*/
|
|
113
|
+
function dedupeContextEntries(entries: GrepOutputEntry[]): GrepOutputEntry[] {
|
|
114
|
+
if (entries.length === 0) return entries;
|
|
115
|
+
|
|
116
|
+
const byLine = new Map<number, GrepAnchoredEntry>();
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (entry.kind === "separator") continue;
|
|
119
|
+
const existing = byLine.get(entry.line.line);
|
|
120
|
+
if (!existing || (entry.kind === "match" && existing.kind === "context")) {
|
|
121
|
+
byLine.set(entry.line.line, entry);
|
|
138
122
|
}
|
|
139
123
|
}
|
|
140
|
-
return records;
|
|
141
|
-
}
|
|
142
124
|
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
let totalMatches = 0;
|
|
149
|
-
|
|
150
|
-
for (const line of lines) {
|
|
151
|
-
const matchResult = line.match(IR_MATCH_LINE_RE);
|
|
152
|
-
let filePath: string | undefined;
|
|
153
|
-
let kind: "match" | "context" = "context";
|
|
154
|
-
|
|
155
|
-
if (matchResult) {
|
|
156
|
-
filePath = matchResult[1];
|
|
157
|
-
kind = "match";
|
|
158
|
-
totalMatches++;
|
|
159
|
-
} else {
|
|
160
|
-
const contextResult = line.match(IR_CONTEXT_LINE_RE);
|
|
161
|
-
if (contextResult) {
|
|
162
|
-
filePath = contextResult[1];
|
|
163
|
-
kind = "context";
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (!filePath) continue;
|
|
168
|
-
|
|
169
|
-
let file = fileMap.get(filePath);
|
|
170
|
-
if (!file) {
|
|
171
|
-
file = { path: filePath, matchCount: 0, lines: [] };
|
|
172
|
-
fileMap.set(filePath, file);
|
|
125
|
+
const sorted = [...byLine.entries()].sort(([a], [b]) => a - b);
|
|
126
|
+
const result: GrepOutputEntry[] = [];
|
|
127
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
128
|
+
if (i > 0 && sorted[i][0] > sorted[i - 1][0] + 1) {
|
|
129
|
+
result.push({ kind: "separator", text: "--" });
|
|
173
130
|
}
|
|
174
|
-
|
|
175
|
-
file.lines.push({ kind, raw: line });
|
|
176
|
-
if (kind === "match") file.matchCount++;
|
|
131
|
+
result.push(sorted[i][1]);
|
|
177
132
|
}
|
|
178
133
|
|
|
179
|
-
return
|
|
134
|
+
return result;
|
|
180
135
|
}
|
|
181
136
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Keep at most {@link GREP_MAX_MATCHES_PER_FILE} matches (with their context)
|
|
139
|
+
* per file, appending a `... +N more matches` separator when matches are
|
|
140
|
+
* dropped. The group's `matchCount` retains the pre-truncation total.
|
|
141
|
+
*/
|
|
142
|
+
function truncateGroupEntries(group: GrepOutputGroup): GrepOutputGroup {
|
|
143
|
+
let matchesSeen = 0;
|
|
144
|
+
let truncatedCount = 0;
|
|
145
|
+
const kept: GrepOutputEntry[] = [];
|
|
146
|
+
|
|
147
|
+
for (const entry of group.entries) {
|
|
148
|
+
if (entry.kind === "match") {
|
|
149
|
+
matchesSeen++;
|
|
150
|
+
if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
151
|
+
kept.push(entry);
|
|
152
|
+
} else {
|
|
153
|
+
truncatedCount++;
|
|
197
154
|
}
|
|
155
|
+
} else if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
156
|
+
kept.push(entry);
|
|
198
157
|
}
|
|
199
|
-
output = blocks.join("\n");
|
|
200
158
|
}
|
|
201
159
|
|
|
202
|
-
if (
|
|
203
|
-
|
|
160
|
+
if (truncatedCount > 0) {
|
|
161
|
+
kept.push({ kind: "separator", text: `... +${truncatedCount} more matches` });
|
|
204
162
|
}
|
|
205
163
|
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const GREP_TRUNCATION_THRESHOLD = 50;
|
|
210
|
-
const GREP_MAX_MATCHES_PER_FILE = 10;
|
|
211
|
-
|
|
212
|
-
export function truncateGrepIR(ir: GrepIR): GrepIR {
|
|
213
|
-
if (ir.totalMatches <= GREP_TRUNCATION_THRESHOLD) return ir;
|
|
214
|
-
|
|
215
|
-
const files = ir.files.map((file) => {
|
|
216
|
-
let matchesSeen = 0;
|
|
217
|
-
const keptLines: GrepIRLine[] = [];
|
|
218
|
-
let truncatedCount = 0;
|
|
219
|
-
|
|
220
|
-
for (const line of file.lines) {
|
|
221
|
-
if (line.kind === "match") {
|
|
222
|
-
matchesSeen++;
|
|
223
|
-
if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
224
|
-
keptLines.push(line);
|
|
225
|
-
} else {
|
|
226
|
-
truncatedCount++;
|
|
227
|
-
}
|
|
228
|
-
} else if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
229
|
-
keptLines.push(line);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (truncatedCount > 0) {
|
|
234
|
-
keptLines.push({
|
|
235
|
-
kind: "separator",
|
|
236
|
-
raw: `... +${truncatedCount} more matches`,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return { ...file, lines: keptLines };
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
return { ...ir, files };
|
|
164
|
+
return { ...group, entries: kept };
|
|
244
165
|
}
|
|
245
166
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
for (const line of lines) {
|
|
253
|
-
const match = line.raw.match(LINE_NUM_RE);
|
|
254
|
-
if (!match) continue;
|
|
255
|
-
const lineNum = Number.parseInt(match[1], 10);
|
|
256
|
-
const existing = byLineNum.get(lineNum);
|
|
257
|
-
if (!existing || (line.kind === "match" && existing.kind === "context")) {
|
|
258
|
-
byLineNum.set(lineNum, line);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const sorted = [...byLineNum.entries()].sort(([a], [b]) => a - b);
|
|
263
|
-
const result: GrepIRLine[] = [];
|
|
264
|
-
|
|
265
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
266
|
-
if (i > 0 && sorted[i][0] > sorted[i - 1][0] + 1) {
|
|
267
|
-
result.push({ kind: "separator", raw: "--" });
|
|
268
|
-
}
|
|
269
|
-
result.push(sorted[i][1]);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return result;
|
|
167
|
+
function recordsFromGroups(groups: GrepOutputGroup[]): GrepOutputRecord[] {
|
|
168
|
+
return groups.flatMap((group) =>
|
|
169
|
+
group.entries.flatMap((entry) =>
|
|
170
|
+
entry.kind === "separator" ? [] : [{ path: group.absolutePath, ...entry.line, kind: entry.kind }],
|
|
171
|
+
),
|
|
172
|
+
);
|
|
273
173
|
}
|
|
274
174
|
|
|
275
175
|
/**
|
|
@@ -281,241 +181,90 @@ function escapeForRegex(s: string): string {
|
|
|
281
181
|
|
|
282
182
|
interface GrepToolOptions {
|
|
283
183
|
searchGuideline?: string;
|
|
284
|
-
onFileAnchored?:
|
|
184
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
285
185
|
}
|
|
286
186
|
|
|
287
|
-
export
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
const tool = {
|
|
298
|
-
name: "grep",
|
|
299
|
-
label: "grep",
|
|
300
|
-
description: GREP_PROMPT_METADATA.description,
|
|
301
|
-
parameters: grepSchema,
|
|
302
|
-
ptc: toolConfig,
|
|
303
|
-
promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
|
|
304
|
-
promptGuidelines: options.searchGuideline
|
|
305
|
-
? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
|
|
306
|
-
: GREP_PROMPT_METADATA.promptGuidelines,
|
|
307
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
308
|
-
await ensureHashInit();
|
|
309
|
-
const rawParams = params as GrepParams;
|
|
310
|
-
const context = coerceObviousBase10Int(rawParams.context, "context");
|
|
311
|
-
if (!context.ok) {
|
|
312
|
-
return buildToolErrorResult("grep", "invalid-params-combo", context.message);
|
|
313
|
-
}
|
|
314
|
-
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
315
|
-
if (!limit.ok) {
|
|
316
|
-
return buildToolErrorResult("grep", "invalid-limit", limit.message);
|
|
317
|
-
}
|
|
318
|
-
const scopeContext = coerceObviousBase10Int(rawParams.scopeContext, "scopeContext");
|
|
319
|
-
if (!scopeContext.ok) {
|
|
320
|
-
return buildToolErrorResult("grep", "invalid-params-combo", scopeContext.message);
|
|
321
|
-
}
|
|
322
|
-
if (scopeContext.value !== undefined && rawParams.scope !== "symbol") {
|
|
323
|
-
const message = 'Invalid scopeContext: requires scope: "symbol". For normal surrounding-line context outside symbol scope, use the `context` parameter.';
|
|
324
|
-
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
325
|
-
}
|
|
326
|
-
if (scopeContext.value !== undefined && scopeContext.value < 0) {
|
|
327
|
-
const message = `Invalid scopeContext: expected a non-negative integer, received ${scopeContext.value}.`;
|
|
328
|
-
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
329
|
-
}
|
|
330
|
-
const p: GrepParams = {
|
|
331
|
-
...rawParams,
|
|
332
|
-
context: context.value,
|
|
333
|
-
limit: limit.value,
|
|
334
|
-
scopeContext: scopeContext.value,
|
|
335
|
-
};
|
|
336
|
-
const builtin = createGrepTool(ctx.cwd);
|
|
337
|
-
const result = await builtin.execute(
|
|
338
|
-
toolCallId,
|
|
339
|
-
{
|
|
340
|
-
...p,
|
|
341
|
-
context: context.value,
|
|
342
|
-
limit: limit.value,
|
|
343
|
-
},
|
|
344
|
-
signal,
|
|
345
|
-
onUpdate,
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
const textBlock = result.content?.find(
|
|
349
|
-
(item): item is { type: "text"; text: string } =>
|
|
350
|
-
item.type === "text" && "text" in item && typeof (item as { text?: unknown }).text === "string",
|
|
351
|
-
);
|
|
352
|
-
if (!textBlock?.text) return result;
|
|
187
|
+
export interface ExecuteGrepOptions {
|
|
188
|
+
toolCallId: string;
|
|
189
|
+
params: unknown;
|
|
190
|
+
signal: AbortSignal | undefined;
|
|
191
|
+
onUpdate: any;
|
|
192
|
+
cwd: string;
|
|
193
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
194
|
+
}
|
|
353
195
|
|
|
354
|
-
|
|
355
|
-
|
|
196
|
+
export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
197
|
+
const { toolCallId, params, signal, onUpdate, cwd, onFileAnchored } = opts;
|
|
198
|
+
await ensureHashInit();
|
|
199
|
+
const rawParams = params as GrepParams;
|
|
200
|
+
const context = coerceObviousBase10Int(rawParams.context, "context");
|
|
201
|
+
if (!context.ok) {
|
|
202
|
+
return buildToolErrorResult("grep", "invalid-params-combo", context.message);
|
|
203
|
+
}
|
|
204
|
+
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
205
|
+
if (!limit.ok) {
|
|
206
|
+
return buildToolErrorResult("grep", "invalid-limit", limit.message);
|
|
207
|
+
}
|
|
208
|
+
const scopeContext = coerceObviousBase10Int(rawParams.scopeContext, "scopeContext");
|
|
209
|
+
if (!scopeContext.ok) {
|
|
210
|
+
return buildToolErrorResult("grep", "invalid-params-combo", scopeContext.message);
|
|
211
|
+
}
|
|
212
|
+
if (scopeContext.value !== undefined && rawParams.scope !== "symbol") {
|
|
213
|
+
const message = 'Invalid scopeContext: requires scope: "symbol". For normal surrounding-line context outside symbol scope, use the `context` parameter.';
|
|
214
|
+
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
215
|
+
}
|
|
216
|
+
if (scopeContext.value !== undefined && scopeContext.value < 0) {
|
|
217
|
+
const message = `Invalid scopeContext: expected a non-negative integer, received ${scopeContext.value}.`;
|
|
218
|
+
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
219
|
+
}
|
|
220
|
+
const p: GrepParams = {
|
|
221
|
+
...rawParams,
|
|
222
|
+
context: context.value,
|
|
223
|
+
limit: limit.value,
|
|
224
|
+
scopeContext: scopeContext.value,
|
|
225
|
+
};
|
|
226
|
+
const builtin = createGrepTool(cwd);
|
|
227
|
+
const result = await builtin.execute(
|
|
228
|
+
toolCallId,
|
|
229
|
+
{
|
|
230
|
+
...p,
|
|
231
|
+
context: context.value,
|
|
232
|
+
limit: limit.value,
|
|
233
|
+
},
|
|
234
|
+
signal,
|
|
235
|
+
onUpdate,
|
|
236
|
+
);
|
|
356
237
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
// Warn when the user targets a single binary file directly — grep
|
|
364
|
-
// silently skips binary files and would return 0 matches with no
|
|
365
|
-
// indication of why.
|
|
366
|
-
if (!searchPathIsDirectory) {
|
|
367
|
-
try {
|
|
368
|
-
const buf = await fsReadFile(searchPath);
|
|
369
|
-
if (looksLikeBinary(buf)) {
|
|
370
|
-
const warning = `[Warning: '${p.path ?? searchPath}' appears to be a binary file — grep skips binary files by default. Use a hex tool or the read tool to inspect it.]`;
|
|
371
|
-
return {
|
|
372
|
-
...result,
|
|
373
|
-
content: result.content.map((item) =>
|
|
374
|
-
item === textBlock ? ({ ...item, text: warning } as typeof item) : item,
|
|
375
|
-
),
|
|
376
|
-
details: {
|
|
377
|
-
...(typeof result.details === "object" && result.details !== null ? result.details : {}),
|
|
378
|
-
readseekValue: {
|
|
379
|
-
tool: "grep",
|
|
380
|
-
summary: !!p.summary,
|
|
381
|
-
totalMatches: 0,
|
|
382
|
-
records: [],
|
|
383
|
-
},
|
|
384
|
-
},
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
} catch {
|
|
388
|
-
// can't read file — let normal flow continue
|
|
389
|
-
}
|
|
390
|
-
}
|
|
238
|
+
const textBlock = result.content?.find(
|
|
239
|
+
(item): item is { type: "text"; text: string } =>
|
|
240
|
+
item.type === "text" && "text" in item && typeof (item as { text?: unknown }).text === "string",
|
|
241
|
+
);
|
|
242
|
+
if (!textBlock?.text) return result;
|
|
391
243
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const getFileLines = async (absolutePath: string): Promise<string[] | undefined> => {
|
|
395
|
-
throwIfAborted(signal);
|
|
396
|
-
if (fileCache.has(absolutePath)) return fileCache.get(absolutePath);
|
|
397
|
-
try {
|
|
398
|
-
const rawBuffer = await fsReadFile(absolutePath);
|
|
399
|
-
if (looksLikeBinary(rawBuffer)) {
|
|
400
|
-
fileCache.set(absolutePath, undefined);
|
|
401
|
-
return undefined;
|
|
402
|
-
}
|
|
403
|
-
const raw = rawBuffer.toString("utf-8");
|
|
404
|
-
if (hasBareCarriageReturn(raw)) bareCRFiles.add(absolutePath);
|
|
405
|
-
const lines = normalizeToLF(stripBom(raw).text).split("\n");
|
|
406
|
-
fileCache.set(absolutePath, lines);
|
|
407
|
-
return lines;
|
|
408
|
-
} catch {
|
|
409
|
-
fileCache.set(absolutePath, undefined);
|
|
410
|
-
return undefined;
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
const toAbsolutePath = (displayPath: string): string => {
|
|
415
|
-
if (searchPathIsDirectory) return path.resolve(searchPath, displayPath);
|
|
416
|
-
return searchPath;
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
const transformed: string[] = [];
|
|
420
|
-
const passthroughLines: string[] = [];
|
|
421
|
-
const recordByRenderedLine = new Map<string, GrepReadseekRecord>();
|
|
422
|
-
let parsedCount = 0;
|
|
423
|
-
let candidateUnparsedCount = 0;
|
|
424
|
-
const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
|
|
425
|
-
|
|
426
|
-
for (const line of textBlock.text.split("\n")) {
|
|
427
|
-
throwIfAborted(signal);
|
|
428
|
-
const parsed = parseGrepOutputLine(line);
|
|
429
|
-
if (!parsed || !Number.isFinite(parsed.lineNumber) || parsed.lineNumber < 1) {
|
|
430
|
-
if (candidateLinePattern.test(line)) {
|
|
431
|
-
candidateUnparsedCount++;
|
|
432
|
-
}
|
|
433
|
-
const trimmed = line.trim();
|
|
434
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
435
|
-
passthroughLines.push(trimmed);
|
|
436
|
-
}
|
|
437
|
-
transformed.push(line);
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
parsedCount++;
|
|
441
|
-
const absolute = toAbsolutePath(parsed.displayPath);
|
|
442
|
-
const fileLines = await getFileLines(absolute);
|
|
443
|
-
if (fileLines === undefined) continue;
|
|
444
|
-
// Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
|
|
445
|
-
// builtin grep tool may strip \r before this code sees the output. So
|
|
446
|
-
// parsed.text is just the first CR-separated fragment and parsed.lineNumber
|
|
447
|
-
// is always 1 — both are wrong for match lines. Only remap when
|
|
448
|
-
// parsed.kind === "match"; context lines are irrelevant here (rg won’t
|
|
449
|
-
// produce them for bare-CR files in any meaningful way).
|
|
450
|
-
if (parsed.kind === "match" && bareCRFiles.has(absolute)) {
|
|
451
|
-
const gp = p;
|
|
452
|
-
const flags = gp.ignoreCase ? "i" : "";
|
|
453
|
-
let patternRe: RegExp | null = null;
|
|
454
|
-
try {
|
|
455
|
-
patternRe = gp.literal
|
|
456
|
-
? new RegExp(escapeForRegex(gp.pattern), flags)
|
|
457
|
-
: new RegExp(gp.pattern, flags);
|
|
458
|
-
} catch {
|
|
459
|
-
// Malformed regex — fall through to normal anchor path
|
|
460
|
-
}
|
|
461
|
-
if (patternRe !== null) {
|
|
462
|
-
let emitted = false;
|
|
463
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
464
|
-
if (!patternRe.test(fileLines[i])) continue;
|
|
465
|
-
const lineNum = i + 1;
|
|
466
|
-
const marker = ">>";
|
|
467
|
-
const renderedLine = `${parsed.displayPath}:${marker}${formatHashlineDisplay(lineNum, fileLines[i])}`;
|
|
468
|
-
transformed.push(renderedLine);
|
|
469
|
-
const built = buildReadseekLine(lineNum, fileLines[i]);
|
|
470
|
-
recordByRenderedLine.set(renderedLine, {
|
|
471
|
-
path: toAbsolutePath(parsed.displayPath),
|
|
472
|
-
line: built.line,
|
|
473
|
-
hash: built.hash,
|
|
474
|
-
anchor: built.anchor,
|
|
475
|
-
kind: "match",
|
|
476
|
-
raw: built.raw,
|
|
477
|
-
display: built.display,
|
|
478
|
-
});
|
|
479
|
-
emitted = true;
|
|
480
|
-
}
|
|
481
|
-
if (emitted) continue;
|
|
482
|
-
// No lines matched — fall through to normal path
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
// Normal (non-bare-CR) path
|
|
486
|
-
const sourceLine = fileLines?.[parsed.lineNumber - 1] ?? parsed.text;
|
|
487
|
-
const built = buildReadseekLine(parsed.lineNumber, sourceLine);
|
|
488
|
-
const marker = parsed.kind === "match" ? ">>" : " ";
|
|
489
|
-
const renderedDisplay = escapeControlCharsForDisplay(parsed.text);
|
|
490
|
-
const renderedLine = `${parsed.displayPath}:${marker}${built.anchor}|${renderedDisplay}`;
|
|
491
|
-
transformed.push(renderedLine);
|
|
492
|
-
recordByRenderedLine.set(renderedLine, {
|
|
493
|
-
path: toAbsolutePath(parsed.displayPath),
|
|
494
|
-
line: built.line,
|
|
495
|
-
hash: built.hash,
|
|
496
|
-
anchor: built.anchor,
|
|
497
|
-
kind: parsed.kind,
|
|
498
|
-
raw: built.raw,
|
|
499
|
-
display: renderedDisplay,
|
|
500
|
-
});
|
|
501
|
-
}
|
|
244
|
+
const { path: rawSearchPath } = p;
|
|
245
|
+
const searchPath = resolveToCwd(rawSearchPath || ".", cwd);
|
|
502
246
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
247
|
+
let searchPathIsDirectory = false;
|
|
248
|
+
try {
|
|
249
|
+
searchPathIsDirectory = (await fsStat(searchPath)).isDirectory();
|
|
250
|
+
} catch {
|
|
251
|
+
searchPathIsDirectory = false;
|
|
252
|
+
}
|
|
253
|
+
// Warn when the user targets a single binary file directly — grep
|
|
254
|
+
// silently skips binary files and would return 0 matches with no
|
|
255
|
+
// indication of why.
|
|
256
|
+
if (!searchPathIsDirectory) {
|
|
257
|
+
try {
|
|
258
|
+
const buf = await fsReadFile(searchPath);
|
|
259
|
+
if (looksLikeBinary(buf)) {
|
|
260
|
+
const warning = `[Warning: '${p.path ?? searchPath}' appears to be a binary file — grep skips binary files by default. Use a hex tool or the read tool to inspect it.]`;
|
|
510
261
|
return {
|
|
511
262
|
...result,
|
|
512
263
|
content: result.content.map((item) =>
|
|
513
|
-
item === textBlock ? ({ ...item, text:
|
|
264
|
+
item === textBlock ? ({ ...item, text: warning } as typeof item) : item,
|
|
514
265
|
),
|
|
515
266
|
details: {
|
|
516
|
-
...
|
|
517
|
-
hashlinePassthrough: true,
|
|
518
|
-
hashlineWarning: warning,
|
|
267
|
+
...(typeof result.details === "object" && result.details !== null ? result.details : {}),
|
|
519
268
|
readseekValue: {
|
|
520
269
|
tool: "grep",
|
|
521
270
|
summary: !!p.summary,
|
|
@@ -525,119 +274,233 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
|
|
|
525
274
|
},
|
|
526
275
|
};
|
|
527
276
|
}
|
|
277
|
+
} catch {
|
|
278
|
+
// can't read file — let normal flow continue
|
|
279
|
+
}
|
|
280
|
+
}
|
|
528
281
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
282
|
+
const fileCache = new Map<string, string[] | undefined>();
|
|
283
|
+
const bareCRFiles = new Set<string>();
|
|
284
|
+
const getFileLines = async (absolutePath: string): Promise<string[] | undefined> => {
|
|
285
|
+
throwIfAborted(signal);
|
|
286
|
+
if (fileCache.has(absolutePath)) return fileCache.get(absolutePath);
|
|
287
|
+
try {
|
|
288
|
+
const rawBuffer = await fsReadFile(absolutePath);
|
|
289
|
+
if (looksLikeBinary(rawBuffer)) {
|
|
290
|
+
fileCache.set(absolutePath, undefined);
|
|
291
|
+
return undefined;
|
|
532
292
|
}
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
293
|
+
const raw = rawBuffer.toString("utf-8");
|
|
294
|
+
if (hasBareCarriageReturn(raw)) bareCRFiles.add(absolutePath);
|
|
295
|
+
const lines = normalizeToLF(stripBom(raw).text).split("\n");
|
|
296
|
+
fileCache.set(absolutePath, lines);
|
|
297
|
+
return lines;
|
|
298
|
+
} catch {
|
|
299
|
+
fileCache.set(absolutePath, undefined);
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const toAbsolutePath = (displayPath: string): string => {
|
|
305
|
+
if (searchPathIsDirectory) return path.resolve(searchPath, displayPath);
|
|
306
|
+
return searchPath;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const groupsByPath = new Map<string, GrepOutputGroup>();
|
|
310
|
+
const passthroughLines: string[] = [];
|
|
311
|
+
let totalMatches = 0;
|
|
312
|
+
let parsedCount = 0;
|
|
313
|
+
let candidateUnparsedCount = 0;
|
|
314
|
+
const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
|
|
315
|
+
|
|
316
|
+
const addEntry = (displayPath: string, absolutePath: string, kind: "match" | "context", line: ReadseekLine) => {
|
|
317
|
+
let group = groupsByPath.get(displayPath);
|
|
318
|
+
if (!group) {
|
|
319
|
+
group = { displayPath, absolutePath, matchCount: 0, entries: [] };
|
|
320
|
+
groupsByPath.set(displayPath, group);
|
|
321
|
+
}
|
|
322
|
+
group.entries.push({ kind, line });
|
|
323
|
+
if (kind === "match") {
|
|
324
|
+
group.matchCount++;
|
|
325
|
+
totalMatches++;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
for (const line of textBlock.text.split("\n")) {
|
|
330
|
+
throwIfAborted(signal);
|
|
331
|
+
const parsed = parseGrepOutputLine(line);
|
|
332
|
+
if (!parsed || !Number.isFinite(parsed.lineNumber) || parsed.lineNumber < 1) {
|
|
333
|
+
if (candidateLinePattern.test(line)) {
|
|
334
|
+
candidateUnparsedCount++;
|
|
335
|
+
}
|
|
336
|
+
const trimmed = line.trim();
|
|
337
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
338
|
+
passthroughLines.push(trimmed);
|
|
339
|
+
}
|
|
340
|
+
continue;
|
|
549
341
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
342
|
+
parsedCount++;
|
|
343
|
+
const absolute = toAbsolutePath(parsed.displayPath);
|
|
344
|
+
const fileLines = await getFileLines(absolute);
|
|
345
|
+
if (fileLines === undefined) continue;
|
|
346
|
+
// Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
|
|
347
|
+
// builtin grep tool may strip \r before this code sees the output. So
|
|
348
|
+
// parsed.text is just the first CR-separated fragment and parsed.lineNumber
|
|
349
|
+
// is always 1 — both are wrong for match lines. Only remap when
|
|
350
|
+
// parsed.kind === "match"; context lines are irrelevant here (rg won’t
|
|
351
|
+
// produce them for bare-CR files in any meaningful way).
|
|
352
|
+
if (parsed.kind === "match" && bareCRFiles.has(absolute)) {
|
|
353
|
+
const flags = p.ignoreCase ? "i" : "";
|
|
354
|
+
let patternRe: RegExp | null = null;
|
|
355
|
+
try {
|
|
356
|
+
patternRe = p.literal
|
|
357
|
+
? new RegExp(escapeForRegex(p.pattern), flags)
|
|
358
|
+
: new RegExp(p.pattern, flags);
|
|
359
|
+
} catch {
|
|
360
|
+
// Malformed regex — fall through to normal anchor path
|
|
361
|
+
}
|
|
362
|
+
if (patternRe !== null) {
|
|
363
|
+
let emitted = false;
|
|
364
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
365
|
+
if (!patternRe.test(fileLines[i])) continue;
|
|
366
|
+
addEntry(parsed.displayPath, absolute, "match", buildReadseekLine(i + 1, fileLines[i]));
|
|
367
|
+
emitted = true;
|
|
368
|
+
}
|
|
369
|
+
if (emitted) continue;
|
|
370
|
+
// No lines matched — fall through to normal path
|
|
371
|
+
}
|
|
553
372
|
}
|
|
373
|
+
// Normal (non-bare-CR) path
|
|
374
|
+
const sourceLine = fileLines[parsed.lineNumber - 1] ?? parsed.text;
|
|
375
|
+
const built = buildReadseekLine(parsed.lineNumber, sourceLine);
|
|
376
|
+
addEntry(parsed.displayPath, absolute, parsed.kind, {
|
|
377
|
+
...built,
|
|
378
|
+
display: escapeControlCharsForDisplay(parsed.text),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (parsedCount === 0 && candidateUnparsedCount > 0) {
|
|
383
|
+
const warning =
|
|
384
|
+
"[hashline grep passthrough] Unparsed grep format; returned original output.";
|
|
385
|
+
const passthroughDetails =
|
|
386
|
+
typeof result.details === "object" && result.details !== null
|
|
387
|
+
? (result.details as Record<string, unknown>)
|
|
388
|
+
: {};
|
|
554
389
|
return {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
390
|
+
...result,
|
|
391
|
+
content: result.content.map((item) =>
|
|
392
|
+
item === textBlock ? ({ ...item, text: `${textBlock.text}\n\n${warning}` } as typeof item) : item,
|
|
393
|
+
),
|
|
394
|
+
details: {
|
|
395
|
+
...passthroughDetails,
|
|
396
|
+
hashlinePassthrough: true,
|
|
397
|
+
hashlineWarning: warning,
|
|
398
|
+
readseekValue: {
|
|
399
|
+
tool: "grep",
|
|
400
|
+
summary: !!p.summary,
|
|
401
|
+
totalMatches: 0,
|
|
402
|
+
records: [],
|
|
403
|
+
},
|
|
562
404
|
},
|
|
563
405
|
};
|
|
564
|
-
}
|
|
565
|
-
}));
|
|
406
|
+
}
|
|
566
407
|
|
|
567
|
-
|
|
568
|
-
|
|
408
|
+
const summary = p.summary;
|
|
409
|
+
const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
|
|
410
|
+
const groups = [...groupsByPath.values()];
|
|
411
|
+
for (const group of groups) {
|
|
412
|
+
group.entries = dedupeContextEntries(group.entries);
|
|
413
|
+
}
|
|
414
|
+
let renderedGroups: GrepOutputGroup[] =
|
|
415
|
+
totalMatches > GREP_TRUNCATION_THRESHOLD ? groups.map(truncateGroupEntries) : groups;
|
|
416
|
+
let scopeWarnings: GrepScopeWarning[] = [];
|
|
417
|
+
|
|
418
|
+
if (p.scope === "symbol" && !summary) {
|
|
419
|
+
const fileLinesByPath = new Map<string, string[]>();
|
|
420
|
+
const fileMapsByPath = new Map<string, Awaited<ReturnType<typeof getOrGenerateMap>>>();
|
|
421
|
+
|
|
422
|
+
for (const group of renderedGroups) {
|
|
423
|
+
const lines = await getFileLines(group.absolutePath);
|
|
424
|
+
if (lines) fileLinesByPath.set(group.absolutePath, lines);
|
|
425
|
+
fileMapsByPath.set(group.absolutePath, await getOrGenerateMap(group.absolutePath));
|
|
426
|
+
}
|
|
569
427
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
428
|
+
const scoped = scopeGrepGroupsToSymbols({
|
|
429
|
+
groups: renderedGroups,
|
|
430
|
+
fileLinesByPath,
|
|
431
|
+
fileMapsByPath,
|
|
432
|
+
contextLines: typeof p.context === "number" ? p.context : 0,
|
|
433
|
+
scopeContext: typeof p.scopeContext === "number" ? p.scopeContext : undefined,
|
|
434
|
+
});
|
|
573
435
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (lines) fileLinesByPath.set(group.absolutePath, lines);
|
|
577
|
-
fileMapsByPath.set(group.absolutePath, await getOrGenerateMap(group.absolutePath));
|
|
436
|
+
renderedGroups = scoped.groups;
|
|
437
|
+
scopeWarnings = scoped.warnings;
|
|
578
438
|
}
|
|
579
|
-
|
|
580
|
-
const
|
|
439
|
+
const readseekRecords = recordsFromGroups(renderedGroups);
|
|
440
|
+
const builtOutput = buildGrepOutput({
|
|
441
|
+
summary: !!summary,
|
|
442
|
+
totalMatches,
|
|
581
443
|
groups: renderedGroups,
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
444
|
+
limit: effectiveLimit,
|
|
445
|
+
records: readseekRecords,
|
|
446
|
+
scopeMode: p.scope === "symbol" && !summary ? "symbol" : undefined,
|
|
447
|
+
scopeWarnings,
|
|
448
|
+
passthroughLines,
|
|
586
449
|
});
|
|
587
450
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
],
|
|
451
|
+
if (!summary && readseekRecords.length > 0) {
|
|
452
|
+
const anchoredPaths = new Set(readseekRecords.map((record) => record.path));
|
|
453
|
+
for (const absolutePath of anchoredPaths) {
|
|
454
|
+
onFileAnchored?.(absolutePath);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const existingDetails =
|
|
459
|
+
typeof result.details === "object" && result.details !== null
|
|
460
|
+
? (result.details as Record<string, unknown>)
|
|
461
|
+
: {};
|
|
462
|
+
const { linesTruncated: _ignoredLinesTruncated, truncation: _ignoredTruncation, ...compactDetails } = existingDetails;
|
|
463
|
+
return {
|
|
464
|
+
...result,
|
|
465
|
+
content: result.content.map((item) =>
|
|
466
|
+
item === textBlock ? ({ ...item, text: builtOutput.text } as typeof item) : item,
|
|
605
467
|
),
|
|
606
|
-
|
|
468
|
+
details: {
|
|
469
|
+
...compactDetails,
|
|
470
|
+
readseekValue: builtOutput.readseekValue,
|
|
471
|
+
},
|
|
472
|
+
};
|
|
607
473
|
}
|
|
608
|
-
const builtOutput = buildGrepOutput({
|
|
609
|
-
summary: !!summary,
|
|
610
|
-
totalMatches: grepIR.totalMatches,
|
|
611
|
-
groups: renderedGroups,
|
|
612
|
-
limit: effectiveLimit,
|
|
613
|
-
records: readseekRecords,
|
|
614
|
-
scopeMode: p.scope === "symbol" && !summary ? "symbol" : undefined,
|
|
615
|
-
scopeWarnings,
|
|
616
|
-
passthroughLines,
|
|
617
|
-
});
|
|
618
474
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
475
|
+
export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
|
|
476
|
+
const toolConfig = {
|
|
477
|
+
callable: true,
|
|
478
|
+
enabled: true,
|
|
479
|
+
policy: "read-only" as const,
|
|
480
|
+
readOnly: true,
|
|
481
|
+
pythonName: "grep",
|
|
482
|
+
defaultExposure: "safe-by-default" as const,
|
|
483
|
+
};
|
|
625
484
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
485
|
+
const tool = {
|
|
486
|
+
name: "grep",
|
|
487
|
+
label: "grep",
|
|
488
|
+
description: GREP_PROMPT_METADATA.description,
|
|
489
|
+
parameters: grepSchema,
|
|
490
|
+
ptc: toolConfig,
|
|
491
|
+
promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
|
|
492
|
+
promptGuidelines: options.searchGuideline
|
|
493
|
+
? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
|
|
494
|
+
: GREP_PROMPT_METADATA.promptGuidelines,
|
|
495
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
496
|
+
return executeGrep({
|
|
497
|
+
toolCallId,
|
|
498
|
+
params,
|
|
499
|
+
signal,
|
|
500
|
+
onUpdate,
|
|
501
|
+
cwd: ctx.cwd,
|
|
502
|
+
onFileAnchored: options.onFileAnchored,
|
|
503
|
+
});
|
|
641
504
|
},
|
|
642
505
|
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
643
506
|
const context = rest[0] ?? {};
|