@oh-my-pi/pi-coding-agent 6.1.0 → 6.7.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +25 -25
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +824 -639
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +89 -41
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -326
@@ -1,5 +1,5 @@
1
1
  import nodePath from "node:path";
2
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import { StringEnum } from "@oh-my-pi/pi-ai";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
@@ -93,416 +93,204 @@ export interface GrepToolOptions {
93
93
  operations?: GrepOperations;
94
94
  }
95
95
 
96
- export function createGrepTool(session: ToolSession, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
97
- const ops = options?.operations ?? defaultGrepOperations;
98
- return {
99
- name: "grep",
100
- label: "Grep",
101
- description: renderPromptTemplate(grepDescription),
102
- parameters: grepSchema,
103
- execute: async (
104
- _toolCallId: string,
105
- {
106
- pattern,
107
- path: searchDir,
108
- glob,
109
- type,
110
- ignoreCase,
111
- caseSensitive,
112
- literal,
113
- multiline,
114
- context,
115
- limit,
116
- outputMode,
117
- headLimit,
118
- offset,
119
- }: {
120
- pattern: string;
121
- path?: string;
122
- glob?: string;
123
- type?: string;
124
- ignoreCase?: boolean;
125
- caseSensitive?: boolean;
126
- literal?: boolean;
127
- multiline?: boolean;
128
- context?: number;
129
- limit?: number;
130
- outputMode?: "content" | "files_with_matches" | "count";
131
- headLimit?: number;
132
- offset?: number;
133
- },
134
- signal?: AbortSignal,
135
- ) => {
136
- return untilAborted(signal, async () => {
137
- const rgPath = await ensureTool("rg", true);
138
- if (!rgPath) {
139
- throw new Error("ripgrep (rg) is not available and could not be downloaded");
140
- }
141
-
142
- const searchPath = resolveToCwd(searchDir || ".", session.cwd);
143
- const scopePath = (() => {
144
- const relative = nodePath.relative(session.cwd, searchPath).replace(/\\/g, "/");
145
- return relative.length === 0 ? "." : relative;
146
- })();
96
+ interface GrepParams {
97
+ pattern: string;
98
+ path?: string;
99
+ glob?: string;
100
+ type?: string;
101
+ ignoreCase?: boolean;
102
+ caseSensitive?: boolean;
103
+ literal?: boolean;
104
+ multiline?: boolean;
105
+ context?: number;
106
+ limit?: number;
107
+ outputMode?: "content" | "files_with_matches" | "count";
108
+ headLimit?: number;
109
+ offset?: number;
110
+ }
147
111
 
148
- let isDirectory: boolean;
149
- try {
150
- isDirectory = await ops.isDirectory(searchPath);
151
- } catch {
152
- throw new Error(`Path not found: ${searchPath}`);
153
- }
154
- const contextValue = context && context > 0 ? context : 0;
155
- const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
156
- const effectiveOutputMode = outputMode ?? "content";
157
- const effectiveOffset = offset && offset > 0 ? offset : 0;
158
- const hasHeadLimit = headLimit !== undefined && headLimit > 0;
159
-
160
- const formatPath = (filePath: string): string => {
161
- if (isDirectory) {
162
- const relative = nodePath.relative(searchPath, filePath);
163
- if (relative && !relative.startsWith("..")) {
164
- return relative.replace(/\\/g, "/");
165
- }
166
- }
167
- return nodePath.basename(filePath);
168
- };
112
+ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
113
+ public readonly name = "grep";
114
+ public readonly label = "Grep";
115
+ public readonly description: string;
116
+ public readonly parameters = grepSchema;
117
+
118
+ private readonly session: ToolSession;
119
+ private readonly ops: GrepOperations;
120
+
121
+ constructor(session: ToolSession, options?: GrepToolOptions) {
122
+ this.session = session;
123
+ this.ops = options?.operations ?? defaultGrepOperations;
124
+ this.description = renderPromptTemplate(grepDescription);
125
+ }
126
+
127
+ public async execute(
128
+ _toolCallId: string,
129
+ params: GrepParams,
130
+ signal?: AbortSignal,
131
+ _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
132
+ _context?: AgentToolContext,
133
+ ): Promise<AgentToolResult<GrepToolDetails>> {
134
+ const {
135
+ pattern,
136
+ path: searchDir,
137
+ glob,
138
+ type,
139
+ ignoreCase,
140
+ caseSensitive,
141
+ literal,
142
+ multiline,
143
+ context,
144
+ limit,
145
+ outputMode,
146
+ headLimit,
147
+ offset,
148
+ } = params;
149
+
150
+ return untilAborted(signal, async () => {
151
+ const rgPath = await ensureTool("rg", true);
152
+ if (!rgPath) {
153
+ throw new Error("ripgrep (rg) is not available and could not be downloaded");
154
+ }
169
155
 
170
- const fileCache = new Map<string, Promise<string[]>>();
171
- const getFileLines = async (filePath: string): Promise<string[]> => {
172
- let linesPromise = fileCache.get(filePath);
173
- if (!linesPromise) {
174
- linesPromise = (async () => {
175
- try {
176
- const content = await ops.readFile(filePath);
177
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
178
- } catch {
179
- return [];
180
- }
181
- })();
182
- fileCache.set(filePath, linesPromise);
156
+ const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
157
+ const scopePath = (() => {
158
+ const relative = nodePath.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
159
+ return relative.length === 0 ? "." : relative;
160
+ })();
161
+
162
+ let isDirectory: boolean;
163
+ try {
164
+ isDirectory = await this.ops.isDirectory(searchPath);
165
+ } catch {
166
+ throw new Error(`Path not found: ${searchPath}`);
167
+ }
168
+ const contextValue = context && context > 0 ? context : 0;
169
+ const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
170
+ const effectiveOutputMode = outputMode ?? "content";
171
+ const effectiveOffset = offset && offset > 0 ? offset : 0;
172
+ const hasHeadLimit = headLimit !== undefined && headLimit > 0;
173
+
174
+ const formatPath = (filePath: string): string => {
175
+ if (isDirectory) {
176
+ const relative = nodePath.relative(searchPath, filePath);
177
+ if (relative && !relative.startsWith("..")) {
178
+ return relative.replace(/\\/g, "/");
183
179
  }
184
- return linesPromise;
185
- };
186
-
187
- const args: string[] = [];
188
-
189
- // Base arguments depend on output mode
190
- if (effectiveOutputMode === "files_with_matches") {
191
- args.push("--files-with-matches", "--color=never", "--hidden");
192
- } else if (effectiveOutputMode === "count") {
193
- args.push("--count", "--color=never", "--hidden");
194
- } else {
195
- args.push("--json", "--line-number", "--color=never", "--hidden");
196
- }
197
-
198
- if (caseSensitive) {
199
- args.push("--case-sensitive");
200
- } else if (ignoreCase) {
201
- args.push("--ignore-case");
202
- } else {
203
- args.push("--smart-case");
204
- }
205
-
206
- if (multiline) {
207
- args.push("--multiline");
208
- }
209
-
210
- if (literal) {
211
- args.push("--fixed-strings");
212
- }
213
-
214
- if (glob) {
215
- args.push("--glob", glob);
216
180
  }
217
-
218
- if (type) {
219
- args.push("--type", type);
220
- }
221
-
222
- args.push("--", pattern, searchPath);
223
-
224
- const child: Subprocess = Bun.spawn([rgPath, ...args], {
225
- stdin: "ignore",
226
- stdout: "pipe",
227
- stderr: "pipe",
228
- });
229
-
230
- let stderr = "";
231
- let matchCount = 0;
232
- let matchLimitReached = false;
233
- let linesTruncated = false;
234
- let aborted = false;
235
- let killedDueToLimit = false;
236
- const outputLines: string[] = [];
237
- const files = new Set<string>();
238
- const fileList: string[] = [];
239
- const fileMatchCounts = new Map<string, number>();
240
-
241
- const recordFile = (filePath: string) => {
242
- const relative = formatPath(filePath);
243
- if (!files.has(relative)) {
244
- files.add(relative);
245
- fileList.push(relative);
246
- }
247
- };
248
-
249
- const recordFileMatch = (filePath: string) => {
250
- const relative = formatPath(filePath);
251
- fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1);
252
- };
253
-
254
- const stopChild = (dueToLimit: boolean = false) => {
255
- killedDueToLimit = dueToLimit;
256
- child.kill();
257
- };
258
-
259
- using signalScope = new ScopeSignal(signal ? { signal } : undefined);
260
- signalScope.catch(() => {
261
- aborted = true;
262
- stopChild();
263
- });
264
-
265
- // For simple output modes (files_with_matches, count), process text directly
266
- if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") {
267
- const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
268
- const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
269
- const decoder = new TextDecoder();
270
- let stdout = "";
271
-
272
- await Promise.all([
273
- (async () => {
274
- while (true) {
275
- const { done, value } = await stdoutReader.read();
276
- if (done) break;
277
- stdout += decoder.decode(value, { stream: true });
278
- }
279
- })(),
280
- (async () => {
281
- while (true) {
282
- const { done, value } = await stderrReader.read();
283
- if (done) break;
284
- stderr += decoder.decode(value, { stream: true });
285
- }
286
- })(),
287
- ]);
288
-
289
- const exitCode = await child.exited;
290
-
291
- if (aborted) {
292
- throw new Error("Operation aborted");
293
- }
294
-
295
- if (exitCode !== 0 && exitCode !== 1) {
296
- const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`;
297
- throw new Error(errorMsg);
298
- }
299
-
300
- const lines = stdout
301
- .trim()
302
- .split("\n")
303
- .filter((line) => line.length > 0);
304
-
305
- if (lines.length === 0) {
306
- return {
307
- content: [{ type: "text", text: "No matches found" }],
308
- details: {
309
- scopePath,
310
- matchCount: 0,
311
- fileCount: 0,
312
- files: [],
313
- mode: effectiveOutputMode,
314
- truncated: false,
315
- },
316
- };
317
- }
318
-
319
- // Apply offset and headLimit
320
- let processedLines = lines;
321
- if (effectiveOffset > 0) {
322
- processedLines = processedLines.slice(effectiveOffset);
323
- }
324
- if (hasHeadLimit) {
325
- processedLines = processedLines.slice(0, headLimit);
326
- }
327
-
328
- let simpleMatchCount = 0;
329
- let fileCount = 0;
330
- const simpleFiles = new Set<string>();
331
- const simpleFileList: string[] = [];
332
- const simpleFileMatchCounts = new Map<string, number>();
333
-
334
- const recordSimpleFile = (filePath: string) => {
335
- const relative = formatPath(filePath);
336
- if (!simpleFiles.has(relative)) {
337
- simpleFiles.add(relative);
338
- simpleFileList.push(relative);
181
+ return nodePath.basename(filePath);
182
+ };
183
+
184
+ const fileCache = new Map<string, Promise<string[]>>();
185
+ const getFileLines = async (filePath: string): Promise<string[]> => {
186
+ let linesPromise = fileCache.get(filePath);
187
+ if (!linesPromise) {
188
+ linesPromise = (async () => {
189
+ try {
190
+ const content = await this.ops.readFile(filePath);
191
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
192
+ } catch {
193
+ return [];
339
194
  }
340
- };
341
-
342
- // Count mode: ripgrep provides total count per file, so we set directly (not increment)
343
- const setFileMatchCount = (filePath: string, count: number) => {
344
- const relative = formatPath(filePath);
345
- simpleFileMatchCounts.set(relative, count);
346
- };
347
-
348
- if (effectiveOutputMode === "files_with_matches") {
349
- for (const line of lines) {
350
- recordSimpleFile(line);
351
- }
352
- fileCount = simpleFiles.size;
353
- simpleMatchCount = fileCount;
354
- } else {
355
- for (const line of lines) {
356
- const separatorIndex = line.lastIndexOf(":");
357
- const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
358
- const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
359
- const count = Number.parseInt(countPart, 10);
360
- recordSimpleFile(filePart);
361
- if (!Number.isNaN(count)) {
362
- simpleMatchCount += count;
363
- setFileMatchCount(filePart, count);
364
- }
365
- }
366
- fileCount = simpleFiles.size;
367
- }
368
-
369
- const truncatedByHeadLimit = hasHeadLimit && processedLines.length < lines.length;
370
-
371
- // For count mode, format as "path:count"
372
- if (effectiveOutputMode === "count") {
373
- const formatted = processedLines.map((line) => {
374
- const separatorIndex = line.lastIndexOf(":");
375
- const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex));
376
- const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1);
377
- return `${relative}:${count}`;
378
- });
379
- const output = formatted.join("\n");
380
- return {
381
- content: [{ type: "text", text: output }],
382
- details: {
383
- scopePath,
384
- matchCount: simpleMatchCount,
385
- fileCount,
386
- files: simpleFileList,
387
- fileMatches: simpleFileList.map((path) => ({
388
- path,
389
- count: simpleFileMatchCounts.get(path) ?? 0,
390
- })),
391
- mode: effectiveOutputMode,
392
- truncated: truncatedByHeadLimit,
393
- headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
394
- },
395
- };
396
- }
397
-
398
- // For files_with_matches, format paths
399
- const formatted = processedLines.map((line) => formatPath(line));
400
- const output = formatted.join("\n");
401
- return {
402
- content: [{ type: "text", text: output }],
403
- details: {
404
- scopePath,
405
- matchCount: simpleMatchCount,
406
- fileCount,
407
- files: simpleFileList,
408
- mode: effectiveOutputMode,
409
- truncated: truncatedByHeadLimit,
410
- headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
411
- },
412
- };
195
+ })();
196
+ fileCache.set(filePath, linesPromise);
413
197
  }
198
+ return linesPromise;
199
+ };
200
+
201
+ const args: string[] = [];
202
+
203
+ // Base arguments depend on output mode
204
+ if (effectiveOutputMode === "files_with_matches") {
205
+ args.push("--files-with-matches", "--color=never", "--hidden");
206
+ } else if (effectiveOutputMode === "count") {
207
+ args.push("--count", "--color=never", "--hidden");
208
+ } else {
209
+ args.push("--json", "--line-number", "--color=never", "--hidden");
210
+ }
414
211
 
415
- // Content mode - existing JSON processing
416
- const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
417
- const relativePath = formatPath(filePath);
418
- const lines = await getFileLines(filePath);
419
- if (!lines.length) {
420
- return [`${relativePath}:${lineNumber}: (unable to read file)`];
421
- }
422
-
423
- const block: string[] = [];
424
- const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
425
- const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
426
-
427
- for (let current = start; current <= end; current++) {
428
- const lineText = lines[current - 1] ?? "";
429
- const sanitized = lineText.replace(/\r/g, "");
430
- const isMatchLine = current === lineNumber;
431
-
432
- const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
433
- if (wasTruncated) {
434
- linesTruncated = true;
435
- }
212
+ if (caseSensitive) {
213
+ args.push("--case-sensitive");
214
+ } else if (ignoreCase) {
215
+ args.push("--ignore-case");
216
+ } else {
217
+ args.push("--smart-case");
218
+ }
436
219
 
437
- if (isMatchLine) {
438
- block.push(`${relativePath}:${current}: ${truncatedText}`);
439
- } else {
440
- block.push(`${relativePath}-${current}- ${truncatedText}`);
441
- }
442
- }
220
+ if (multiline) {
221
+ args.push("--multiline");
222
+ }
443
223
 
444
- return block;
445
- };
224
+ if (literal) {
225
+ args.push("--fixed-strings");
226
+ }
446
227
 
447
- const processLine = async (line: string): Promise<void> => {
448
- if (!line.trim() || matchCount >= effectiveLimit) {
449
- return;
450
- }
228
+ if (glob) {
229
+ args.push("--glob", glob);
230
+ }
451
231
 
452
- let event: { type: string; data?: { path?: { text?: string }; line_number?: number } };
453
- try {
454
- event = JSON.parse(line);
455
- } catch {
456
- return;
457
- }
232
+ if (type) {
233
+ args.push("--type", type);
234
+ }
458
235
 
459
- if (event.type === "match") {
460
- matchCount++;
461
- const filePath = event.data?.path?.text;
462
- const lineNumber = event.data?.line_number;
236
+ args.push("--", pattern, searchPath);
463
237
 
464
- if (filePath && typeof lineNumber === "number") {
465
- recordFile(filePath);
466
- recordFileMatch(filePath);
467
- const block = await formatBlock(filePath, lineNumber);
468
- outputLines.push(...block);
469
- }
238
+ const child: Subprocess = Bun.spawn([rgPath, ...args], {
239
+ stdin: "ignore",
240
+ stdout: "pipe",
241
+ stderr: "pipe",
242
+ });
470
243
 
471
- if (matchCount >= effectiveLimit) {
472
- matchLimitReached = true;
473
- stopChild(true);
474
- }
475
- }
476
- };
244
+ let stderr = "";
245
+ let matchCount = 0;
246
+ let matchLimitReached = false;
247
+ let linesTruncated = false;
248
+ let aborted = false;
249
+ let killedDueToLimit = false;
250
+ const outputLines: string[] = [];
251
+ const files = new Set<string>();
252
+ const fileList: string[] = [];
253
+ const fileMatchCounts = new Map<string, number>();
254
+
255
+ const recordFile = (filePath: string) => {
256
+ const relative = formatPath(filePath);
257
+ if (!files.has(relative)) {
258
+ files.add(relative);
259
+ fileList.push(relative);
260
+ }
261
+ };
262
+
263
+ const recordFileMatch = (filePath: string) => {
264
+ const relative = formatPath(filePath);
265
+ fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1);
266
+ };
267
+
268
+ const stopChild = (dueToLimit: boolean = false) => {
269
+ killedDueToLimit = dueToLimit;
270
+ child.kill();
271
+ };
272
+
273
+ using signalScope = new ScopeSignal(signal ? { signal } : undefined);
274
+ signalScope.catch(() => {
275
+ aborted = true;
276
+ stopChild();
277
+ });
477
278
 
478
- // Read streams using Bun's ReadableStream API
279
+ // For simple output modes (files_with_matches, count), process text directly
280
+ if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") {
479
281
  const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
480
282
  const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
481
283
  const decoder = new TextDecoder();
482
- let stdoutBuffer = "";
284
+ let stdout = "";
483
285
 
484
286
  await Promise.all([
485
- // Process stdout line by line
486
287
  (async () => {
487
288
  while (true) {
488
289
  const { done, value } = await stdoutReader.read();
489
290
  if (done) break;
490
-
491
- stdoutBuffer += decoder.decode(value, { stream: true });
492
- const lines = stdoutBuffer.split("\n");
493
- // Keep the last incomplete line in the buffer
494
- stdoutBuffer = lines.pop() ?? "";
495
-
496
- for (const line of lines) {
497
- await processLine(line);
498
- }
499
- }
500
- // Process any remaining content
501
- if (stdoutBuffer.trim()) {
502
- await processLine(stdoutBuffer);
291
+ stdout += decoder.decode(value, { stream: true });
503
292
  }
504
293
  })(),
505
- // Collect stderr
506
294
  (async () => {
507
295
  while (true) {
508
296
  const { done, value } = await stderrReader.read();
@@ -518,12 +306,17 @@ export function createGrepTool(session: ToolSession, options?: GrepToolOptions):
518
306
  throw new Error("Operation aborted");
519
307
  }
520
308
 
521
- if (!killedDueToLimit && exitCode !== 0 && exitCode !== 1) {
309
+ if (exitCode !== 0 && exitCode !== 1) {
522
310
  const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`;
523
311
  throw new Error(errorMsg);
524
312
  }
525
313
 
526
- if (matchCount === 0) {
314
+ const lines = stdout
315
+ .trim()
316
+ .split("\n")
317
+ .filter((line) => line.length > 0);
318
+
319
+ if (lines.length === 0) {
527
320
  return {
528
321
  content: [{ type: "text", text: "No matches found" }],
529
322
  details: {
@@ -537,8 +330,8 @@ export function createGrepTool(session: ToolSession, options?: GrepToolOptions):
537
330
  };
538
331
  }
539
332
 
540
- // Apply offset and headLimit to output lines
541
- let processedLines = outputLines;
333
+ // Apply offset and headLimit
334
+ let processedLines = lines;
542
335
  if (effectiveOffset > 0) {
543
336
  processedLines = processedLines.slice(effectiveOffset);
544
337
  }
@@ -546,57 +339,277 @@ export function createGrepTool(session: ToolSession, options?: GrepToolOptions):
546
339
  processedLines = processedLines.slice(0, headLimit);
547
340
  }
548
341
 
549
- // Apply byte truncation (no line limit since we already have match limit)
550
- const rawOutput = processedLines.join("\n");
551
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
552
-
553
- let output = truncation.content;
554
- const truncatedByHeadLimit = hasHeadLimit && processedLines.length < outputLines.length;
555
- const details: GrepToolDetails = {
556
- scopePath,
557
- matchCount,
558
- fileCount: files.size,
559
- files: fileList,
560
- fileMatches: fileList.map((path) => ({
561
- path,
562
- count: fileMatchCounts.get(path) ?? 0,
563
- })),
564
- mode: effectiveOutputMode,
565
- truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit,
566
- headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
342
+ let simpleMatchCount = 0;
343
+ let fileCount = 0;
344
+ const simpleFiles = new Set<string>();
345
+ const simpleFileList: string[] = [];
346
+ const simpleFileMatchCounts = new Map<string, number>();
347
+
348
+ const recordSimpleFile = (filePath: string) => {
349
+ const relative = formatPath(filePath);
350
+ if (!simpleFiles.has(relative)) {
351
+ simpleFiles.add(relative);
352
+ simpleFileList.push(relative);
353
+ }
567
354
  };
568
355
 
569
- // Build notices
570
- const notices: string[] = [];
356
+ // Count mode: ripgrep provides total count per file, so we set directly (not increment)
357
+ const setFileMatchCount = (filePath: string, count: number) => {
358
+ const relative = formatPath(filePath);
359
+ simpleFileMatchCounts.set(relative, count);
360
+ };
571
361
 
572
- if (matchLimitReached) {
573
- notices.push(
574
- `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
575
- );
576
- details.matchLimitReached = effectiveLimit;
362
+ if (effectiveOutputMode === "files_with_matches") {
363
+ for (const line of lines) {
364
+ recordSimpleFile(line);
365
+ }
366
+ fileCount = simpleFiles.size;
367
+ simpleMatchCount = fileCount;
368
+ } else {
369
+ for (const line of lines) {
370
+ const separatorIndex = line.lastIndexOf(":");
371
+ const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
372
+ const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
373
+ const count = Number.parseInt(countPart, 10);
374
+ recordSimpleFile(filePart);
375
+ if (!Number.isNaN(count)) {
376
+ simpleMatchCount += count;
377
+ setFileMatchCount(filePart, count);
378
+ }
379
+ }
380
+ fileCount = simpleFiles.size;
577
381
  }
578
382
 
579
- if (truncation.truncated) {
580
- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
581
- details.truncation = truncation;
383
+ const truncatedByHeadLimit = hasHeadLimit && processedLines.length < lines.length;
384
+
385
+ // For count mode, format as "path:count"
386
+ if (effectiveOutputMode === "count") {
387
+ const formatted = processedLines.map((line) => {
388
+ const separatorIndex = line.lastIndexOf(":");
389
+ const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex));
390
+ const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1);
391
+ return `${relative}:${count}`;
392
+ });
393
+ const output = formatted.join("\n");
394
+ return {
395
+ content: [{ type: "text", text: output }],
396
+ details: {
397
+ scopePath,
398
+ matchCount: simpleMatchCount,
399
+ fileCount,
400
+ files: simpleFileList,
401
+ fileMatches: simpleFileList.map((path) => ({
402
+ path,
403
+ count: simpleFileMatchCounts.get(path) ?? 0,
404
+ })),
405
+ mode: effectiveOutputMode,
406
+ truncated: truncatedByHeadLimit,
407
+ headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
408
+ },
409
+ };
582
410
  }
583
411
 
584
- if (linesTruncated) {
585
- notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
586
- details.linesTruncated = true;
412
+ // For files_with_matches, format paths
413
+ const formatted = processedLines.map((line) => formatPath(line));
414
+ const output = formatted.join("\n");
415
+ return {
416
+ content: [{ type: "text", text: output }],
417
+ details: {
418
+ scopePath,
419
+ matchCount: simpleMatchCount,
420
+ fileCount,
421
+ files: simpleFileList,
422
+ mode: effectiveOutputMode,
423
+ truncated: truncatedByHeadLimit,
424
+ headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
425
+ },
426
+ };
427
+ }
428
+
429
+ // Content mode - existing JSON processing
430
+ const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
431
+ const relativePath = formatPath(filePath);
432
+ const lines = await getFileLines(filePath);
433
+ if (!lines.length) {
434
+ return [`${relativePath}:${lineNumber}: (unable to read file)`];
587
435
  }
588
436
 
589
- if (notices.length > 0) {
590
- output += `\n\n[${notices.join(". ")}]`;
437
+ const block: string[] = [];
438
+ const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
439
+ const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
440
+
441
+ for (let current = start; current <= end; current++) {
442
+ const lineText = lines[current - 1] ?? "";
443
+ const sanitized = lineText.replace(/\r/g, "");
444
+ const isMatchLine = current === lineNumber;
445
+
446
+ const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
447
+ if (wasTruncated) {
448
+ linesTruncated = true;
449
+ }
450
+
451
+ if (isMatchLine) {
452
+ block.push(`${relativePath}:${current}: ${truncatedText}`);
453
+ } else {
454
+ block.push(`${relativePath}-${current}- ${truncatedText}`);
455
+ }
591
456
  }
592
457
 
458
+ return block;
459
+ };
460
+
461
+ const processLine = async (line: string): Promise<void> => {
462
+ if (!line.trim() || matchCount >= effectiveLimit) {
463
+ return;
464
+ }
465
+
466
+ let event: { type: string; data?: { path?: { text?: string }; line_number?: number } };
467
+ try {
468
+ event = JSON.parse(line);
469
+ } catch {
470
+ return;
471
+ }
472
+
473
+ if (event.type === "match") {
474
+ matchCount++;
475
+ const filePath = event.data?.path?.text;
476
+ const lineNumber = event.data?.line_number;
477
+
478
+ if (filePath && typeof lineNumber === "number") {
479
+ recordFile(filePath);
480
+ recordFileMatch(filePath);
481
+ const block = await formatBlock(filePath, lineNumber);
482
+ outputLines.push(...block);
483
+ }
484
+
485
+ if (matchCount >= effectiveLimit) {
486
+ matchLimitReached = true;
487
+ stopChild(true);
488
+ }
489
+ }
490
+ };
491
+
492
+ // Read streams using Bun's ReadableStream API
493
+ const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
494
+ const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
495
+ const decoder = new TextDecoder();
496
+ let stdoutBuffer = "";
497
+
498
+ await Promise.all([
499
+ // Process stdout line by line
500
+ (async () => {
501
+ while (true) {
502
+ const { done, value } = await stdoutReader.read();
503
+ if (done) break;
504
+
505
+ stdoutBuffer += decoder.decode(value, { stream: true });
506
+ const lines = stdoutBuffer.split("\n");
507
+ // Keep the last incomplete line in the buffer
508
+ stdoutBuffer = lines.pop() ?? "";
509
+
510
+ for (const line of lines) {
511
+ await processLine(line);
512
+ }
513
+ }
514
+ // Process any remaining content
515
+ if (stdoutBuffer.trim()) {
516
+ await processLine(stdoutBuffer);
517
+ }
518
+ })(),
519
+ // Collect stderr
520
+ (async () => {
521
+ while (true) {
522
+ const { done, value } = await stderrReader.read();
523
+ if (done) break;
524
+ stderr += decoder.decode(value, { stream: true });
525
+ }
526
+ })(),
527
+ ]);
528
+
529
+ const exitCode = await child.exited;
530
+
531
+ if (aborted) {
532
+ throw new Error("Operation aborted");
533
+ }
534
+
535
+ if (!killedDueToLimit && exitCode !== 0 && exitCode !== 1) {
536
+ const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`;
537
+ throw new Error(errorMsg);
538
+ }
539
+
540
+ if (matchCount === 0) {
593
541
  return {
594
- content: [{ type: "text", text: output }],
595
- details: Object.keys(details).length > 0 ? details : undefined,
542
+ content: [{ type: "text", text: "No matches found" }],
543
+ details: {
544
+ scopePath,
545
+ matchCount: 0,
546
+ fileCount: 0,
547
+ files: [],
548
+ mode: effectiveOutputMode,
549
+ truncated: false,
550
+ },
596
551
  };
597
- });
598
- },
599
- };
552
+ }
553
+
554
+ // Apply offset and headLimit to output lines
555
+ let processedLines = outputLines;
556
+ if (effectiveOffset > 0) {
557
+ processedLines = processedLines.slice(effectiveOffset);
558
+ }
559
+ if (hasHeadLimit) {
560
+ processedLines = processedLines.slice(0, headLimit);
561
+ }
562
+
563
+ // Apply byte truncation (no line limit since we already have match limit)
564
+ const rawOutput = processedLines.join("\n");
565
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
566
+
567
+ let output = truncation.content;
568
+ const truncatedByHeadLimit = hasHeadLimit && processedLines.length < outputLines.length;
569
+ const details: GrepToolDetails = {
570
+ scopePath,
571
+ matchCount,
572
+ fileCount: files.size,
573
+ files: fileList,
574
+ fileMatches: fileList.map((path) => ({
575
+ path,
576
+ count: fileMatchCounts.get(path) ?? 0,
577
+ })),
578
+ mode: effectiveOutputMode,
579
+ truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit,
580
+ headLimitReached: truncatedByHeadLimit ? headLimit : undefined,
581
+ };
582
+
583
+ // Build notices
584
+ const notices: string[] = [];
585
+
586
+ if (matchLimitReached) {
587
+ notices.push(
588
+ `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
589
+ );
590
+ details.matchLimitReached = effectiveLimit;
591
+ }
592
+
593
+ if (truncation.truncated) {
594
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
595
+ details.truncation = truncation;
596
+ }
597
+
598
+ if (linesTruncated) {
599
+ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
600
+ details.linesTruncated = true;
601
+ }
602
+
603
+ if (notices.length > 0) {
604
+ output += `\n\n[${notices.join(". ")}]`;
605
+ }
606
+
607
+ return {
608
+ content: [{ type: "text", text: output }],
609
+ details,
610
+ };
611
+ });
612
+ }
600
613
  }
601
614
 
602
615
  // =============================================================================