centaurus-cli 2.9.3 → 2.9.4

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.
Files changed (92) hide show
  1. package/dist/cli-adapter.d.ts +72 -8
  2. package/dist/cli-adapter.d.ts.map +1 -1
  3. package/dist/cli-adapter.js +607 -141
  4. package/dist/cli-adapter.js.map +1 -1
  5. package/dist/commands/CommandParser.d.ts +1 -1
  6. package/dist/commands/CommandParser.d.ts.map +1 -1
  7. package/dist/commands/CommandParser.js +113 -0
  8. package/dist/commands/CommandParser.js.map +1 -1
  9. package/dist/config/slash-commands.d.ts +2 -0
  10. package/dist/config/slash-commands.d.ts.map +1 -1
  11. package/dist/config/slash-commands.js +28 -0
  12. package/dist/config/slash-commands.js.map +1 -1
  13. package/dist/context/context-manager.d.ts +1 -1
  14. package/dist/context/context-manager.d.ts.map +1 -1
  15. package/dist/context/context-manager.js +3 -1
  16. package/dist/context/context-manager.js.map +1 -1
  17. package/dist/context/handlers/docker-handler.d.ts +9 -0
  18. package/dist/context/handlers/docker-handler.d.ts.map +1 -1
  19. package/dist/context/handlers/docker-handler.js +99 -10
  20. package/dist/context/handlers/docker-handler.js.map +1 -1
  21. package/dist/context/handlers/ssh-handler.d.ts +20 -0
  22. package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
  23. package/dist/context/handlers/ssh-handler.js +129 -1
  24. package/dist/context/handlers/ssh-handler.js.map +1 -1
  25. package/dist/context/subshell-handler.d.ts +15 -0
  26. package/dist/context/subshell-handler.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/services/ai-service-client.d.ts.map +1 -1
  30. package/dist/services/ai-service-client.js +33 -11
  31. package/dist/services/ai-service-client.js.map +1 -1
  32. package/dist/services/api-client.js +1 -1
  33. package/dist/services/api-client.js.map +1 -1
  34. package/dist/services/warpify-detector.d.ts +43 -0
  35. package/dist/services/warpify-detector.d.ts.map +1 -0
  36. package/dist/services/warpify-detector.js +203 -0
  37. package/dist/services/warpify-detector.js.map +1 -0
  38. package/dist/services/workflow-storage.d.ts +72 -0
  39. package/dist/services/workflow-storage.d.ts.map +1 -0
  40. package/dist/services/workflow-storage.js +239 -0
  41. package/dist/services/workflow-storage.js.map +1 -0
  42. package/dist/tools/command.d.ts.map +1 -1
  43. package/dist/tools/command.js +14 -0
  44. package/dist/tools/command.js.map +1 -1
  45. package/dist/tools/enter-remote-session.d.ts +13 -0
  46. package/dist/tools/enter-remote-session.d.ts.map +1 -0
  47. package/dist/tools/enter-remote-session.js +226 -0
  48. package/dist/tools/enter-remote-session.js.map +1 -0
  49. package/dist/tools/find-files.d.ts.map +1 -1
  50. package/dist/tools/find-files.js +9 -2
  51. package/dist/tools/find-files.js.map +1 -1
  52. package/dist/tools/grep-search.d.ts +104 -31
  53. package/dist/tools/grep-search.d.ts.map +1 -1
  54. package/dist/tools/grep-search.js +699 -430
  55. package/dist/tools/grep-search.js.map +1 -1
  56. package/dist/tools/workflow-tool.d.ts +11 -0
  57. package/dist/tools/workflow-tool.d.ts.map +1 -0
  58. package/dist/tools/workflow-tool.js +87 -0
  59. package/dist/tools/workflow-tool.js.map +1 -0
  60. package/dist/types/workflow.d.ts +110 -0
  61. package/dist/types/workflow.d.ts.map +1 -0
  62. package/dist/types/workflow.js +8 -0
  63. package/dist/types/workflow.js.map +1 -0
  64. package/dist/ui/components/App.d.ts +10 -1
  65. package/dist/ui/components/App.d.ts.map +1 -1
  66. package/dist/ui/components/App.js +117 -4
  67. package/dist/ui/components/App.js.map +1 -1
  68. package/dist/ui/components/Breadcrumbs.d.ts +4 -3
  69. package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
  70. package/dist/ui/components/Breadcrumbs.js +60 -54
  71. package/dist/ui/components/Breadcrumbs.js.map +1 -1
  72. package/dist/ui/components/ConnectionStatusMessage.js +2 -2
  73. package/dist/ui/components/ConnectionStatusMessage.js.map +1 -1
  74. package/dist/ui/components/InputBox.d.ts +1 -0
  75. package/dist/ui/components/InputBox.d.ts.map +1 -1
  76. package/dist/ui/components/InputBox.js +168 -2
  77. package/dist/ui/components/InputBox.js.map +1 -1
  78. package/dist/ui/components/InteractiveShell.d.ts +2 -0
  79. package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
  80. package/dist/ui/components/InteractiveShell.js +13 -3
  81. package/dist/ui/components/InteractiveShell.js.map +1 -1
  82. package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
  83. package/dist/ui/components/ToolExecutionMessage.js +164 -25
  84. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  85. package/dist/ui/components/WorkflowCreatorScreen.d.ts +25 -0
  86. package/dist/ui/components/WorkflowCreatorScreen.d.ts.map +1 -0
  87. package/dist/ui/components/WorkflowCreatorScreen.js +164 -0
  88. package/dist/ui/components/WorkflowCreatorScreen.js.map +1 -0
  89. package/dist/utils/input-classifier.d.ts.map +1 -1
  90. package/dist/utils/input-classifier.js +2 -1
  91. package/dist/utils/input-classifier.js.map +1 -1
  92. 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 - Search for text patterns across files in the codebase
12
+ * GrepSearchTool - Cross-platform text search with multiple backends
8
13
  *
9
- * Improvements over basic implementation:
10
- * 1. Security: Uses execFile to prevent shell injection
11
- * 2. Robustness: Handles binary files and encoding issues (via rg/grep)
12
- * 3. AI-Friendly Output: Formats results for easy parsing
13
- * 4. Fallback: Gracefully degrades from ripgrep -> grep -> findstr
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 MAX_MATCHES = 50;
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 ripgrep is available
35
+ * Check if a command is available
21
36
  */
22
- async hasRipgrep() {
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('rg', ['--version']),
28
- timeoutPromise
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
- * Check if native grep is available (Linux/Mac)
38
- */
55
+ async hasRipgrep() {
56
+ return this.hasCommand('rg', ['--version']);
57
+ }
39
58
  async hasGrep() {
40
- try {
41
- // Add timeout to prevent hanging
42
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000));
43
- await Promise.race([
44
- execFileAsync('grep', ['--version']),
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
- catch {
50
- return false;
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 use forward slashes
79
+ * Normalize file path to forward slashes
55
80
  */
56
81
  normalizePath(filePath) {
57
82
  return filePath.replace(/\\/g, '/');
58
83
  }
59
84
  /**
60
- * Check if a file path matches the Includes patterns
61
- * Returns true if there are no patterns or if the file matches at least one pattern
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; // No filter, all files match
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); // Get ".ts" from "*.ts"
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
- * Truncate long lines
93
- */
94
- truncateLine(line) {
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
169
+ * Check if file is likely binary
102
170
  */
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 { stdout } = await execFileAsync('rg', args, { cwd, maxBuffer: 10 * 1024 * 1024 });
138
- return this.parseRipgrepOutput(stdout, params.Query);
139
- }
140
- catch (error) {
141
- // ripgrep returns exit code 1 when no matches found
142
- if (error.code === 1) {
143
- return {
144
- matches: [],
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
- throw error;
182
+ return false;
183
+ }
184
+ catch {
185
+ return true; // Assume binary on error
151
186
  }
152
187
  }
153
- /**
154
- * Parse ripgrep JSON output
155
- */
156
- parseRipgrepOutput(output, searchPattern) {
157
- const lines = output.trim().split('\n').filter(line => line);
158
- const matches = [];
159
- let currentMatch = null;
160
- let contextBefore = [];
161
- let contextAfter = [];
162
- let totalMatches = 0;
163
- // Helper to finalize a match
164
- const pushMatch = () => {
165
- if (currentMatch && currentMatch.file && currentMatch.line !== undefined) {
166
- matches.push({
167
- file: this.normalizePath(currentMatch.file),
168
- line: currentMatch.line,
169
- match: this.truncateLine(currentMatch.match || ''),
170
- contextBefore: contextBefore.map(l => this.truncateLine(l)),
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
- for (const line of lines) {
179
- try {
180
- const data = JSON.parse(line);
181
- if (data.type === 'match') {
182
- pushMatch(); // Push previous match if any
183
- totalMatches++;
184
- if (matches.length < GrepSearchTool.MAX_MATCHES) {
185
- const matchData = data.data;
186
- currentMatch = {
187
- file: matchData.path.text,
188
- line: matchData.line_number,
189
- match: matchData.lines.text.trimEnd(),
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
- else if (data.type === 'context') {
194
- const contextData = data.data;
195
- const contextLine = contextData.lines.text.trimEnd();
196
- if (currentMatch && currentMatch.line !== undefined) {
197
- // This is context AFTER the current match
198
- contextAfter.push(contextLine);
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;
274
+ }
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++;
283
+ }
284
+ else {
285
+ contextQueue.push(ctxLine);
286
+ if (contextQueue.length > GrepSearchTool.CONTEXT_LINES) {
287
+ contextQueue.shift();
288
+ }
289
+ }
199
290
  }
200
- else {
201
- // This is context BEFORE the next match (or we are between matches)
202
- // Since rg --json streams, context before a match usually comes before the match event.
203
- // But wait, if we just finished a match, 'context' could be after.
204
- // Actually, rg JSON output structure:
205
- // context (before) -> match -> context (after)
206
- // But if matches are close, context after match 1 might be context before match 2.
207
- // For simplicity in this parser:
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();
291
+ else if (event.type === 'match' && event.data) {
292
+ // Finalize previous match
293
+ if (pendingMatch) {
294
+ matches.push(pendingMatch);
214
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
- else if (data.type === 'end') {
218
- // End of a search result for a file?
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
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);
222
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
- catch (parseError) {
225
- continue;
350
+ if (!params.IsRegex) {
351
+ args.push('-F'); // fixed strings
226
352
  }
227
- }
228
- pushMatch(); // Ensure flush
229
- return {
230
- matches,
231
- totalMatches,
232
- truncated: totalMatches > matches.length,
233
- searchPattern,
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
- * Execute search using native grep (Linux/Mac)
238
- */
239
- async searchWithGrep(params, cwd) {
240
- const args = ['-rn'];
241
- // Case sensitivity
242
- if (params.CaseInsensitive) {
243
- args.push('-i');
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
- // Context lines
246
- args.push(`--context=${GrepSearchTool.CONTEXT_LINES}`);
247
- // Includes
248
- if (params.Includes && params.Includes.length > 0) {
249
- params.Includes.forEach(pattern => {
250
- args.push(`--include=${pattern}`);
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
- // Fixed string vs Regex
254
- if (!params.IsRegex) {
255
- args.push('-F');
256
- }
257
- // Pattern
258
- args.push(params.Query);
259
- // Path
260
- args.push(params.SearchPath);
261
- try {
262
- const { stdout } = await execFileAsync('grep', args, { cwd, maxBuffer: 10 * 1024 * 1024 });
263
- return this.parseGrepOutput(stdout, params.Query);
264
- }
265
- catch (error) {
266
- if (error.code === 1) {
267
- return {
268
- matches: [],
269
- totalMatches: 0,
270
- truncated: false,
271
- searchPattern: params.Query,
272
- };
273
- }
274
- throw error;
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
- parseGrepOutput(output, searchPattern) {
278
- // Grep output with context:
279
- // file:line:match
280
- // file-line-context
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
- // Simple parsing strategy: group by file and proximity
285
- // For robust parsing similar to rg, we'd need more complex logic.
286
- // Given the constraints, we'll do a best-effort parse.
287
- let currentMatch = null;
288
- let contextBefore = [];
289
- let contextAfter = [];
290
- const pushMatch = () => {
291
- if (currentMatch && currentMatch.file && currentMatch.line !== undefined) {
292
- matches.push({
293
- file: this.normalizePath(currentMatch.file),
294
- line: currentMatch.line,
295
- match: this.truncateLine(currentMatch.match || ''),
296
- contextBefore: contextBefore.map(l => this.truncateLine(l)),
297
- contextAfter: contextAfter.map(l => this.truncateLine(l)),
298
- });
299
- currentMatch = null;
300
- contextBefore = [];
301
- contextAfter = [];
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
- for (const line of lines) {
305
- if (line === '--') {
306
- pushMatch();
576
+ // Skip binary files
577
+ if (await this.isBinaryFile(filePath)) {
307
578
  continue;
308
579
  }
309
- // Match: file:line:content
310
- const matchRegex = /^([^:]+):(\d+):(.*)$/;
311
- // Context: file-line-content
312
- const contextRegex = /^([^:]+)-(\d+)-(.*)$/;
313
- const matchM = line.match(matchRegex);
314
- const contextM = line.match(contextRegex);
315
- if (matchM) {
316
- pushMatch(); // Flush previous
317
- currentMatch = {
318
- file: matchM[1],
319
- line: parseInt(matchM[2], 10),
320
- match: matchM[3]
321
- };
322
- }
323
- else if (contextM) {
324
- if (currentMatch) {
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
- else {
328
- contextBefore.push(contextM[3]);
329
- if (contextBefore.length > GrepSearchTool.CONTEXT_LINES)
330
- contextBefore.shift();
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: matches.slice(0, GrepSearchTool.MAX_MATCHES),
337
- totalMatches: matches.length, // grep doesn't give total easily without another run
338
- truncated: matches.length >= GrepSearchTool.MAX_MATCHES,
339
- searchPattern
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
- * Execute search using findstr (Windows)
344
- * NOTE: Context is DISABLED for findstr to avoid performance/encoding issues.
660
+ * Recursively collect files from directory
345
661
  */
346
- async searchWithFindstr(params, cwd) {
347
- const args = [
348
- '/N', // Print line numbers
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 stats = await fs.promises.stat(path.resolve(cwd, params.SearchPath));
377
- if (stats.isDirectory()) {
378
- target = path.join(params.SearchPath, searchMask);
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
- // Assume it's a pattern or file that doesn't exist yet?
383
- // Just pass it as is.
384
- }
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
- }
686
+ // Ignore directories that can't be read
428
687
  }
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
- * Execute the grep search
438
- */
689
+ // ========================================================================
690
+ // MAIN EXECUTE METHOD
691
+ // ========================================================================
439
692
  async execute(params, cwd) {
440
693
  let result;
441
- // Add overall timeout for the search operation (30 seconds)
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
- result = await Promise.race([
446
- (async () => {
447
- // Try ripgrep first
448
- if (await this.hasRipgrep()) {
449
- return await this.searchWithRipgrep(params, cwd);
450
- }
451
- // Try native grep (Linux/Mac)
452
- else if (await this.hasGrep()) {
453
- return await this.searchWithGrep(params, cwd);
454
- }
455
- // Fall back to findstr (Windows)
456
- else if (process.platform === 'win32') {
457
- return await this.searchWithFindstr(params, cwd);
458
- }
459
- else {
460
- throw new Error('No search tool available. Please install ripgrep for better performance: https://github.com/BurntSushi/ripgrep');
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
- if (error.message.includes('timed out')) {
468
- throw new Error('Search operation timed out. Try narrowing your search path or using more specific patterns.');
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 patterns (ensures robust filtering across all backends)
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
- truncated: result.truncated && filteredMatches.length >= GrepSearchTool.MAX_MATCHES,
479
- searchPattern: result.searchPattern,
732
+ backend
480
733
  };
481
734
  }
482
- return result;
735
+ return { ...result, backend };
483
736
  }
484
737
  }
485
- /**
486
- * Format grep search results for AI consumption
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.totalMatches} match${result.totalMatches === 1 ? '' : 'es'} for pattern "${result.searchPattern}"`);
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
- output.push(`(showing first ${result.matches.length} matches)`);
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,21 @@ function formatGrepResults(result) {
511
761
  matchesByFile.forEach((matches, file) => {
512
762
  output.push(`\n${file}`);
513
763
  matches.forEach(match => {
514
- // Context Before
515
- match.contextBefore.forEach((ctx, idx) => {
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
- // Match
533
- output.push(`${match.line}:> ${match.match}`);
534
- // Context After
535
- match.contextAfter.forEach((ctx, idx) => {
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
+ // TOOL EXPORT
778
+ // ============================================================================
543
779
  export const grepSearchTool = {
544
780
  schema: {
545
781
  name: 'grep_search',
@@ -558,7 +794,7 @@ MULTI-WORD SEARCH: When you search for "control system", this tool will automati
558
794
  Results are returned with file paths and line numbers:
559
795
  filename
560
796
  line: context
561
- line:> match
797
+ line:> match (col X)
562
798
  line: context
563
799
 
564
800
  NOT for: Finding files/folders by NAME. Use find_files for that instead.
@@ -583,9 +819,13 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
583
819
  description: 'Glob patterns to filter files (e.g. ["*.ts"]).',
584
820
  items: { type: 'string' }
585
821
  },
822
+ MaxResults: {
823
+ type: 'number',
824
+ description: 'Optional: Maximum number of results to return. Default is 50. Increase this if you need to see more matches.',
825
+ },
586
826
  CaseInsensitive: {
587
827
  type: 'boolean',
588
- description: 'If true, performs a case-insensitive search. Default: false',
828
+ description: 'If true, performs a case-insensitive search. Default: false (case-sensitive)',
589
829
  },
590
830
  IsRegex: {
591
831
  type: 'boolean',
@@ -597,17 +837,20 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
597
837
  },
598
838
  async execute(args, context) {
599
839
  const tool = new GrepSearchTool();
600
- // Check if query has multiple words for multi-term search
840
+ const startTime = Date.now();
601
841
  const query = args.Query;
602
842
  const words = query.trim().split(/\s+/).filter(w => w.length > 2);
603
- // If query has multiple words, search for combined AND individual terms
604
843
  const allMatches = [];
605
- const seenMatches = new Set(); // Deduplicate by file:line
606
- let totalMatches = 0;
607
- // Build list of queries to run
608
- const queries = [query]; // Always search for the full query first
844
+ const seenMatches = new Set();
845
+ const matchesPerQuery = {};
846
+ const queriesExecuted = [];
847
+ let rawTotalMatches = 0;
848
+ let backend = 'ripgrep';
849
+ let truncated = false;
850
+ let truncationReason;
851
+ let encodingFallback = false;
852
+ const queries = [query];
609
853
  if (words.length > 1 && !args.IsRegex) {
610
- // Add individual words as additional searches
611
854
  for (const word of words) {
612
855
  if (word !== query) {
613
856
  queries.push(word);
@@ -616,38 +859,64 @@ Total results are capped at 50 matches. Use Includes to filter by file type.`,
616
859
  }
617
860
  try {
618
861
  for (const searchQuery of queries) {
862
+ queriesExecuted.push(searchQuery);
619
863
  const params = {
620
864
  Query: searchQuery,
621
865
  SearchPath: args.SearchPath,
622
866
  Includes: args.Includes,
623
- CaseInsensitive: args.CaseInsensitive ?? true, // Default to case-insensitive for better results
867
+ MaxResults: args.MaxResults,
868
+ CaseInsensitive: args.CaseInsensitive ?? false,
624
869
  IsRegex: args.IsRegex,
625
870
  };
626
871
  try {
627
872
  const result = await tool.execute(params, context.cwd);
628
- totalMatches += result.totalMatches;
629
- // Add unique matches only
873
+ backend = result.backend;
874
+ matchesPerQuery[searchQuery] = result.totalMatches;
875
+ rawTotalMatches += result.totalMatches;
876
+ if (result.truncated) {
877
+ truncated = true;
878
+ truncationReason = result.truncationReason;
879
+ }
880
+ if (result.encodingFallback) {
881
+ encodingFallback = true;
882
+ }
883
+ // Add unique matches with improved dedupe key
630
884
  for (const match of result.matches) {
631
- const key = `${match.file}:${match.line}`;
632
- if (!seenMatches.has(key) && allMatches.length < 50) {
885
+ // Use file:line:column:matchText for more accurate deduplication
886
+ const key = `${match.file}:${match.line}:${match.column ?? 0}:${match.match.substring(0, 50)}`;
887
+ const maxMatches = args.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
888
+ if (!seenMatches.has(key) && allMatches.length < maxMatches) {
633
889
  seenMatches.add(key);
634
890
  allMatches.push(match);
635
891
  }
636
892
  }
637
893
  }
638
- catch (e) {
639
- // Skip individual query errors
894
+ catch {
895
+ matchesPerQuery[searchQuery] = 0;
640
896
  continue;
641
897
  }
642
898
  }
643
- // Format merged results
644
- const mergedResult = {
899
+ const endTime = Date.now();
900
+ const uniqueMatchesCount = allMatches.length;
901
+ const max = args.MaxResults || GrepSearchTool.DEFAULT_MAX_MATCHES;
902
+ const finalResult = {
645
903
  matches: allMatches,
646
- totalMatches: totalMatches,
647
- truncated: allMatches.length >= 50,
904
+ totalMatches: rawTotalMatches,
905
+ uniqueMatchesCount,
906
+ truncated: truncated || allMatches.length >= max,
907
+ truncationReason: allMatches.length >= max ? 'max_matches' : truncationReason,
648
908
  searchPattern: queries.length > 1 ? `"${query}" (+ individual words)` : query,
909
+ metadata: {
910
+ backend,
911
+ searchDurationMs: endTime - startTime,
912
+ queriesExecuted,
913
+ matchesPerQuery,
914
+ encodingFallback
915
+ },
916
+ formattedOutput: '',
649
917
  };
650
- return formatGrepResults(mergedResult);
918
+ finalResult.formattedOutput = formatGrepResults(finalResult);
919
+ return finalResult.formattedOutput;
651
920
  }
652
921
  catch (error) {
653
922
  throw new Error(`Grep search failed: ${error.message}`);