langflower 0.0.6 → 0.0.7

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.
@@ -407,19 +407,42 @@ const invokeTool = (state, call, options, cancelSignal) => {
407
407
  ? `Error: Sub-Agent ${payload.callId} timed out after ${options.recovery.subagentTimeoutMs}ms.`
408
408
  : `Error: ${classifyLlmFailure(error).message}`)), mergeMap((result) => toolResultPackets(state, call, result, options, []))));
409
409
  }
410
- const invocation$ = defer(() => invokeInventoryTool(options.harness, options.inventoryTools, call, options.toolCtx, options.policy.toolNotAllowedText === undefined
411
- ? undefined
412
- : {
413
- notInAllowlistText: options.policy.toolNotAllowedText,
414
- }));
410
+ const toolAbort = new AbortController();
411
+ const abortTool = () => {
412
+ toolAbort.abort();
413
+ };
414
+ cancelSignal.addEventListener('abort', abortTool);
415
+ const invokeOptions = {
416
+ ...(options.policy.toolNotAllowedText === undefined
417
+ ? {}
418
+ : {
419
+ notInAllowlistText: options.policy.toolNotAllowedText,
420
+ }),
421
+ signal: toolAbort.signal,
422
+ };
423
+ const invocation$ = defer(() => invokeInventoryTool(options.harness, options.inventoryTools, call, options.toolCtx, invokeOptions).catch((error) => {
424
+ // Avoid unhandled rejection when timeout/cancel aborts in-flight work.
425
+ if (toolAbort.signal.aborted) {
426
+ return {
427
+ ok: false,
428
+ text: 'Tool aborted.',
429
+ };
430
+ }
431
+ throw error;
432
+ })).pipe(finalize(() => {
433
+ cancelSignal.removeEventListener('abort', abortTool);
434
+ }));
415
435
  const boundedInvocation$ = options.recovery.toolTimeoutMs > 0
416
436
  ? invocation$.pipe(timeout({
417
437
  first: options.recovery.toolTimeoutMs,
418
438
  }))
419
439
  : invocation$;
420
- return concat(of(callLog), boundedInvocation$.pipe(map((result) => result.ok ? result.text : `Error: ${result.text}`), catchError((error) => of(error instanceof TimeoutError
421
- ? `Error: Tool ${call.name} timed out after ${options.recovery.toolTimeoutMs}ms.`
422
- : `Error: ${classifyLlmFailure(error).message}`)), mergeMap((result) => toolResultPackets(state, call, result, options, []))));
440
+ return concat(of(callLog), boundedInvocation$.pipe(map((result) => result.ok ? result.text : `Error: ${result.text}`), catchError((error) => {
441
+ abortTool();
442
+ return of(error instanceof TimeoutError
443
+ ? `Error: Tool ${call.name} timed out after ${options.recovery.toolTimeoutMs}ms.`
444
+ : `Error: ${classifyLlmFailure(error).message}`);
445
+ }), mergeMap((result) => toolResultPackets(state, call, result, options, []))));
423
446
  };
424
447
  const toolResultPackets = (state, call, result, options, prefix) => {
425
448
  const normalized = normalizeToolResult(result, options.recovery.maxToolResultChars);
@@ -15,6 +15,7 @@ export declare const parseToolArgs: (raw: string) => Readonly<Record<string, unk
15
15
  */
16
16
  export declare const invokeInventoryTool: (harness: Harness | undefined, tools: readonly ToolHandle[], call: ChatCompletionToolCall, toolCtx: ToolHandlerContext | undefined, options?: {
17
17
  readonly notInAllowlistText?: (toolName: string) => string;
18
+ readonly signal?: AbortSignal;
18
19
  }) => Promise<{
19
20
  readonly ok: boolean;
20
21
  readonly text: string;
@@ -60,8 +60,11 @@ export const invokeInventoryTool = async (harness, tools, call, toolCtx, options
60
60
  }
61
61
  const args = parseToolArgs(call.arguments);
62
62
  const toolId = handle.toolId.length > 0 ? handle.toolId : handle.name;
63
+ const ctx = options?.signal === undefined
64
+ ? toolCtx
65
+ : { ...toolCtx, signal: options.signal };
63
66
  if (isBuiltinToolId(toolId)) {
64
- const authorize = toolCtx.authorize ?? harness?.authorize;
67
+ const authorize = ctx.authorize ?? harness?.authorize;
65
68
  if (authorize === undefined) {
66
69
  return {
67
70
  ok: false,
@@ -77,7 +80,7 @@ export const invokeInventoryTool = async (harness, tools, call, toolCtx, options
77
80
  }
78
81
  }
79
82
  try {
80
- const text = await handle.invoke(args, toolCtx);
83
+ const text = await handle.invoke(args, ctx);
81
84
  return { ok: true, text };
82
85
  }
83
86
  catch (error) {
@@ -23,10 +23,15 @@ export const wrapBuiltinToolHandles = (harness, enabledToolIds, permission) => {
23
23
  name: reg.name,
24
24
  description: reg.description,
25
25
  inputSchema: reg.inputSchema,
26
- invoke: async (args) => {
26
+ invoke: async (args, toolCtx) => {
27
+ // Shell passes tools host bag (optional signal); SDK type stays identity-only.
28
+ const hostCtx = toolCtx;
27
29
  const result = await harness.invoke({
28
30
  toolId: reg.toolId,
29
31
  args,
32
+ ...(hostCtx?.signal !== undefined
33
+ ? { signal: hostCtx.signal }
34
+ : {}),
30
35
  });
31
36
  if (!result.ok) {
32
37
  throw new Error(result.text);
@@ -75,13 +75,13 @@ export declare const BUILTIN_TOOLS: readonly [{
75
75
  readonly registration: {
76
76
  readonly toolId: "grep";
77
77
  readonly name: "grep";
78
- readonly description: "Regex search across project files (gitignore-aware by default; Node walk not ripgrep). Optional postProcess.";
78
+ readonly description: "Regex search across project files (gitignore-aware by default; ripgrep when available, else grep, else bounded Node walk). Optional postProcess.";
79
79
  readonly inputSchema: {
80
80
  readonly type: "object";
81
81
  readonly properties: {
82
82
  readonly pattern: {
83
83
  readonly type: "string";
84
- readonly description: "JavaScript RegExp source";
84
+ readonly description: "Regex pattern (ripgrep dialect when rg is available; JavaScript RegExp on Node fallback)";
85
85
  };
86
86
  readonly path: {
87
87
  readonly type: "string";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * True when `cmd` resolves on PATH (`where` on Windows, `which` elsewhere).
3
+ * Result is cached per process.
4
+ */
5
+ export declare const commandExists: (cmd: string) => Promise<boolean>;
@@ -0,0 +1,27 @@
1
+ import { spawn } from 'node:child_process';
2
+ const cache = new Map();
3
+ /**
4
+ * True when `cmd` resolves on PATH (`where` on Windows, `which` elsewhere).
5
+ * Result is cached per process.
6
+ */
7
+ export const commandExists = async (cmd) => {
8
+ const cached = cache.get(cmd);
9
+ if (cached !== undefined) {
10
+ return cached;
11
+ }
12
+ const checkCmd = process.platform === 'win32' ? 'where' : 'which';
13
+ const exists = await new Promise((resolve) => {
14
+ const child = spawn(checkCmd, [cmd], {
15
+ stdio: 'ignore',
16
+ windowsHide: true,
17
+ });
18
+ child.on('error', () => {
19
+ resolve(false);
20
+ });
21
+ child.on('close', (code) => {
22
+ resolve(code === 0);
23
+ });
24
+ });
25
+ cache.set(cmd, exists);
26
+ return exists;
27
+ };
@@ -0,0 +1,43 @@
1
+ import { type SpawnCaptureOptions, type SpawnCaptureResult } from '../spawn-capture.js';
2
+ export declare const MAX_GREP_MATCHES = 100;
3
+ type GrepHit = {
4
+ readonly file: string;
5
+ readonly line: number;
6
+ readonly text: string;
7
+ };
8
+ export type GrepSearchInput = {
9
+ readonly pattern: string;
10
+ readonly caseInsensitive: boolean;
11
+ readonly respectGitignore: boolean;
12
+ /** Absolute search file or directory (already fenced). */
13
+ readonly searchAbsolute: string;
14
+ /** Fence root for relative display paths. */
15
+ readonly fenceRoot: string;
16
+ readonly displayPath: (absolute: string) => string;
17
+ readonly signal?: AbortSignal;
18
+ };
19
+ export type GrepSearchDeps = {
20
+ readonly commandExists?: (cmd: string) => Promise<boolean>;
21
+ readonly spawnCapture?: (command: string, args: readonly string[], options?: SpawnCaptureOptions) => Promise<SpawnCaptureResult>;
22
+ };
23
+ type GrepSearchOutcome = {
24
+ readonly ok: true;
25
+ readonly hits: readonly GrepHit[];
26
+ readonly truncated: boolean;
27
+ readonly backend: 'rg' | 'grep' | 'node';
28
+ } | {
29
+ readonly ok: false;
30
+ readonly reason: 'unavailable' | 'error';
31
+ readonly message: string;
32
+ };
33
+ export declare const searchWithNodeWalk: (input: GrepSearchInput) => Promise<GrepSearchOutcome>;
34
+ /**
35
+ * ts-scan-style cascade: rg → grep → bounded Node walk.
36
+ * Soft-falls through when a CLI binary is missing or fails to spawn;
37
+ * Node tier surfaces invalid JS RegExp errors.
38
+ */
39
+ export declare const runGrepCascade: (input: GrepSearchInput, deps?: GrepSearchDeps) => Promise<{
40
+ readonly body: string;
41
+ readonly backend: "rg" | "grep" | "node";
42
+ }>;
43
+ export {};
@@ -0,0 +1,315 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { commandExists } from '../command-exists.js';
4
+ import { spawnCapture, } from '../spawn-capture.js';
5
+ import { WALK_EXCLUDE_DIR_NAMES, walkFiles } from '../walk-files.js';
6
+ export const MAX_GREP_MATCHES = 100;
7
+ const MAX_GREP_FILES_SCANNED = 5_000;
8
+ const MAX_GREP_FILE_BYTES = 1_048_576;
9
+ const MAX_GREP_LINE_CHARS = 8_000;
10
+ const SEARCH_EXCLUDE_GLOBS = [
11
+ '!**/node_modules/**',
12
+ '!**/dist/**',
13
+ '!**/build/**',
14
+ '!**/.git/**',
15
+ ];
16
+ const throwIfAborted = (signal) => {
17
+ if (signal?.aborted) {
18
+ throw new Error('aborted');
19
+ }
20
+ };
21
+ const formatHits = (hits, truncated) => {
22
+ if (hits.length === 0) {
23
+ return '(no matches)';
24
+ }
25
+ const body = hits
26
+ .map((hit) => `${hit.file}:${hit.line}:${hit.text}`)
27
+ .join('\n');
28
+ return truncated
29
+ ? `${body}\n…[truncated at ${MAX_GREP_MATCHES}; refine pattern or path]`
30
+ : body;
31
+ };
32
+ const formatGrepBody = (hits, truncated) => formatHits(hits, truncated);
33
+ const parseLineHits = (stdout, displayPath, fenceRoot, maxHits) => {
34
+ const hits = [];
35
+ const lines = stdout.split(/\r?\n/);
36
+ for (const raw of lines) {
37
+ if (raw.length === 0) {
38
+ continue;
39
+ }
40
+ const match = /^(.+?):(\d+):(.*)$/.exec(raw);
41
+ if (match === null) {
42
+ continue;
43
+ }
44
+ const fileAbs = path.isAbsolute(match[1] ?? '')
45
+ ? (match[1] ?? '')
46
+ : path.join(fenceRoot, match[1] ?? '');
47
+ const line = Number(match[2]);
48
+ const text = match[3] ?? '';
49
+ if (!Number.isFinite(line)) {
50
+ continue;
51
+ }
52
+ hits.push({
53
+ file: displayPath(fileAbs),
54
+ line,
55
+ text: text.length > MAX_GREP_LINE_CHARS
56
+ ? `${text.slice(0, MAX_GREP_LINE_CHARS)}…`
57
+ : text,
58
+ });
59
+ if (hits.length >= maxHits) {
60
+ return { hits, truncated: true };
61
+ }
62
+ }
63
+ return { hits, truncated: false };
64
+ };
65
+ const isBinaryAbortError = (error) => error instanceof Error &&
66
+ (error.name === 'AbortError' || error.message === 'aborted');
67
+ const searchWithRipgrep = async (input, deps = {}) => {
68
+ const exists = deps.commandExists ?? commandExists;
69
+ const run = deps.spawnCapture ?? spawnCapture;
70
+ if (!(await exists('rg'))) {
71
+ return {
72
+ ok: false,
73
+ reason: 'unavailable',
74
+ message: 'ripgrep (rg) is not available on this system',
75
+ };
76
+ }
77
+ throwIfAborted(input.signal);
78
+ const args = [
79
+ '-n',
80
+ '--color',
81
+ 'never',
82
+ '--max-filesize',
83
+ `${MAX_GREP_FILE_BYTES}`,
84
+ ...(input.respectGitignore ? [] : ['--no-ignore']),
85
+ ...SEARCH_EXCLUDE_GLOBS.flatMap((glob) => ['--glob', glob]),
86
+ ...(input.caseInsensitive ? ['-i'] : []),
87
+ '-e',
88
+ input.pattern,
89
+ input.searchAbsolute,
90
+ ];
91
+ try {
92
+ const result = await run('rg', args, {
93
+ cwd: input.fenceRoot,
94
+ ...(input.signal !== undefined ? { signal: input.signal } : {}),
95
+ });
96
+ // rg: 0 = matches, 1 = no matches, 2 = error
97
+ if (result.code !== null && result.code >= 2) {
98
+ const detail = result.stderr.trim();
99
+ return {
100
+ ok: false,
101
+ reason: 'error',
102
+ message: detail.length > 0
103
+ ? `ripgrep failed: ${detail}`
104
+ : 'ripgrep failed',
105
+ };
106
+ }
107
+ const parsed = parseLineHits(result.stdout, input.displayPath, input.fenceRoot, MAX_GREP_MATCHES);
108
+ return {
109
+ ok: true,
110
+ hits: parsed.hits,
111
+ truncated: parsed.truncated,
112
+ backend: 'rg',
113
+ };
114
+ }
115
+ catch (error) {
116
+ if (isBinaryAbortError(error)) {
117
+ throw new Error('aborted');
118
+ }
119
+ return {
120
+ ok: false,
121
+ reason: 'unavailable',
122
+ message: error instanceof Error
123
+ ? error.message
124
+ : 'Error executing ripgrep',
125
+ };
126
+ }
127
+ };
128
+ const searchWithGrep = async (input, deps = {}) => {
129
+ const exists = deps.commandExists ?? commandExists;
130
+ const run = deps.spawnCapture ?? spawnCapture;
131
+ if (!(await exists('grep'))) {
132
+ return {
133
+ ok: false,
134
+ reason: 'unavailable',
135
+ message: 'grep is not available on this system',
136
+ };
137
+ }
138
+ throwIfAborted(input.signal);
139
+ const args = [
140
+ '-r',
141
+ '-n',
142
+ '-E',
143
+ '-I',
144
+ ...(input.caseInsensitive ? ['-i'] : []),
145
+ ...WALK_EXCLUDE_DIR_NAMES.flatMap((name) => ['--exclude-dir', name]),
146
+ '-e',
147
+ input.pattern,
148
+ input.searchAbsolute,
149
+ ];
150
+ try {
151
+ const result = await run('grep', args, {
152
+ cwd: input.fenceRoot,
153
+ ...(input.signal !== undefined ? { signal: input.signal } : {}),
154
+ });
155
+ // grep: 0 = matches, 1 = no matches, ≥2 = error
156
+ if (result.code !== null && result.code >= 2) {
157
+ const detail = result.stderr.trim();
158
+ return {
159
+ ok: false,
160
+ reason: 'error',
161
+ message: detail.length > 0
162
+ ? `grep failed: ${detail}`
163
+ : 'grep failed',
164
+ };
165
+ }
166
+ const parsed = parseLineHits(result.stdout, input.displayPath, input.fenceRoot, MAX_GREP_MATCHES);
167
+ return {
168
+ ok: true,
169
+ hits: parsed.hits,
170
+ truncated: parsed.truncated,
171
+ backend: 'grep',
172
+ };
173
+ }
174
+ catch (error) {
175
+ if (isBinaryAbortError(error)) {
176
+ throw new Error('aborted');
177
+ }
178
+ return {
179
+ ok: false,
180
+ reason: 'unavailable',
181
+ message: error instanceof Error ? error.message : 'Error executing grep',
182
+ };
183
+ }
184
+ };
185
+ const looksBinary = (buf) => {
186
+ const sample = buf.subarray(0, Math.min(buf.length, 8_192));
187
+ return sample.includes(0);
188
+ };
189
+ const yieldEventLoop = () => new Promise((resolve) => {
190
+ setImmediate(resolve);
191
+ });
192
+ export const searchWithNodeWalk = async (input) => {
193
+ throwIfAborted(input.signal);
194
+ let regex;
195
+ try {
196
+ regex = new RegExp(input.pattern, input.caseInsensitive ? 'i' : '');
197
+ }
198
+ catch (error) {
199
+ const message = error instanceof Error ? error.message : String(error);
200
+ return {
201
+ ok: false,
202
+ reason: 'error',
203
+ message: `Invalid regex «${input.pattern}»: ${message}. Escape special characters or simplify the pattern.`,
204
+ };
205
+ }
206
+ const stat = await fs.stat(input.searchAbsolute).catch(() => null);
207
+ if (stat === null) {
208
+ return {
209
+ ok: false,
210
+ reason: 'error',
211
+ message: `Path not found: ${input.searchAbsolute}`,
212
+ };
213
+ }
214
+ const files = stat.isFile()
215
+ ? [input.searchAbsolute]
216
+ : (await walkFiles(input.fenceRoot, input.searchAbsolute, {
217
+ respectGitignore: input.respectGitignore,
218
+ ...(input.signal !== undefined
219
+ ? { signal: input.signal }
220
+ : {}),
221
+ maxFiles: MAX_GREP_FILES_SCANNED,
222
+ })).map((relative) => path.join(input.fenceRoot, relative));
223
+ const hits = [];
224
+ let matchCapHit = false;
225
+ let scanned = 0;
226
+ for (const fileAbs of files) {
227
+ throwIfAborted(input.signal);
228
+ scanned += 1;
229
+ if (scanned % 16 === 0) {
230
+ await yieldEventLoop();
231
+ throwIfAborted(input.signal);
232
+ }
233
+ if (hits.length >= MAX_GREP_MATCHES) {
234
+ matchCapHit = true;
235
+ break;
236
+ }
237
+ let buf;
238
+ try {
239
+ const handle = await fs.open(fileAbs, 'r');
240
+ try {
241
+ const st = await handle.stat();
242
+ if (st.size > MAX_GREP_FILE_BYTES) {
243
+ continue;
244
+ }
245
+ buf = Buffer.alloc(Number(st.size));
246
+ await handle.read(buf, 0, buf.length, 0);
247
+ }
248
+ finally {
249
+ await handle.close();
250
+ }
251
+ }
252
+ catch {
253
+ continue;
254
+ }
255
+ if (looksBinary(buf)) {
256
+ continue;
257
+ }
258
+ const text = buf.toString('utf8');
259
+ const lines = text.split(/\r?\n/);
260
+ for (let i = 0; i < lines.length; i += 1) {
261
+ let line = lines[i] ?? '';
262
+ if (line.length > MAX_GREP_LINE_CHARS) {
263
+ line = line.slice(0, MAX_GREP_LINE_CHARS);
264
+ }
265
+ if (regex.test(line)) {
266
+ hits.push({
267
+ file: input.displayPath(fileAbs),
268
+ line: i + 1,
269
+ text: line,
270
+ });
271
+ if (hits.length >= MAX_GREP_MATCHES) {
272
+ matchCapHit = true;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ }
278
+ const fileCapHit = !stat.isFile() && files.length >= MAX_GREP_FILES_SCANNED;
279
+ return {
280
+ ok: true,
281
+ hits,
282
+ truncated: matchCapHit || fileCapHit,
283
+ backend: 'node',
284
+ };
285
+ };
286
+ /**
287
+ * ts-scan-style cascade: rg → grep → bounded Node walk.
288
+ * Soft-falls through when a CLI binary is missing or fails to spawn;
289
+ * Node tier surfaces invalid JS RegExp errors.
290
+ */
291
+ export const runGrepCascade = async (input, deps = {}) => {
292
+ throwIfAborted(input.signal);
293
+ const rg = await searchWithRipgrep(input, deps);
294
+ if (rg.ok) {
295
+ return {
296
+ body: formatGrepBody(rg.hits, rg.truncated),
297
+ backend: rg.backend,
298
+ };
299
+ }
300
+ const grep = await searchWithGrep(input, deps);
301
+ if (grep.ok) {
302
+ return {
303
+ body: formatGrepBody(grep.hits, grep.truncated),
304
+ backend: grep.backend,
305
+ };
306
+ }
307
+ const node = await searchWithNodeWalk(input);
308
+ if (!node.ok) {
309
+ throw new Error(node.message);
310
+ }
311
+ return {
312
+ body: formatGrepBody(node.hits, node.truncated),
313
+ backend: node.backend,
314
+ };
315
+ };
@@ -4,13 +4,13 @@ export declare const grepTool: {
4
4
  readonly registration: {
5
5
  readonly toolId: "grep";
6
6
  readonly name: "grep";
7
- readonly description: "Regex search across project files (gitignore-aware by default; Node walk not ripgrep). Optional postProcess.";
7
+ readonly description: "Regex search across project files (gitignore-aware by default; ripgrep when available, else grep, else bounded Node walk). Optional postProcess.";
8
8
  readonly inputSchema: {
9
9
  readonly type: "object";
10
10
  readonly properties: {
11
11
  readonly pattern: {
12
12
  readonly type: "string";
13
- readonly description: "JavaScript RegExp source";
13
+ readonly description: "Regex pattern (ripgrep dialect when rg is available; JavaScript RegExp on Node fallback)";
14
14
  };
15
15
  readonly path: {
16
16
  readonly type: "string";
@@ -4,23 +4,15 @@ import { formatNotFound, resolveFenceRoot, resolveProjectPath, } from '../../pat
4
4
  import { applyPostProcess } from '../apply-post-process.js';
5
5
  import { asBoolean, asString } from '../args.js';
6
6
  import { displayPath, fenceOptions } from '../fence.js';
7
- import { walkFiles } from '../walk-files.js';
8
- const MAX_GREP_MATCHES = 100;
7
+ import { runGrepCascade } from './search.js';
9
8
  const invoke = async (ctx, args) => {
10
9
  const pattern = asString(args, 'pattern');
11
10
  if (pattern === undefined) {
12
11
  throw new Error('grep requires string argument «pattern».');
13
12
  }
14
- let regex;
15
- try {
16
- regex = new RegExp(pattern, asBoolean(args, 'caseInsensitive', false) ? 'i' : '');
17
- }
18
- catch (error) {
19
- const message = error instanceof Error ? error.message : String(error);
20
- throw new Error(`Invalid regex «${pattern}»: ${message}. Escape special characters or simplify the pattern.`);
21
- }
22
13
  const searchPath = asString(args, 'path') ?? '.';
23
14
  const respectGitignore = asBoolean(args, 'respectGitignore', true);
15
+ const caseInsensitive = asBoolean(args, 'caseInsensitive', false);
24
16
  const absolute = resolveProjectPath(ctx.projectRoot, searchPath, fenceOptions(ctx));
25
17
  const stat = await fs.stat(absolute).catch(() => null);
26
18
  if (stat === null) {
@@ -28,39 +20,15 @@ const invoke = async (ctx, args) => {
28
20
  }
29
21
  const fenceRoot = resolveFenceRoot(ctx.projectRoot, absolute, ctx.allowedRoots) ??
30
22
  path.resolve(ctx.projectRoot);
31
- const files = stat.isFile()
32
- ? [displayPath(ctx, absolute)]
33
- : (await walkFiles(fenceRoot, absolute, respectGitignore)).map((file) => displayPath(ctx, path.join(fenceRoot, file)));
34
- const hits = [];
35
- for (const file of files) {
36
- if (hits.length >= MAX_GREP_MATCHES) {
37
- break;
38
- }
39
- const fileAbs = resolveProjectPath(ctx.projectRoot, file, fenceOptions(ctx));
40
- let text;
41
- try {
42
- text = await fs.readFile(fileAbs, 'utf8');
43
- }
44
- catch {
45
- continue;
46
- }
47
- const lines = text.split(/\r?\n/);
48
- for (let i = 0; i < lines.length; i += 1) {
49
- const line = lines[i] ?? '';
50
- if (regex.test(line)) {
51
- hits.push(`${file}:${i + 1}:${line}`);
52
- if (hits.length >= MAX_GREP_MATCHES) {
53
- break;
54
- }
55
- }
56
- }
57
- }
58
- const body = hits.length === 0
59
- ? '(no matches)'
60
- : hits.join('\n') +
61
- (hits.length >= MAX_GREP_MATCHES
62
- ? `\n…[truncated at ${MAX_GREP_MATCHES}; refine pattern or path]`
63
- : '');
23
+ const { body } = await runGrepCascade({
24
+ pattern,
25
+ caseInsensitive,
26
+ respectGitignore,
27
+ searchAbsolute: absolute,
28
+ fenceRoot,
29
+ displayPath: (fileAbs) => displayPath(ctx, fileAbs),
30
+ ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
31
+ });
64
32
  return applyPostProcess(args, body);
65
33
  };
66
34
  export const grepTool = {
@@ -68,13 +36,13 @@ export const grepTool = {
68
36
  registration: {
69
37
  toolId: 'grep',
70
38
  name: 'grep',
71
- description: 'Regex search across project files (gitignore-aware by default; Node walk not ripgrep). Optional postProcess.',
39
+ description: 'Regex search across project files (gitignore-aware by default; ripgrep when available, else grep, else bounded Node walk). Optional postProcess.',
72
40
  inputSchema: {
73
41
  type: 'object',
74
42
  properties: {
75
43
  pattern: {
76
44
  type: 'string',
77
- description: 'JavaScript RegExp source',
45
+ description: 'Regex pattern (ripgrep dialect when rg is available; JavaScript RegExp on Node fallback)',
78
46
  },
79
47
  path: {
80
48
  type: 'string',
@@ -0,0 +1,15 @@
1
+ export type SpawnCaptureResult = {
2
+ readonly stdout: string;
3
+ readonly stderr: string;
4
+ readonly code: number | null;
5
+ };
6
+ export type SpawnCaptureOptions = {
7
+ readonly cwd?: string;
8
+ readonly signal?: AbortSignal;
9
+ readonly maxStdout?: number;
10
+ };
11
+ /**
12
+ * Async spawn with arg array (no shell). Honors AbortSignal via Node's
13
+ * spawn `signal` (kills the child). Caps captured stdout.
14
+ */
15
+ export declare const spawnCapture: (command: string, args: readonly string[], options?: SpawnCaptureOptions) => Promise<SpawnCaptureResult>;
@@ -0,0 +1,42 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Async spawn with arg array (no shell). Honors AbortSignal via Node's
4
+ * spawn `signal` (kills the child). Caps captured stdout.
5
+ */
6
+ export const spawnCapture = (command, args, options = {}) => {
7
+ const maxStdout = options.maxStdout ?? 2_000_000;
8
+ return new Promise((resolve, reject) => {
9
+ if (options.signal?.aborted) {
10
+ reject(new Error('aborted'));
11
+ return;
12
+ }
13
+ const child = spawn(command, [...args], {
14
+ cwd: options.cwd,
15
+ windowsHide: true,
16
+ shell: false,
17
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
18
+ });
19
+ let stdout = '';
20
+ let stderr = '';
21
+ child.stdout?.on('data', (chunk) => {
22
+ if (stdout.length < maxStdout) {
23
+ stdout += String(chunk);
24
+ if (stdout.length > maxStdout) {
25
+ stdout = stdout.slice(0, maxStdout);
26
+ }
27
+ }
28
+ });
29
+ child.stderr?.on('data', (chunk) => {
30
+ stderr += String(chunk);
31
+ if (stderr.length > 64_000) {
32
+ stderr = stderr.slice(0, 64_000);
33
+ }
34
+ });
35
+ child.on('error', (error) => {
36
+ reject(error);
37
+ });
38
+ child.on('close', (code) => {
39
+ resolve({ stdout, stderr, code });
40
+ });
41
+ });
42
+ };