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.
- package/package.json +1 -1
- package/ui-dist/index.html +1 -1
- package/ui-dist/{main-RCFTD5AZ.js → main-SMO3L7U5.js} +76 -76
- package/vendor/common-nodes/dist/ai/llm-loop/run-llm-loop.js +31 -8
- package/vendor/common-nodes/dist/tools/inventory-tool-round.d.ts +1 -0
- package/vendor/common-nodes/dist/tools/inventory-tool-round.js +5 -2
- package/vendor/server/dist/bridge/wrap-builtin-tool-handles.js +6 -1
- package/vendor/tools/dist/builtins/catalog.d.ts +2 -2
- package/vendor/tools/dist/builtins/command-exists.d.ts +5 -0
- package/vendor/tools/dist/builtins/command-exists.js +27 -0
- package/vendor/tools/dist/builtins/grep/search.d.ts +43 -0
- package/vendor/tools/dist/builtins/grep/search.js +315 -0
- package/vendor/tools/dist/builtins/grep/tool.d.ts +2 -2
- package/vendor/tools/dist/builtins/grep/tool.js +13 -45
- package/vendor/tools/dist/builtins/spawn-capture.d.ts +15 -0
- package/vendor/tools/dist/builtins/spawn-capture.js +42 -0
- package/vendor/tools/dist/builtins/types.d.ts +2 -0
- package/vendor/tools/dist/builtins/walk-files.d.ts +15 -1
- package/vendor/tools/dist/builtins/walk-files.js +55 -1
- package/vendor/tools/dist/create-project-harness.js +4 -1
- package/vendor/tools/dist/domain/domain-tool-configs.d.ts +2 -0
- package/vendor/tools/dist/harness-types.d.ts +2 -0
|
@@ -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
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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) =>
|
|
421
|
-
|
|
422
|
-
|
|
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 =
|
|
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,
|
|
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;
|
|
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
|
|
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,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;
|
|
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
|
|
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 {
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
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;
|
|
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
|
|
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
|
+
};
|