bone-agent 1.3.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,393 @@
1
+ """Ripgrep search tool."""
2
+
3
+ import logging
4
+ import re
5
+ import shlex
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 _prepare_execution_environment, run_shell_command
13
+ from .helpers.converters import coerce_bool, coerce_int
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Default match limit for vault searches (separate from repo limit)
18
+ _VAULT_MAX_MATCHES = 20
19
+
20
+
21
+ @tool(
22
+ name="rg",
23
+ 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.",
24
+ parameters={
25
+ "type": "object",
26
+ "properties": {
27
+ "pattern": {
28
+ "type": "string",
29
+ "description": "Regular expression pattern to search for"
30
+ },
31
+ "path": {
32
+ "type": "string",
33
+ "description": "File or directory to search (default: current directory)"
34
+ },
35
+ "glob": {
36
+ "type": "string",
37
+ "description": "Glob filter (e.g. \"*.js\", \"**/*.tsx\")"
38
+ },
39
+ "type": {
40
+ "type": "string",
41
+ "description": "File type (e.g. js, py, rust, go, java)"
42
+ },
43
+ "output_mode": {
44
+ "type": "string",
45
+ "enum": ["content", "files_with_matches", "count"],
46
+ "description": "Output mode (default: files_with_matches)"
47
+ },
48
+ "context_lines": {
49
+ "type": "integer",
50
+ "description": "Context lines around matches (requires output_mode: content)"
51
+ },
52
+ "case_insensitive": {
53
+ "type": "boolean",
54
+ "description": "Case insensitive search"
55
+ },
56
+ "multiline": {
57
+ "type": "boolean",
58
+ "description": "Patterns can span lines"
59
+ },
60
+ "max_matches": {
61
+ "type": "integer",
62
+ "description": "Max matches across all files (default: 100, 0 = use line limit)"
63
+ }
64
+ },
65
+ "required": ["pattern"]
66
+ },
67
+ requires_approval=False
68
+ )
69
+ def rg(
70
+ pattern: str,
71
+ repo_root: Path,
72
+ rg_exe_path: str,
73
+ console,
74
+ chat_manager,
75
+ debug_mode: bool = False,
76
+ gitignore_spec = None,
77
+ path: Optional[str] = None,
78
+ glob: Optional[str] = None,
79
+ output_mode: str = "files_with_matches",
80
+ vault_root: Optional[str] = None,
81
+ **kwargs
82
+ ) -> str:
83
+ """Search for patterns using ripgrep.
84
+
85
+ Args:
86
+ pattern: Regular expression pattern to search for
87
+ repo_root: Repository root directory (injected by context)
88
+ rg_exe_path: Path to rg executable (injected by context)
89
+ console: Rich console for output (injected by context)
90
+ chat_manager: ChatManager instance (injected by context)
91
+ debug_mode: Whether debug mode is enabled (injected by context)
92
+ gitignore_spec: PathSpec for .gitignore filtering (injected by context)
93
+ path: File or directory to search in (default: current directory)
94
+ glob: Glob pattern to filter files
95
+ output_mode: Output mode (content/files_with_matches/count)
96
+ vault_root: Obsidian vault root path (injected by context)
97
+ **kwargs: Additional keyword arguments (type, multiline, context_lines, case_insensitive)
98
+
99
+ Returns:
100
+ Search results with exit code. Vault results are included in a
101
+ separate section when vault_root is active.
102
+ """
103
+ if not isinstance(pattern, str) or not pattern.strip():
104
+ return "exit_code=1\nrg requires a non-empty 'pattern' argument."
105
+
106
+ # Build rg command from arguments
107
+ cmd_parts = ["rg"]
108
+
109
+ # Add --line-number for content mode
110
+ if output_mode == "content":
111
+ cmd_parts.append("--line-number")
112
+
113
+ # Add multiline flag
114
+ multiline = coerce_bool(kwargs.get("multiline"), default=False)
115
+ if multiline:
116
+ cmd_parts.append("-U")
117
+ cmd_parts.append("--multiline-dotall")
118
+
119
+ # Add case insensitive flag
120
+ case_insensitive = coerce_bool(kwargs.get("case_insensitive"), default=False)
121
+ if case_insensitive:
122
+ cmd_parts.append("--ignore-case")
123
+
124
+ # Add context lines flag
125
+ context_lines = coerce_int(kwargs.get("context_lines"))[0] if kwargs.get("context_lines") else None
126
+ if context_lines:
127
+ cmd_parts.append(f"--context={context_lines}")
128
+
129
+ # Add glob pattern
130
+ if glob:
131
+ cmd_parts.append(f"--glob={glob}")
132
+
133
+ # Add file type filter
134
+ file_type = kwargs.get("type")
135
+ if file_type:
136
+ cmd_parts.append(f"--type={file_type}")
137
+
138
+ # Add files-with-matches flag for count mode
139
+ if output_mode == "files_with_matches":
140
+ cmd_parts.append("--files-with-matches")
141
+ elif output_mode == "count":
142
+ cmd_parts.append("--count")
143
+
144
+ # Add pattern - quote if it contains spaces
145
+ if " " in pattern:
146
+ cmd_parts.append(shlex.quote(pattern))
147
+ else:
148
+ cmd_parts.append(pattern)
149
+
150
+ # Add path (default to current directory)
151
+ search_path = path or "."
152
+ cmd_parts.append(search_path)
153
+
154
+ # Build command string
155
+ command = " ".join(cmd_parts)
156
+
157
+ # Get max_matches from kwargs (default: 100, set to 0 for no limit)
158
+ raw = coerce_int(kwargs.get("max_matches"))[0] if kwargs.get("max_matches") is not None else None
159
+ max_matches = raw if raw is not None and raw >= 0 else 100
160
+
161
+ # Execute repo search
162
+ try:
163
+ repo_result = run_shell_command(
164
+ command, repo_root, rg_exe_path, console, debug_mode, gitignore_spec,
165
+ max_matches=max_matches
166
+ )
167
+ except Exception as e:
168
+ return f"exit_code=1\nrg command failed: {str(e)}"
169
+
170
+ # If no vault configured, return repo results directly
171
+ if not vault_root:
172
+ return repo_result
173
+
174
+ # Run vault search and merge results
175
+ vault_output = _search_vault(
176
+ vault_root, rg_exe_path, output_mode, debug_mode, console,
177
+ pattern=pattern,
178
+ glob=glob,
179
+ file_type=kwargs.get("type"),
180
+ case_insensitive=case_insensitive,
181
+ multiline=multiline,
182
+ context_lines=context_lines,
183
+ max_matches=_VAULT_MAX_MATCHES,
184
+ )
185
+
186
+ if not vault_output:
187
+ return repo_result
188
+
189
+ # Merge results: repo section + vault section with absolute paths
190
+ return _merge_results(repo_result, vault_output, output_mode)
191
+
192
+
193
+ def _search_vault(vault_root, rg_exe_path, output_mode, debug_mode, console,
194
+ pattern, glob=None, file_type=None, case_insensitive=False,
195
+ multiline=False, context_lines=None, max_matches=20):
196
+ """Run rg against the vault and return formatted output string (or None).
197
+
198
+ Builds its own command from explicit parameters to avoid mutating any
199
+ shared state. Uses direct subprocess with cwd=vault_root so that
200
+ any .gitignore in the vault doesn't filter searchable content.
201
+ """
202
+ vault_path = Path(vault_root).resolve()
203
+ if not vault_path.is_dir():
204
+ return None
205
+
206
+ try:
207
+ # Build exclude globs from obsidian_settings
208
+ from utils.settings import obsidian_settings
209
+ try:
210
+ exclude_folders = obsidian_settings.exclude_folders_list
211
+ except (AttributeError, TypeError):
212
+ exclude_folders = []
213
+
214
+ # Build vault rg command from scratch (no shared state mutation)
215
+ vault_args = ["--no-ignore"]
216
+
217
+ # Add exclude folders first so they can't be overridden
218
+ for folder in exclude_folders:
219
+ vault_args.append(f"--glob=!{folder}")
220
+
221
+ # Add output-mode flags
222
+ if output_mode == "content":
223
+ vault_args.append("--line-number")
224
+ elif output_mode == "files_with_matches":
225
+ vault_args.append("--files-with-matches")
226
+ elif output_mode == "count":
227
+ vault_args.append("--count")
228
+
229
+ # Add search flags
230
+ if multiline:
231
+ vault_args.append("-U")
232
+ vault_args.append("--multiline-dotall")
233
+ if case_insensitive:
234
+ vault_args.append("--ignore-case")
235
+ if context_lines:
236
+ vault_args.append(f"--context={context_lines}")
237
+ if glob:
238
+ vault_args.append(f"--glob={glob}")
239
+ if file_type:
240
+ vault_args.append(f"--type={file_type}")
241
+
242
+ # Pattern and search path (always "." since cwd is vault root)
243
+ # No quoting needed — subprocess list args bypass the shell
244
+ vault_args.append(pattern)
245
+ vault_args.append(".")
246
+
247
+ # Prepare environment with rg on PATH
248
+ env = _prepare_execution_environment(vault_path, rg_exe_path)
249
+
250
+ if debug_mode and console:
251
+ console.print(f"[dim]→ Vault search: rg {' '.join(vault_args)}[/dim]")
252
+ console.print(f"[dim]→ Vault cwd: {vault_path}[/dim]")
253
+
254
+ result = subprocess.run(
255
+ [str(rg_exe_path)] + vault_args,
256
+ capture_output=True,
257
+ text=True,
258
+ encoding="utf-8",
259
+ errors="replace",
260
+ timeout=30,
261
+ cwd=str(vault_path),
262
+ env=env,
263
+ )
264
+
265
+ if debug_mode and console:
266
+ console.print(f"[dim]→ Vault exit code: {result.returncode}[/dim]")
267
+
268
+ # rg returns 1 for no matches — that's fine, just no vault results
269
+ if result.returncode == 2:
270
+ logger.debug("Vault rg error (exit 2): %s", result.stderr.strip())
271
+ return None
272
+
273
+ output = (result.stdout or "").strip()
274
+ if not output:
275
+ return None
276
+
277
+ # Format vault output with its own match limit
278
+ formatted = format_tool_result(
279
+ result,
280
+ command="rg " + " ".join(vault_args),
281
+ is_rg=True,
282
+ debug_mode=debug_mode,
283
+ max_matches=max_matches,
284
+ )
285
+
286
+ # Prefix vault paths with absolute vault root for clarity.
287
+ # Only rewrite lines that look like they start with an rg file path.
288
+ # rg output: "relative/path:linenum:match" or "relative/path-linenum-context"
289
+ # or "relative/path:count" (count mode). Must contain / or . before any
290
+ # colon to avoid matching content-only lines or binary headers.
291
+ _path_line_re = re.compile(r"^[^\s:|].*[/.]")
292
+ vault_prefix = str(vault_path)
293
+
294
+ lines = formatted.split("\n")
295
+ rewritten = []
296
+ for line in lines:
297
+ # Skip metadata lines (exit_code, matches/files)
298
+ if line.startswith("exit_code=") or line.startswith("matches=") or line.startswith("files="):
299
+ rewritten.append(line)
300
+ continue
301
+ if not line.strip() or line.startswith("... ("):
302
+ rewritten.append(line)
303
+ continue
304
+ # Only rewrite lines that start with a relative path
305
+ m = _path_line_re.match(line)
306
+ if m:
307
+ rewritten.append(f"{vault_prefix}/{line}")
308
+ else:
309
+ rewritten.append(line)
310
+
311
+ return "\n".join(rewritten)
312
+
313
+ except Exception:
314
+ logger.warning("Vault search failed", exc_info=True)
315
+ return None
316
+
317
+
318
+ def _merge_results(repo_result, vault_output, output_mode):
319
+ """Merge repo and vault results into a single response.
320
+
321
+ Both inputs are raw formatted strings from format_tool_result.
322
+ We extract the content sections and combine them under headers.
323
+ Metadata (matches/files counts) is preserved so the display parser
324
+ can extract a summary for the user.
325
+ """
326
+ def _extract_content(formatted):
327
+ """Extract content lines (skip metadata header)."""
328
+ lines = formatted.split("\n")
329
+ content_lines = []
330
+ for line in lines:
331
+ stripped = line.strip()
332
+ if not stripped or stripped.startswith("exit_code=") or stripped.startswith("matches=") or stripped.startswith("files="):
333
+ continue
334
+ if stripped.startswith("... ("):
335
+ continue
336
+ content_lines.append(line)
337
+ return "\n".join(content_lines).strip()
338
+
339
+ def _extract_exit_code(formatted):
340
+ for line in formatted.split("\n"):
341
+ if line.startswith("exit_code="):
342
+ return line.split("=", 1)[1]
343
+ return "0"
344
+
345
+ def _extract_count(formatted):
346
+ """Extract matches=N or files=N count from formatted result."""
347
+ for line in formatted.split("\n"):
348
+ if line.startswith("matches="):
349
+ try:
350
+ return ("matches", int(line.split("=", 1)[1].strip()))
351
+ except (ValueError, IndexError):
352
+ pass
353
+ elif line.startswith("files="):
354
+ try:
355
+ return ("files", int(line.split("=", 1)[1].strip()))
356
+ except (ValueError, IndexError):
357
+ pass
358
+ return None
359
+
360
+ repo_exit_code = _extract_exit_code(repo_result)
361
+ repo_content = _extract_content(repo_result)
362
+ vault_content = _extract_content(vault_output)
363
+
364
+ if not vault_content:
365
+ return repo_result
366
+
367
+ # Build combined metadata line for the display parser
368
+ repo_count = _extract_count(repo_result)
369
+ vault_count = _extract_count(vault_output)
370
+
371
+ metadata_line = ""
372
+ if repo_count and vault_count and repo_count[0] == vault_count[0]:
373
+ # Same count type (both matches or both files) — sum them
374
+ combined = repo_count[1] + vault_count[1]
375
+ metadata_line = f"{repo_count[0]}={combined}"
376
+ elif repo_count:
377
+ metadata_line = f"{repo_count[0]}={repo_count[1]}"
378
+ elif vault_count:
379
+ metadata_line = f"{vault_count[0]}={vault_count[1]}"
380
+
381
+ if not repo_content:
382
+ # Only vault results — return with vault label
383
+ header = f"exit_code=0"
384
+ if metadata_line:
385
+ header += f"\n{metadata_line}"
386
+ return f"{header}\n[vault]\n{vault_content}\n\n"
387
+
388
+ # Both — present under labeled sections, preserve repo exit code
389
+ header = f"exit_code={repo_exit_code}"
390
+ if metadata_line:
391
+ header += f"\n{metadata_line}"
392
+ merged = f"{header}\n[repo]\n{repo_content}\n\n[vault]\n{vault_content}\n\n"
393
+ return merged
@@ -0,0 +1,109 @@
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)