@oh-my-pi/pi-coding-agent 8.12.2 → 8.12.5
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/package.json +7 -10
- package/src/config/settings-manager.ts +36 -0
- package/src/config.ts +0 -5
- package/src/exec/bash-executor.ts +4 -4
- package/src/exec/exec.ts +9 -12
- package/src/extensibility/plugins/doctor.ts +0 -2
- package/src/ipy/kernel.ts +11 -13
- package/src/migrations.ts +1 -46
- package/src/modes/components/settings-defs.ts +23 -0
- package/src/modes/rpc/rpc-client.ts +4 -4
- package/src/session/compaction/compaction.ts +37 -3
- package/src/ssh/ssh-executor.ts +3 -5
- package/src/system-prompt.ts +14 -9
- package/src/tools/ask.ts +38 -6
- package/src/tools/fetch.ts +9 -61
- package/src/tools/find.ts +19 -14
- package/src/tools/grep.ts +110 -562
- package/src/tools/read.ts +31 -25
- package/src/utils/image-convert.ts +7 -11
- package/src/utils/image-resize.ts +15 -25
- package/src/utils/tools-manager.ts +3 -43
- package/src/web/scrapers/utils.ts +11 -6
- package/src/web/scrapers/youtube.ts +21 -49
- package/src/utils/utils.ts +0 -1
- package/src/vendor/photon/LICENSE.md +0 -201
- package/src/vendor/photon/README.md +0 -158
- package/src/vendor/photon/index.d.ts +0 -3013
- package/src/vendor/photon/index.js +0 -4521
- package/src/vendor/photon/photon_rs_bg.wasm +0 -0
- package/src/vendor/photon/photon_rs_bg.wasm.d.ts +0 -193
package/src/tools/grep.ts
CHANGED
|
@@ -1,26 +1,23 @@
|
|
|
1
1
|
import * as nodePath from "node:path";
|
|
2
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
|
+
import { type GrepMatch as WasmGrepMatch, grep as wasmGrep } from "@oh-my-pi/pi-natives";
|
|
4
5
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
5
6
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
6
|
-
import {
|
|
7
|
-
import { Type } from "@sinclair/typebox";
|
|
8
|
-
import { $ } from "bun";
|
|
7
|
+
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
8
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
9
9
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
10
10
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
11
11
|
import type { Theme } from "../modes/theme/theme";
|
|
12
12
|
import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
|
|
13
13
|
import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
|
|
14
|
-
import { ensureTool } from "../utils/tools-manager";
|
|
15
|
-
import { untilAborted } from "../utils/utils";
|
|
16
14
|
import type { ToolSession } from ".";
|
|
17
|
-
import { applyListLimit } from "./list-limit";
|
|
18
15
|
import type { OutputMeta } from "./output-meta";
|
|
19
16
|
import { resolveToCwd } from "./path-utils";
|
|
20
17
|
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
|
|
21
|
-
import {
|
|
18
|
+
import { ToolError } from "./tool-errors";
|
|
22
19
|
import { toolResult } from "./tool-result";
|
|
23
|
-
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead
|
|
20
|
+
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "./truncate";
|
|
24
21
|
|
|
25
22
|
const grepSchema = Type.Object({
|
|
26
23
|
pattern: Type.String({ description: "Regex pattern to search for" }),
|
|
@@ -28,16 +25,13 @@ const grepSchema = Type.Object({
|
|
|
28
25
|
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern (e.g., '*.js')" })),
|
|
29
26
|
type: Type.Optional(Type.String({ description: "Filter by file type (e.g., js, py, rust)" })),
|
|
30
27
|
output_mode: Type.Optional(
|
|
31
|
-
StringEnum(["
|
|
28
|
+
StringEnum(["filesWithMatches", "content", "count"], {
|
|
32
29
|
description: "Output format (default: files_with_matches)",
|
|
33
30
|
}),
|
|
34
31
|
),
|
|
35
32
|
i: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
|
|
36
33
|
n: Type.Optional(Type.Boolean({ description: "Show line numbers (default: true)" })),
|
|
37
|
-
|
|
38
|
-
b: Type.Optional(Type.Number({ description: "Lines to show before each match (default: 0)" })),
|
|
39
|
-
c: Type.Optional(Type.Number({ description: "Lines of context (before and after) (default: 0)" })),
|
|
40
|
-
context: Type.Optional(Type.Number({ description: "Lines of context (alias for c)" })),
|
|
34
|
+
context: Type.Optional(Type.Number({ description: "Lines of context (default: 5)" })),
|
|
41
35
|
multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching (default: false)" })),
|
|
42
36
|
limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 100 in content mode)" })),
|
|
43
37
|
offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
|
|
@@ -51,109 +45,26 @@ export interface GrepToolDetails {
|
|
|
51
45
|
resultLimitReached?: number;
|
|
52
46
|
linesTruncated?: boolean;
|
|
53
47
|
meta?: OutputMeta;
|
|
54
|
-
// Fields for TUI rendering
|
|
55
48
|
scopePath?: string;
|
|
56
49
|
matchCount?: number;
|
|
57
50
|
fileCount?: number;
|
|
58
51
|
files?: string[];
|
|
59
52
|
fileMatches?: Array<{ path: string; count: number }>;
|
|
60
|
-
mode?: "content" | "
|
|
53
|
+
mode?: "content" | "filesWithMatches" | "count";
|
|
61
54
|
truncated?: boolean;
|
|
62
55
|
error?: string;
|
|
63
56
|
}
|
|
64
57
|
|
|
65
|
-
export interface RgResult {
|
|
66
|
-
stdout: string;
|
|
67
|
-
stderr: string;
|
|
68
|
-
exitCode: number | null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Run rg command and capture output.
|
|
73
|
-
*
|
|
74
|
-
* @throws ToolAbortError if signal is aborted
|
|
75
|
-
*/
|
|
76
|
-
export async function runRg(
|
|
77
|
-
rgPath: string,
|
|
78
|
-
args: string[],
|
|
79
|
-
options?: { signal?: AbortSignal; timeoutMs?: number },
|
|
80
|
-
): Promise<RgResult> {
|
|
81
|
-
const child = ptree.cspawn([rgPath, ...args], { signal: options?.signal, timeout: options?.timeoutMs });
|
|
82
|
-
const timeoutSeconds = options?.timeoutMs ? Math.max(1, Math.round(options.timeoutMs / 1000)) : undefined;
|
|
83
|
-
const timeoutMessage = timeoutSeconds ? `rg timed out after ${timeoutSeconds}s` : "rg timed out";
|
|
84
|
-
|
|
85
|
-
let stdout: string;
|
|
86
|
-
try {
|
|
87
|
-
stdout = await child.nothrow().text();
|
|
88
|
-
} catch (err) {
|
|
89
|
-
if (err instanceof ptree.TimeoutError) {
|
|
90
|
-
throw new ToolError(timeoutMessage);
|
|
91
|
-
}
|
|
92
|
-
if (err instanceof ptree.Exception && err.aborted) {
|
|
93
|
-
throw new ToolAbortError();
|
|
94
|
-
}
|
|
95
|
-
throw err;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
let exitError: unknown;
|
|
99
|
-
try {
|
|
100
|
-
await child.exited;
|
|
101
|
-
} catch (err) {
|
|
102
|
-
exitError = err;
|
|
103
|
-
if (err instanceof ptree.TimeoutError) {
|
|
104
|
-
throw new ToolError(timeoutMessage);
|
|
105
|
-
}
|
|
106
|
-
if (err instanceof ptree.Exception && err.aborted) {
|
|
107
|
-
throw new ToolAbortError();
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const exitCode = child.exitCode ?? (exitError instanceof ptree.Exception ? exitError.exitCode : null);
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
stdout,
|
|
115
|
-
stderr: child.peekStderr(),
|
|
116
|
-
exitCode,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Pluggable operations for the grep tool.
|
|
122
|
-
* Override these to delegate search to remote systems (e.g., SSH).
|
|
123
|
-
*/
|
|
124
58
|
export interface GrepOperations {
|
|
125
|
-
/** Check if path is a directory. Throws if path doesn't exist. */
|
|
126
59
|
isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
|
|
127
|
-
/** Read file contents for context lines */
|
|
128
60
|
readFile: (absolutePath: string) => Promise<string> | string;
|
|
129
61
|
}
|
|
130
62
|
|
|
131
|
-
const defaultGrepOperations: GrepOperations = {
|
|
132
|
-
isDirectory: async p => (await Bun.file(p).stat()).isDirectory(),
|
|
133
|
-
readFile: p => Bun.file(p).text(),
|
|
134
|
-
};
|
|
135
|
-
|
|
136
63
|
export interface GrepToolOptions {
|
|
137
|
-
/** Custom operations for grep. Default: local filesystem + ripgrep */
|
|
138
64
|
operations?: GrepOperations;
|
|
139
65
|
}
|
|
140
66
|
|
|
141
|
-
|
|
142
|
-
pattern: string;
|
|
143
|
-
path?: string;
|
|
144
|
-
glob?: string;
|
|
145
|
-
type?: string;
|
|
146
|
-
output_mode?: "content" | "files_with_matches" | "count";
|
|
147
|
-
i?: boolean;
|
|
148
|
-
n?: boolean;
|
|
149
|
-
a?: number;
|
|
150
|
-
b?: number;
|
|
151
|
-
c?: number;
|
|
152
|
-
context?: number;
|
|
153
|
-
multiline?: boolean;
|
|
154
|
-
limit?: number;
|
|
155
|
-
offset?: number;
|
|
156
|
-
}
|
|
67
|
+
type GrepParams = Static<typeof grepSchema>;
|
|
157
68
|
|
|
158
69
|
export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
159
70
|
public readonly name = "grep";
|
|
@@ -162,61 +73,20 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
|
162
73
|
public readonly parameters = grepSchema;
|
|
163
74
|
|
|
164
75
|
private readonly session: ToolSession;
|
|
165
|
-
private readonly ops: GrepOperations;
|
|
166
76
|
|
|
167
|
-
constructor(session: ToolSession,
|
|
77
|
+
constructor(session: ToolSession, _options?: GrepToolOptions) {
|
|
168
78
|
this.session = session;
|
|
169
|
-
this.ops = options?.operations ?? defaultGrepOperations;
|
|
170
79
|
this.description = renderPromptTemplate(grepDescription);
|
|
171
80
|
}
|
|
172
81
|
|
|
173
|
-
/**
|
|
174
|
-
* Validates a pattern against ripgrep's regex engine.
|
|
175
|
-
* Uses a quick dry-run against /dev/null to check for parse errors.
|
|
176
|
-
*/
|
|
177
|
-
private async validateRegexPattern(pattern: string, rgPath?: string): Promise<{ valid: boolean; error?: string }> {
|
|
178
|
-
if (!rgPath) {
|
|
179
|
-
return { valid: true }; // Can't validate, assume valid
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Run ripgrep against /dev/null with the pattern - this validates regex syntax
|
|
183
|
-
// without searching any files
|
|
184
|
-
const result = await $`${rgPath} --no-config --quiet -- ${pattern} /dev/null`.quiet().nothrow();
|
|
185
|
-
const stderr = result.stderr?.toString() ?? "";
|
|
186
|
-
const exitCode = result.exitCode ?? 0;
|
|
187
|
-
|
|
188
|
-
// Exit code 1 = no matches (pattern is valid), 0 = matches found
|
|
189
|
-
// Exit code 2 = error (often regex parse error)
|
|
190
|
-
if (exitCode === 2 && stderr.includes("regex parse error")) {
|
|
191
|
-
return { valid: false, error: stderr.trim() };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return { valid: true };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
82
|
public async execute(
|
|
198
83
|
_toolCallId: string,
|
|
199
84
|
params: GrepParams,
|
|
200
85
|
signal?: AbortSignal,
|
|
201
86
|
_onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
|
|
202
|
-
|
|
87
|
+
_toolContext?: AgentToolContext,
|
|
203
88
|
): Promise<AgentToolResult<GrepToolDetails>> {
|
|
204
|
-
const {
|
|
205
|
-
pattern,
|
|
206
|
-
path: searchDir,
|
|
207
|
-
glob,
|
|
208
|
-
type,
|
|
209
|
-
output_mode,
|
|
210
|
-
i,
|
|
211
|
-
n,
|
|
212
|
-
a,
|
|
213
|
-
b,
|
|
214
|
-
c,
|
|
215
|
-
context,
|
|
216
|
-
multiline,
|
|
217
|
-
limit,
|
|
218
|
-
offset,
|
|
219
|
-
} = params;
|
|
89
|
+
const { pattern, path: searchDir, glob, type, output_mode, i, n, context, multiline, limit, offset } = params;
|
|
220
90
|
|
|
221
91
|
return untilAborted(signal, async () => {
|
|
222
92
|
const normalizedPattern = pattern.trim();
|
|
@@ -235,51 +105,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
|
235
105
|
}
|
|
236
106
|
const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined;
|
|
237
107
|
|
|
238
|
-
const
|
|
239
|
-
if (value === undefined) return 0;
|
|
240
|
-
const normalized = Number.isFinite(value) ? Math.floor(value) : Number.NaN;
|
|
241
|
-
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
242
|
-
throw new ToolError(`${label} must be a non-negative number`);
|
|
243
|
-
}
|
|
244
|
-
return normalized;
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const normalizedAfter = normalizeContext(a, "After context");
|
|
248
|
-
const normalizedBefore = normalizeContext(b, "Before context");
|
|
249
|
-
const hasContextParam = context !== undefined;
|
|
250
|
-
const hasCParam = c !== undefined;
|
|
251
|
-
if (hasContextParam && hasCParam) {
|
|
252
|
-
throw new ToolError("Cannot combine context with c");
|
|
253
|
-
}
|
|
254
|
-
const normalizedContext = normalizeContext(hasContextParam ? context : c, "Context");
|
|
255
|
-
if (normalizedContext > 0 && (normalizedAfter > 0 || normalizedBefore > 0)) {
|
|
256
|
-
throw new ToolError("Cannot combine context with a or b");
|
|
257
|
-
}
|
|
258
|
-
const contextAfterValue = normalizedContext > 0 ? normalizedContext : normalizedAfter;
|
|
259
|
-
const contextBeforeValue = normalizedContext > 0 ? normalizedContext : normalizedBefore;
|
|
108
|
+
const normalizedContext = context ?? 5;
|
|
260
109
|
const showLineNumbers = n ?? true;
|
|
261
110
|
const ignoreCase = i ?? false;
|
|
262
|
-
const
|
|
263
|
-
const normalizedType = type?.trim() ?? "";
|
|
264
|
-
const hasContentHints =
|
|
265
|
-
limit !== undefined || context !== undefined || c !== undefined || a !== undefined || b !== undefined;
|
|
266
|
-
|
|
267
|
-
// Validate regex patterns early to surface parse errors before running rg
|
|
268
|
-
const rgPath = await ensureTool("rg", {
|
|
269
|
-
silent: true,
|
|
270
|
-
notify: message => toolContext?.ui?.notify(message, "info"),
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
if (!rgPath) {
|
|
274
|
-
throw new ToolError("rg is not available and could not be downloaded");
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const validation = await this.validateRegexPattern(normalizedPattern, rgPath);
|
|
278
|
-
if (!validation.valid) {
|
|
279
|
-
throw new ToolError(validation.error ?? "Invalid regex pattern");
|
|
280
|
-
}
|
|
111
|
+
const hasContentHints = limit !== undefined || context !== undefined;
|
|
281
112
|
|
|
282
|
-
// rgPath resolved earlier
|
|
283
113
|
const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
|
|
284
114
|
const scopePath = (() => {
|
|
285
115
|
const relative = nodePath.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
|
|
@@ -288,95 +118,50 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
|
288
118
|
|
|
289
119
|
let isDirectory: boolean;
|
|
290
120
|
try {
|
|
291
|
-
|
|
121
|
+
const stat = await Bun.file(searchPath).stat();
|
|
122
|
+
isDirectory = stat.isDirectory();
|
|
292
123
|
} catch {
|
|
293
124
|
throw new ToolError(`Path not found: ${searchPath}`);
|
|
294
125
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const effectiveOffset = normalizedOffset > 0 ? normalizedOffset : 0;
|
|
126
|
+
|
|
127
|
+
const effectiveOutputMode = output_mode ?? (!isDirectory || hasContentHints ? "content" : "filesWithMatches");
|
|
298
128
|
const effectiveLimit =
|
|
299
129
|
effectiveOutputMode === "content" ? (normalizedLimit ?? DEFAULT_MATCH_LIMIT) : normalizedLimit;
|
|
300
130
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
})();
|
|
323
|
-
fileCache.set(filePath, linesPromise);
|
|
324
|
-
}
|
|
325
|
-
return linesPromise;
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
const args: string[] = [];
|
|
329
|
-
|
|
330
|
-
// Base arguments depend on output mode
|
|
331
|
-
if (effectiveOutputMode === "files_with_matches") {
|
|
332
|
-
args.push("--files-with-matches", "--color=never");
|
|
333
|
-
} else if (effectiveOutputMode === "count") {
|
|
334
|
-
args.push("--count", "--color=never");
|
|
335
|
-
} else {
|
|
336
|
-
args.push("--json", "--color=never");
|
|
337
|
-
if (showLineNumbers) {
|
|
338
|
-
args.push("--line-number");
|
|
131
|
+
// Run WASM grep
|
|
132
|
+
let result: Awaited<ReturnType<typeof wasmGrep>>;
|
|
133
|
+
try {
|
|
134
|
+
result = await wasmGrep({
|
|
135
|
+
pattern: normalizedPattern,
|
|
136
|
+
path: searchPath,
|
|
137
|
+
glob: glob?.trim() || undefined,
|
|
138
|
+
type: type?.trim() || undefined,
|
|
139
|
+
ignoreCase,
|
|
140
|
+
multiline: multiline ?? false,
|
|
141
|
+
hidden: true,
|
|
142
|
+
maxCount: effectiveLimit,
|
|
143
|
+
offset: normalizedOffset > 0 ? normalizedOffset : undefined,
|
|
144
|
+
context: effectiveOutputMode === "content" ? normalizedContext : undefined,
|
|
145
|
+
maxColumns: DEFAULT_MAX_COLUMN,
|
|
146
|
+
mode: effectiveOutputMode,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof Error && err.message.startsWith("regex parse error")) {
|
|
150
|
+
throw new ToolError(err.message);
|
|
339
151
|
}
|
|
152
|
+
throw err;
|
|
340
153
|
}
|
|
341
154
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
args.push("--case-sensitive");
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (multiline) {
|
|
351
|
-
args.push("--multiline");
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
if (normalizedGlob) {
|
|
355
|
-
args.push("--glob", normalizedGlob);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
args.push("--glob", "!**/.git/**");
|
|
359
|
-
args.push("--glob", "!**/node_modules/**");
|
|
360
|
-
|
|
361
|
-
if (normalizedType) {
|
|
362
|
-
args.push("--type", normalizedType);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (effectiveOutputMode === "content") {
|
|
366
|
-
if (normalizedContext > 0) {
|
|
367
|
-
args.push("-C", String(normalizedContext));
|
|
155
|
+
const formatPath = (filePath: string): string => {
|
|
156
|
+
// WASM returns paths starting with / (the virtual root)
|
|
157
|
+
const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
|
158
|
+
if (isDirectory) {
|
|
159
|
+
return cleanPath.replace(/\\/g, "/");
|
|
368
160
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
args.push("--", normalizedPattern, searchPath);
|
|
372
|
-
|
|
373
|
-
const child = ptree.cspawn([rgPath, ...args], { signal });
|
|
161
|
+
return nodePath.basename(cleanPath);
|
|
162
|
+
};
|
|
374
163
|
|
|
375
|
-
|
|
376
|
-
let matchLimitReached = false;
|
|
377
|
-
let linesTruncated = false;
|
|
378
|
-
let killedDueToLimit = false;
|
|
379
|
-
const outputLines: string[] = [];
|
|
164
|
+
// Build output
|
|
380
165
|
const files = new Set<string>();
|
|
381
166
|
const fileList: string[] = [];
|
|
382
167
|
const fileMatchCounts = new Map<string, number>();
|
|
@@ -389,316 +174,88 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
|
389
174
|
}
|
|
390
175
|
};
|
|
391
176
|
|
|
392
|
-
|
|
393
|
-
const relative = formatPath(filePath);
|
|
394
|
-
fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1);
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
// For simple output modes (files_with_matches, count), process text directly
|
|
398
|
-
if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") {
|
|
399
|
-
const stdout = await child.text().catch(x => {
|
|
400
|
-
if (x instanceof ptree.Exception && x.exitCode === 1) {
|
|
401
|
-
return "";
|
|
402
|
-
}
|
|
403
|
-
return Promise.reject(x);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
const exitCode = child.exitCode ?? 0;
|
|
407
|
-
if (exitCode !== 0 && exitCode !== 1) {
|
|
408
|
-
const errorMsg = child.peekStderr().trim() || `ripgrep exited with code ${exitCode}`;
|
|
409
|
-
throw new ToolError(errorMsg);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const lines = stdout
|
|
413
|
-
.trim()
|
|
414
|
-
.split("\n")
|
|
415
|
-
.filter(line => line.length > 0);
|
|
416
|
-
|
|
417
|
-
if (lines.length === 0) {
|
|
418
|
-
const details: GrepToolDetails = {
|
|
419
|
-
scopePath,
|
|
420
|
-
matchCount: 0,
|
|
421
|
-
fileCount: 0,
|
|
422
|
-
files: [],
|
|
423
|
-
mode: effectiveOutputMode,
|
|
424
|
-
truncated: false,
|
|
425
|
-
};
|
|
426
|
-
return toolResult(details).text("No matches found").done();
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const offsetLines = effectiveOffset > 0 ? lines.slice(effectiveOffset) : lines;
|
|
430
|
-
const listLimit = applyListLimit(offsetLines, {
|
|
431
|
-
limit: normalizedLimit,
|
|
432
|
-
limitType: "result",
|
|
433
|
-
});
|
|
434
|
-
const processedLines = listLimit.items;
|
|
435
|
-
const limitMeta = listLimit.meta;
|
|
436
|
-
|
|
437
|
-
let simpleMatchCount = 0;
|
|
438
|
-
let fileCount = 0;
|
|
439
|
-
const simpleFiles = new Set<string>();
|
|
440
|
-
const simpleFileList: string[] = [];
|
|
441
|
-
const simpleFileMatchCounts = new Map<string, number>();
|
|
442
|
-
|
|
443
|
-
const recordSimpleFile = (filePath: string) => {
|
|
444
|
-
const relative = formatPath(filePath);
|
|
445
|
-
if (!simpleFiles.has(relative)) {
|
|
446
|
-
simpleFiles.add(relative);
|
|
447
|
-
simpleFileList.push(relative);
|
|
448
|
-
}
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
// Count mode: ripgrep provides total count per file, so we set directly (not increment)
|
|
452
|
-
const setFileMatchCount = (filePath: string, count: number) => {
|
|
453
|
-
const relative = formatPath(filePath);
|
|
454
|
-
simpleFileMatchCounts.set(relative, count);
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
if (effectiveOutputMode === "files_with_matches") {
|
|
458
|
-
for (const line of processedLines) {
|
|
459
|
-
recordSimpleFile(line);
|
|
460
|
-
}
|
|
461
|
-
fileCount = simpleFiles.size;
|
|
462
|
-
simpleMatchCount = fileCount;
|
|
463
|
-
} else {
|
|
464
|
-
for (const line of processedLines) {
|
|
465
|
-
const separatorIndex = line.lastIndexOf(":");
|
|
466
|
-
const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
|
|
467
|
-
const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
|
|
468
|
-
const count = Number.parseInt(countPart, 10);
|
|
469
|
-
recordSimpleFile(filePart);
|
|
470
|
-
if (!Number.isNaN(count)) {
|
|
471
|
-
simpleMatchCount += count;
|
|
472
|
-
setFileMatchCount(filePart, count);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
fileCount = simpleFiles.size;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const truncatedByLimit = Boolean(limitMeta.resultLimit);
|
|
479
|
-
|
|
480
|
-
// For count mode, format as "path:count"
|
|
481
|
-
if (effectiveOutputMode === "count") {
|
|
482
|
-
const formatted = processedLines.map(line => {
|
|
483
|
-
const separatorIndex = line.lastIndexOf(":");
|
|
484
|
-
const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex));
|
|
485
|
-
const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1);
|
|
486
|
-
return `${relative}:${count}`;
|
|
487
|
-
});
|
|
488
|
-
const output = formatted.join("\n");
|
|
489
|
-
const details: GrepToolDetails = {
|
|
490
|
-
scopePath,
|
|
491
|
-
matchCount: simpleMatchCount,
|
|
492
|
-
fileCount,
|
|
493
|
-
files: simpleFileList,
|
|
494
|
-
fileMatches: simpleFileList.map(path => ({
|
|
495
|
-
path,
|
|
496
|
-
count: simpleFileMatchCounts.get(path) ?? 0,
|
|
497
|
-
})),
|
|
498
|
-
mode: effectiveOutputMode,
|
|
499
|
-
truncated: truncatedByLimit,
|
|
500
|
-
resultLimitReached: limitMeta.resultLimit?.reached,
|
|
501
|
-
};
|
|
502
|
-
return toolResult(details)
|
|
503
|
-
.text(output)
|
|
504
|
-
.limits({
|
|
505
|
-
resultLimit: limitMeta.resultLimit?.reached,
|
|
506
|
-
})
|
|
507
|
-
.done();
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// For files_with_matches, format paths
|
|
511
|
-
const formatted = processedLines.map(line => formatPath(line));
|
|
512
|
-
const output = formatted.join("\n");
|
|
177
|
+
if (result.totalMatches === 0) {
|
|
513
178
|
const details: GrepToolDetails = {
|
|
514
179
|
scopePath,
|
|
515
|
-
matchCount:
|
|
516
|
-
fileCount,
|
|
517
|
-
files:
|
|
180
|
+
matchCount: 0,
|
|
181
|
+
fileCount: 0,
|
|
182
|
+
files: [],
|
|
518
183
|
mode: effectiveOutputMode,
|
|
519
|
-
truncated:
|
|
520
|
-
resultLimitReached: limitMeta.resultLimit?.reached,
|
|
184
|
+
truncated: false,
|
|
521
185
|
};
|
|
522
|
-
return toolResult(details)
|
|
523
|
-
.text(output)
|
|
524
|
-
.limits({
|
|
525
|
-
resultLimit: limitMeta.resultLimit?.reached,
|
|
526
|
-
})
|
|
527
|
-
.done();
|
|
186
|
+
return toolResult(details).text("No matches found").done();
|
|
528
187
|
}
|
|
529
188
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const relativePath = formatPath(filePath);
|
|
533
|
-
const lines = await getFileLines(filePath);
|
|
534
|
-
if (!lines.length) {
|
|
535
|
-
return showLineNumbers
|
|
536
|
-
? [`${relativePath}:${lineNumber}: (unable to read file)`]
|
|
537
|
-
: [`${relativePath}: (unable to read file)`];
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const block: string[] = [];
|
|
541
|
-
const start = contextBeforeValue > 0 ? Math.max(1, lineNumber - contextBeforeValue) : lineNumber;
|
|
542
|
-
const end = contextAfterValue > 0 ? Math.min(lines.length, lineNumber + contextAfterValue) : lineNumber;
|
|
543
|
-
|
|
544
|
-
for (let current = start; current <= end; current++) {
|
|
545
|
-
const lineText = lines[current - 1] ?? "";
|
|
546
|
-
const sanitized = lineText.replace(/\r/g, "");
|
|
547
|
-
const isMatchLine = current === lineNumber;
|
|
548
|
-
|
|
549
|
-
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
|
550
|
-
if (wasTruncated) {
|
|
551
|
-
linesTruncated = true;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
if (isMatchLine) {
|
|
555
|
-
block.push(
|
|
556
|
-
showLineNumbers
|
|
557
|
-
? `${relativePath}:${current}: ${truncatedText}`
|
|
558
|
-
: `${relativePath}: ${truncatedText}`,
|
|
559
|
-
);
|
|
560
|
-
} else {
|
|
561
|
-
block.push(
|
|
562
|
-
showLineNumbers
|
|
563
|
-
? `${relativePath}-${current}- ${truncatedText}`
|
|
564
|
-
: `${relativePath}- ${truncatedText}`,
|
|
565
|
-
);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
return block;
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
const maxMatches = effectiveLimit !== undefined ? effectiveLimit + effectiveOffset : undefined;
|
|
573
|
-
const processEvent = async (event: unknown): Promise<void> => {
|
|
574
|
-
if (!event || typeof event !== "object") {
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
const parsed = event as { type?: string; data?: { path?: { text?: string }; line_number?: number } };
|
|
578
|
-
if (parsed.type !== "match") {
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const nextIndex = matchCount + 1;
|
|
583
|
-
if (maxMatches !== undefined && nextIndex > maxMatches) {
|
|
584
|
-
matchLimitReached = true;
|
|
585
|
-
killedDueToLimit = true;
|
|
586
|
-
child.kill("SIGKILL");
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
matchCount = nextIndex;
|
|
591
|
-
const filePath = parsed.data?.path?.text;
|
|
592
|
-
const lineNumber = parsed.data?.line_number;
|
|
189
|
+
let outputLines: string[] = [];
|
|
190
|
+
let linesTruncated = false;
|
|
593
191
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
192
|
+
for (const match of result.matches) {
|
|
193
|
+
recordFile(match.path);
|
|
194
|
+
const relativePath = formatPath(match.path);
|
|
195
|
+
|
|
196
|
+
if (effectiveOutputMode === "content") {
|
|
197
|
+
// Add context before
|
|
198
|
+
if (match.contextBefore) {
|
|
199
|
+
for (const ctx of match.contextBefore) {
|
|
200
|
+
outputLines.push(
|
|
201
|
+
showLineNumbers
|
|
202
|
+
? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
|
|
203
|
+
: `${relativePath}- ${ctx.line}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
597
206
|
}
|
|
598
|
-
recordFile(filePath);
|
|
599
|
-
recordFileMatch(filePath);
|
|
600
|
-
const block = await formatBlock(filePath, lineNumber);
|
|
601
|
-
outputLines.push(...block);
|
|
602
|
-
}
|
|
603
|
-
};
|
|
604
207
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
await processEvent(value);
|
|
612
|
-
}
|
|
208
|
+
// Add match line
|
|
209
|
+
outputLines.push(
|
|
210
|
+
showLineNumbers
|
|
211
|
+
? `${relativePath}:${match.lineNumber}: ${match.line}`
|
|
212
|
+
: `${relativePath}: ${match.line}`,
|
|
213
|
+
);
|
|
613
214
|
|
|
614
|
-
if (
|
|
615
|
-
|
|
215
|
+
if (match.truncated) {
|
|
216
|
+
linesTruncated = true;
|
|
616
217
|
}
|
|
617
218
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
219
|
+
// Add context after
|
|
220
|
+
if (match.contextAfter) {
|
|
221
|
+
for (const ctx of match.contextAfter) {
|
|
222
|
+
outputLines.push(
|
|
223
|
+
showLineNumbers
|
|
224
|
+
? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
|
|
225
|
+
: `${relativePath}- ${ctx.line}`,
|
|
226
|
+
);
|
|
623
227
|
}
|
|
624
|
-
buffer = buffer.slice(nextNewline + 1);
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
if (result.read === 0) {
|
|
629
|
-
break;
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
// Process stdout stream with JSONL chunk parsing
|
|
635
|
-
try {
|
|
636
|
-
for await (const chunk of child.stdout) {
|
|
637
|
-
if (killedDueToLimit) {
|
|
638
|
-
break;
|
|
639
228
|
}
|
|
640
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
641
|
-
await parseBuffer();
|
|
642
|
-
}
|
|
643
|
-
if (!killedDueToLimit) {
|
|
644
|
-
buffer += decoder.decode();
|
|
645
|
-
await parseBuffer();
|
|
646
|
-
}
|
|
647
|
-
} catch (err) {
|
|
648
|
-
if (err instanceof ptree.Exception && err.aborted) {
|
|
649
|
-
throw new ToolAbortError();
|
|
650
|
-
}
|
|
651
|
-
// Stream may close early if we killed due to limit - that's ok
|
|
652
|
-
if (!killedDueToLimit) {
|
|
653
|
-
throw err;
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
229
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
throw new ToolAbortError();
|
|
664
|
-
}
|
|
665
|
-
// Non-zero exit is ok if we killed due to limit or exit code 1 (no matches)
|
|
666
|
-
if (!killedDueToLimit && err.exitCode !== 1) {
|
|
667
|
-
const errorMsg = child.peekStderr().trim() || `ripgrep exited with code ${err.exitCode}`;
|
|
668
|
-
throw new ToolError(errorMsg);
|
|
669
|
-
}
|
|
230
|
+
// Track per-file counts
|
|
231
|
+
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
|
232
|
+
} else if (effectiveOutputMode === "filesWithMatches") {
|
|
233
|
+
// One line per file
|
|
234
|
+
const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
|
|
235
|
+
fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 1);
|
|
670
236
|
} else {
|
|
671
|
-
|
|
237
|
+
// count mode
|
|
238
|
+
const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
|
|
239
|
+
fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 0);
|
|
672
240
|
}
|
|
673
241
|
}
|
|
674
242
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
files: [],
|
|
681
|
-
mode: effectiveOutputMode,
|
|
682
|
-
truncated: false,
|
|
683
|
-
};
|
|
684
|
-
return toolResult(details).text("No matches found").done();
|
|
243
|
+
// Format output based on mode
|
|
244
|
+
if (effectiveOutputMode === "filesWithMatches") {
|
|
245
|
+
outputLines = fileList;
|
|
246
|
+
} else if (effectiveOutputMode === "count") {
|
|
247
|
+
outputLines = fileList.map(f => `${f}:${fileMatchCounts.get(f) ?? 0}`);
|
|
685
248
|
}
|
|
686
249
|
|
|
687
|
-
const limitMeta =
|
|
688
|
-
matchLimitReached && effectiveLimit !== undefined
|
|
689
|
-
? { matchLimit: { reached: effectiveLimit, suggestion: effectiveLimit * 2 } }
|
|
690
|
-
: {};
|
|
691
|
-
|
|
692
|
-
// Apply byte truncation (no line limit since we already have match limit)
|
|
693
250
|
const rawOutput = outputLines.join("\n");
|
|
694
251
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
695
|
-
|
|
696
252
|
const output = truncation.content;
|
|
697
|
-
|
|
253
|
+
|
|
254
|
+
const truncated = Boolean(result.limitReached || truncation.truncated || linesTruncated);
|
|
698
255
|
const details: GrepToolDetails = {
|
|
699
256
|
scopePath,
|
|
700
|
-
matchCount:
|
|
701
|
-
fileCount:
|
|
257
|
+
matchCount: result.totalMatches,
|
|
258
|
+
fileCount: result.filesWithMatches,
|
|
702
259
|
files: fileList,
|
|
703
260
|
fileMatches: fileList.map(path => ({
|
|
704
261
|
path,
|
|
@@ -706,22 +263,19 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
|
|
706
263
|
})),
|
|
707
264
|
mode: effectiveOutputMode,
|
|
708
265
|
truncated,
|
|
709
|
-
matchLimitReached:
|
|
266
|
+
matchLimitReached: result.limitReached ? effectiveLimit : undefined,
|
|
710
267
|
};
|
|
711
268
|
|
|
712
|
-
// Keep TUI compatibility fields
|
|
713
|
-
if (matchLimitReached && effectiveLimit !== undefined) {
|
|
714
|
-
details.matchLimitReached = effectiveLimit;
|
|
715
|
-
}
|
|
716
269
|
if (truncation.truncated) details.truncation = truncation;
|
|
717
270
|
if (linesTruncated) details.linesTruncated = true;
|
|
718
271
|
|
|
719
272
|
const resultBuilder = toolResult(details)
|
|
720
273
|
.text(output)
|
|
721
274
|
.limits({
|
|
722
|
-
matchLimit:
|
|
275
|
+
matchLimit: result.limitReached ? effectiveLimit : undefined,
|
|
723
276
|
columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined,
|
|
724
277
|
});
|
|
278
|
+
|
|
725
279
|
if (truncation.truncated) {
|
|
726
280
|
resultBuilder.truncation(truncation, { direction: "head" });
|
|
727
281
|
}
|
|
@@ -742,9 +296,6 @@ interface GrepRenderArgs {
|
|
|
742
296
|
type?: string;
|
|
743
297
|
i?: boolean;
|
|
744
298
|
n?: boolean;
|
|
745
|
-
a?: number;
|
|
746
|
-
b?: number;
|
|
747
|
-
c?: number;
|
|
748
299
|
context?: number;
|
|
749
300
|
multiline?: boolean;
|
|
750
301
|
output_mode?: string;
|
|
@@ -762,13 +313,10 @@ export const grepToolRenderer = {
|
|
|
762
313
|
if (args.path) meta.push(`in ${args.path}`);
|
|
763
314
|
if (args.glob) meta.push(`glob:${args.glob}`);
|
|
764
315
|
if (args.type) meta.push(`type:${args.type}`);
|
|
765
|
-
if (args.output_mode && args.output_mode !== "
|
|
316
|
+
if (args.output_mode && args.output_mode !== "filesWithMatches") meta.push(`mode:${args.output_mode}`);
|
|
766
317
|
if (args.i) meta.push("case:insensitive");
|
|
767
318
|
if (args.n === false) meta.push("no-line-numbers");
|
|
768
|
-
|
|
769
|
-
if (contextValue !== undefined && contextValue > 0) meta.push(`context:${contextValue}`);
|
|
770
|
-
if (args.a !== undefined && args.a > 0) meta.push(`after:${args.a}`);
|
|
771
|
-
if (args.b !== undefined && args.b > 0) meta.push(`before:${args.b}`);
|
|
319
|
+
if (args.context !== undefined && args.context > 0) meta.push(`context:${args.context}`);
|
|
772
320
|
if (args.multiline) meta.push("multiline");
|
|
773
321
|
if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
|
|
774
322
|
if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
|
|
@@ -821,7 +369,7 @@ export const grepToolRenderer = {
|
|
|
821
369
|
|
|
822
370
|
const matchCount = details?.matchCount ?? 0;
|
|
823
371
|
const fileCount = details?.fileCount ?? 0;
|
|
824
|
-
const mode = details?.mode ?? "
|
|
372
|
+
const mode = details?.mode ?? "filesWithMatches";
|
|
825
373
|
const truncation = details?.meta?.truncation;
|
|
826
374
|
const limits = details?.meta?.limits;
|
|
827
375
|
const truncated = Boolean(
|
|
@@ -838,7 +386,7 @@ export const grepToolRenderer = {
|
|
|
838
386
|
}
|
|
839
387
|
|
|
840
388
|
const summaryParts =
|
|
841
|
-
mode === "
|
|
389
|
+
mode === "filesWithMatches"
|
|
842
390
|
? [formatCount("file", fileCount)]
|
|
843
391
|
: [formatCount("match", matchCount), formatCount("file", fileCount)];
|
|
844
392
|
const meta = [...summaryParts];
|