minovative-mind-cli 2.5.1 → 2.6.0

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 (43) hide show
  1. package/README.md +28 -25
  2. package/dist/commands/chat.js +1 -1
  3. package/dist/services/agent/inputHandler.d.ts +9 -0
  4. package/dist/services/agent/inputHandler.js +34 -0
  5. package/dist/services/agent/slashCommands.js +158 -37
  6. package/dist/services/agent/syntaxAgent.d.ts +40 -0
  7. package/dist/services/agent/syntaxAgent.js +237 -23
  8. package/dist/services/agent/toolLoop.js +10 -1
  9. package/dist/services/agent/types.d.ts +1 -0
  10. package/dist/services/agent-tools.d.ts +156 -1
  11. package/dist/services/agent-tools.js +259 -67
  12. package/dist/services/agent.d.ts +74 -0
  13. package/dist/services/agent.js +192 -30
  14. package/dist/services/ai.d.ts +5 -0
  15. package/dist/services/ai.js +80 -87
  16. package/dist/services/chatHistoryService.d.ts +11 -0
  17. package/dist/services/chatHistoryService.js +20 -1
  18. package/dist/services/contextAgent.d.ts +1 -1
  19. package/dist/services/contextAgent.js +9 -29
  20. package/dist/services/orchestration/investigationAgent.d.ts +2 -1
  21. package/dist/services/orchestration/investigationAgent.js +7 -2
  22. package/dist/services/orchestration/investigationOrchestrator.d.ts +1 -1
  23. package/dist/services/orchestration/investigationOrchestrator.js +13 -2
  24. package/dist/services/orchestration/orchestrator.js +21 -4
  25. package/dist/services/orchestration/subAgent.d.ts +2 -1
  26. package/dist/services/orchestration/subAgent.js +12 -6
  27. package/dist/services/workspaceRegistry.d.ts +7 -0
  28. package/dist/services/workspaceRegistry.js +22 -0
  29. package/dist/utils/analysisRunner.d.ts +27 -4
  30. package/dist/utils/analysisRunner.js +100 -20
  31. package/dist/utils/config.d.ts +2 -0
  32. package/dist/utils/config.js +2 -0
  33. package/dist/utils/fuzzyMatch.d.ts +32 -0
  34. package/dist/utils/fuzzyMatch.js +215 -27
  35. package/dist/utils/localSyntaxValidator.d.ts +2 -2
  36. package/dist/utils/localSyntaxValidator.js +280 -81
  37. package/dist/utils/performanceAuditor.d.ts +2 -7
  38. package/dist/utils/performanceAuditor.js +541 -89
  39. package/dist/utils/projectStorage.js +9 -0
  40. package/dist/utils/systemPrompts.d.ts +3 -2
  41. package/dist/utils/systemPrompts.js +29 -5
  42. package/oclif.manifest.json +2 -2
  43. package/package.json +1 -1
@@ -1,3 +1,9 @@
1
+ /**
2
+ * @file agent-tools.ts
3
+ * @description Implements tool declarations, approval state management, ignore rule parser,
4
+ * atomic file operations, search/grep engines, dependency tracer, scratchpad script runner,
5
+ * and central tool dispatcher used by the Minovative Mind AI agents.
6
+ */
1
7
  import { exec } from 'node:child_process';
2
8
  import { promises as fs } from 'node:fs';
3
9
  import os from 'node:os';
@@ -9,7 +15,7 @@ import { workspaceRegistry } from './workspaceRegistry.js';
9
15
  import { changeLogger } from './changeLogger.js';
10
16
  import { findBestMatch, applyMatch } from '../utils/fuzzyMatch.js';
11
17
  import { localValidate } from '../utils/localSyntaxValidator.js';
12
- import { validateAndFixSyntax } from './agent/syntaxAgent.js';
18
+ import { validateAndFixSyntax, aiFuzzyMatch } from './agent/syntaxAgent.js';
13
19
  import { sanitizeForCDATA } from '../utils/contextPrompts.js';
14
20
  import { findDependencies, formatDependencyResult } from '../utils/dependencyTracer.js';
15
21
  import { atomicWriteFile } from '../utils/atomicWrite.js';
@@ -20,6 +26,12 @@ import { getCurrentAgentId } from '../utils/asyncContext.js';
20
26
  import ignore from 'ignore';
21
27
  const execAsync = promisify(exec);
22
28
  // ─── Tool Declarations for Gemini Function Calling ───────────────────
29
+ /**
30
+ * Returns the list of available function declarations for Gemini function calling.
31
+ *
32
+ * @param options - Configuration options such as `isExecutionAgent`.
33
+ * @returns An array of Google Generative AI `FunctionDeclaration` objects.
34
+ */
23
35
  export function getToolDeclarations(options) {
24
36
  if (options?.isExecutionAgent) {
25
37
  return toolDeclarations;
@@ -31,6 +43,17 @@ export function getToolDeclarations(options) {
31
43
  * every tool the agent can invoke. Passed to the model at init.
32
44
  */
33
45
  export const toolDeclarations = [
46
+ {
47
+ name: 'perform_web_search',
48
+ description: 'Search the web for documentation, solutions, or real-time information.',
49
+ parameters: {
50
+ type: SchemaType.OBJECT,
51
+ properties: {
52
+ query: { type: SchemaType.STRING, description: 'The search query to look up on the web' },
53
+ },
54
+ required: ['query'],
55
+ },
56
+ },
34
57
  {
35
58
  name: 'read_file',
36
59
  description: 'Read the contents of a file at the given path relative to the workspace root. Supports text files and native parsing of .pdf files (including math and diagrams). Use startLine and endLine to read specific chunks of massive files to avoid context limits. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
@@ -110,7 +133,7 @@ export const toolDeclarations = [
110
133
  },
111
134
  {
112
135
  name: 'modify_file',
113
- description: 'Perform one or multiple targeted search-and-replace edits in a single file. CRITICAL REQUIREMENT: You MUST use read_file or grep_search to fetch the exact current file content BEFORE using this tool. Do NOT guess or hallucinate the searchContent without reading the exact lines first, or the edit will fail. The search strings must match the current file exactly (including whitespace). This is preferred over write_file for editing existing files. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
136
+ description: 'Perform one or multiple targeted search-and-replace edits in a single file. CRITICAL REQUIREMENT: You MUST have the exact current file content before using this tool. If the file is NOT already in your context, you MUST use read_file or grep_search to fetch it. Do NOT guess or hallucinate the searchContent without reading the exact lines first, or the edit will fail. The search strings must match the current file exactly (including whitespace). This is preferred over write_file for editing existing files. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
114
137
  parameters: {
115
138
  type: SchemaType.OBJECT,
116
139
  properties: {
@@ -174,7 +197,7 @@ export const toolDeclarations = [
174
197
  },
175
198
  {
176
199
  name: 'grep_search',
177
- description: 'Search for a text pattern across files in the workspace. Returns matching file paths with line numbers and content snippets. Uses Extended Regular Expressions (grep -E). IMPORTANT: grep searches line-by-line. Do NOT search for long lists of Tailwind classes or multi-line strings, as they will fail if line-wrapped. Search for short, unique substrings. Use the workspace parameter to search external workspaces.',
200
+ description: 'Search for a text pattern across files in the workspace. Returns matching file paths with line numbers and content snippets. Uses Extended Regular Expressions (grep -E) by default, or fixed strings (grep -F) if fixedStrings is true. IMPORTANT: grep searches line-by-line. Do NOT search for long lists of Tailwind classes or multi-line strings, as they will fail if line-wrapped. Search for short, unique substrings. Use the workspace parameter to search external workspaces.',
178
201
  parameters: {
179
202
  type: SchemaType.OBJECT,
180
203
  properties: {
@@ -186,6 +209,14 @@ export const toolDeclarations = [
186
209
  type: SchemaType.STRING,
187
210
  description: 'Optional glob to restrict file types, e.g. "*.ts" or "*.py". Defaults to all files.',
188
211
  },
212
+ fixedStrings: {
213
+ type: SchemaType.BOOLEAN,
214
+ description: 'Optional. If true, treats the pattern as a fixed string instead of a regular expression (grep -F). Useful for searching code containing regex special characters.',
215
+ },
216
+ dirPath: {
217
+ type: SchemaType.STRING,
218
+ description: 'Optional. The directory path to restrict the search to, relative to the workspace root. Defaults to the workspace root.',
219
+ },
189
220
  workspace: {
190
221
  type: SchemaType.STRING,
191
222
  description: 'Optional. Workspace alias to search in (e.g., "backend"), or "all" to search all registered workspaces. Defaults to the primary workspace.',
@@ -297,16 +328,36 @@ export const toolDeclarations = [
297
328
  },
298
329
  ];
299
330
  let currentApprovalMode = 'ask';
331
+ /**
332
+ * Retrieves the current command execution approval mode.
333
+ *
334
+ * @returns The active {@link ApprovalMode}.
335
+ */
300
336
  export function getApprovalMode() {
301
337
  return currentApprovalMode;
302
338
  }
339
+ /**
340
+ * Sets the command execution approval mode.
341
+ *
342
+ * @param mode - The {@link ApprovalMode} to set.
343
+ */
303
344
  export function setApprovalMode(mode) {
304
345
  currentApprovalMode = mode;
305
346
  }
306
347
  let subAgentsEnabled = true;
348
+ /**
349
+ * Checks whether sub-agents are currently enabled.
350
+ *
351
+ * @returns `true` if sub-agents are enabled, `false` otherwise.
352
+ */
307
353
  export function isSubAgentsEnabled() {
308
354
  return subAgentsEnabled;
309
355
  }
356
+ /**
357
+ * Enables or disables sub-agents.
358
+ *
359
+ * @param enabled - Boolean indicating whether sub-agents should be enabled.
360
+ */
310
361
  export function setSubAgentsEnabled(enabled) {
311
362
  subAgentsEnabled = enabled;
312
363
  }
@@ -347,6 +398,9 @@ const DEFAULT_IGNORED_FILES = new Set([
347
398
  ]);
348
399
  /**
349
400
  * Parses .gitignore and .minovativemindignore to supplement the default ignore lists.
401
+ *
402
+ * @param workspaceRoot - Absolute path to the workspace root directory.
403
+ * @returns An object containing `ignoredDirs` (Set), `ignoredFiles` (Set), and `ig` (ignore instance).
350
404
  */
351
405
  async function getIgnoredPaths(workspaceRoot) {
352
406
  const ignoredDirs = new Set(DEFAULT_IGNORED_DIRS);
@@ -378,6 +432,18 @@ async function getIgnoredPaths(workspaceRoot) {
378
432
  return { ignoredDirs, ignoredFiles, ig };
379
433
  }
380
434
  // ─── Tool Implementations ────────────────────────────────────────────
435
+ /**
436
+ * Reads the contents of a file at the given relative or absolute path, supporting text files,
437
+ * native PDF parsing, SQLite database schema dumping, Jupyter Notebook (.ipynb) cell extraction,
438
+ * and CSV-to-Markdown table formatting.
439
+ *
440
+ * @param workspaceRoot - Absolute path to the workspace root directory.
441
+ * @param filePath - Relative path to the target file (or `@alias/` prefixed path).
442
+ * @param startLine - Optional 1-indexed starting line number.
443
+ * @param endLine - Optional 1-indexed ending line number.
444
+ * @param targetElements - Optional array of specific symbol or function names to extract.
445
+ * @returns A promise resolving to a {@link ToolResult} containing the file content or error details.
446
+ */
381
447
  export async function readFile(workspaceRoot, filePath, startLine, endLine, targetElements) {
382
448
  try {
383
449
  const absPath = resolveAndValidatePath(workspaceRoot, filePath);
@@ -573,7 +639,7 @@ export async function readFile(workspaceRoot, filePath, startLine, endLine, targ
573
639
  }
574
640
  const attrLines = startLine || endLine ? ` lines="${startLine || 1}-${endLine || 'end'}"` : '';
575
641
  const attrTargets = targetElements && targetElements.length > 0 ? ` elements="${targetElements.join(',')}"` : '';
576
- const wrappedContent = `<workspace_file path="${filePath}"${attrLines}${attrTargets}>\n<content_data><![CDATA[\n${sanitizeForCDATA(content)}\n]]></content_data>\n</workspace_file>`;
642
+ const wrappedContent = `<workspace_file path="${filePath}"${attrLines}${attrTargets}>\n<content_data><![CDATA[\n${sanitizeForCDATA(content)}\n]]\\u200B></content_data>\n</workspace_file>`;
577
643
  return { output: wrappedContent };
578
644
  }
579
645
  catch (err) {
@@ -581,6 +647,15 @@ export async function readFile(workspaceRoot, filePath, startLine, endLine, targ
581
647
  return { output: '', error: `Failed to read file "${filePath}": ${message}` };
582
648
  }
583
649
  }
650
+ /**
651
+ * Creates a new file or completely overwrites an existing file with the provided content,
652
+ * performing local syntax validation and iterative AI syntax auto-correction if validation fails.
653
+ *
654
+ * @param workspaceRoot - Absolute path to the workspace root directory.
655
+ * @param filePath - Relative path to the target file.
656
+ * @param content - The complete content to write.
657
+ * @returns A promise resolving to a {@link ToolResult} indicating success or failure.
658
+ */
584
659
  export async function writeFile(workspaceRoot, filePath, content) {
585
660
  try {
586
661
  const absPath = resolveAndValidatePath(workspaceRoot, filePath);
@@ -640,6 +715,13 @@ export async function writeFile(workspaceRoot, filePath, content) {
640
715
  };
641
716
  }
642
717
  }
718
+ /**
719
+ * Deletes a file from the filesystem and records the deletion in the change logger.
720
+ *
721
+ * @param workspaceRoot - Absolute path to the workspace root directory.
722
+ * @param filePath - Relative path to the file to delete.
723
+ * @returns A promise resolving to a {@link ToolResult} indicating success or failure.
724
+ */
643
725
  export async function deleteFile(workspaceRoot, filePath) {
644
726
  try {
645
727
  const absPath = resolveAndValidatePath(workspaceRoot, filePath);
@@ -659,6 +741,14 @@ export async function deleteFile(workspaceRoot, filePath) {
659
741
  return { output: '', error: `Failed to delete file "${filePath}": ${message}` };
660
742
  }
661
743
  }
744
+ /**
745
+ * Moves or renames a file from `sourcePath` to `targetPath`.
746
+ *
747
+ * @param workspaceRoot - Absolute path to the workspace root directory.
748
+ * @param sourcePath - Original relative file path.
749
+ * @param targetPath - New destination relative file path.
750
+ * @returns A promise resolving to a {@link ToolResult} indicating success or failure.
751
+ */
662
752
  export async function renameFile(workspaceRoot, sourcePath, targetPath) {
663
753
  try {
664
754
  const absSource = resolveAndValidatePath(workspaceRoot, sourcePath);
@@ -683,6 +773,15 @@ export async function renameFile(workspaceRoot, sourcePath, targetPath) {
683
773
  return { output: '', error: `Failed to move/rename file: ${message}` };
684
774
  }
685
775
  }
776
+ /**
777
+ * Performs one or multiple targeted search-and-replace edits on a file, utilizing deterministic fuzzy matching,
778
+ * fallback AI fuzzy matching, syntax validation, and atomic writes.
779
+ *
780
+ * @param workspaceRoot - Absolute path to the workspace root directory.
781
+ * @param filePath - Relative path to the target file.
782
+ * @param edits - An array of edit objects containing `searchContent` and `replaceContent`.
783
+ * @returns A promise resolving to a {@link ToolResult} indicating success or failure.
784
+ */
686
785
  export async function modifyFile(workspaceRoot, filePath, edits) {
687
786
  const MAX_MODIFY_RETRIES = 4;
688
787
  const absPath = resolveAndValidatePath(workspaceRoot, filePath);
@@ -691,6 +790,7 @@ export async function modifyFile(workspaceRoot, filePath, edits) {
691
790
  const existing = await fs.readFile(absPath, 'utf-8');
692
791
  let modified = existing;
693
792
  const strategies = [];
793
+ const appliedEdits = [];
694
794
  for (let i = 0; i < edits.length; i++) {
695
795
  const edit = edits[i];
696
796
  const match = findBestMatch(modified, edit.searchContent);
@@ -703,15 +803,29 @@ export async function modifyFile(workspaceRoot, filePath, edits) {
703
803
  modified = existing; // reset
704
804
  break;
705
805
  }
806
+ // Fall back to AI fuzzy search matching when deterministic findBestMatch fails after retries
807
+ const aiResult = await aiFuzzyMatch(modified, edit.searchContent, edit.replaceContent, filePath);
808
+ if (aiResult.success && aiResult.content) {
809
+ modified = aiResult.content;
810
+ strategies.push(`Edit #${i + 1}: AI Fuzzy Match`);
811
+ appliedEdits.push({ index: i + 1, line: -1, strategy: 'AI Fuzzy Match' });
812
+ continue;
813
+ }
706
814
  // Provide a preview of the file to help the AI self-correct
707
815
  const preview = modified.split('\n').slice(0, 30).join('\n');
816
+ const priorEditsSummary = appliedEdits.length > 0
817
+ ? `\nPrior applied edits before failure:\n` + appliedEdits.map((e) => ` - Edit #${e.index}: Line ${e.line === -1 ? 'N/A' : e.line} (${e.strategy})`).join('\n') + '\n'
818
+ : '';
708
819
  return {
709
820
  output: '',
710
- error: `Edit #${i + 1} failed: Search content not found in "${filePath}". Please call 'read_file' to check the exact lines of code before retrying modify_file.\n\nFile start preview:\n${preview}\n...`,
821
+ error: `Edit #${i + 1} failed: Search content not found in "${filePath}".${priorEditsSummary}\n` +
822
+ `Actionable guidance: Ensure searchContent contains sufficient unique context (e.g. 5-10 surrounding lines) to match distinctly. Call 'read_file' to examine the latest file contents before retrying.\n\nFile start preview:\n${preview}\n...`,
711
823
  };
712
824
  }
825
+ const matchLine = modified.slice(0, match.start).split('\n').length;
713
826
  modified = applyMatch(modified, match, edit.replaceContent);
714
- strategies.push(`Edit #${i + 1}: ${match.strategy}`);
827
+ strategies.push(`Edit #${i + 1}: Line ${matchLine} (${match.strategy})`);
828
+ appliedEdits.push({ index: i + 1, line: matchLine, strategy: match.strategy });
715
829
  const collector = getMetricCollector();
716
830
  if (collector) {
717
831
  if (match.strategy.startsWith('Exact Match')) {
@@ -741,22 +855,29 @@ export async function modifyFile(workspaceRoot, filePath, edits) {
741
855
  let success = false;
742
856
  while (attempts < MAX_RETRIES && !success) {
743
857
  attempts++;
744
- const fixed = await validateAndFixSyntax(finalModified, filePath);
858
+ const fixed = await validateAndFixSyntax(finalModified, filePath, localResult.error);
745
859
  if (fixed) {
746
860
  finalModified = fixed;
747
861
  const recheck = localValidate(filePath, finalModified);
748
862
  if (recheck.isValid) {
749
863
  success = true;
750
864
  }
865
+ else {
866
+ localResult = recheck;
867
+ }
751
868
  }
752
869
  else {
753
870
  break;
754
871
  }
755
872
  }
756
873
  if (!success) {
874
+ const appliedSummary = appliedEdits.length > 0
875
+ ? `\nApplied edit details:\n` + appliedEdits.map((e) => ` - Edit #${e.index}: Line ${e.line === -1 ? 'N/A' : e.line} (${e.strategy})`).join('\n')
876
+ : '';
757
877
  return {
758
878
  output: '',
759
- error: `Syntax validation failed after ${attempts} attempts: ${localResult.error || 'Unknown syntax error'}`,
879
+ error: `Syntax validation failed after ${attempts} attempts: ${localResult.error || 'Unknown syntax error'}${appliedSummary}\n` +
880
+ `Actionable guidance: A search block may have matched at an incorrect line due to non-unique search context in a large file. Re-examine the target file with 'read_file' and provide a searchContent block with more unique surrounding lines (e.g. 5-10 lines of distinct context).`,
760
881
  };
761
882
  }
762
883
  }
@@ -779,6 +900,15 @@ export async function modifyFile(workspaceRoot, filePath, edits) {
779
900
  }
780
901
  return { output: '', error: 'Modify failed.' };
781
902
  }
903
+ /**
904
+ * Lists files and subdirectories within a directory, returning a recursive ASCII tree structure
905
+ * while respecting ignore rules and excluded extensions.
906
+ *
907
+ * @param workspaceRoot - Absolute path to the workspace root directory.
908
+ * @param dirPath - Relative path to the target directory.
909
+ * @param maxDepth - Maximum recursion depth (defaults to 3).
910
+ * @returns A promise resolving to a {@link ToolResult} containing the formatted tree output.
911
+ */
782
912
  export async function listDirectory(workspaceRoot, dirPath, maxDepth = 3) {
783
913
  try {
784
914
  const absPath = resolveAndValidatePath(workspaceRoot, dirPath);
@@ -833,11 +963,19 @@ export async function listDirectory(workspaceRoot, dirPath, maxDepth = 3) {
833
963
  };
834
964
  }
835
965
  }
966
+ /**
967
+ * Executes a shell command within the workspace root with a timeout and buffer limit.
968
+ *
969
+ * @param workspaceRoot - Absolute path to the workspace root directory.
970
+ * @param command - The shell command string to execute.
971
+ * @param abortSignal - Optional AbortSignal to cancel execution.
972
+ * @returns A promise resolving to a {@link ToolResult} containing stdout/stderr or an error.
973
+ */
836
974
  export async function runCommand(workspaceRoot, command, abortSignal) {
837
975
  try {
838
976
  const { stdout, stderr } = await execAsync(command, {
839
977
  cwd: workspaceRoot,
840
- timeout: 120_000, // 120 secgot these ond timeout
978
+ timeout: 120_000, // 120 second timeout
841
979
  maxBuffer: 1024 * 1024 * 2, // 2 MB buffer
842
980
  signal: abortSignal,
843
981
  });
@@ -862,45 +1000,38 @@ export async function runCommand(workspaceRoot, command, abortSignal) {
862
1000
  return { output: '', error: `Command failed: ${truncatedMsg}` };
863
1001
  }
864
1002
  }
865
- export async function grepSearch(workspaceRoot, pattern, fileGlob, abortSignal) {
1003
+ /**
1004
+ * Performs a grep text or regular expression search across files in the workspace.
1005
+ *
1006
+ * @param workspaceRoot - Absolute path to the workspace root directory.
1007
+ * @param pattern - The search text or regex pattern.
1008
+ * @param fileGlob - Optional file glob to restrict matched files.
1009
+ * @param fixedStrings - If true, treats the pattern as a literal fixed string.
1010
+ * @param dirPath - Optional subdirectory path to restrict the search.
1011
+ * @param abortSignal - Optional AbortSignal to cancel execution.
1012
+ * @returns A promise resolving to a {@link ToolResult} containing matching lines and file paths.
1013
+ */
1014
+ export async function grepSearch(workspaceRoot, pattern, fileGlob, fixedStrings, dirPath, abortSignal) {
866
1015
  try {
867
- const { ignoredDirs, ig } = await getIgnoredPaths(workspaceRoot);
868
- if (os.platform() !== 'win32') {
869
- const excludeDirArgs = Array.from(ignoredDirs)
870
- .map((dir) => `--exclude-dir="${dir}"`)
871
- .concat('--exclude-dir=".*"') // Exclude all hidden directories
872
- .join(' ');
873
- const excludeFileArgs = EXCLUDED_EXTENSIONS.map((ext) => `--exclude="${ext}"`)
874
- .concat('--exclude=".*"') // Exclude all hidden files
875
- .join(' ');
876
- // Build a grep command that works cross-platform via node
877
- // We use grep -rnIEi for recursive, line numbers, skip binary, extended regex, case-insensitive
878
- const globArg = fileGlob ? ` --include="${fileGlob}"` : '';
879
- const cmd = `grep -rnIEi --color=never ${excludeDirArgs} ${excludeFileArgs}${globArg} "${pattern.replace(/"/g, '\\"')}" .`;
880
- const { stdout } = await execAsync(cmd, {
881
- cwd: workspaceRoot,
882
- timeout: 15_000,
883
- maxBuffer: 1024 * 1024 * 2,
884
- signal: abortSignal,
885
- });
886
- // Limit output to 50 results
887
- const lines = stdout.trim().split('\n');
888
- const limited = lines.slice(0, 50);
889
- const resultText = limited.join('\n') + (lines.length > 50 ? `\n\n... (${lines.length - 50} more results truncated)` : '');
890
- if (!resultText)
891
- return { output: `No matches found for "${pattern}".` };
892
- const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
893
- return { output: wrappedResult };
1016
+ const { ig } = await getIgnoredPaths(workspaceRoot);
1017
+ // Node.js cross-platform implementation
1018
+ // This avoids shell injection risks and correctly supports path globs via 'ignore' package.
1019
+ const finalPattern = fixedStrings ? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : pattern;
1020
+ let regexPattern;
1021
+ try {
1022
+ regexPattern = new RegExp(finalPattern, 'gi');
1023
+ }
1024
+ catch (err) {
1025
+ return { output: `Invalid search pattern: ${err.message}` };
894
1026
  }
895
- // Node.js fallback for Windows
896
- const regexPattern = new RegExp(pattern, 'gi'); // Emulate grep -i and global match
897
- const globPattern = fileGlob
898
- ? new RegExp('^' + fileGlob.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$', 'i')
899
- : null;
1027
+ // Use ignore package to evaluate path globs (e.g. src/**/*.ts)
1028
+ const globMatcher = fileGlob ? ignore().add(fileGlob) : null;
900
1029
  const results = [];
901
1030
  async function walk(dir) {
902
1031
  if (results.length >= 50)
903
1032
  return;
1033
+ if (abortSignal?.aborted)
1034
+ return;
904
1035
  let entries;
905
1036
  try {
906
1037
  entries = await fs.readdir(dir, { withFileTypes: true });
@@ -911,11 +1042,14 @@ export async function grepSearch(workspaceRoot, pattern, fileGlob, abortSignal)
911
1042
  for (const entry of entries) {
912
1043
  if (results.length >= 50)
913
1044
  return;
1045
+ if (abortSignal?.aborted)
1046
+ return;
914
1047
  if (entry.name.startsWith('.'))
915
1048
  continue;
916
1049
  const entryPath = path.join(dir, entry.name);
917
1050
  const relPath = path.relative(workspaceRoot, entryPath).replace(/\\/g, '/');
918
1051
  if (entry.isDirectory()) {
1052
+ // Check if directory is ignored by .gitignore
919
1053
  if (ig.ignores(relPath + '/'))
920
1054
  continue;
921
1055
  await walk(entryPath);
@@ -925,7 +1059,8 @@ export async function grepSearch(workspaceRoot, pattern, fileGlob, abortSignal)
925
1059
  continue;
926
1060
  if (EXCLUDED_EXTENSIONS.some((ext) => entry.name.endsWith(ext.replace('*', ''))))
927
1061
  continue;
928
- if (globPattern && !globPattern.test(entry.name))
1062
+ // If a fileGlob was provided, verify this path matches the glob (i.e. 'ignore' would ignore it)
1063
+ if (globMatcher && !globMatcher.ignores(relPath))
929
1064
  continue;
930
1065
  try {
931
1066
  const filePath = path.join(dir, entry.name);
@@ -936,8 +1071,8 @@ export async function grepSearch(workspaceRoot, pattern, fileGlob, abortSignal)
936
1071
  break;
937
1072
  regexPattern.lastIndex = 0;
938
1073
  if (regexPattern.test(lines[i])) {
939
- const relPath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/');
940
- results.push(`${relPath}:${i + 1}:${lines[i]}`);
1074
+ const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/');
1075
+ results.push(`${relativePath}:${i + 1}:${lines[i]}`);
941
1076
  }
942
1077
  }
943
1078
  }
@@ -947,36 +1082,38 @@ export async function grepSearch(workspaceRoot, pattern, fileGlob, abortSignal)
947
1082
  }
948
1083
  }
949
1084
  }
950
- await walk(workspaceRoot);
1085
+ const searchRoot = dirPath ? path.resolve(workspaceRoot, dirPath) : workspaceRoot;
1086
+ await walk(searchRoot);
1087
+ if (abortSignal?.aborted) {
1088
+ return { output: '', error: 'Grep search aborted by user.' };
1089
+ }
951
1090
  if (results.length === 0)
952
1091
  return { output: `No matches found for "${pattern}".` };
953
1092
  const resultText = results.join('\n') + (results.length >= 50 ? `\n\n... (more results truncated)` : '');
954
- const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
1093
+ const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]\\u200B></content_data>\n</workspace_file>`;
955
1094
  return { output: wrappedResult };
956
1095
  }
957
1096
  catch (err) {
958
- if (err instanceof Error && 'code' in err && err.code === 1) {
959
- return { output: `No matches found for "${pattern}".` };
960
- }
961
- if (err instanceof Error && err.name === 'AbortError') {
962
- return { output: '', error: 'Grep search aborted by user.' };
963
- }
964
1097
  const message = err instanceof Error ? err.message : String(err);
965
- // Also handle stringified exit codes just in case
966
- if (message.includes('Command failed') &&
967
- (message.includes('exit code 1') || message.includes('exited with code 1'))) {
968
- return { output: `No matches found for "${pattern}".` };
969
- }
970
- return { output: '', error: `Grep failed: ${message}` };
1098
+ return { output: '', error: `Grep search failed: ${message}` };
971
1099
  }
972
1100
  }
1101
+ /**
1102
+ * Traces file dependencies (forward imports and reverse dependants) to determine the blast radius of changes.
1103
+ *
1104
+ * @param workspaceRoot - Absolute path to the workspace root directory.
1105
+ * @param filePath - Relative path to the file to trace.
1106
+ * @param direction - Trace direction (`'both'`, `'forward'`, or `'reverse'`).
1107
+ * @param maxDepth - Maximum recursion depth (defaults to 3).
1108
+ * @returns A promise resolving to a {@link ToolResult} containing formatted dependency results.
1109
+ */
973
1110
  export async function traceDependencies(workspaceRoot, filePath, direction, maxDepth) {
974
1111
  try {
975
1112
  const dir = direction === 'forward' || direction === 'reverse' ? direction : 'both';
976
1113
  const depth = maxDepth && maxDepth > 0 ? Math.min(maxDepth, 5) : 3;
977
1114
  const result = await findDependencies(workspaceRoot, filePath, dir, depth);
978
1115
  const formatted = formatDependencyResult(result);
979
- const wrappedResult = `<workspace_file path="dependency_trace_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(formatted)}\n]]></content_data>\n</workspace_file>`;
1116
+ const wrappedResult = `<workspace_file path="dependency_trace_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(formatted)}\n]]\\u200B></content_data>\n</workspace_file>`;
980
1117
  return { output: wrappedResult };
981
1118
  }
982
1119
  catch (err) {
@@ -984,6 +1121,15 @@ export async function traceDependencies(workspaceRoot, filePath, direction, maxD
984
1121
  return { output: '', error: `Dependency tracing failed for "${filePath}": ${message}` };
985
1122
  }
986
1123
  }
1124
+ /**
1125
+ * Finds files modified within the workspace recently (within a specified time window).
1126
+ *
1127
+ * @param workspaceRoot - Absolute path to the workspace root directory.
1128
+ * @param dirPath - Subdirectory path to search from (defaults to `'.'`).
1129
+ * @param minutes - Time window in minutes (defaults to 60).
1130
+ * @param maxDepth - Maximum directory traversal depth (defaults to 5).
1131
+ * @returns A promise resolving to a {@link ToolResult} containing recently modified files.
1132
+ */
987
1133
  export async function findRecentChanges(workspaceRoot, dirPath = '.', minutes = 60, maxDepth = 5) {
988
1134
  try {
989
1135
  const absPath = resolveAndValidatePath(workspaceRoot, dirPath);
@@ -1041,7 +1187,7 @@ export async function findRecentChanges(workspaceRoot, dirPath = '.', minutes =
1041
1187
  return `- ${f.path} (${minsAgo} minutes ago)`;
1042
1188
  });
1043
1189
  const resultText = `Files modified in the last ${minutes} minutes:\n${lines.join('\n')}`;
1044
- const wrappedResult = `<workspace_file path="recent_changes">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
1190
+ const wrappedResult = `<workspace_file path="recent_changes">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]\\u200B></content_data>\n</workspace_file>`;
1045
1191
  return { output: wrappedResult };
1046
1192
  }
1047
1193
  catch (err) {
@@ -1053,6 +1199,16 @@ export async function findRecentChanges(workspaceRoot, dirPath = '.', minutes =
1053
1199
  }
1054
1200
  }
1055
1201
  // ─── Tool Dispatcher ─────────────────────────────────────────────────
1202
+ /**
1203
+ * Writes a disposable scratchpad script to a temporary file, executes it using the specified runtime,
1204
+ * and returns standard output and standard error.
1205
+ *
1206
+ * @param workspaceRoot - Absolute path to the workspace root directory.
1207
+ * @param language - Runtime language (`'node'`, `'ts-node'`, `'python'`, `'bash'`, `'go'`, or `'rust'`).
1208
+ * @param code - The exact script source code to execute.
1209
+ * @param abortSignal - Optional AbortSignal to cancel execution.
1210
+ * @returns A promise resolving to a {@link ToolResult} containing execution output.
1211
+ */
1056
1212
  export async function runDebugScript(workspaceRoot, language, code, abortSignal) {
1057
1213
  const extMap = {
1058
1214
  node: '.js',
@@ -1144,6 +1300,11 @@ export async function runDebugScript(workspaceRoot, language, code, abortSignal)
1144
1300
  /**
1145
1301
  * Resolves `@alias/` prefixed paths in tool arguments to the correct workspace root
1146
1302
  * and relative path. Returns the effective workspaceRoot and the cleaned arguments.
1303
+ *
1304
+ * @param primaryRoot - Absolute path to the primary workspace root.
1305
+ * @param toolName - Name of the tool being executed.
1306
+ * @param args - Tool arguments object.
1307
+ * @returns An object containing `effectiveRoot` and `resolvedArgs`.
1147
1308
  */
1148
1309
  function resolveWorkspaceArgs(primaryRoot, toolName, args) {
1149
1310
  // Only resolve if workspaces are registered
@@ -1183,12 +1344,20 @@ function resolveWorkspaceArgs(primaryRoot, toolName, args) {
1183
1344
  /**
1184
1345
  * Runs grep across all registered workspaces (primary + external) and merges results.
1185
1346
  * Results from external workspaces are prefixed with @alias/ for disambiguation.
1347
+ *
1348
+ * @param primaryRoot - Absolute path to the primary workspace root.
1349
+ * @param pattern - The search text or regex pattern.
1350
+ * @param fileGlob - Optional file glob restriction.
1351
+ * @param fixedStrings - If true, treats the pattern as a literal fixed string.
1352
+ * @param dirPath - Optional subdirectory restriction.
1353
+ * @param abortSignal - Optional AbortSignal to cancel execution.
1354
+ * @returns A promise resolving to a {@link ToolResult} containing merged search results.
1186
1355
  */
1187
- async function crossWorkspaceGrep(primaryRoot, pattern, fileGlob, abortSignal) {
1356
+ async function crossWorkspaceGrep(primaryRoot, pattern, fileGlob, fixedStrings, dirPath, abortSignal) {
1188
1357
  const allRoots = workspaceRegistry.getAllRoots(primaryRoot);
1189
1358
  const allResults = [];
1190
1359
  for (const { alias, root } of allRoots) {
1191
- const result = await grepSearch(root, pattern, fileGlob, abortSignal);
1360
+ const result = await grepSearch(root, pattern, fileGlob, fixedStrings, dirPath, abortSignal);
1192
1361
  if (result.error)
1193
1362
  continue;
1194
1363
  // Extract the raw text from the XML wrapper
@@ -1214,10 +1383,20 @@ async function crossWorkspaceGrep(primaryRoot, pattern, fileGlob, abortSignal) {
1214
1383
  }
1215
1384
  const limited = allResults.slice(0, 80);
1216
1385
  const resultText = limited.join('\n') + (allResults.length > 80 ? `\n\n... (${allResults.length - 80} more results truncated)` : '');
1217
- const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
1386
+ const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]\\u200B></content_data>\n</workspace_file>`;
1218
1387
  return { output: wrappedResult };
1219
1388
  }
1220
1389
  const currentTasksByAgent = new Map();
1390
+ /**
1391
+ * Central tool dispatcher that resolves multi-workspace paths, executes the requested tool
1392
+ * with the provided arguments, records metrics, and returns the standardized {@link ToolResult}.
1393
+ *
1394
+ * @param workspaceRoot - Absolute path to the primary workspace root directory.
1395
+ * @param toolName - Name of the tool to invoke.
1396
+ * @param args - Key-value map of tool arguments.
1397
+ * @param abortSignal - Optional AbortSignal to cancel tool execution.
1398
+ * @returns A promise resolving to a {@link ToolResult}.
1399
+ */
1221
1400
  export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
1222
1401
  // ─── Multi-Workspace Path Resolution ─────────────────────────────
1223
1402
  // Intercept @alias/ prefixed paths and swap workspaceRoot + relative path
@@ -1225,6 +1404,19 @@ export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
1225
1404
  const { effectiveRoot, resolvedArgs } = resolveWorkspaceArgs(workspaceRoot, toolName, args);
1226
1405
  let result;
1227
1406
  switch (toolName) {
1407
+ case 'perform_web_search': {
1408
+ const { createWebSearchAgentSession } = await import('./ai.js');
1409
+ const webSession = createWebSearchAgentSession();
1410
+ try {
1411
+ const webResult = await webSession.sendMessage(`Please search the web for the following query and summarize your findings:\n"${resolvedArgs.query}"`, undefined, abortSignal);
1412
+ const searchSummary = webResult.response.text()?.trim() || 'No relevant information found.';
1413
+ result = { output: `Web Search Findings:\n${searchSummary}` };
1414
+ }
1415
+ catch (e) {
1416
+ result = { error: e.message || 'Failed to search the web', output: '' };
1417
+ }
1418
+ break;
1419
+ }
1228
1420
  case 'read_file':
1229
1421
  result = await readFile(effectiveRoot, resolvedArgs.filePath, resolvedArgs.startLine, resolvedArgs.endLine, resolvedArgs.targetElements);
1230
1422
  break;
@@ -1317,15 +1509,15 @@ export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
1317
1509
  const wsParam = resolvedArgs.workspace;
1318
1510
  if (wsParam === 'all') {
1319
1511
  // Cross-workspace search across all registered workspaces
1320
- result = await crossWorkspaceGrep(workspaceRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
1512
+ result = await crossWorkspaceGrep(workspaceRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, resolvedArgs.fixedStrings, resolvedArgs.dirPath, abortSignal);
1321
1513
  }
1322
1514
  else if (wsParam && workspaceRegistry.get(wsParam)) {
1323
1515
  // Search in a specific external workspace
1324
1516
  const ws = workspaceRegistry.get(wsParam);
1325
- result = await grepSearch(ws.absolutePath, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
1517
+ result = await grepSearch(ws.absolutePath, resolvedArgs.pattern, resolvedArgs.fileGlob, resolvedArgs.fixedStrings, resolvedArgs.dirPath, abortSignal);
1326
1518
  }
1327
1519
  else {
1328
- result = await grepSearch(effectiveRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
1520
+ result = await grepSearch(effectiveRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, resolvedArgs.fixedStrings, resolvedArgs.dirPath, abortSignal);
1329
1521
  }
1330
1522
  break;
1331
1523
  }