bone-agent 1.3.2 → 1.4.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 (87) hide show
  1. package/README.md +19 -2
  2. package/config.yaml.example +13 -2
  3. package/package.json +3 -2
  4. package/prompts/main/ask_questions.md +31 -0
  5. package/prompts/main/batch_independent_calls.md +5 -0
  6. package/prompts/main/casual_interactions.md +11 -0
  7. package/prompts/main/code_references.md +8 -0
  8. package/prompts/main/communication_style.md +12 -0
  9. package/prompts/main/context_reliability.md +12 -0
  10. package/prompts/main/conversational_tool_calling.md +15 -0
  11. package/prompts/main/dream.md +50 -0
  12. package/prompts/main/editing_pattern.md +13 -0
  13. package/prompts/main/error_handling.md +6 -0
  14. package/prompts/main/exploration_pattern.md +21 -0
  15. package/prompts/main/intro.md +1 -0
  16. package/prompts/main/obsidian.md +16 -0
  17. package/prompts/main/obsidian_project.md +79 -0
  18. package/prompts/main/professional_objectivity.md +3 -0
  19. package/prompts/main/skills.md +3 -0
  20. package/prompts/main/targeted_searching.md +10 -0
  21. package/prompts/main/task_lists_pattern.md +8 -0
  22. package/prompts/main/temp_folder.md +9 -0
  23. package/prompts/main/think_before_acting.md +10 -0
  24. package/prompts/main/tone_and_style.md +4 -0
  25. package/prompts/main/tool_preferences.md +24 -0
  26. package/prompts/main/trust_subagent_context.md +21 -0
  27. package/prompts/main/when_to_use_sub_agent.md +7 -0
  28. package/prompts/micro/ask_questions.md +1 -0
  29. package/prompts/micro/batch_independent_calls.md +1 -0
  30. package/prompts/micro/casual_interactions.md +1 -0
  31. package/prompts/micro/code_references.md +1 -0
  32. package/prompts/micro/communication_style.md +1 -0
  33. package/prompts/micro/context_reliability.md +1 -0
  34. package/prompts/micro/conversational_tool_calling.md +1 -0
  35. package/prompts/micro/editing_pattern.md +1 -0
  36. package/prompts/micro/error_handling.md +1 -0
  37. package/prompts/micro/exploration_pattern.md +1 -0
  38. package/prompts/micro/intro.md +1 -0
  39. package/prompts/micro/obsidian.md +4 -0
  40. package/prompts/micro/obsidian_project.md +5 -0
  41. package/prompts/micro/professional_objectivity.md +1 -0
  42. package/prompts/micro/skills.md +1 -0
  43. package/prompts/micro/targeted_searching.md +1 -0
  44. package/prompts/micro/task_lists_pattern.md +1 -0
  45. package/prompts/micro/temp_folder.md +1 -0
  46. package/prompts/micro/think_before_acting.md +5 -0
  47. package/prompts/micro/tone_and_style.md +1 -0
  48. package/prompts/micro/tool_preferences.md +1 -0
  49. package/prompts/micro/trust_subagent_context.md +1 -0
  50. package/prompts/micro/when_to_use_sub_agent.md +1 -0
  51. package/src/core/agentic.py +134 -106
  52. package/src/core/chat_manager.py +60 -12
  53. package/src/core/config_manager.py +14 -1
  54. package/src/core/cron.py +57 -6
  55. package/src/core/memory.py +3 -90
  56. package/src/core/metadata.py +75 -0
  57. package/src/core/skills.py +463 -0
  58. package/src/core/sub_agent.py +93 -43
  59. package/src/core/tool_feedback.py +87 -76
  60. package/src/llm/client.py +7 -2
  61. package/src/llm/codex_provider.py +350 -0
  62. package/src/llm/config.py +74 -4
  63. package/src/llm/prompts.py +261 -502
  64. package/src/llm/providers.py +28 -7
  65. package/src/llm/token_tracker.py +32 -1
  66. package/src/tools/__init__.py +24 -85
  67. package/src/tools/create_file.py +1 -1
  68. package/src/tools/directory.py +1 -1
  69. package/src/tools/edit.py +13 -7
  70. package/src/tools/file_reader.py +1 -1
  71. package/src/tools/helpers/__init__.py +1 -7
  72. package/src/tools/helpers/base.py +65 -16
  73. package/src/tools/helpers/loader.py +2 -88
  74. package/src/tools/helpers/path_resolver.py +70 -13
  75. package/src/tools/helpers/plugin_manifest.py +99 -70
  76. package/src/tools/review_sub_agent.py +2 -1
  77. package/src/tools/rg_search.py +119 -35
  78. package/src/tools/search_plugins.py +140 -72
  79. package/src/tools/shell.py +3 -3
  80. package/src/ui/commands.py +470 -33
  81. package/src/ui/displays.py +27 -1
  82. package/src/ui/main.py +1 -4
  83. package/src/ui/tool_confirmation.py +16 -5
  84. package/src/utils/editor.py +88 -39
  85. package/src/utils/settings.py +25 -4
  86. package/src/utils/user_message_logger.py +120 -0
  87. package/src/utils/validation.py +10 -0
@@ -15,6 +15,57 @@ from .file_helpers import _is_reserved_windows_name
15
15
  _path_resolution_times = []
16
16
  _path_validation_errors = {}
17
17
 
18
+ # Session-scoped filesystem access flag
19
+ # When True, boundary enforcement is skipped — agent can access any path.
20
+ _full_filesystem_access = False
21
+
22
+ # Boundary error prefixes — used by both resolve_and_validate() and is_boundary_error().
23
+ _BOUNDARY_ERROR_PREFIXES = (
24
+ "Path is outside allowed directories:",
25
+ "Path is outside repository:",
26
+ )
27
+
28
+
29
+ def has_full_filesystem_access() -> bool:
30
+ """Check if full filesystem access has been granted this session."""
31
+ return _full_filesystem_access
32
+
33
+
34
+ def set_full_filesystem_access(enabled: bool):
35
+ """Grant or revoke full filesystem access for this session."""
36
+ global _full_filesystem_access
37
+ _full_filesystem_access = enabled
38
+
39
+
40
+ def _boundary_error_line(result: str) -> Optional[tuple[str, str]]:
41
+ """Return the boundary prefix and message line from a tool result."""
42
+ if not result:
43
+ return None
44
+
45
+ for line in result.splitlines():
46
+ stripped = line.strip()
47
+ if stripped.lower().startswith("error:"):
48
+ stripped = stripped[len("error:"):].strip()
49
+ for prefix in _BOUNDARY_ERROR_PREFIXES:
50
+ if stripped.startswith(prefix):
51
+ return prefix, stripped
52
+ return None
53
+
54
+
55
+ def is_boundary_error(result: str) -> bool:
56
+ """Check if a tool result is a path boundary violation."""
57
+ return _boundary_error_line(result) is not None
58
+
59
+
60
+ def extract_boundary_path(result: str) -> str:
61
+ """Extract the offending path from a boundary error message."""
62
+ boundary_line = _boundary_error_line(result)
63
+ if not boundary_line:
64
+ return ""
65
+
66
+ prefix, line = boundary_line
67
+ return line[len(prefix):].strip()
68
+
18
69
 
19
70
  class PathResolver:
20
71
  """Centralized path resolution and validation.
@@ -107,24 +158,30 @@ class PathResolver:
107
158
  # Resolve to absolute path (handles .. and symlinks)
108
159
  path = path.resolve()
109
160
 
110
- # Step 2b: Security boundary — path must be within repo_root or vault_path
111
- if enforce_boundary:
161
+ # Step 2b: Security boundary — path must be within repo_root, vault_path,
162
+ # or the agent's own data directory (~/.bone/).
163
+ if enforce_boundary and not _full_filesystem_access:
112
164
  try:
113
165
  path.relative_to(self.repo_root)
114
166
  except ValueError:
115
- if self.vault_path is not None:
116
- try:
117
- path.relative_to(self.vault_path)
118
- except ValueError:
167
+ # Check ~/.bone/ — agent data dir is always accessible
168
+ bone_root = Path.home() / ".bone"
169
+ try:
170
+ path.relative_to(bone_root)
171
+ except ValueError:
172
+ if self.vault_path is not None:
173
+ try:
174
+ path.relative_to(self.vault_path)
175
+ except ValueError:
176
+ elapsed = time.time() - start_time
177
+ _track_validation_error("outside_allowed_roots")
178
+ _path_resolution_times.append(elapsed)
179
+ return None, f"{_BOUNDARY_ERROR_PREFIXES[0]} {path_str}"
180
+ else:
119
181
  elapsed = time.time() - start_time
120
- _track_validation_error("outside_allowed_roots")
182
+ _track_validation_error("outside_repo")
121
183
  _path_resolution_times.append(elapsed)
122
- return None, f"Path is outside allowed directories: {path_str}"
123
- else:
124
- elapsed = time.time() - start_time
125
- _track_validation_error("outside_repo")
126
- _path_resolution_times.append(elapsed)
127
- return None, f"Path is outside repository: {path_str}"
184
+ return None, f"{_BOUNDARY_ERROR_PREFIXES[1]} {path_str}"
128
185
 
129
186
  # Step 3: Check existence if required
130
187
  if must_exist:
@@ -1,4 +1,4 @@
1
- """Plugin manifest for on-demand tool discovery.
1
+ """Capability manifest for on-demand plugin and skill discovery.
2
2
 
3
3
  Plugin-tier tools are registered here instead of ToolRegistry at import time.
4
4
  This keeps plugin schemas out of the LLM context window until explicitly
@@ -6,19 +6,41 @@ activated via the search_plugins core tool.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Dict, List, Optional
9
+ from dataclasses import dataclass
10
+ from typing import Dict, Iterable, List, Optional
11
+
12
+ from core.skills import (
13
+ SearchCandidate,
14
+ iter_skill_summaries,
15
+ search_candidates,
16
+ )
17
+ from utils.settings import tool_settings
10
18
 
11
19
  from .base import ToolDefinition
12
20
 
13
21
  logger = logging.getLogger(__name__)
14
22
 
15
23
 
24
+ @dataclass
25
+ class CapabilityMatch:
26
+ kind: str
27
+ name: str
28
+ description: str
29
+ category: str | None = None
30
+ tags: list[str] | None = None
31
+ tool_def: ToolDefinition | None = None
32
+ preview: str | None = None
33
+ activated: bool = False
34
+ already_active: bool = False
35
+
36
+
16
37
  class PluginManifest:
17
- """Index of plugin-tier tools available for on-demand activation.
38
+ """Index of plugin-tier tools and stored skills for discovery surfaces.
18
39
 
19
- Tools are registered here when modules with @tool(tier="plugin") are
20
- imported. The search_plugins core tool queries this index to find and
21
- activate matching plugins.
40
+ Plugin tools are registered here when modules with @tool(tier="plugin")
41
+ are imported. Stored skills are discovered from disk on demand. The
42
+ search_plugins core tool queries this manifest to find and activate or
43
+ load capabilities.
22
44
  """
23
45
 
24
46
  def __init__(self):
@@ -58,70 +80,77 @@ class PluginManifest:
58
80
  """
59
81
  return list(self._plugins.values())
60
82
 
61
- def search(self, query: str, category: str = None, max_results: int = 5) -> List[ToolDefinition]:
62
- """Search the manifest for plugins matching a query.
63
-
64
- Args:
65
- query: Search query (matched against name, description, tags, category)
66
- category: Optional category filter
67
- max_results: Maximum number of results to return
68
-
69
- Returns:
70
- List of matching ToolDefinitions, sorted by relevance score
71
- """
72
- query_lower = query.lower()
73
- query_terms = query_lower.split()
74
-
75
- scored = []
76
- for tool_def in self._plugins.values():
77
- # Apply category filter if specified
78
- if category and tool_def.category != category:
79
- continue
80
-
81
- # Calculate relevance score
82
- score = 0.0
83
-
84
- # Exact name match (highest priority)
85
- if query_lower == tool_def.name.lower():
86
- score += 100.0
87
-
88
- # Name contains query
89
- if query_lower in tool_def.name.lower():
90
- score += 50.0
91
-
92
- # Name contains individual terms
93
- for term in query_terms:
94
- if term in tool_def.name.lower():
95
- score += 20.0
96
-
97
- # Description match
98
- if query_lower in tool_def.description.lower():
99
- score += 30.0
100
- for term in query_terms:
101
- if term in tool_def.description.lower():
102
- score += 10.0
103
-
104
- # Tag match
105
- for tag in tool_def.tags:
106
- if query_lower in tag.lower():
107
- score += 15.0
108
- for term in query_terms:
109
- if term in tag.lower():
110
- score += 5.0
111
-
112
- # Category match
113
- if category and tool_def.category == category:
114
- score += 10.0
115
- elif not category and query_lower in tool_def.category.lower():
116
- score += 15.0
117
-
118
- if score > 0:
119
- scored.append((score, tool_def))
120
-
121
- # Sort by score descending
122
- scored.sort(key=lambda x: x[0], reverse=True)
123
-
124
- return [tool_def for _, tool_def in scored[:max_results]]
83
+ def _iter_capabilities(self, category: str = None) -> Iterable[CapabilityMatch]:
84
+ """Yield available plugin and skill capabilities for discovery surfaces."""
85
+ include_plugins = category in (None, "plugin")
86
+ include_skills = category in (None, "skill")
87
+ disabled_tools = set(tool_settings.disabled_tools or [])
88
+ hidden_skills = set(tool_settings.hidden_skills or [])
89
+
90
+ if include_plugins:
91
+ for tool_def in self._plugins.values():
92
+ if tool_def.name in disabled_tools:
93
+ continue
94
+ if category not in (None, "plugin") and tool_def.category != category:
95
+ continue
96
+ yield CapabilityMatch(
97
+ kind="plugin",
98
+ name=tool_def.name,
99
+ description=tool_def.description,
100
+ category=tool_def.category,
101
+ tags=list(tool_def.tags or []),
102
+ tool_def=tool_def,
103
+ )
104
+
105
+ if include_skills:
106
+ for summary in iter_skill_summaries():
107
+ if summary.name in hidden_skills:
108
+ continue
109
+ yield CapabilityMatch(
110
+ kind="skill",
111
+ name=summary.name,
112
+ description=summary.description or summary.preview,
113
+ category="skill",
114
+ tags=summary.tags or ["skill"],
115
+ preview=summary.preview,
116
+ )
117
+
118
+ def search_capabilities(
119
+ self,
120
+ query: str,
121
+ category: str = None,
122
+ max_results: int = 5,
123
+ ) -> List[CapabilityMatch]:
124
+ """Search plugins and skills through one shared discovery path."""
125
+ combined_candidates = [
126
+ SearchCandidate(
127
+ item=capability,
128
+ text=" ".join(
129
+ part
130
+ for part in [
131
+ capability.name,
132
+ capability.description,
133
+ capability.category or "",
134
+ " ".join(capability.tags or []),
135
+ ]
136
+ if part
137
+ ),
138
+ compact_text="",
139
+ exact_text=capability.name,
140
+ )
141
+ for capability in self._iter_capabilities(category=category)
142
+ ]
143
+ combined_matches = search_candidates(
144
+ query,
145
+ combined_candidates,
146
+ max_results=max_results,
147
+ item_key=lambda capability: f"{capability.kind}:{capability.name}",
148
+ )
149
+ return [match.item for match in combined_matches]
150
+
151
+ def list_all_capabilities(self, category: str = None) -> List[CapabilityMatch]:
152
+ """Return all available capabilities without fuzzy scoring."""
153
+ return list(self._iter_capabilities(category=category))
125
154
 
126
155
  def get_categories(self) -> List[str]:
127
156
  """Get all unique categories in the manifest.
@@ -172,7 +172,8 @@ def review_changes(
172
172
  panel.set_complete({
173
173
  'prompt_tokens': usage.get('prompt_tokens', 0),
174
174
  'completion_tokens': usage.get('completion_tokens', 0),
175
- 'total_tokens': usage.get('total_tokens', 0)
175
+ 'total_tokens': usage.get('total_tokens', 0),
176
+ 'context_tokens': usage.get('context_tokens', 0),
176
177
  })
177
178
 
178
179
  raw_result = sub_agent_data.get('result', '')
@@ -2,25 +2,88 @@
2
2
 
3
3
  import logging
4
4
  import re
5
- import shlex
5
+ import stat
6
6
  import subprocess
7
7
  from pathlib import Path
8
- from typing import Optional
8
+ from typing import Optional, Sequence
9
9
 
10
10
  from .helpers.base import tool
11
11
  from .helpers.formatters import format_tool_result
12
- from .shell import _prepare_execution_environment, run_shell_command
12
+ from .shell import _execute_direct_command, _prepare_execution_environment
13
13
  from .helpers.converters import coerce_bool, coerce_int
14
+ from utils.settings import tool_settings
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
  # Default match limit for vault searches (separate from repo limit)
18
19
  _VAULT_MAX_MATCHES = 20
19
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
+
20
83
 
21
84
  @tool(
22
85
  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.",
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'.",
24
87
  parameters={
25
88
  "type": "object",
26
89
  "properties": {
@@ -30,7 +93,12 @@ _VAULT_MAX_MATCHES = 20
30
93
  },
31
94
  "path": {
32
95
  "type": "string",
33
- "description": "File or directory to search (default: current directory)"
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'."
34
102
  },
35
103
  "glob": {
36
104
  "type": "string",
@@ -75,6 +143,7 @@ def rg(
75
143
  debug_mode: bool = False,
76
144
  gitignore_spec = None,
77
145
  path: Optional[str] = None,
146
+ paths: Optional[Sequence[str]] = None,
78
147
  glob: Optional[str] = None,
79
148
  output_mode: str = "files_with_matches",
80
149
  vault_root: Optional[str] = None,
@@ -90,7 +159,8 @@ def rg(
90
159
  chat_manager: ChatManager instance (injected by context)
91
160
  debug_mode: Whether debug mode is enabled (injected by context)
92
161
  gitignore_spec: PathSpec for .gitignore filtering (injected by context)
93
- path: File or directory to search in (default: current directory)
162
+ path: Single file or directory to search in (default: current directory)
163
+ paths: Multiple files or directories to search; overrides path when set
94
164
  glob: Glob pattern to filter files
95
165
  output_mode: Output mode (content/files_with_matches/count)
96
166
  vault_root: Obsidian vault root path (injected by context)
@@ -103,72 +173,84 @@ def rg(
103
173
  if not isinstance(pattern, str) or not pattern.strip():
104
174
  return "exit_code=1\nrg requires a non-empty 'pattern' argument."
105
175
 
106
- # Build rg command from arguments
107
- cmd_parts = ["rg"]
176
+ # Build rg args as a list (bypass string roundtrip to avoid shlex issues with regex metacharacters)
177
+ args = []
108
178
 
109
179
  # Add --line-number for content mode
110
180
  if output_mode == "content":
111
- cmd_parts.append("--line-number")
181
+ args.append("--line-number")
112
182
 
113
183
  # Add multiline flag
114
184
  multiline = coerce_bool(kwargs.get("multiline"), default=False)
115
185
  if multiline:
116
- cmd_parts.append("-U")
117
- cmd_parts.append("--multiline-dotall")
186
+ args.append("-U")
187
+ args.append("--multiline-dotall")
118
188
 
119
189
  # Add case insensitive flag
120
190
  case_insensitive = coerce_bool(kwargs.get("case_insensitive"), default=False)
121
191
  if case_insensitive:
122
- cmd_parts.append("--ignore-case")
192
+ args.append("--ignore-case")
123
193
 
124
194
  # Add context lines flag
125
195
  context_lines = coerce_int(kwargs.get("context_lines"))[0] if kwargs.get("context_lines") else None
126
196
  if context_lines:
127
- cmd_parts.append(f"--context={context_lines}")
197
+ args.append(f"--context={context_lines}")
128
198
 
129
199
  # Add glob pattern
130
200
  if glob:
131
- cmd_parts.append(f"--glob={glob}")
201
+ args.append(f"--glob={glob}")
132
202
 
133
203
  # Add file type filter
134
204
  file_type = kwargs.get("type")
135
205
  if file_type:
136
- cmd_parts.append(f"--type={file_type}")
206
+ args.append(f"--type={file_type}")
137
207
 
138
- # Add files-with-matches flag for count mode
208
+ # Add output mode flags
139
209
  if output_mode == "files_with_matches":
140
- cmd_parts.append("--files-with-matches")
210
+ args.append("--files-with-matches")
141
211
  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))
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."
147
225
  else:
148
- cmd_parts.append(pattern)
226
+ search_paths = [path or "."]
149
227
 
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)
228
+ args.extend(search_paths)
156
229
 
157
230
  # Get max_matches from kwargs (default: 100, set to 0 for no limit)
158
231
  raw = coerce_int(kwargs.get("max_matches"))[0] if kwargs.get("max_matches") is not None else None
159
232
  max_matches = raw if raw is not None and raw >= 0 else 100
160
233
 
161
- # Execute repo search
234
+ # Execute repo search directly (no string→shlex roundtrip)
162
235
  try:
163
- repo_result = run_shell_command(
164
- command, repo_root, rg_exe_path, console, debug_mode, gitignore_spec,
165
- max_matches=max_matches
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,
166
247
  )
167
248
  except Exception as e:
168
249
  return f"exit_code=1\nrg command failed: {str(e)}"
169
250
 
170
251
  # If no vault configured, return repo results directly
171
252
  if not vault_root:
253
+ repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
172
254
  return repo_result
173
255
 
174
256
  # Run vault search and merge results
@@ -184,9 +266,12 @@ def rg(
184
266
  )
185
267
 
186
268
  if not vault_output:
269
+ repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
187
270
  return repo_result
188
271
 
189
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)
190
275
  return _merge_results(repo_result, vault_output, output_mode)
191
276
 
192
277
 
@@ -257,7 +342,7 @@ def _search_vault(vault_root, rg_exe_path, output_mode, debug_mode, console,
257
342
  text=True,
258
343
  encoding="utf-8",
259
344
  errors="replace",
260
- timeout=30,
345
+ timeout=tool_settings.command_timeout_sec,
261
346
  cwd=str(vault_path),
262
347
  env=env,
263
348
  )
@@ -288,7 +373,6 @@ def _search_vault(vault_root, rg_exe_path, output_mode, debug_mode, console,
288
373
  # rg output: "relative/path:linenum:match" or "relative/path-linenum-context"
289
374
  # or "relative/path:count" (count mode). Must contain / or . before any
290
375
  # colon to avoid matching content-only lines or binary headers.
291
- _path_line_re = re.compile(r"^[^\s:|].*[/.]")
292
376
  vault_prefix = str(vault_path)
293
377
 
294
378
  lines = formatted.split("\n")