bone-agent 1.3.3 → 2.0.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 (121) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -184
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -141
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -36
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/targeted_searching.md +0 -10
  25. package/prompts/main/task_lists_pattern.md +0 -8
  26. package/prompts/main/temp_folder.md +0 -9
  27. package/prompts/main/think_before_acting.md +0 -10
  28. package/prompts/main/tone_and_style.md +0 -4
  29. package/prompts/main/tool_preferences.md +0 -24
  30. package/prompts/main/trust_subagent_context.md +0 -21
  31. package/prompts/main/when_to_use_sub_agent.md +0 -7
  32. package/prompts/micro/ask_questions.md +0 -1
  33. package/prompts/micro/batch_independent_calls.md +0 -1
  34. package/prompts/micro/casual_interactions.md +0 -1
  35. package/prompts/micro/code_references.md +0 -1
  36. package/prompts/micro/communication_style.md +0 -1
  37. package/prompts/micro/context_reliability.md +0 -1
  38. package/prompts/micro/conversational_tool_calling.md +0 -1
  39. package/prompts/micro/editing_pattern.md +0 -1
  40. package/prompts/micro/error_handling.md +0 -1
  41. package/prompts/micro/exploration_pattern.md +0 -1
  42. package/prompts/micro/intro.md +0 -1
  43. package/prompts/micro/obsidian.md +0 -4
  44. package/prompts/micro/obsidian_project.md +0 -5
  45. package/prompts/micro/professional_objectivity.md +0 -1
  46. package/prompts/micro/targeted_searching.md +0 -1
  47. package/prompts/micro/task_lists_pattern.md +0 -1
  48. package/prompts/micro/temp_folder.md +0 -1
  49. package/prompts/micro/think_before_acting.md +0 -5
  50. package/prompts/micro/tone_and_style.md +0 -1
  51. package/prompts/micro/tool_preferences.md +0 -1
  52. package/prompts/micro/trust_subagent_context.md +0 -1
  53. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  54. package/requirements.txt +0 -9
  55. package/src/__init__.py +0 -11
  56. package/src/core/__init__.py +0 -1
  57. package/src/core/agentic.py +0 -985
  58. package/src/core/chat_manager.py +0 -1564
  59. package/src/core/config_manager.py +0 -253
  60. package/src/core/cron.py +0 -582
  61. package/src/core/cron_allowlist.py +0 -118
  62. package/src/core/memory.py +0 -145
  63. package/src/core/retry.py +0 -71
  64. package/src/core/sub_agent.py +0 -326
  65. package/src/core/tool_approval.py +0 -220
  66. package/src/core/tool_feedback.py +0 -778
  67. package/src/exceptions.py +0 -79
  68. package/src/llm/__init__.py +0 -1
  69. package/src/llm/client.py +0 -171
  70. package/src/llm/config.py +0 -492
  71. package/src/llm/prompts.py +0 -489
  72. package/src/llm/providers.py +0 -436
  73. package/src/llm/streaming.py +0 -163
  74. package/src/llm/token_tracker.py +0 -384
  75. package/src/tools/__init__.py +0 -212
  76. package/src/tools/constants.py +0 -59
  77. package/src/tools/create_file.py +0 -136
  78. package/src/tools/directory.py +0 -389
  79. package/src/tools/edit.py +0 -545
  80. package/src/tools/file_reader.py +0 -322
  81. package/src/tools/helpers/__init__.py +0 -105
  82. package/src/tools/helpers/base.py +0 -550
  83. package/src/tools/helpers/converters.py +0 -44
  84. package/src/tools/helpers/file_helpers.py +0 -189
  85. package/src/tools/helpers/formatters.py +0 -411
  86. package/src/tools/helpers/loader.py +0 -231
  87. package/src/tools/helpers/parallel_executor.py +0 -231
  88. package/src/tools/helpers/path_resolver.py +0 -232
  89. package/src/tools/helpers/plugin_manifest.py +0 -156
  90. package/src/tools/obsidian.py +0 -96
  91. package/src/tools/review_sub_agent.py +0 -189
  92. package/src/tools/rg_search.py +0 -460
  93. package/src/tools/search_plugins.py +0 -109
  94. package/src/tools/select_option.py +0 -600
  95. package/src/tools/shell.py +0 -302
  96. package/src/tools/sub_agent.py +0 -139
  97. package/src/tools/task_list.py +0 -269
  98. package/src/tools/web_search.py +0 -61
  99. package/src/ui/__init__.py +0 -1
  100. package/src/ui/banner.py +0 -87
  101. package/src/ui/commands.py +0 -2809
  102. package/src/ui/displays.py +0 -214
  103. package/src/ui/loader.py +0 -284
  104. package/src/ui/main.py +0 -647
  105. package/src/ui/prompt_utils.py +0 -113
  106. package/src/ui/setting_selector.py +0 -590
  107. package/src/ui/setup_wizard.py +0 -294
  108. package/src/ui/sub_agent_panel.py +0 -234
  109. package/src/ui/tool_confirmation.py +0 -215
  110. package/src/utils/__init__.py +0 -1
  111. package/src/utils/citation_parser.py +0 -199
  112. package/src/utils/editor.py +0 -158
  113. package/src/utils/gitignore_filter.py +0 -149
  114. package/src/utils/logger.py +0 -254
  115. package/src/utils/paths.py +0 -30
  116. package/src/utils/result_parsers.py +0 -108
  117. package/src/utils/safe_commands.py +0 -243
  118. package/src/utils/settings.py +0 -191
  119. package/src/utils/user_message_logger.py +0 -120
  120. package/src/utils/validation.py +0 -191
  121. package/src/utils/web_search.py +0 -173
@@ -1,460 +0,0 @@
1
- """Ripgrep search tool."""
2
-
3
- import logging
4
- import re
5
- import stat
6
- import subprocess
7
- from pathlib import Path
8
- from typing import Optional
9
-
10
- from .helpers.base import tool
11
- from .helpers.formatters import format_tool_result
12
- from .shell import _execute_direct_command, _prepare_execution_environment
13
- from .helpers.converters import coerce_bool, coerce_int
14
- from utils.settings import tool_settings
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
- # Default match limit for vault searches (separate from repo limit)
19
- _VAULT_MAX_MATCHES = 20
20
-
21
- # Regex for detecting file-path lines in rg output (shared by _annotate_file_sizes and _search_vault)
22
- _path_line_re = re.compile(r"^[^\s:|].*[/.]")
23
-
24
-
25
- def _format_file_size(size_bytes: int) -> str:
26
- """Format file size in human-readable form."""
27
- if size_bytes < 1024:
28
- return f"{size_bytes} B"
29
- elif size_bytes < 1024 * 1024:
30
- return f"{size_bytes / 1024:.1f} KB"
31
- else:
32
- return f"{size_bytes / (1024 * 1024):.1f} MB"
33
-
34
-
35
- def _annotate_file_sizes(formatted_output: str, base_path: Path, output_mode: str = "files_with_matches") -> str:
36
- """Append human-readable file sizes to each file path line in rg output.
37
-
38
- Works on files_with_matches and count output modes where each content
39
- line starts with a file path. Skips metadata, truncation, and section
40
- header lines. Skipped entirely for content mode (no benefit there).
41
- """
42
- if output_mode == "content":
43
- return formatted_output
44
-
45
- lines = formatted_output.split("\n")
46
- annotated = []
47
- for line in lines:
48
- stripped = line.strip()
49
- if (not stripped
50
- or stripped.startswith("exit_code=")
51
- or stripped.startswith("matches=")
52
- or stripped.startswith("files=")
53
- or stripped.startswith("... (")
54
- or stripped.startswith("[repo]")
55
- or stripped.startswith("[vault]")):
56
- annotated.append(line)
57
- continue
58
- # Only annotate pure file-path lines (files_with_matches: "file",
59
- # count: "file:N"). Skip content-mode match lines ("file:line:match")
60
- # which always have 2+ colons or a colon-digit-dash pattern.
61
- parts = line.split(":")
62
- is_file_line = (
63
- _path_line_re.match(line)
64
- and len(parts) <= 2
65
- and (len(parts) == 1 or parts[1].strip().isdigit())
66
- )
67
- if is_file_line:
68
- file_part = parts[0].strip()
69
- full_path = base_path / file_part
70
- try:
71
- st = full_path.stat()
72
- if stat.S_ISREG(st.st_mode):
73
- size = _format_file_size(st.st_size)
74
- annotated.append(f"{line} {size:>8}")
75
- else:
76
- annotated.append(line)
77
- except (OSError, ValueError):
78
- annotated.append(line)
79
- else:
80
- annotated.append(line)
81
- return "\n".join(annotated)
82
-
83
-
84
- @tool(
85
- name="rg",
86
- description="Search files using ripgrep. Use for ALL code searches (never shell commands). Supports regex, glob/type filtering, and output modes: content, files_with_matches, or count.",
87
- parameters={
88
- "type": "object",
89
- "properties": {
90
- "pattern": {
91
- "type": "string",
92
- "description": "Regular expression pattern to search for"
93
- },
94
- "path": {
95
- "type": "string",
96
- "description": "File or directory to search (default: current directory)"
97
- },
98
- "glob": {
99
- "type": "string",
100
- "description": "Glob filter (e.g. \"*.js\", \"**/*.tsx\")"
101
- },
102
- "type": {
103
- "type": "string",
104
- "description": "File type (e.g. js, py, rust, go, java)"
105
- },
106
- "output_mode": {
107
- "type": "string",
108
- "enum": ["content", "files_with_matches", "count"],
109
- "description": "Output mode (default: files_with_matches)"
110
- },
111
- "context_lines": {
112
- "type": "integer",
113
- "description": "Context lines around matches (requires output_mode: content)"
114
- },
115
- "case_insensitive": {
116
- "type": "boolean",
117
- "description": "Case insensitive search"
118
- },
119
- "multiline": {
120
- "type": "boolean",
121
- "description": "Patterns can span lines"
122
- },
123
- "max_matches": {
124
- "type": "integer",
125
- "description": "Max matches across all files (default: 100, 0 = use line limit)"
126
- }
127
- },
128
- "required": ["pattern"]
129
- },
130
- requires_approval=False
131
- )
132
- def rg(
133
- pattern: str,
134
- repo_root: Path,
135
- rg_exe_path: str,
136
- console,
137
- chat_manager,
138
- debug_mode: bool = False,
139
- gitignore_spec = None,
140
- path: Optional[str] = None,
141
- glob: Optional[str] = None,
142
- output_mode: str = "files_with_matches",
143
- vault_root: Optional[str] = None,
144
- **kwargs
145
- ) -> str:
146
- """Search for patterns using ripgrep.
147
-
148
- Args:
149
- pattern: Regular expression pattern to search for
150
- repo_root: Repository root directory (injected by context)
151
- rg_exe_path: Path to rg executable (injected by context)
152
- console: Rich console for output (injected by context)
153
- chat_manager: ChatManager instance (injected by context)
154
- debug_mode: Whether debug mode is enabled (injected by context)
155
- gitignore_spec: PathSpec for .gitignore filtering (injected by context)
156
- path: File or directory to search in (default: current directory)
157
- glob: Glob pattern to filter files
158
- output_mode: Output mode (content/files_with_matches/count)
159
- vault_root: Obsidian vault root path (injected by context)
160
- **kwargs: Additional keyword arguments (type, multiline, context_lines, case_insensitive)
161
-
162
- Returns:
163
- Search results with exit code. Vault results are included in a
164
- separate section when vault_root is active.
165
- """
166
- if not isinstance(pattern, str) or not pattern.strip():
167
- return "exit_code=1\nrg requires a non-empty 'pattern' argument."
168
-
169
- # Build rg args as a list (bypass string roundtrip to avoid shlex issues with regex metacharacters)
170
- args = []
171
-
172
- # Add --line-number for content mode
173
- if output_mode == "content":
174
- args.append("--line-number")
175
-
176
- # Add multiline flag
177
- multiline = coerce_bool(kwargs.get("multiline"), default=False)
178
- if multiline:
179
- args.append("-U")
180
- args.append("--multiline-dotall")
181
-
182
- # Add case insensitive flag
183
- case_insensitive = coerce_bool(kwargs.get("case_insensitive"), default=False)
184
- if case_insensitive:
185
- args.append("--ignore-case")
186
-
187
- # Add context lines flag
188
- context_lines = coerce_int(kwargs.get("context_lines"))[0] if kwargs.get("context_lines") else None
189
- if context_lines:
190
- args.append(f"--context={context_lines}")
191
-
192
- # Add glob pattern
193
- if glob:
194
- args.append(f"--glob={glob}")
195
-
196
- # Add file type filter
197
- file_type = kwargs.get("type")
198
- if file_type:
199
- args.append(f"--type={file_type}")
200
-
201
- # Add output mode flags
202
- if output_mode == "files_with_matches":
203
- args.append("--files-with-matches")
204
- elif output_mode == "count":
205
- args.append("--count")
206
-
207
- # Pattern and search path — no quoting needed, subprocess list form bypasses shell
208
- args.append(pattern)
209
-
210
- search_path = path or "."
211
- args.append(search_path)
212
-
213
- # Get max_matches from kwargs (default: 100, set to 0 for no limit)
214
- raw = coerce_int(kwargs.get("max_matches"))[0] if kwargs.get("max_matches") is not None else None
215
- max_matches = raw if raw is not None and raw >= 0 else 100
216
-
217
- # Execute repo search directly (no string→shlex roundtrip)
218
- try:
219
- env = _prepare_execution_environment(repo_root, rg_exe_path)
220
-
221
- result = _execute_direct_command(
222
- [str(rg_exe_path)] + args,
223
- repo_root, env, debug_mode, console,
224
- )
225
-
226
- command_display = "rg " + " ".join(args)
227
- repo_result = format_tool_result(
228
- result, command=command_display, is_rg=True,
229
- debug_mode=debug_mode, max_matches=max_matches,
230
- )
231
- except Exception as e:
232
- return f"exit_code=1\nrg command failed: {str(e)}"
233
-
234
- # If no vault configured, return repo results directly
235
- if not vault_root:
236
- repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
237
- return repo_result
238
-
239
- # Run vault search and merge results
240
- vault_output = _search_vault(
241
- vault_root, rg_exe_path, output_mode, debug_mode, console,
242
- pattern=pattern,
243
- glob=glob,
244
- file_type=kwargs.get("type"),
245
- case_insensitive=case_insensitive,
246
- multiline=multiline,
247
- context_lines=context_lines,
248
- max_matches=_VAULT_MAX_MATCHES,
249
- )
250
-
251
- if not vault_output:
252
- repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
253
- return repo_result
254
-
255
- # Merge results: repo section + vault section with absolute paths
256
- repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
257
- vault_output = _annotate_file_sizes(vault_output, Path(vault_root), output_mode)
258
- return _merge_results(repo_result, vault_output, output_mode)
259
-
260
-
261
- def _search_vault(vault_root, rg_exe_path, output_mode, debug_mode, console,
262
- pattern, glob=None, file_type=None, case_insensitive=False,
263
- multiline=False, context_lines=None, max_matches=20):
264
- """Run rg against the vault and return formatted output string (or None).
265
-
266
- Builds its own command from explicit parameters to avoid mutating any
267
- shared state. Uses direct subprocess with cwd=vault_root so that
268
- any .gitignore in the vault doesn't filter searchable content.
269
- """
270
- vault_path = Path(vault_root).resolve()
271
- if not vault_path.is_dir():
272
- return None
273
-
274
- try:
275
- # Build exclude globs from obsidian_settings
276
- from utils.settings import obsidian_settings
277
- try:
278
- exclude_folders = obsidian_settings.exclude_folders_list
279
- except (AttributeError, TypeError):
280
- exclude_folders = []
281
-
282
- # Build vault rg command from scratch (no shared state mutation)
283
- vault_args = ["--no-ignore"]
284
-
285
- # Add exclude folders first so they can't be overridden
286
- for folder in exclude_folders:
287
- vault_args.append(f"--glob=!{folder}")
288
-
289
- # Add output-mode flags
290
- if output_mode == "content":
291
- vault_args.append("--line-number")
292
- elif output_mode == "files_with_matches":
293
- vault_args.append("--files-with-matches")
294
- elif output_mode == "count":
295
- vault_args.append("--count")
296
-
297
- # Add search flags
298
- if multiline:
299
- vault_args.append("-U")
300
- vault_args.append("--multiline-dotall")
301
- if case_insensitive:
302
- vault_args.append("--ignore-case")
303
- if context_lines:
304
- vault_args.append(f"--context={context_lines}")
305
- if glob:
306
- vault_args.append(f"--glob={glob}")
307
- if file_type:
308
- vault_args.append(f"--type={file_type}")
309
-
310
- # Pattern and search path (always "." since cwd is vault root)
311
- # No quoting needed — subprocess list args bypass the shell
312
- vault_args.append(pattern)
313
- vault_args.append(".")
314
-
315
- # Prepare environment with rg on PATH
316
- env = _prepare_execution_environment(vault_path, rg_exe_path)
317
-
318
- if debug_mode and console:
319
- console.print(f"[dim]→ Vault search: rg {' '.join(vault_args)}[/dim]")
320
- console.print(f"[dim]→ Vault cwd: {vault_path}[/dim]")
321
-
322
- result = subprocess.run(
323
- [str(rg_exe_path)] + vault_args,
324
- capture_output=True,
325
- text=True,
326
- encoding="utf-8",
327
- errors="replace",
328
- timeout=tool_settings.command_timeout_sec,
329
- cwd=str(vault_path),
330
- env=env,
331
- )
332
-
333
- if debug_mode and console:
334
- console.print(f"[dim]→ Vault exit code: {result.returncode}[/dim]")
335
-
336
- # rg returns 1 for no matches — that's fine, just no vault results
337
- if result.returncode == 2:
338
- logger.debug("Vault rg error (exit 2): %s", result.stderr.strip())
339
- return None
340
-
341
- output = (result.stdout or "").strip()
342
- if not output:
343
- return None
344
-
345
- # Format vault output with its own match limit
346
- formatted = format_tool_result(
347
- result,
348
- command="rg " + " ".join(vault_args),
349
- is_rg=True,
350
- debug_mode=debug_mode,
351
- max_matches=max_matches,
352
- )
353
-
354
- # Prefix vault paths with absolute vault root for clarity.
355
- # Only rewrite lines that look like they start with an rg file path.
356
- # rg output: "relative/path:linenum:match" or "relative/path-linenum-context"
357
- # or "relative/path:count" (count mode). Must contain / or . before any
358
- # colon to avoid matching content-only lines or binary headers.
359
- vault_prefix = str(vault_path)
360
-
361
- lines = formatted.split("\n")
362
- rewritten = []
363
- for line in lines:
364
- # Skip metadata lines (exit_code, matches/files)
365
- if line.startswith("exit_code=") or line.startswith("matches=") or line.startswith("files="):
366
- rewritten.append(line)
367
- continue
368
- if not line.strip() or line.startswith("... ("):
369
- rewritten.append(line)
370
- continue
371
- # Only rewrite lines that start with a relative path
372
- m = _path_line_re.match(line)
373
- if m:
374
- rewritten.append(f"{vault_prefix}/{line}")
375
- else:
376
- rewritten.append(line)
377
-
378
- return "\n".join(rewritten)
379
-
380
- except Exception:
381
- logger.warning("Vault search failed", exc_info=True)
382
- return None
383
-
384
-
385
- def _merge_results(repo_result, vault_output, output_mode):
386
- """Merge repo and vault results into a single response.
387
-
388
- Both inputs are raw formatted strings from format_tool_result.
389
- We extract the content sections and combine them under headers.
390
- Metadata (matches/files counts) is preserved so the display parser
391
- can extract a summary for the user.
392
- """
393
- def _extract_content(formatted):
394
- """Extract content lines (skip metadata header)."""
395
- lines = formatted.split("\n")
396
- content_lines = []
397
- for line in lines:
398
- stripped = line.strip()
399
- if not stripped or stripped.startswith("exit_code=") or stripped.startswith("matches=") or stripped.startswith("files="):
400
- continue
401
- if stripped.startswith("... ("):
402
- continue
403
- content_lines.append(line)
404
- return "\n".join(content_lines).strip()
405
-
406
- def _extract_exit_code(formatted):
407
- for line in formatted.split("\n"):
408
- if line.startswith("exit_code="):
409
- return line.split("=", 1)[1]
410
- return "0"
411
-
412
- def _extract_count(formatted):
413
- """Extract matches=N or files=N count from formatted result."""
414
- for line in formatted.split("\n"):
415
- if line.startswith("matches="):
416
- try:
417
- return ("matches", int(line.split("=", 1)[1].strip()))
418
- except (ValueError, IndexError):
419
- pass
420
- elif line.startswith("files="):
421
- try:
422
- return ("files", int(line.split("=", 1)[1].strip()))
423
- except (ValueError, IndexError):
424
- pass
425
- return None
426
-
427
- repo_exit_code = _extract_exit_code(repo_result)
428
- repo_content = _extract_content(repo_result)
429
- vault_content = _extract_content(vault_output)
430
-
431
- if not vault_content:
432
- return repo_result
433
-
434
- # Build combined metadata line for the display parser
435
- repo_count = _extract_count(repo_result)
436
- vault_count = _extract_count(vault_output)
437
-
438
- metadata_line = ""
439
- if repo_count and vault_count and repo_count[0] == vault_count[0]:
440
- # Same count type (both matches or both files) — sum them
441
- combined = repo_count[1] + vault_count[1]
442
- metadata_line = f"{repo_count[0]}={combined}"
443
- elif repo_count:
444
- metadata_line = f"{repo_count[0]}={repo_count[1]}"
445
- elif vault_count:
446
- metadata_line = f"{vault_count[0]}={vault_count[1]}"
447
-
448
- if not repo_content:
449
- # Only vault results — return with vault label
450
- header = f"exit_code=0"
451
- if metadata_line:
452
- header += f"\n{metadata_line}"
453
- return f"{header}\n[vault]\n{vault_content}\n\n"
454
-
455
- # Both — present under labeled sections, preserve repo exit code
456
- header = f"exit_code={repo_exit_code}"
457
- if metadata_line:
458
- header += f"\n{metadata_line}"
459
- merged = f"{header}\n[repo]\n{repo_content}\n\n[vault]\n{vault_content}\n\n"
460
- return merged
@@ -1,109 +0,0 @@
1
- """search_plugins core tool for on-demand plugin discovery.
2
-
3
- This tool lets the LLM agent search for available plugin tools and
4
- activate them. Plugin schemas are not sent by default to avoid context
5
- bloat — they are only included after activation.
6
- """
7
-
8
- from typing import List, Optional
9
- from pathlib import Path
10
-
11
- from tools.helpers.base import tool, ToolRegistry, TERMINAL_NONE
12
-
13
-
14
- @tool(
15
- name="search_plugins",
16
- description=(
17
- "Search for available plugin tools that can help with your task. "
18
- "Plugin tools are NOT in your available tools by default — use this "
19
- "to discover and activate them. Returns matching plugin names and "
20
- "descriptions. Once activated, the plugin's full schema will be "
21
- "available in your next response."
22
- ),
23
- parameters={
24
- "type": "object",
25
- "properties": {
26
- "query": {
27
- "type": "string",
28
- "description": "Search query describing what you need (e.g., 'send email', 'query database', 'http request')"
29
- },
30
- "category": {
31
- "type": "string",
32
- "description": "Optional category filter (e.g., 'email', 'database', 'analysis')"
33
- }
34
- },
35
- "required": ["query"]
36
- },
37
- requires_approval=False,
38
- terminal_policy=TERMINAL_NONE,
39
- tier="core",
40
- tags=["plugin", "discovery", "meta"],
41
- category="core"
42
- )
43
- def search_plugins(
44
- query: str,
45
- category: str = None,
46
- ) -> str:
47
- """Search the plugin manifest and activate matching plugins.
48
-
49
- Args:
50
- query: Search query describing the needed capability
51
- category: Optional category filter
52
-
53
- Returns:
54
- Formatted result with activated plugin names and descriptions
55
- """
56
- from tools.helpers.plugin_manifest import plugin_manifest
57
-
58
- # Check if any core tool matches the query — early return
59
- core_tools = ToolRegistry.get_all(include_plugins=False)
60
- query_lower = query.lower()
61
- for ct in core_tools:
62
- if query_lower == ct.name.lower() or query_lower in ct.name.lower():
63
- return (
64
- f"exit_code=0\n"
65
- f"'{query}' matches a core tool that is already available: **{ct.name}**.\n"
66
- f"Description: {ct.description}"
67
- )
68
-
69
- # Search the plugin manifest
70
- matches = plugin_manifest.search(query, category=category, max_results=5)
71
-
72
- if not matches:
73
- # No matches — suggest available categories
74
- categories = plugin_manifest.get_categories()
75
- if categories:
76
- cat_list = ", ".join(f"'{c}'" for c in categories)
77
- return (
78
- f"exit_code=0\n"
79
- f"No plugins found matching '{query}'.\n"
80
- f"Available plugin categories: {cat_list}\n"
81
- f"Total plugins in manifest: {plugin_manifest.plugin_count()}"
82
- )
83
- return (
84
- f"exit_code=0\n"
85
- f"No plugins found matching '{query}'. "
86
- f"No plugins are currently registered in the manifest."
87
- )
88
-
89
- # Activate matched plugins in the registry
90
- activated = []
91
- already_active = []
92
- for tool_def in matches:
93
- if ToolRegistry.is_plugin_active(tool_def.name):
94
- already_active.append(tool_def.name)
95
- else:
96
- ToolRegistry.activate_plugin(tool_def)
97
- activated.append(tool_def.name)
98
-
99
- # Build result
100
- lines = [f"exit_code=0\nFound {len(matches)} plugin(s) matching '{query}':\n"]
101
-
102
- for tool_def in matches:
103
- status = "activated" if tool_def.name in activated else "already active"
104
- cat_part = f" [{tool_def.category}]" if tool_def.category else ""
105
- lines.append(f"- **{tool_def.name}**{cat_part} ({status}): {tool_def.description}")
106
- if tool_def.tags:
107
- lines.append(f" Tags: {', '.join(tool_def.tags)}")
108
-
109
- return "\n".join(lines)