mini-coder 0.5.11 → 0.5.12
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/BENCHMARK.md +408 -0
- package/PROGRESS.md +5 -0
- package/README.md +14 -1
- package/assets/mc-claude-smart.png +0 -0
- package/assets/mc-gpt-smart.png +0 -0
- package/benchmark-loop.sh +19 -0
- package/package.json +1 -1
- package/src/headless.ts +62 -21
- package/src/index.ts +35 -56
- package/src/prompt.ts +8 -0
- package/src/session-message.ts +393 -0
- package/src/session.ts +102 -396
- package/src/settings.ts +19 -15
- package/src/shared.ts +39 -0
- package/src/submit.ts +3 -25
- package/src/text.ts +71 -0
- package/src/tool-common.ts +91 -0
- package/src/tool-grep.ts +606 -0
- package/src/tool-read.ts +313 -0
- package/src/tool-shell.ts +869 -0
- package/src/tools.ts +186 -995
- package/src/ui/agent.ts +199 -110
- package/src/ui/commands.test.ts +14 -13
- package/src/ui/commands.ts +21 -47
- package/src/ui/conversation.test.ts +360 -0
- package/src/ui/conversation.ts +496 -151
- package/src/ui/input.test.ts +1 -43
- package/src/ui/runtime.ts +69 -0
- package/src/ui.ts +196 -114
package/src/tool-grep.ts
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grep-tool implementation and grep-specific helpers.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, statSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join, normalize, relative } from "node:path";
|
|
9
|
+
import type { Static, Tool } from "@mariozechner/pi-ai";
|
|
10
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
11
|
+
import type { ToolHandler } from "./agent.ts";
|
|
12
|
+
import {
|
|
13
|
+
type ToolExecResult,
|
|
14
|
+
textResult,
|
|
15
|
+
validateBuiltinToolArgs,
|
|
16
|
+
} from "./tool-common.ts";
|
|
17
|
+
|
|
18
|
+
const grepToolParameters = Type.Object({
|
|
19
|
+
pattern: Type.String({
|
|
20
|
+
description: "Text or regex pattern to search for",
|
|
21
|
+
}),
|
|
22
|
+
path: Type.Optional(
|
|
23
|
+
Type.String({
|
|
24
|
+
description:
|
|
25
|
+
"Optional file or directory path to search (relative to cwd)",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
glob: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description: "Optional glob to include or exclude files",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
ignoreCase: Type.Optional(
|
|
34
|
+
Type.Boolean({
|
|
35
|
+
description: "Whether the search should ignore case",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
literal: Type.Optional(
|
|
39
|
+
Type.Boolean({
|
|
40
|
+
description: "Whether to treat the pattern as a literal string",
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
context: Type.Optional(
|
|
44
|
+
Type.Integer({
|
|
45
|
+
minimum: 0,
|
|
46
|
+
description: "How many context lines to include before and after matches",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
limit: Type.Optional(
|
|
50
|
+
Type.Integer({
|
|
51
|
+
minimum: 1,
|
|
52
|
+
description: "Maximum number of matches to return across the result set",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Default maximum number of matches returned when `limit` is omitted. */
|
|
58
|
+
export const DEFAULT_GREP_LIMIT = 50;
|
|
59
|
+
|
|
60
|
+
/** Arguments for the `grep` tool. */
|
|
61
|
+
export type GrepArgs = Static<typeof grepToolParameters>;
|
|
62
|
+
|
|
63
|
+
/** A single rendered grep result line. */
|
|
64
|
+
export interface GrepResultLine {
|
|
65
|
+
/** Whether this line is a direct match or surrounding context. */
|
|
66
|
+
kind: "match" | "context";
|
|
67
|
+
/** 1-based file line number. */
|
|
68
|
+
lineNumber: number;
|
|
69
|
+
/** Full text for the line, including any trailing newline from ripgrep. */
|
|
70
|
+
text: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Group of grep result lines for one file. */
|
|
74
|
+
export interface GrepResultFile {
|
|
75
|
+
/** File path relative to the session cwd when possible. */
|
|
76
|
+
path: string;
|
|
77
|
+
/** Matching and context lines in emission order. */
|
|
78
|
+
lines: GrepResultLine[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Structured grep result returned to the model and parsed by the UI. */
|
|
82
|
+
export interface GrepResult {
|
|
83
|
+
/** Effective match limit applied to this search. */
|
|
84
|
+
limit: number;
|
|
85
|
+
/** Whether additional matches were omitted after hitting the limit. */
|
|
86
|
+
truncated: boolean;
|
|
87
|
+
/** Grouped file results. */
|
|
88
|
+
files: GrepResultFile[];
|
|
89
|
+
/** Optional continuation hint for refining or broadening the query. */
|
|
90
|
+
hint?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Options for grep execution. */
|
|
94
|
+
export interface GrepOpts {
|
|
95
|
+
/** Abort signal for interruption. */
|
|
96
|
+
signal?: AbortSignal;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** pi-ai tool definition for `grep`. */
|
|
100
|
+
export const grepTool: Tool<typeof grepToolParameters> = {
|
|
101
|
+
name: "grep",
|
|
102
|
+
description:
|
|
103
|
+
"Search file contents with ripgrep-style options and structured results. " +
|
|
104
|
+
"Takes a pattern plus optional path, glob, case-sensitivity, literal, context, and limit options. " +
|
|
105
|
+
"Prefer this tool over raw `grep` / `rg` when you want to find relevant files or matching lines.",
|
|
106
|
+
parameters: grepToolParameters,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
interface RgTextField {
|
|
110
|
+
text?: string;
|
|
111
|
+
bytes?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface RgJsonEvent {
|
|
115
|
+
type?: string;
|
|
116
|
+
data?: {
|
|
117
|
+
path?: RgTextField;
|
|
118
|
+
lines?: RgTextField;
|
|
119
|
+
line_number?: number | null;
|
|
120
|
+
submatches?: unknown[];
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveGrepPath(path: string | undefined, cwd: string): string {
|
|
125
|
+
if (!path) {
|
|
126
|
+
return cwd;
|
|
127
|
+
}
|
|
128
|
+
return isAbsolute(path) ? path : join(cwd, path);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function decodeRgText(field: RgTextField | undefined): string {
|
|
132
|
+
if (!field) {
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
if (typeof field.text === "string") {
|
|
136
|
+
return field.text;
|
|
137
|
+
}
|
|
138
|
+
if (typeof field.bytes === "string") {
|
|
139
|
+
return Buffer.from(field.bytes, "base64").toString("utf-8");
|
|
140
|
+
}
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeDisplayPath(filePath: string, cwd: string): string {
|
|
145
|
+
if (filePath === "") {
|
|
146
|
+
return "(unknown path)";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!isAbsolute(filePath)) {
|
|
150
|
+
const normalizedPath = normalize(filePath);
|
|
151
|
+
return normalizedPath.startsWith("./")
|
|
152
|
+
? normalizedPath.slice(2)
|
|
153
|
+
: normalizedPath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const relativePath = relative(cwd, filePath);
|
|
157
|
+
if (relativePath === "") {
|
|
158
|
+
return ".";
|
|
159
|
+
}
|
|
160
|
+
if (relativePath.startsWith("..")) {
|
|
161
|
+
return filePath;
|
|
162
|
+
}
|
|
163
|
+
return normalize(relativePath);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readStreamText(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
167
|
+
return new Response(stream).text();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildGrepSpawnArgs(args: GrepArgs, cwd: string): string[] {
|
|
171
|
+
const searchPath = resolveGrepPath(args.path, cwd);
|
|
172
|
+
const rgArgs = ["rg", "--json", "--color=never"];
|
|
173
|
+
|
|
174
|
+
if (args.literal) {
|
|
175
|
+
rgArgs.push("--fixed-strings");
|
|
176
|
+
}
|
|
177
|
+
if (args.ignoreCase) {
|
|
178
|
+
rgArgs.push("--ignore-case");
|
|
179
|
+
}
|
|
180
|
+
if (args.glob) {
|
|
181
|
+
rgArgs.push("--glob", args.glob);
|
|
182
|
+
}
|
|
183
|
+
if (args.context && args.context > 0) {
|
|
184
|
+
rgArgs.push("--context", String(args.context));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
rgArgs.push("--", args.pattern);
|
|
188
|
+
if (args.path) {
|
|
189
|
+
rgArgs.push(searchPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return rgArgs;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseRgEvent(rawLine: string): RgJsonEvent | null {
|
|
196
|
+
if (rawLine.trim() === "") {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(rawLine) as RgJsonEvent;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getRenderableGrepEvent(
|
|
208
|
+
event: RgJsonEvent,
|
|
209
|
+
cwd: string,
|
|
210
|
+
): {
|
|
211
|
+
kind: "match" | "context";
|
|
212
|
+
path: string;
|
|
213
|
+
lineNumber: number;
|
|
214
|
+
text: string;
|
|
215
|
+
} | null {
|
|
216
|
+
if (event.type !== "match" && event.type !== "context") {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const lineNumber = event.data?.line_number;
|
|
221
|
+
if (typeof lineNumber !== "number") {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
kind: event.type,
|
|
227
|
+
path: normalizeDisplayPath(decodeRgText(event.data?.path), cwd),
|
|
228
|
+
lineNumber,
|
|
229
|
+
text: decodeRgText(event.data?.lines),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getRgMatchCount(event: RgJsonEvent): number {
|
|
234
|
+
const submatches = event.data?.submatches;
|
|
235
|
+
if (Array.isArray(submatches) && submatches.length > 0) {
|
|
236
|
+
return submatches.length;
|
|
237
|
+
}
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface GrepTrailingContextWindow {
|
|
242
|
+
/** File path for the included match that hit the limit. */
|
|
243
|
+
path: string;
|
|
244
|
+
/** Next trailing-context line number still eligible for inclusion. */
|
|
245
|
+
nextLineNumber: number;
|
|
246
|
+
/** Remaining trailing-context lines to include for that match. */
|
|
247
|
+
remainingLines: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface GrepParseState {
|
|
251
|
+
totalMatches: number;
|
|
252
|
+
truncated: boolean;
|
|
253
|
+
limitReached: boolean;
|
|
254
|
+
trailingContext: GrepTrailingContextWindow | null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function appendGrepEvent(
|
|
258
|
+
ensureFile: (path: string) => GrepResultFile,
|
|
259
|
+
event: {
|
|
260
|
+
kind: "match" | "context";
|
|
261
|
+
path: string;
|
|
262
|
+
lineNumber: number;
|
|
263
|
+
text: string;
|
|
264
|
+
} | null,
|
|
265
|
+
): void {
|
|
266
|
+
if (!event) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
ensureFile(event.path).lines.push({
|
|
271
|
+
kind: event.kind,
|
|
272
|
+
lineNumber: event.lineNumber,
|
|
273
|
+
text: event.text,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function handleGrepMatchEvent(
|
|
278
|
+
parsedEvent: RgJsonEvent,
|
|
279
|
+
cwd: string,
|
|
280
|
+
limit: number,
|
|
281
|
+
contextLines: number,
|
|
282
|
+
state: GrepParseState,
|
|
283
|
+
ensureFile: (path: string) => GrepResultFile,
|
|
284
|
+
): void {
|
|
285
|
+
if (state.limitReached) {
|
|
286
|
+
state.truncated = true;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const event = getRenderableGrepEvent(parsedEvent, cwd);
|
|
291
|
+
appendGrepEvent(ensureFile, event);
|
|
292
|
+
state.totalMatches += getRgMatchCount(parsedEvent);
|
|
293
|
+
if (state.totalMatches < limit) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
state.limitReached = true;
|
|
298
|
+
state.trailingContext =
|
|
299
|
+
contextLines > 0 && event
|
|
300
|
+
? {
|
|
301
|
+
path: event.path,
|
|
302
|
+
nextLineNumber: event.lineNumber + 1,
|
|
303
|
+
remainingLines: contextLines,
|
|
304
|
+
}
|
|
305
|
+
: null;
|
|
306
|
+
if (state.totalMatches > limit) {
|
|
307
|
+
state.truncated = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function handleGrepContextEvent(
|
|
312
|
+
parsedEvent: RgJsonEvent,
|
|
313
|
+
cwd: string,
|
|
314
|
+
state: GrepParseState,
|
|
315
|
+
ensureFile: (path: string) => GrepResultFile,
|
|
316
|
+
): void {
|
|
317
|
+
const event = getRenderableGrepEvent(parsedEvent, cwd);
|
|
318
|
+
if (!event) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (state.limitReached) {
|
|
323
|
+
const trailingContext = state.trailingContext;
|
|
324
|
+
if (
|
|
325
|
+
!trailingContext ||
|
|
326
|
+
event.path !== trailingContext.path ||
|
|
327
|
+
event.lineNumber !== trailingContext.nextLineNumber
|
|
328
|
+
) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
trailingContext.nextLineNumber += 1;
|
|
333
|
+
trailingContext.remainingLines -= 1;
|
|
334
|
+
if (trailingContext.remainingLines <= 0) {
|
|
335
|
+
state.trailingContext = null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
appendGrepEvent(ensureFile, event);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseGrepEvents(
|
|
343
|
+
stdout: string,
|
|
344
|
+
cwd: string,
|
|
345
|
+
limit: number,
|
|
346
|
+
contextLines: number,
|
|
347
|
+
): GrepResult {
|
|
348
|
+
const filesByPath = new Map<string, GrepResultFile>();
|
|
349
|
+
const orderedFiles: GrepResultFile[] = [];
|
|
350
|
+
const state: GrepParseState = {
|
|
351
|
+
totalMatches: 0,
|
|
352
|
+
truncated: false,
|
|
353
|
+
limitReached: false,
|
|
354
|
+
trailingContext: null,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const ensureFile = (path: string): GrepResultFile => {
|
|
358
|
+
const existing = filesByPath.get(path);
|
|
359
|
+
if (existing) {
|
|
360
|
+
return existing;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const file: GrepResultFile = { path, lines: [] };
|
|
364
|
+
filesByPath.set(path, file);
|
|
365
|
+
orderedFiles.push(file);
|
|
366
|
+
return file;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
for (const rawLine of stdout.split("\n")) {
|
|
370
|
+
const parsedEvent = parseRgEvent(rawLine);
|
|
371
|
+
if (!parsedEvent) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (parsedEvent.type === "match") {
|
|
376
|
+
handleGrepMatchEvent(
|
|
377
|
+
parsedEvent,
|
|
378
|
+
cwd,
|
|
379
|
+
limit,
|
|
380
|
+
contextLines,
|
|
381
|
+
state,
|
|
382
|
+
ensureFile,
|
|
383
|
+
);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (parsedEvent.type === "context") {
|
|
387
|
+
handleGrepContextEvent(parsedEvent, cwd, state, ensureFile);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
orderedFiles.sort((left, right) => left.path.localeCompare(right.path));
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
limit,
|
|
395
|
+
truncated: state.truncated,
|
|
396
|
+
files: orderedFiles,
|
|
397
|
+
...(state.truncated
|
|
398
|
+
? {
|
|
399
|
+
hint: "Results truncated. Narrow the path or pattern, or rerun with a higher limit.",
|
|
400
|
+
}
|
|
401
|
+
: {}),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function parseStructuredGrepLine(line: unknown): GrepResultLine | null {
|
|
406
|
+
if (
|
|
407
|
+
typeof line !== "object" ||
|
|
408
|
+
line === null ||
|
|
409
|
+
((line as { kind?: unknown }).kind !== "match" &&
|
|
410
|
+
(line as { kind?: unknown }).kind !== "context") ||
|
|
411
|
+
typeof (line as { lineNumber?: unknown }).lineNumber !== "number" ||
|
|
412
|
+
typeof (line as { text?: unknown }).text !== "string"
|
|
413
|
+
) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
kind: (line as { kind: "match" | "context" }).kind,
|
|
419
|
+
lineNumber: (line as { lineNumber: number }).lineNumber,
|
|
420
|
+
text: (line as { text: string }).text,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function parseStructuredGrepFile(file: unknown): GrepResultFile | null {
|
|
425
|
+
if (
|
|
426
|
+
typeof file !== "object" ||
|
|
427
|
+
file === null ||
|
|
428
|
+
typeof (file as { path?: unknown }).path !== "string" ||
|
|
429
|
+
!Array.isArray((file as { lines?: unknown }).lines)
|
|
430
|
+
) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const lines = (file as { lines: unknown[] }).lines
|
|
435
|
+
.map((line) => parseStructuredGrepLine(line))
|
|
436
|
+
.filter((line): line is GrepResultLine => line !== null);
|
|
437
|
+
if (lines.length !== (file as { lines: unknown[] }).lines.length) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
path: (file as { path: string }).path,
|
|
443
|
+
lines,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Parse a serialized grep result into its structured form. */
|
|
448
|
+
export function parseGrepResult(text: string): GrepResult | null {
|
|
449
|
+
let parsed: unknown;
|
|
450
|
+
try {
|
|
451
|
+
parsed = JSON.parse(text) as unknown;
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const candidate = parsed as {
|
|
461
|
+
limit?: unknown;
|
|
462
|
+
truncated?: unknown;
|
|
463
|
+
files?: unknown;
|
|
464
|
+
hint?: unknown;
|
|
465
|
+
};
|
|
466
|
+
if (
|
|
467
|
+
typeof candidate.limit !== "number" ||
|
|
468
|
+
typeof candidate.truncated !== "boolean" ||
|
|
469
|
+
!Array.isArray(candidate.files)
|
|
470
|
+
) {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const files = candidate.files
|
|
475
|
+
.map((file) => parseStructuredGrepFile(file))
|
|
476
|
+
.filter((file): file is GrepResultFile => file !== null);
|
|
477
|
+
if (files.length !== candidate.files.length) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
limit: candidate.limit,
|
|
483
|
+
truncated: candidate.truncated,
|
|
484
|
+
files,
|
|
485
|
+
...(typeof candidate.hint === "string" ? { hint: candidate.hint } : {}),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function validateGrepSearchPath(
|
|
490
|
+
args: GrepArgs,
|
|
491
|
+
cwd: string,
|
|
492
|
+
): ToolExecResult | string {
|
|
493
|
+
const searchPath = resolveGrepPath(args.path, cwd);
|
|
494
|
+
if (!existsSync(searchPath)) {
|
|
495
|
+
return textResult(`Path not found: ${args.path ?? "."}`, true);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
statSync(searchPath);
|
|
500
|
+
return searchPath;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
503
|
+
return textResult(`Failed to stat ${args.path ?? "."}: ${message}`, true);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function registerGrepAbort(
|
|
508
|
+
proc: ReturnType<typeof Bun.spawn>,
|
|
509
|
+
signal: AbortSignal | undefined,
|
|
510
|
+
): (() => void) | null {
|
|
511
|
+
if (!signal) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const abortListener = (): void => {
|
|
516
|
+
proc.kill();
|
|
517
|
+
};
|
|
518
|
+
if (signal.aborted) {
|
|
519
|
+
proc.kill();
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
524
|
+
return () => {
|
|
525
|
+
signal.removeEventListener("abort", abortListener);
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function readGrepProcess(
|
|
530
|
+
proc: ReturnType<typeof Bun.spawn>,
|
|
531
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
532
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
533
|
+
readStreamText(proc.stdout as ReadableStream<Uint8Array>),
|
|
534
|
+
readStreamText(proc.stderr as ReadableStream<Uint8Array>),
|
|
535
|
+
proc.exited,
|
|
536
|
+
]);
|
|
537
|
+
return { stdout, stderr, exitCode };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Search file contents with ripgrep and return structured JSON results.
|
|
542
|
+
*
|
|
543
|
+
* @param args - Grep arguments.
|
|
544
|
+
* @param cwd - Working directory for resolving relative paths.
|
|
545
|
+
* @param opts - Optional abort signal.
|
|
546
|
+
* @returns A JSON text result containing grouped matches or an error.
|
|
547
|
+
*/
|
|
548
|
+
export async function executeGrep(
|
|
549
|
+
args: GrepArgs,
|
|
550
|
+
cwd: string,
|
|
551
|
+
opts?: GrepOpts,
|
|
552
|
+
): Promise<ToolExecResult> {
|
|
553
|
+
const searchPathValidation = validateGrepSearchPath(args, cwd);
|
|
554
|
+
if (typeof searchPathValidation !== "string") {
|
|
555
|
+
return searchPathValidation;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let cleanupAbort: (() => void) | null = null;
|
|
559
|
+
try {
|
|
560
|
+
const proc = Bun.spawn(buildGrepSpawnArgs(args, cwd), {
|
|
561
|
+
cwd,
|
|
562
|
+
env: { ...process.env },
|
|
563
|
+
stdin: "ignore",
|
|
564
|
+
stdout: "pipe",
|
|
565
|
+
stderr: "pipe",
|
|
566
|
+
});
|
|
567
|
+
cleanupAbort = registerGrepAbort(proc, opts?.signal);
|
|
568
|
+
|
|
569
|
+
const { stdout, stderr, exitCode } = await readGrepProcess(proc);
|
|
570
|
+
if (opts?.signal?.aborted) {
|
|
571
|
+
return textResult("Grep aborted", true);
|
|
572
|
+
}
|
|
573
|
+
if (exitCode !== 0 && exitCode !== 1) {
|
|
574
|
+
return textResult(
|
|
575
|
+
stderr.trim() || `rg exited with code ${exitCode}`,
|
|
576
|
+
true,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const result = parseGrepEvents(
|
|
581
|
+
stdout,
|
|
582
|
+
cwd,
|
|
583
|
+
args.limit ?? DEFAULT_GREP_LIMIT,
|
|
584
|
+
args.context ?? 0,
|
|
585
|
+
);
|
|
586
|
+
return textResult(JSON.stringify(result, null, 2), false);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
589
|
+
return textResult(`Grep failed: ${message}`, true);
|
|
590
|
+
} finally {
|
|
591
|
+
cleanupAbort?.();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Tool handler that validates grep arguments before execution.
|
|
597
|
+
*
|
|
598
|
+
* @param args - Raw parsed tool-call arguments.
|
|
599
|
+
* @param cwd - Working directory for path resolution.
|
|
600
|
+
* @param signal - Optional abort signal.
|
|
601
|
+
* @returns The grep tool result.
|
|
602
|
+
*/
|
|
603
|
+
export const grepToolHandler: ToolHandler = (args, cwd, signal) =>
|
|
604
|
+
executeGrep(validateBuiltinToolArgs(grepTool, args), cwd, {
|
|
605
|
+
...(signal ? { signal } : {}),
|
|
606
|
+
});
|