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.
- package/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -201
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -144
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -50
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/skills.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/skills.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -1085
- package/src/core/chat_manager.py +0 -1577
- package/src/core/config_manager.py +0 -260
- package/src/core/cron.py +0 -578
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/metadata.py +0 -75
- package/src/core/retry.py +0 -71
- package/src/core/skills.py +0 -463
- package/src/core/sub_agent.py +0 -376
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -789
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -176
- package/src/llm/codex_provider.py +0 -350
- package/src/llm/config.py +0 -536
- package/src/llm/prompts.py +0 -494
- package/src/llm/providers.py +0 -438
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -399
- package/src/tools/__init__.py +0 -151
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -549
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -99
- package/src/tools/helpers/base.py +0 -599
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -145
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -283
- package/src/tools/helpers/plugin_manifest.py +0 -185
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -190
- package/src/tools/rg_search.py +0 -477
- package/src/tools/search_plugins.py +0 -177
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -3131
- package/src/ui/displays.py +0 -239
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -643
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -226
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -207
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -195
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -201
- package/src/utils/web_search.py +0 -173
package/src/tools/rg_search.py
DELETED
|
@@ -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)
|