centaurus-cli 2.9.3 → 2.9.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/dist/cli-adapter.d.ts +74 -10
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +898 -244
- package/dist/cli-adapter.js.map +1 -1
- package/dist/commands/CommandParser.d.ts +1 -1
- package/dist/commands/CommandParser.d.ts.map +1 -1
- package/dist/commands/CommandParser.js +113 -0
- package/dist/commands/CommandParser.js.map +1 -1
- package/dist/config/slash-commands.d.ts +2 -0
- package/dist/config/slash-commands.d.ts.map +1 -1
- package/dist/config/slash-commands.js +28 -0
- package/dist/config/slash-commands.js.map +1 -1
- package/dist/context/context-manager.d.ts +7 -1
- package/dist/context/context-manager.d.ts.map +1 -1
- package/dist/context/context-manager.js +14 -1
- package/dist/context/context-manager.js.map +1 -1
- package/dist/context/handlers/docker-handler.d.ts +11 -0
- package/dist/context/handlers/docker-handler.d.ts.map +1 -1
- package/dist/context/handlers/docker-handler.js +159 -14
- package/dist/context/handlers/docker-handler.js.map +1 -1
- package/dist/context/handlers/ssh-handler.d.ts +20 -0
- package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
- package/dist/context/handlers/ssh-handler.js +129 -1
- package/dist/context/handlers/ssh-handler.js.map +1 -1
- package/dist/context/subshell-handler.d.ts +15 -0
- package/dist/context/subshell-handler.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/services/ai-service-client.d.ts.map +1 -1
- package/dist/services/ai-service-client.js +33 -11
- package/dist/services/ai-service-client.js.map +1 -1
- package/dist/services/api-client.js +1 -1
- package/dist/services/api-client.js.map +1 -1
- package/dist/services/local-chat-storage.d.ts +3 -1
- package/dist/services/local-chat-storage.d.ts.map +1 -1
- package/dist/services/local-chat-storage.js +8 -3
- package/dist/services/local-chat-storage.js.map +1 -1
- package/dist/services/warpify-detector.d.ts +43 -0
- package/dist/services/warpify-detector.d.ts.map +1 -0
- package/dist/services/warpify-detector.js +203 -0
- package/dist/services/warpify-detector.js.map +1 -0
- package/dist/services/workflow-storage.d.ts +72 -0
- package/dist/services/workflow-storage.d.ts.map +1 -0
- package/dist/services/workflow-storage.js +239 -0
- package/dist/services/workflow-storage.js.map +1 -0
- package/dist/tools/command.d.ts.map +1 -1
- package/dist/tools/command.js +106 -38
- package/dist/tools/command.js.map +1 -1
- package/dist/tools/enter-remote-session.d.ts +13 -0
- package/dist/tools/enter-remote-session.d.ts.map +1 -0
- package/dist/tools/enter-remote-session.js +226 -0
- package/dist/tools/enter-remote-session.js.map +1 -0
- package/dist/tools/find-files.d.ts.map +1 -1
- package/dist/tools/find-files.js +9 -2
- package/dist/tools/find-files.js.map +1 -1
- package/dist/tools/grep-search.d.ts +104 -31
- package/dist/tools/grep-search.d.ts.map +1 -1
- package/dist/tools/grep-search.js +779 -431
- package/dist/tools/grep-search.js.map +1 -1
- package/dist/tools/workflow-tool.d.ts +11 -0
- package/dist/tools/workflow-tool.d.ts.map +1 -0
- package/dist/tools/workflow-tool.js +87 -0
- package/dist/tools/workflow-tool.js.map +1 -0
- package/dist/types/workflow.d.ts +110 -0
- package/dist/types/workflow.d.ts.map +1 -0
- package/dist/types/workflow.js +8 -0
- package/dist/types/workflow.js.map +1 -0
- package/dist/ui/components/App.d.ts +10 -1
- package/dist/ui/components/App.d.ts.map +1 -1
- package/dist/ui/components/App.js +135 -8
- package/dist/ui/components/App.js.map +1 -1
- package/dist/ui/components/Breadcrumbs.d.ts +4 -3
- package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/ui/components/Breadcrumbs.js +80 -54
- package/dist/ui/components/Breadcrumbs.js.map +1 -1
- package/dist/ui/components/ConnectionStatusMessage.js +2 -2
- package/dist/ui/components/ConnectionStatusMessage.js.map +1 -1
- package/dist/ui/components/InputBox.d.ts +1 -0
- package/dist/ui/components/InputBox.d.ts.map +1 -1
- package/dist/ui/components/InputBox.js +226 -19
- package/dist/ui/components/InputBox.js.map +1 -1
- package/dist/ui/components/InteractiveShell.d.ts +4 -0
- package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
- package/dist/ui/components/InteractiveShell.js +52 -15
- package/dist/ui/components/InteractiveShell.js.map +1 -1
- package/dist/ui/components/KeyboardHelp.d.ts.map +1 -1
- package/dist/ui/components/KeyboardHelp.js +14 -6
- package/dist/ui/components/KeyboardHelp.js.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.js +165 -27
- package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
- package/dist/ui/components/WorkflowCreatorScreen.d.ts +25 -0
- package/dist/ui/components/WorkflowCreatorScreen.d.ts.map +1 -0
- package/dist/ui/components/WorkflowCreatorScreen.js +164 -0
- package/dist/ui/components/WorkflowCreatorScreen.js.map +1 -0
- package/dist/utils/ansi-encoder.d.ts.map +1 -1
- package/dist/utils/ansi-encoder.js +7 -0
- package/dist/utils/ansi-encoder.js.map +1 -1
- package/dist/utils/editor-utils.d.ts +9 -0
- package/dist/utils/editor-utils.d.ts.map +1 -1
- package/dist/utils/editor-utils.js +105 -0
- package/dist/utils/editor-utils.js.map +1 -1
- package/dist/utils/input-classifier.d.ts.map +1 -1
- package/dist/utils/input-classifier.js +2 -1
- package/dist/utils/input-classifier.js.map +1 -1
- package/dist/utils/terminal-output.d.ts +3 -1
- package/dist/utils/terminal-output.d.ts.map +1 -1
- package/dist/utils/terminal-output.js +138 -157
- package/dist/utils/terminal-output.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,87 +1,164 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
1
2
|
import { execFile } from 'child_process';
|
|
2
3
|
import { promisify } from 'util';
|
|
3
4
|
import * as path from 'path';
|
|
4
5
|
import * as fs from 'fs';
|
|
6
|
+
import * as readline from 'readline';
|
|
5
7
|
const execFileAsync = promisify(execFile);
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// GREP SEARCH TOOL CLASS - CROSS-PLATFORM ROBUST IMPLEMENTATION
|
|
10
|
+
// ============================================================================
|
|
6
11
|
/**
|
|
7
|
-
* GrepSearchTool -
|
|
12
|
+
* GrepSearchTool - Cross-platform text search with multiple backends
|
|
8
13
|
*
|
|
9
|
-
*
|
|
10
|
-
* 1.
|
|
11
|
-
* 2.
|
|
12
|
-
* 3.
|
|
13
|
-
* 4.
|
|
14
|
+
* Backend priority:
|
|
15
|
+
* 1. ripgrep (fastest, cross-platform)
|
|
16
|
+
* 2. grep (Linux/macOS native)
|
|
17
|
+
* 3. PowerShell Select-String (Windows, good Unicode support)
|
|
18
|
+
* 4. Pure Node.js (universal fallback, no dependencies)
|
|
19
|
+
*
|
|
20
|
+
* Features:
|
|
21
|
+
* - Streaming output to handle large repos
|
|
22
|
+
* - Early abort when MAX_MATCHES reached
|
|
23
|
+
* - Proper encoding handling (UTF-8 with fallback)
|
|
24
|
+
* - CRLF normalization
|
|
25
|
+
* - Windows path support (drive letters, UNC)
|
|
14
26
|
*/
|
|
15
27
|
export class GrepSearchTool {
|
|
16
|
-
static
|
|
28
|
+
static DEFAULT_MAX_MATCHES = 50;
|
|
17
29
|
static MAX_LINE_LENGTH = 300;
|
|
18
30
|
static CONTEXT_LINES = 2;
|
|
31
|
+
static SEARCH_TIMEOUT = 30000;
|
|
32
|
+
// Cache tool availability to avoid repeated checks
|
|
33
|
+
static toolCache = new Map();
|
|
19
34
|
/**
|
|
20
|
-
* Check if
|
|
35
|
+
* Check if a command is available
|
|
21
36
|
*/
|
|
22
|
-
async
|
|
37
|
+
async hasCommand(cmd, args = ['--version']) {
|
|
38
|
+
const cacheKey = `${cmd}:${args.join(',')}`;
|
|
39
|
+
if (GrepSearchTool.toolCache.has(cacheKey)) {
|
|
40
|
+
return GrepSearchTool.toolCache.get(cacheKey);
|
|
41
|
+
}
|
|
23
42
|
try {
|
|
24
|
-
// Add timeout to prevent hanging
|
|
25
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000));
|
|
26
43
|
await Promise.race([
|
|
27
|
-
execFileAsync(
|
|
28
|
-
|
|
44
|
+
execFileAsync(cmd, args, { timeout: 2000 }),
|
|
45
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000))
|
|
29
46
|
]);
|
|
47
|
+
GrepSearchTool.toolCache.set(cacheKey, true);
|
|
30
48
|
return true;
|
|
31
49
|
}
|
|
32
50
|
catch {
|
|
51
|
+
GrepSearchTool.toolCache.set(cacheKey, false);
|
|
33
52
|
return false;
|
|
34
53
|
}
|
|
35
54
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
async hasRipgrep() {
|
|
56
|
+
return this.hasCommand('rg', ['--version']);
|
|
57
|
+
}
|
|
39
58
|
async hasGrep() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
timeoutPromise
|
|
46
|
-
]);
|
|
59
|
+
return this.hasCommand('grep', ['--version']);
|
|
60
|
+
}
|
|
61
|
+
async hasPowerShell() {
|
|
62
|
+
// Try pwsh first (PowerShell Core), then powershell (Windows PowerShell)
|
|
63
|
+
if (await this.hasCommand('pwsh', ['-Version']))
|
|
47
64
|
return true;
|
|
65
|
+
if (process.platform === 'win32') {
|
|
66
|
+
return this.hasCommand('powershell', ['-Version']);
|
|
48
67
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get the PowerShell executable name
|
|
72
|
+
*/
|
|
73
|
+
async getPowerShellExe() {
|
|
74
|
+
if (await this.hasCommand('pwsh', ['-Version']))
|
|
75
|
+
return 'pwsh';
|
|
76
|
+
return 'powershell';
|
|
52
77
|
}
|
|
53
78
|
/**
|
|
54
|
-
* Normalize file path to
|
|
79
|
+
* Normalize file path to forward slashes
|
|
55
80
|
*/
|
|
56
81
|
normalizePath(filePath) {
|
|
57
82
|
return filePath.replace(/\\/g, '/');
|
|
58
83
|
}
|
|
59
84
|
/**
|
|
60
|
-
*
|
|
61
|
-
|
|
85
|
+
* Truncate long lines
|
|
86
|
+
*/
|
|
87
|
+
truncateLine(line) {
|
|
88
|
+
if (line.length <= GrepSearchTool.MAX_LINE_LENGTH) {
|
|
89
|
+
return line;
|
|
90
|
+
}
|
|
91
|
+
return line.substring(0, GrepSearchTool.MAX_LINE_LENGTH) + ' [truncated]';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Normalize line endings (CRLF -> LF)
|
|
95
|
+
*/
|
|
96
|
+
normalizeLineEndings(text) {
|
|
97
|
+
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parse file:line:content format robustly, handling Windows paths
|
|
101
|
+
* E.g., "C:\foo\bar.ts:42:content" or "/foo/bar.ts:42:content"
|
|
102
|
+
*/
|
|
103
|
+
parseGrepLine(line) {
|
|
104
|
+
// Windows absolute path: C:\path\to\file:line:content
|
|
105
|
+
const m1 = line.match(/^([A-Za-z]:\\.+?):(\d+):(.*)$/);
|
|
106
|
+
if (m1)
|
|
107
|
+
return { file: m1[1], lineNum: +m1[2], content: m1[3] };
|
|
108
|
+
// UNC path: \\server\share\path:line:content
|
|
109
|
+
const m2 = line.match(/^(\\\\.+?):(\d+):(.*)$/);
|
|
110
|
+
if (m2)
|
|
111
|
+
return { file: m2[1], lineNum: +m2[2], content: m2[3] };
|
|
112
|
+
// Unix / relative path: /path/to/file:line:content or path/to/file:line:content
|
|
113
|
+
const m3 = line.match(/^(.+?):(\d+):(.*)$/);
|
|
114
|
+
if (m3)
|
|
115
|
+
return { file: m3[1], lineNum: +m3[2], content: m3[3] };
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Create regex from pattern
|
|
120
|
+
*/
|
|
121
|
+
createSearchRegex(pattern, isRegex, caseInsensitive) {
|
|
122
|
+
const flags = caseInsensitive ? 'gi' : 'g';
|
|
123
|
+
if (isRegex) {
|
|
124
|
+
try {
|
|
125
|
+
return new RegExp(pattern, flags);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Invalid regex, escape and use as literal
|
|
129
|
+
return new RegExp(this.escapeRegex(pattern), flags);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return new RegExp(this.escapeRegex(pattern), flags);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Escape regex special characters
|
|
136
|
+
*/
|
|
137
|
+
escapeRegex(str) {
|
|
138
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if file matches include patterns
|
|
62
142
|
*/
|
|
63
143
|
matchesIncludesFilter(filePath, includes) {
|
|
64
144
|
if (!includes || includes.length === 0) {
|
|
65
|
-
return true;
|
|
145
|
+
return true;
|
|
66
146
|
}
|
|
67
147
|
const fileName = path.basename(filePath);
|
|
68
148
|
const normalizedPath = this.normalizePath(filePath);
|
|
69
149
|
for (const pattern of includes) {
|
|
70
|
-
// Handle simple extension patterns like *.ts
|
|
71
150
|
if (pattern.startsWith('*.')) {
|
|
72
|
-
const ext = pattern.substring(1);
|
|
151
|
+
const ext = pattern.substring(1);
|
|
73
152
|
if (fileName.endsWith(ext)) {
|
|
74
153
|
return true;
|
|
75
154
|
}
|
|
76
155
|
}
|
|
77
|
-
// Handle patterns like **/*.ts
|
|
78
156
|
else if (pattern.includes('**')) {
|
|
79
157
|
const ext = pattern.replace('**/', '').replace('*', '');
|
|
80
158
|
if (fileName.endsWith(ext) || normalizedPath.includes(ext)) {
|
|
81
159
|
return true;
|
|
82
160
|
}
|
|
83
161
|
}
|
|
84
|
-
// Handle direct matches
|
|
85
162
|
else if (fileName === pattern || normalizedPath.includes(pattern)) {
|
|
86
163
|
return true;
|
|
87
164
|
}
|
|
@@ -89,418 +166,591 @@ export class GrepSearchTool {
|
|
|
89
166
|
return false;
|
|
90
167
|
}
|
|
91
168
|
/**
|
|
92
|
-
*
|
|
169
|
+
* Check if file is likely binary
|
|
93
170
|
*/
|
|
94
|
-
|
|
95
|
-
if (line.length <= GrepSearchTool.MAX_LINE_LENGTH) {
|
|
96
|
-
return line;
|
|
97
|
-
}
|
|
98
|
-
return line.substring(0, GrepSearchTool.MAX_LINE_LENGTH) + ' [truncated]';
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Execute search using ripgrep
|
|
102
|
-
*/
|
|
103
|
-
async searchWithRipgrep(params, cwd) {
|
|
104
|
-
const args = [
|
|
105
|
-
'--json',
|
|
106
|
-
`--context=${GrepSearchTool.CONTEXT_LINES}`,
|
|
107
|
-
`--max-count=${GrepSearchTool.MAX_MATCHES}`,
|
|
108
|
-
];
|
|
109
|
-
// Case sensitivity
|
|
110
|
-
if (!params.CaseInsensitive) {
|
|
111
|
-
// rg is case-sensitive by default, smart-case is default in CLI but maybe not here
|
|
112
|
-
// We want explicit case sensitivity unless CaseInsensitive is true
|
|
113
|
-
// Actually, rg is case-sensitive by default.
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
args.push('--ignore-case');
|
|
117
|
-
}
|
|
118
|
-
// Fixed string vs Regex
|
|
119
|
-
if (!params.IsRegex) {
|
|
120
|
-
args.push('--fixed-strings');
|
|
121
|
-
}
|
|
122
|
-
// File pattern (Includes)
|
|
123
|
-
if (params.Includes && params.Includes.length > 0) {
|
|
124
|
-
params.Includes.forEach(pattern => {
|
|
125
|
-
args.push('--glob', pattern);
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
// Search Path (file or directory)
|
|
129
|
-
// rg takes the path as the last argument usually, or we can use it as root
|
|
130
|
-
// We'll pass it as an argument.
|
|
131
|
-
// Pattern
|
|
132
|
-
// We pass pattern after flags
|
|
133
|
-
args.push('--', params.Query);
|
|
134
|
-
// Path
|
|
135
|
-
args.push(params.SearchPath);
|
|
171
|
+
async isBinaryFile(filePath) {
|
|
136
172
|
try {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
totalMatches: 0,
|
|
146
|
-
truncated: false,
|
|
147
|
-
searchPattern: params.Query,
|
|
148
|
-
};
|
|
173
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
174
|
+
const buffer = Buffer.alloc(8192);
|
|
175
|
+
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
|
|
176
|
+
await fd.close();
|
|
177
|
+
// Check for null bytes (common in binary files)
|
|
178
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
179
|
+
if (buffer[i] === 0)
|
|
180
|
+
return true;
|
|
149
181
|
}
|
|
150
|
-
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return true; // Assume binary on error
|
|
151
186
|
}
|
|
152
187
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
contextAfter: contextAfter.map(l => this.truncateLine(l)),
|
|
188
|
+
// ========================================================================
|
|
189
|
+
// RIPGREP BACKEND (Streaming)
|
|
190
|
+
// ========================================================================
|
|
191
|
+
async searchWithRipgrepStreaming(params, cwd) {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
const args = [
|
|
194
|
+
'--json',
|
|
195
|
+
`--context=${GrepSearchTool.CONTEXT_LINES}`,
|
|
196
|
+
];
|
|
197
|
+
if (params.CaseInsensitive) {
|
|
198
|
+
args.push('--ignore-case');
|
|
199
|
+
}
|
|
200
|
+
if (!params.IsRegex) {
|
|
201
|
+
args.push('--fixed-strings');
|
|
202
|
+
}
|
|
203
|
+
if (params.Includes && params.Includes.length > 0) {
|
|
204
|
+
params.Includes.forEach(pattern => {
|
|
205
|
+
args.push('--glob', pattern);
|
|
172
206
|
});
|
|
173
|
-
currentMatch = null;
|
|
174
|
-
contextBefore = [];
|
|
175
|
-
contextAfter = [];
|
|
176
207
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
208
|
+
args.push('--', params.Query, params.SearchPath);
|
|
209
|
+
const matches = [];
|
|
210
|
+
let truncated = false;
|
|
211
|
+
let currentFile = null;
|
|
212
|
+
let contextQueue = [];
|
|
213
|
+
let pendingMatch = null;
|
|
214
|
+
let contextAfterCount = 0;
|
|
215
|
+
// RACECONDITION FIX: Guard against double-resolution
|
|
216
|
+
let finished = false;
|
|
217
|
+
const child = spawn('rg', args, {
|
|
218
|
+
cwd,
|
|
219
|
+
env: { ...process.env, LANG: 'en_US.UTF-8' }
|
|
220
|
+
});
|
|
221
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
222
|
+
// Robust cleanup function
|
|
223
|
+
const done = (result) => {
|
|
224
|
+
if (finished)
|
|
225
|
+
return;
|
|
226
|
+
finished = true;
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
// TERMINATION FIX: Use simple kill() for Windows compatibility
|
|
229
|
+
child.kill();
|
|
230
|
+
rl.close();
|
|
231
|
+
resolve(result);
|
|
232
|
+
};
|
|
233
|
+
const fail = (err) => {
|
|
234
|
+
if (finished)
|
|
235
|
+
return;
|
|
236
|
+
finished = true;
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
child.kill();
|
|
239
|
+
rl.close();
|
|
240
|
+
reject(err);
|
|
241
|
+
};
|
|
242
|
+
const timer = setTimeout(() => {
|
|
243
|
+
done({
|
|
244
|
+
matches,
|
|
245
|
+
totalMatches: matches.length,
|
|
246
|
+
truncated: true,
|
|
247
|
+
truncationReason: 'timeout',
|
|
248
|
+
searchPattern: params.Query
|
|
249
|
+
});
|
|
250
|
+
}, GrepSearchTool.SEARCH_TIMEOUT);
|
|
251
|
+
rl.on('line', (line) => {
|
|
252
|
+
if (finished)
|
|
253
|
+
return;
|
|
254
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
255
|
+
if (matches.length >= maxMatches) {
|
|
256
|
+
truncated = true;
|
|
257
|
+
// ABORT FIX: Immediately stop and resolve
|
|
258
|
+
done({
|
|
259
|
+
matches,
|
|
260
|
+
totalMatches: matches.length,
|
|
261
|
+
truncated,
|
|
262
|
+
truncationReason: 'max_matches',
|
|
263
|
+
searchPattern: params.Query
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
192
266
|
}
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
267
|
+
try {
|
|
268
|
+
const event = JSON.parse(line);
|
|
269
|
+
if (event.type === 'begin' && event.data?.path?.text) {
|
|
270
|
+
currentFile = this.normalizePath(event.data.path.text);
|
|
271
|
+
contextQueue = [];
|
|
272
|
+
pendingMatch = null;
|
|
273
|
+
contextAfterCount = 0;
|
|
199
274
|
}
|
|
200
|
-
else {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// If we have a currentMatch, this is context AFTER.
|
|
209
|
-
// If we don't, this is context BEFORE.
|
|
210
|
-
contextBefore.push(contextLine);
|
|
211
|
-
// Keep only last N lines for 'before' context
|
|
212
|
-
if (contextBefore.length > GrepSearchTool.CONTEXT_LINES) {
|
|
213
|
-
contextBefore.shift();
|
|
275
|
+
else if (event.type === 'context' && event.data?.lines?.text) {
|
|
276
|
+
const ctxLine = {
|
|
277
|
+
lineNumber: event.data.line_number,
|
|
278
|
+
text: this.truncateLine(this.normalizeLineEndings(event.data.lines.text).trimEnd())
|
|
279
|
+
};
|
|
280
|
+
if (pendingMatch && contextAfterCount < GrepSearchTool.CONTEXT_LINES) {
|
|
281
|
+
pendingMatch.contextAfter.push(ctxLine);
|
|
282
|
+
contextAfterCount++;
|
|
214
283
|
}
|
|
284
|
+
else {
|
|
285
|
+
contextQueue.push(ctxLine);
|
|
286
|
+
if (contextQueue.length > GrepSearchTool.CONTEXT_LINES) {
|
|
287
|
+
contextQueue.shift();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (event.type === 'match' && event.data) {
|
|
292
|
+
// Finalize previous match
|
|
293
|
+
if (pendingMatch) {
|
|
294
|
+
matches.push(pendingMatch);
|
|
295
|
+
}
|
|
296
|
+
const lineText = event.data.lines?.text || '';
|
|
297
|
+
const submatches = event.data.submatches || [];
|
|
298
|
+
pendingMatch = {
|
|
299
|
+
file: currentFile || this.normalizePath(event.data.path?.text || ''),
|
|
300
|
+
line: event.data.line_number,
|
|
301
|
+
column: submatches.length > 0 ? submatches[0].start + 1 : undefined,
|
|
302
|
+
byteOffset: event.data.absolute_offset,
|
|
303
|
+
match: this.truncateLine(this.normalizeLineEndings(lineText).trimEnd()),
|
|
304
|
+
matchIndices: submatches.map((s) => [s.start, s.end]),
|
|
305
|
+
contextBefore: [...contextQueue],
|
|
306
|
+
contextAfter: []
|
|
307
|
+
};
|
|
308
|
+
contextQueue = [];
|
|
309
|
+
contextAfterCount = 0;
|
|
215
310
|
}
|
|
216
311
|
}
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
// rg emits 'end' stats.
|
|
220
|
-
// We might need to flush the last match if context came after it.
|
|
221
|
-
pushMatch();
|
|
312
|
+
catch {
|
|
313
|
+
// Ignore parse errors
|
|
222
314
|
}
|
|
315
|
+
});
|
|
316
|
+
child.on('close', (code) => {
|
|
317
|
+
if (finished)
|
|
318
|
+
return;
|
|
319
|
+
// Finalize last match
|
|
320
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
321
|
+
if (pendingMatch && matches.length < maxMatches) {
|
|
322
|
+
matches.push(pendingMatch);
|
|
323
|
+
}
|
|
324
|
+
done({
|
|
325
|
+
matches,
|
|
326
|
+
totalMatches: matches.length,
|
|
327
|
+
truncated,
|
|
328
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
329
|
+
searchPattern: params.Query
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
child.on('error', (err) => {
|
|
333
|
+
fail(err);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
// ========================================================================
|
|
338
|
+
// GREP BACKEND (Streaming)
|
|
339
|
+
// ========================================================================
|
|
340
|
+
async searchWithGrepStreaming(params, cwd) {
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const args = [
|
|
343
|
+
'-n', // line numbers
|
|
344
|
+
'-r', // recursive
|
|
345
|
+
`--context=${GrepSearchTool.CONTEXT_LINES}`,
|
|
346
|
+
];
|
|
347
|
+
if (params.CaseInsensitive) {
|
|
348
|
+
args.push('-i');
|
|
223
349
|
}
|
|
224
|
-
|
|
225
|
-
|
|
350
|
+
if (!params.IsRegex) {
|
|
351
|
+
args.push('-F'); // fixed strings
|
|
226
352
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
353
|
+
else {
|
|
354
|
+
args.push('-E'); // extended regex (supports |)
|
|
355
|
+
}
|
|
356
|
+
if (params.Includes && params.Includes.length > 0) {
|
|
357
|
+
params.Includes.forEach(pattern => {
|
|
358
|
+
args.push('--include', pattern);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
args.push(params.Query, params.SearchPath);
|
|
362
|
+
const matches = [];
|
|
363
|
+
let truncated = false;
|
|
364
|
+
let finished = false;
|
|
365
|
+
const child = spawn('grep', args, {
|
|
366
|
+
cwd,
|
|
367
|
+
env: { ...process.env, LANG: 'en_US.UTF-8' }
|
|
368
|
+
});
|
|
369
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
370
|
+
const done = (result) => {
|
|
371
|
+
if (finished)
|
|
372
|
+
return;
|
|
373
|
+
finished = true;
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
child.kill();
|
|
376
|
+
rl.close();
|
|
377
|
+
resolve(result);
|
|
378
|
+
};
|
|
379
|
+
const fail = (err) => {
|
|
380
|
+
if (finished)
|
|
381
|
+
return;
|
|
382
|
+
finished = true;
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
child.kill();
|
|
385
|
+
rl.close();
|
|
386
|
+
reject(err);
|
|
387
|
+
};
|
|
388
|
+
const timer = setTimeout(() => {
|
|
389
|
+
done({
|
|
390
|
+
matches,
|
|
391
|
+
totalMatches: matches.length,
|
|
392
|
+
truncated: true,
|
|
393
|
+
truncationReason: 'timeout',
|
|
394
|
+
searchPattern: params.Query
|
|
395
|
+
});
|
|
396
|
+
}, GrepSearchTool.SEARCH_TIMEOUT);
|
|
397
|
+
rl.on('line', (line) => {
|
|
398
|
+
if (finished)
|
|
399
|
+
return;
|
|
400
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
401
|
+
if (matches.length >= maxMatches) {
|
|
402
|
+
truncated = true;
|
|
403
|
+
done({
|
|
404
|
+
matches,
|
|
405
|
+
totalMatches: matches.length,
|
|
406
|
+
truncated,
|
|
407
|
+
truncationReason: 'max_matches',
|
|
408
|
+
searchPattern: params.Query
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const normalizedLine = this.normalizeLineEndings(line);
|
|
413
|
+
// Skip separator lines
|
|
414
|
+
if (normalizedLine === '--')
|
|
415
|
+
return;
|
|
416
|
+
const parsed = this.parseGrepLine(normalizedLine);
|
|
417
|
+
if (parsed) {
|
|
418
|
+
// For grep, we treat each line as a match (context handling is simpler)
|
|
419
|
+
matches.push({
|
|
420
|
+
file: this.normalizePath(parsed.file),
|
|
421
|
+
line: parsed.lineNum,
|
|
422
|
+
match: this.truncateLine(parsed.content),
|
|
423
|
+
contextBefore: [],
|
|
424
|
+
contextAfter: []
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
child.on('close', () => {
|
|
429
|
+
if (finished)
|
|
430
|
+
return;
|
|
431
|
+
done({
|
|
432
|
+
matches,
|
|
433
|
+
totalMatches: matches.length,
|
|
434
|
+
truncated,
|
|
435
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
436
|
+
searchPattern: params.Query
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
child.on('error', (err) => fail(err));
|
|
440
|
+
});
|
|
235
441
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
async
|
|
240
|
-
const
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
442
|
+
// ========================================================================
|
|
443
|
+
// POWERSHELL BACKEND (Windows)
|
|
444
|
+
// ========================================================================
|
|
445
|
+
async searchWithPowerShell(params, cwd) {
|
|
446
|
+
const psExe = await this.getPowerShellExe();
|
|
447
|
+
// Build the PowerShell command
|
|
448
|
+
// Select-String supports regex by default, -SimpleMatch for literal
|
|
449
|
+
const patternArg = params.IsRegex ? params.Query : params.Query.replace(/'/g, "''");
|
|
450
|
+
const searchPath = path.resolve(cwd, params.SearchPath);
|
|
451
|
+
let psCommand;
|
|
452
|
+
// Check if searching a file or directory
|
|
453
|
+
const stats = await fs.promises.stat(searchPath).catch(() => null);
|
|
454
|
+
const isDirectory = stats?.isDirectory() ?? false;
|
|
455
|
+
// PATH FIX: Use $_.Path instead of $_.Filename for absolute paths
|
|
456
|
+
if (isDirectory) {
|
|
457
|
+
// Search directory recursively
|
|
458
|
+
const includePattern = params.Includes && params.Includes.length > 0
|
|
459
|
+
? params.Includes.map(p => `'${p}'`).join(',')
|
|
460
|
+
: "'*'";
|
|
461
|
+
const max = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
462
|
+
psCommand = params.IsRegex
|
|
463
|
+
? `Get-ChildItem -Path '${searchPath}' -Recurse -File -Include ${includePattern} -ErrorAction SilentlyContinue | Select-String -Pattern '${patternArg}'${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`
|
|
464
|
+
: `Get-ChildItem -Path '${searchPath}' -Recurse -File -Include ${includePattern} -ErrorAction SilentlyContinue | Select-String -Pattern '${patternArg}' -SimpleMatch${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`;
|
|
244
465
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
466
|
+
else {
|
|
467
|
+
// Search single file
|
|
468
|
+
const max = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
469
|
+
psCommand = params.IsRegex
|
|
470
|
+
? `Select-String -Path '${searchPath}' -Pattern '${patternArg}'${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`
|
|
471
|
+
: `Select-String -Path '${searchPath}' -Pattern '${patternArg}' -SimpleMatch${params.CaseInsensitive ? '' : ' -CaseSensitive'} -Context ${GrepSearchTool.CONTEXT_LINES} | Select-Object -First ${max} | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }`;
|
|
472
|
+
}
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
const matches = [];
|
|
475
|
+
let truncated = false;
|
|
476
|
+
let finished = false;
|
|
477
|
+
const child = spawn(psExe, ['-NoProfile', '-Command', psCommand], {
|
|
478
|
+
cwd,
|
|
479
|
+
env: { ...process.env }
|
|
251
480
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
481
|
+
// Robust cleanup function
|
|
482
|
+
const done = (result) => {
|
|
483
|
+
if (finished)
|
|
484
|
+
return;
|
|
485
|
+
finished = true;
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
child.kill();
|
|
488
|
+
resolve(result);
|
|
489
|
+
};
|
|
490
|
+
const fail = (err) => {
|
|
491
|
+
if (finished)
|
|
492
|
+
return;
|
|
493
|
+
finished = true;
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
child.kill();
|
|
496
|
+
reject(err);
|
|
497
|
+
};
|
|
498
|
+
const timer = setTimeout(() => {
|
|
499
|
+
done({
|
|
500
|
+
matches,
|
|
501
|
+
totalMatches: matches.length,
|
|
502
|
+
truncated: true,
|
|
503
|
+
truncationReason: 'timeout',
|
|
504
|
+
searchPattern: params.Query
|
|
505
|
+
});
|
|
506
|
+
}, GrepSearchTool.SEARCH_TIMEOUT);
|
|
507
|
+
let stdout = '';
|
|
508
|
+
let stderr = '';
|
|
509
|
+
child.stdout.on('data', (data) => {
|
|
510
|
+
stdout += data.toString();
|
|
511
|
+
});
|
|
512
|
+
child.stderr.on('data', (data) => {
|
|
513
|
+
stderr += data.toString();
|
|
514
|
+
});
|
|
515
|
+
child.on('close', (code) => {
|
|
516
|
+
if (finished)
|
|
517
|
+
return;
|
|
518
|
+
const lines = this.normalizeLineEndings(stdout).split('\n').filter(l => l.trim());
|
|
519
|
+
for (const line of lines) {
|
|
520
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
521
|
+
if (matches.length >= maxMatches) {
|
|
522
|
+
truncated = true;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
const parsed = this.parseGrepLine(line);
|
|
526
|
+
if (parsed) {
|
|
527
|
+
matches.push({
|
|
528
|
+
file: this.normalizePath(parsed.file),
|
|
529
|
+
line: parsed.lineNum,
|
|
530
|
+
match: this.truncateLine(parsed.content),
|
|
531
|
+
contextBefore: [],
|
|
532
|
+
contextAfter: []
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
done({
|
|
537
|
+
matches,
|
|
538
|
+
totalMatches: matches.length,
|
|
539
|
+
truncated,
|
|
540
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
541
|
+
searchPattern: params.Query
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
child.on('error', (err) => fail(err));
|
|
545
|
+
});
|
|
276
546
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// -- (separator)
|
|
282
|
-
const lines = output.trim().split('\n');
|
|
547
|
+
// ========================================================================
|
|
548
|
+
// PURE NODE.JS BACKEND (Universal Fallback)
|
|
549
|
+
// ========================================================================
|
|
550
|
+
async searchWithNodeJS(params, cwd) {
|
|
283
551
|
const matches = [];
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
552
|
+
let truncated = false;
|
|
553
|
+
let filesSearched = 0;
|
|
554
|
+
let encodingFallback = false;
|
|
555
|
+
const searchPath = path.resolve(cwd, params.SearchPath);
|
|
556
|
+
const regex = this.createSearchRegex(params.Query, params.IsRegex ?? false, params.CaseInsensitive ?? false);
|
|
557
|
+
// Collect files to search
|
|
558
|
+
const filesToSearch = [];
|
|
559
|
+
const stats = await fs.promises.stat(searchPath).catch(() => null);
|
|
560
|
+
if (!stats) {
|
|
561
|
+
return { matches: [], totalMatches: 0, truncated: false, searchPattern: params.Query };
|
|
562
|
+
}
|
|
563
|
+
if (stats.isFile()) {
|
|
564
|
+
filesToSearch.push(searchPath);
|
|
565
|
+
}
|
|
566
|
+
else if (stats.isDirectory()) {
|
|
567
|
+
await this.collectFiles(searchPath, filesToSearch, params.Includes);
|
|
568
|
+
}
|
|
569
|
+
// Search each file
|
|
570
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
571
|
+
for (const filePath of filesToSearch) {
|
|
572
|
+
if (matches.length >= maxMatches) {
|
|
573
|
+
truncated = true;
|
|
574
|
+
break;
|
|
302
575
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (line === '--') {
|
|
306
|
-
pushMatch();
|
|
576
|
+
// Skip binary files
|
|
577
|
+
if (await this.isBinaryFile(filePath)) {
|
|
307
578
|
continue;
|
|
308
579
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
contextAfter.push(contextM[3]);
|
|
580
|
+
filesSearched++;
|
|
581
|
+
try {
|
|
582
|
+
let content;
|
|
583
|
+
const buffer = await fs.promises.readFile(filePath);
|
|
584
|
+
// Try UTF-8 first
|
|
585
|
+
try {
|
|
586
|
+
content = buffer.toString('utf8');
|
|
587
|
+
// Check for replacement characters indicating decode failure
|
|
588
|
+
if (content.includes('\uFFFD')) {
|
|
589
|
+
content = buffer.toString('latin1');
|
|
590
|
+
encodingFallback = true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
content = buffer.toString('latin1');
|
|
595
|
+
encodingFallback = true;
|
|
326
596
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
597
|
+
const lines = this.normalizeLineEndings(content).split('\n');
|
|
598
|
+
for (let i = 0; i < lines.length; i++) {
|
|
599
|
+
if (matches.length >= maxMatches) {
|
|
600
|
+
truncated = true;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
const line = lines[i];
|
|
604
|
+
regex.lastIndex = 0; // Reset regex state
|
|
605
|
+
// LOOP FIX: Capture all matches in the line, not just the first
|
|
606
|
+
let matchResult;
|
|
607
|
+
while ((matchResult = regex.exec(line)) !== null) {
|
|
608
|
+
// Prevent infinite loops with zero-width matches
|
|
609
|
+
if (matchResult.index === regex.lastIndex) {
|
|
610
|
+
regex.lastIndex++;
|
|
611
|
+
}
|
|
612
|
+
const relativePath = path.relative(cwd, filePath);
|
|
613
|
+
// Collect context
|
|
614
|
+
const contextBefore = [];
|
|
615
|
+
const contextAfter = [];
|
|
616
|
+
for (let j = Math.max(0, i - GrepSearchTool.CONTEXT_LINES); j < i; j++) {
|
|
617
|
+
contextBefore.push({
|
|
618
|
+
lineNumber: j + 1,
|
|
619
|
+
text: this.truncateLine(lines[j])
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
for (let j = i + 1; j <= Math.min(lines.length - 1, i + GrepSearchTool.CONTEXT_LINES); j++) {
|
|
623
|
+
contextAfter.push({
|
|
624
|
+
lineNumber: j + 1,
|
|
625
|
+
text: this.truncateLine(lines[j])
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
matches.push({
|
|
629
|
+
file: this.normalizePath(relativePath),
|
|
630
|
+
line: i + 1,
|
|
631
|
+
column: matchResult.index + 1,
|
|
632
|
+
match: this.truncateLine(line),
|
|
633
|
+
contextBefore,
|
|
634
|
+
contextAfter
|
|
635
|
+
});
|
|
636
|
+
if (matches.length >= maxMatches) {
|
|
637
|
+
truncated = true;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (truncated)
|
|
642
|
+
break;
|
|
331
643
|
}
|
|
332
644
|
}
|
|
645
|
+
catch {
|
|
646
|
+
// Skip files that can't be read
|
|
647
|
+
}
|
|
333
648
|
}
|
|
334
|
-
pushMatch();
|
|
335
649
|
return {
|
|
336
|
-
matches
|
|
337
|
-
totalMatches: matches.length,
|
|
338
|
-
truncated
|
|
339
|
-
|
|
650
|
+
matches,
|
|
651
|
+
totalMatches: matches.length,
|
|
652
|
+
truncated,
|
|
653
|
+
truncationReason: truncated ? 'max_matches' : undefined,
|
|
654
|
+
searchPattern: params.Query,
|
|
655
|
+
filesSearched,
|
|
656
|
+
encodingFallback
|
|
340
657
|
};
|
|
341
658
|
}
|
|
342
659
|
/**
|
|
343
|
-
*
|
|
344
|
-
* NOTE: Context is DISABLED for findstr to avoid performance/encoding issues.
|
|
660
|
+
* Recursively collect files from directory
|
|
345
661
|
*/
|
|
346
|
-
async
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
'/S', // Recursive
|
|
350
|
-
];
|
|
351
|
-
if (params.CaseInsensitive) {
|
|
352
|
-
args.push('/I');
|
|
353
|
-
}
|
|
354
|
-
// findstr doesn't support full regex, only limited.
|
|
355
|
-
// If IsRegex is true, we might be limited.
|
|
356
|
-
if (!params.IsRegex) {
|
|
357
|
-
args.push('/L'); // Literal search
|
|
358
|
-
}
|
|
359
|
-
else {
|
|
360
|
-
args.push('/R'); // Regex search
|
|
361
|
-
}
|
|
362
|
-
// Pattern
|
|
363
|
-
args.push('/C:' + params.Query);
|
|
364
|
-
// File mask
|
|
365
|
-
// findstr [options] strings [drive:][path]filename[...]
|
|
366
|
-
// We can pass the path/mask at the end.
|
|
367
|
-
// If Includes is set, we can try to use it, but findstr is limited.
|
|
368
|
-
// We'll just use SearchPath and optional Includes if it's a simple extension.
|
|
369
|
-
let searchMask = '*.*';
|
|
370
|
-
if (params.Includes && params.Includes.length === 1 && params.Includes[0].startsWith('*.')) {
|
|
371
|
-
searchMask = params.Includes[0];
|
|
372
|
-
}
|
|
373
|
-
// If SearchPath is a directory, append mask. If file, use it.
|
|
374
|
-
let target = params.SearchPath;
|
|
662
|
+
async collectFiles(dir, files, includes, maxFiles = 10000) {
|
|
663
|
+
if (files.length >= maxFiles)
|
|
664
|
+
return;
|
|
375
665
|
try {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
666
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
667
|
+
for (const entry of entries) {
|
|
668
|
+
if (files.length >= maxFiles)
|
|
669
|
+
break;
|
|
670
|
+
const fullPath = path.join(dir, entry.name);
|
|
671
|
+
// Skip common non-code directories
|
|
672
|
+
if (entry.isDirectory()) {
|
|
673
|
+
if (['node_modules', '.git', 'dist', 'build', '__pycache__', '.vscode', '.idea'].includes(entry.name)) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
await this.collectFiles(fullPath, files, includes, maxFiles);
|
|
677
|
+
}
|
|
678
|
+
else if (entry.isFile()) {
|
|
679
|
+
if (this.matchesIncludesFilter(fullPath, includes)) {
|
|
680
|
+
files.push(fullPath);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
379
683
|
}
|
|
380
684
|
}
|
|
381
685
|
catch {
|
|
382
|
-
//
|
|
383
|
-
// Just pass it as is.
|
|
686
|
+
// Ignore directories that can't be read
|
|
384
687
|
}
|
|
385
|
-
args.push(target);
|
|
386
|
-
try {
|
|
387
|
-
// Use execFile with 'findstr'
|
|
388
|
-
const { stdout } = await execFileAsync('findstr', args, { cwd, maxBuffer: 10 * 1024 * 1024 });
|
|
389
|
-
return this.parseFindstrOutput(stdout, params.Query);
|
|
390
|
-
}
|
|
391
|
-
catch (error) {
|
|
392
|
-
if (error.code === 1) {
|
|
393
|
-
return { matches: [], totalMatches: 0, truncated: false, searchPattern: params.Query };
|
|
394
|
-
}
|
|
395
|
-
throw error;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
parseFindstrOutput(output, searchPattern) {
|
|
399
|
-
const lines = output.trim().split('\r\n'); // Windows line endings
|
|
400
|
-
const matches = [];
|
|
401
|
-
for (const line of lines) {
|
|
402
|
-
if (!line)
|
|
403
|
-
continue;
|
|
404
|
-
// findstr output format with /N /S flags:
|
|
405
|
-
// C:\path\to\file.ts:1:content
|
|
406
|
-
//
|
|
407
|
-
// Windows paths start with drive letter like "C:" so we need to be careful
|
|
408
|
-
// about finding the line number delimiter
|
|
409
|
-
// Strategy: Find the pattern ":digits:" after the path
|
|
410
|
-
// On Windows, paths have \ not : except for drive letter
|
|
411
|
-
// So we look for the last occurrence of ":\d+:" pattern
|
|
412
|
-
const lineNumMatch = line.match(/:(\d+):/);
|
|
413
|
-
if (lineNumMatch && lineNumMatch.index !== undefined) {
|
|
414
|
-
// Everything before the match is the file path
|
|
415
|
-
const file = line.substring(0, lineNumMatch.index);
|
|
416
|
-
const lineNum = parseInt(lineNumMatch[1], 10);
|
|
417
|
-
// Everything after the ":linenum:" is the content
|
|
418
|
-
const colonAfterLineNum = lineNumMatch.index + lineNumMatch[0].length;
|
|
419
|
-
const content = line.substring(colonAfterLineNum);
|
|
420
|
-
matches.push({
|
|
421
|
-
file: this.normalizePath(file),
|
|
422
|
-
line: lineNum,
|
|
423
|
-
match: this.truncateLine(content),
|
|
424
|
-
contextBefore: [], // No context for findstr
|
|
425
|
-
contextAfter: []
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return {
|
|
430
|
-
matches: matches.slice(0, GrepSearchTool.MAX_MATCHES),
|
|
431
|
-
totalMatches: matches.length,
|
|
432
|
-
truncated: matches.length >= GrepSearchTool.MAX_MATCHES,
|
|
433
|
-
searchPattern
|
|
434
|
-
};
|
|
435
688
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
689
|
+
// ========================================================================
|
|
690
|
+
// MAIN EXECUTE METHOD
|
|
691
|
+
// ========================================================================
|
|
439
692
|
async execute(params, cwd) {
|
|
440
693
|
let result;
|
|
441
|
-
|
|
442
|
-
const searchTimeout = 30000;
|
|
443
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Search operation timed out after 30 seconds')), searchTimeout));
|
|
694
|
+
let backend;
|
|
444
695
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
timeoutPromise
|
|
464
|
-
]);
|
|
696
|
+
// Try backends in order of preference
|
|
697
|
+
if (await this.hasRipgrep()) {
|
|
698
|
+
backend = 'ripgrep';
|
|
699
|
+
result = await this.searchWithRipgrepStreaming(params, cwd);
|
|
700
|
+
}
|
|
701
|
+
else if (await this.hasGrep()) {
|
|
702
|
+
backend = 'grep';
|
|
703
|
+
result = await this.searchWithGrepStreaming(params, cwd);
|
|
704
|
+
}
|
|
705
|
+
else if (process.platform === 'win32' && await this.hasPowerShell()) {
|
|
706
|
+
backend = 'powershell';
|
|
707
|
+
result = await this.searchWithPowerShell(params, cwd);
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
// Universal Node.js fallback
|
|
711
|
+
backend = 'nodejs';
|
|
712
|
+
result = await this.searchWithNodeJS(params, cwd);
|
|
713
|
+
}
|
|
465
714
|
}
|
|
466
715
|
catch (error) {
|
|
467
|
-
|
|
468
|
-
|
|
716
|
+
// If primary backend fails, try fallback
|
|
717
|
+
try {
|
|
718
|
+
backend = 'nodejs';
|
|
719
|
+
result = await this.searchWithNodeJS(params, cwd);
|
|
720
|
+
}
|
|
721
|
+
catch (fallbackError) {
|
|
722
|
+
throw new Error(`Search failed: ${error.message}. Fallback also failed: ${fallbackError.message}`);
|
|
469
723
|
}
|
|
470
|
-
throw error;
|
|
471
724
|
}
|
|
472
|
-
// Apply post-filtering for Includes
|
|
473
|
-
if (params.Includes && params.Includes.length > 0) {
|
|
725
|
+
// Apply post-filtering for Includes if backend didn't support it
|
|
726
|
+
if (params.Includes && params.Includes.length > 0 && backend === 'nodejs') {
|
|
474
727
|
const filteredMatches = result.matches.filter(match => this.matchesIncludesFilter(match.file, params.Includes));
|
|
475
728
|
return {
|
|
729
|
+
...result,
|
|
476
730
|
matches: filteredMatches,
|
|
477
731
|
totalMatches: filteredMatches.length,
|
|
478
|
-
|
|
479
|
-
searchPattern: result.searchPattern,
|
|
732
|
+
backend
|
|
480
733
|
};
|
|
481
734
|
}
|
|
482
|
-
return result;
|
|
735
|
+
return { ...result, backend };
|
|
483
736
|
}
|
|
484
737
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
* Format:
|
|
489
|
-
* filename
|
|
490
|
-
* line: context
|
|
491
|
-
* line:> match
|
|
492
|
-
* line: context
|
|
493
|
-
*/
|
|
738
|
+
// ============================================================================
|
|
739
|
+
// FORMATTING FUNCTION
|
|
740
|
+
// ============================================================================
|
|
494
741
|
function formatGrepResults(result) {
|
|
495
742
|
if (result.matches.length === 0) {
|
|
496
743
|
return `No matches found for pattern "${result.searchPattern}"`;
|
|
497
744
|
}
|
|
745
|
+
const uniqueFiles = new Set();
|
|
746
|
+
result.matches.forEach(match => uniqueFiles.add(match.file));
|
|
747
|
+
const fileCount = uniqueFiles.size;
|
|
498
748
|
const output = [];
|
|
499
|
-
output.push(`Found ${result.
|
|
749
|
+
output.push(`Found ${result.uniqueMatchesCount} match${result.uniqueMatchesCount === 1 ? '' : 'es'} in ${fileCount} file${fileCount === 1 ? '' : 's'} for pattern "${result.searchPattern}"`);
|
|
500
750
|
if (result.truncated) {
|
|
501
|
-
|
|
751
|
+
const reason = result.truncationReason === 'timeout' ? '(timed out)' : '';
|
|
752
|
+
output.push(`(showing first ${result.matches.length} matches${reason ? ' ' + reason : ''})`);
|
|
502
753
|
}
|
|
503
|
-
// Group by file
|
|
504
754
|
const matchesByFile = new Map();
|
|
505
755
|
result.matches.forEach(match => {
|
|
506
756
|
if (!matchesByFile.has(match.file)) {
|
|
@@ -511,35 +761,93 @@ function formatGrepResults(result) {
|
|
|
511
761
|
matchesByFile.forEach((matches, file) => {
|
|
512
762
|
output.push(`\n${file}`);
|
|
513
763
|
matches.forEach(match => {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
// Calculate line number for context if possible?
|
|
517
|
-
// rg json gives line numbers for context, but our interface simplified it.
|
|
518
|
-
// We'll just print it without line number or with a placeholder if we don't have it.
|
|
519
|
-
// Actually, for AI parsing, it's better to have line numbers.
|
|
520
|
-
// But if we don't have them (grep/findstr), we shouldn't fake them incorrectly.
|
|
521
|
-
// However, rg gives them.
|
|
522
|
-
// Let's just indent context.
|
|
523
|
-
// Wait, user requested: "48: function..."
|
|
524
|
-
// If we don't have line numbers for context, maybe we should skip context or just indent?
|
|
525
|
-
// "Use line numbers explicitly for every line."
|
|
526
|
-
// Since we might not have them for context in all cases, let's try to infer or just use indentation.
|
|
527
|
-
// For the match, we definitely have the line number.
|
|
528
|
-
// Let's assume context lines are immediately preceding.
|
|
529
|
-
const startLine = match.line - match.contextBefore.length;
|
|
530
|
-
output.push(`${startLine + idx}: ${ctx}`);
|
|
764
|
+
match.contextBefore.forEach(ctx => {
|
|
765
|
+
output.push(`${ctx.lineNumber}: ${ctx.text}`);
|
|
531
766
|
});
|
|
532
|
-
|
|
533
|
-
output.push(`${match.line}:> ${match.match}`);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
output.push(`${match.line + 1 + idx}: ${ctx}`);
|
|
767
|
+
const columnInfo = match.column ? ` (col ${match.column})` : '';
|
|
768
|
+
output.push(`${match.line}:> ${match.match}${columnInfo}`);
|
|
769
|
+
match.contextAfter.forEach(ctx => {
|
|
770
|
+
output.push(`${ctx.lineNumber}: ${ctx.text}`);
|
|
537
771
|
});
|
|
538
|
-
// Separator if needed? AI usually handles blocks well.
|
|
539
772
|
});
|
|
540
773
|
});
|
|
541
774
|
return output.join('\n');
|
|
542
775
|
}
|
|
776
|
+
// ============================================================================
|
|
777
|
+
// REMOTE SEARCH HELPER
|
|
778
|
+
// ============================================================================
|
|
779
|
+
/**
|
|
780
|
+
* Execute grep search on a remote system via ContextManager
|
|
781
|
+
* Used for SSH/Docker/WSL environments
|
|
782
|
+
*/
|
|
783
|
+
async function searchRemote(params, contextManager) {
|
|
784
|
+
const maxMatches = params.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
785
|
+
// Escape the query for shell usage
|
|
786
|
+
const escapedQuery = params.Query.replace(/'/g, "'\\''");
|
|
787
|
+
// First, check if the path is a file or directory to use correct grep flags
|
|
788
|
+
// Use test -d to check if it's a directory
|
|
789
|
+
const checkResult = await contextManager.executeCommand(`test -d "${params.SearchPath}" && echo "dir" || echo "file"`, 5000);
|
|
790
|
+
const isDirectory = checkResult.stdout.trim() === 'dir';
|
|
791
|
+
// Build grep command - use -r only for directories, -H to always show filename
|
|
792
|
+
let grepArgs = isDirectory ? '-rn' : '-Hn'; // -H for filename, -n for line numbers, -r for recursive
|
|
793
|
+
if (params.CaseInsensitive) {
|
|
794
|
+
grepArgs += 'i';
|
|
795
|
+
}
|
|
796
|
+
if (!params.IsRegex) {
|
|
797
|
+
grepArgs += 'F'; // fixed strings (literal)
|
|
798
|
+
}
|
|
799
|
+
// Add include patterns if specified (only useful for directories)
|
|
800
|
+
let includeArgs = '';
|
|
801
|
+
if (isDirectory && params.Includes && params.Includes.length > 0) {
|
|
802
|
+
includeArgs = params.Includes.map(p => `--include='${p}'`).join(' ');
|
|
803
|
+
}
|
|
804
|
+
// Build the full command with result limit
|
|
805
|
+
const command = `grep ${grepArgs} ${includeArgs} '${escapedQuery}' "${params.SearchPath}" 2>/dev/null | head -n ${maxMatches}`;
|
|
806
|
+
try {
|
|
807
|
+
const result = await contextManager.executeCommand(command, 30000);
|
|
808
|
+
const matches = [];
|
|
809
|
+
const lines = result.stdout.split('\n').filter(line => line.trim());
|
|
810
|
+
for (const line of lines) {
|
|
811
|
+
// Parse grep output: file:line:content
|
|
812
|
+
const match = line.match(/^(.+?):(\d+):(.+)$/);
|
|
813
|
+
if (match) {
|
|
814
|
+
// Truncate long lines
|
|
815
|
+
let content = match[3];
|
|
816
|
+
if (content.length > 300) {
|
|
817
|
+
content = content.substring(0, 300) + ' [truncated]';
|
|
818
|
+
}
|
|
819
|
+
matches.push({
|
|
820
|
+
file: match[1],
|
|
821
|
+
line: parseInt(match[2], 10),
|
|
822
|
+
match: content,
|
|
823
|
+
contextBefore: [],
|
|
824
|
+
contextAfter: [],
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
matches,
|
|
830
|
+
totalMatches: matches.length,
|
|
831
|
+
truncated: matches.length >= maxMatches,
|
|
832
|
+
truncationReason: matches.length >= maxMatches ? 'max_matches' : undefined,
|
|
833
|
+
searchPattern: params.Query,
|
|
834
|
+
backend: 'remote',
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
// If grep fails (command not found, permission denied, etc.), return empty result
|
|
839
|
+
return {
|
|
840
|
+
matches: [],
|
|
841
|
+
totalMatches: 0,
|
|
842
|
+
truncated: false,
|
|
843
|
+
searchPattern: params.Query,
|
|
844
|
+
backend: 'remote',
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// ============================================================================
|
|
849
|
+
// TOOL EXPORT
|
|
850
|
+
// ============================================================================
|
|
543
851
|
export const grepSearchTool = {
|
|
544
852
|
schema: {
|
|
545
853
|
name: 'grep_search',
|
|
@@ -558,7 +866,7 @@ MULTI-WORD SEARCH: When you search for "control system", this tool will automati
|
|
|
558
866
|
Results are returned with file paths and line numbers:
|
|
559
867
|
filename
|
|
560
868
|
line: context
|
|
561
|
-
line:> match
|
|
869
|
+
line:> match (col X)
|
|
562
870
|
line: context
|
|
563
871
|
|
|
564
872
|
NOT for: Finding files/folders by NAME. Use find_files for that instead.
|
|
@@ -583,9 +891,13 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
|
|
|
583
891
|
description: 'Glob patterns to filter files (e.g. ["*.ts"]).',
|
|
584
892
|
items: { type: 'string' }
|
|
585
893
|
},
|
|
894
|
+
MaxResults: {
|
|
895
|
+
type: 'number',
|
|
896
|
+
description: 'Optional: Maximum number of results to return. Default is 50. Increase this if you need to see more matches.',
|
|
897
|
+
},
|
|
586
898
|
CaseInsensitive: {
|
|
587
899
|
type: 'boolean',
|
|
588
|
-
description: 'If true, performs a case-insensitive search. Default: false',
|
|
900
|
+
description: 'If true, performs a case-insensitive search. Default: false (case-sensitive)',
|
|
589
901
|
},
|
|
590
902
|
IsRegex: {
|
|
591
903
|
type: 'boolean',
|
|
@@ -597,57 +909,93 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
|
|
|
597
909
|
},
|
|
598
910
|
async execute(args, context) {
|
|
599
911
|
const tool = new GrepSearchTool();
|
|
600
|
-
|
|
912
|
+
const startTime = Date.now();
|
|
601
913
|
const query = args.Query;
|
|
602
914
|
const words = query.trim().split(/\s+/).filter(w => w.length > 2);
|
|
603
|
-
// If query has multiple words, search for combined AND individual terms
|
|
604
915
|
const allMatches = [];
|
|
605
|
-
const seenMatches = new Set();
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
916
|
+
const seenMatches = new Set();
|
|
917
|
+
const matchesPerQuery = {};
|
|
918
|
+
const queriesExecuted = [];
|
|
919
|
+
let rawTotalMatches = 0;
|
|
920
|
+
let backend = 'ripgrep';
|
|
921
|
+
let truncated = false;
|
|
922
|
+
let truncationReason;
|
|
923
|
+
let encodingFallback = false;
|
|
924
|
+
const queries = [query];
|
|
609
925
|
if (words.length > 1 && !args.IsRegex) {
|
|
610
|
-
// Add individual words as additional searches
|
|
611
926
|
for (const word of words) {
|
|
612
927
|
if (word !== query) {
|
|
613
928
|
queries.push(word);
|
|
614
929
|
}
|
|
615
930
|
}
|
|
616
931
|
}
|
|
932
|
+
// Check if we're in a remote context (SSH, Docker, WSL)
|
|
933
|
+
const contextManager = context.contextManager;
|
|
934
|
+
const currentContext = contextManager?.getCurrentContext();
|
|
935
|
+
const isRemote = currentContext && currentContext.type !== 'local';
|
|
617
936
|
try {
|
|
618
937
|
for (const searchQuery of queries) {
|
|
938
|
+
queriesExecuted.push(searchQuery);
|
|
619
939
|
const params = {
|
|
620
940
|
Query: searchQuery,
|
|
621
941
|
SearchPath: args.SearchPath,
|
|
622
942
|
Includes: args.Includes,
|
|
623
|
-
|
|
943
|
+
MaxResults: args.MaxResults,
|
|
944
|
+
CaseInsensitive: args.CaseInsensitive ?? false,
|
|
624
945
|
IsRegex: args.IsRegex,
|
|
625
946
|
};
|
|
626
947
|
try {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
948
|
+
// Use remote search for SSH/Docker/WSL, local search otherwise
|
|
949
|
+
const result = isRemote
|
|
950
|
+
? await searchRemote(params, contextManager)
|
|
951
|
+
: await tool.execute(params, context.cwd);
|
|
952
|
+
backend = result.backend;
|
|
953
|
+
matchesPerQuery[searchQuery] = result.totalMatches;
|
|
954
|
+
rawTotalMatches += result.totalMatches;
|
|
955
|
+
if (result.truncated) {
|
|
956
|
+
truncated = true;
|
|
957
|
+
truncationReason = result.truncationReason;
|
|
958
|
+
}
|
|
959
|
+
if (result.encodingFallback) {
|
|
960
|
+
encodingFallback = true;
|
|
961
|
+
}
|
|
962
|
+
// Add unique matches with improved dedupe key
|
|
630
963
|
for (const match of result.matches) {
|
|
631
|
-
|
|
632
|
-
|
|
964
|
+
// Use file:line:column:matchText for more accurate deduplication
|
|
965
|
+
const key = `${match.file}:${match.line}:${match.column ?? 0}:${match.match.substring(0, 50)}`;
|
|
966
|
+
const maxMatches = args.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
967
|
+
if (!seenMatches.has(key) && allMatches.length < maxMatches) {
|
|
633
968
|
seenMatches.add(key);
|
|
634
969
|
allMatches.push(match);
|
|
635
970
|
}
|
|
636
971
|
}
|
|
637
972
|
}
|
|
638
|
-
catch
|
|
639
|
-
|
|
973
|
+
catch {
|
|
974
|
+
matchesPerQuery[searchQuery] = 0;
|
|
640
975
|
continue;
|
|
641
976
|
}
|
|
642
977
|
}
|
|
643
|
-
|
|
644
|
-
const
|
|
978
|
+
const endTime = Date.now();
|
|
979
|
+
const uniqueMatchesCount = allMatches.length;
|
|
980
|
+
const max = args.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
|
|
981
|
+
const finalResult = {
|
|
645
982
|
matches: allMatches,
|
|
646
|
-
totalMatches:
|
|
647
|
-
|
|
983
|
+
totalMatches: rawTotalMatches,
|
|
984
|
+
uniqueMatchesCount,
|
|
985
|
+
truncated: truncated || allMatches.length >= max,
|
|
986
|
+
truncationReason: allMatches.length >= max ? 'max_matches' : truncationReason,
|
|
648
987
|
searchPattern: queries.length > 1 ? `"${query}" (+ individual words)` : query,
|
|
988
|
+
metadata: {
|
|
989
|
+
backend,
|
|
990
|
+
searchDurationMs: endTime - startTime,
|
|
991
|
+
queriesExecuted,
|
|
992
|
+
matchesPerQuery,
|
|
993
|
+
encodingFallback
|
|
994
|
+
},
|
|
995
|
+
formattedOutput: '',
|
|
649
996
|
};
|
|
650
|
-
|
|
997
|
+
finalResult.formattedOutput = formatGrepResults(finalResult);
|
|
998
|
+
return finalResult.formattedOutput;
|
|
651
999
|
}
|
|
652
1000
|
catch (error) {
|
|
653
1001
|
throw new Error(`Grep search failed: ${error.message}`);
|