bone-agent 1.4.0 → 2.0.1

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