pi-readseek 0.3.18 → 0.3.20

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