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.
- package/README.md +19 -2
- package/config.yaml.example +13 -2
- package/package.json +3 -2
- package/prompts/main/ask_questions.md +31 -0
- package/prompts/main/batch_independent_calls.md +5 -0
- package/prompts/main/casual_interactions.md +11 -0
- package/prompts/main/code_references.md +8 -0
- package/prompts/main/communication_style.md +12 -0
- package/prompts/main/context_reliability.md +12 -0
- package/prompts/main/conversational_tool_calling.md +15 -0
- package/prompts/main/dream.md +50 -0
- package/prompts/main/editing_pattern.md +13 -0
- package/prompts/main/error_handling.md +6 -0
- package/prompts/main/exploration_pattern.md +21 -0
- package/prompts/main/intro.md +1 -0
- package/prompts/main/obsidian.md +16 -0
- package/prompts/main/obsidian_project.md +79 -0
- package/prompts/main/professional_objectivity.md +3 -0
- package/prompts/main/skills.md +3 -0
- package/prompts/main/targeted_searching.md +10 -0
- package/prompts/main/task_lists_pattern.md +8 -0
- package/prompts/main/temp_folder.md +9 -0
- package/prompts/main/think_before_acting.md +10 -0
- package/prompts/main/tone_and_style.md +4 -0
- package/prompts/main/tool_preferences.md +24 -0
- package/prompts/main/trust_subagent_context.md +21 -0
- package/prompts/main/when_to_use_sub_agent.md +7 -0
- package/prompts/micro/ask_questions.md +1 -0
- package/prompts/micro/batch_independent_calls.md +1 -0
- package/prompts/micro/casual_interactions.md +1 -0
- package/prompts/micro/code_references.md +1 -0
- package/prompts/micro/communication_style.md +1 -0
- package/prompts/micro/context_reliability.md +1 -0
- package/prompts/micro/conversational_tool_calling.md +1 -0
- package/prompts/micro/editing_pattern.md +1 -0
- package/prompts/micro/error_handling.md +1 -0
- package/prompts/micro/exploration_pattern.md +1 -0
- package/prompts/micro/intro.md +1 -0
- package/prompts/micro/obsidian.md +4 -0
- package/prompts/micro/obsidian_project.md +5 -0
- package/prompts/micro/professional_objectivity.md +1 -0
- package/prompts/micro/skills.md +1 -0
- package/prompts/micro/targeted_searching.md +1 -0
- package/prompts/micro/task_lists_pattern.md +1 -0
- package/prompts/micro/temp_folder.md +1 -0
- package/prompts/micro/think_before_acting.md +5 -0
- package/prompts/micro/tone_and_style.md +1 -0
- package/prompts/micro/tool_preferences.md +1 -0
- package/prompts/micro/trust_subagent_context.md +1 -0
- package/prompts/micro/when_to_use_sub_agent.md +1 -0
- package/src/core/agentic.py +134 -106
- package/src/core/chat_manager.py +60 -12
- package/src/core/config_manager.py +14 -1
- package/src/core/cron.py +57 -6
- package/src/core/memory.py +3 -90
- package/src/core/metadata.py +75 -0
- package/src/core/skills.py +463 -0
- package/src/core/sub_agent.py +93 -43
- package/src/core/tool_feedback.py +87 -76
- package/src/llm/client.py +7 -2
- package/src/llm/codex_provider.py +350 -0
- package/src/llm/config.py +74 -4
- package/src/llm/prompts.py +261 -502
- package/src/llm/providers.py +28 -7
- package/src/llm/token_tracker.py +32 -1
- package/src/tools/__init__.py +24 -85
- package/src/tools/create_file.py +1 -1
- package/src/tools/directory.py +1 -1
- package/src/tools/edit.py +13 -7
- package/src/tools/file_reader.py +1 -1
- package/src/tools/helpers/__init__.py +1 -7
- package/src/tools/helpers/base.py +65 -16
- package/src/tools/helpers/loader.py +2 -88
- package/src/tools/helpers/path_resolver.py +70 -13
- package/src/tools/helpers/plugin_manifest.py +99 -70
- package/src/tools/review_sub_agent.py +2 -1
- package/src/tools/rg_search.py +119 -35
- package/src/tools/search_plugins.py +140 -72
- package/src/tools/shell.py +3 -3
- package/src/ui/commands.py +470 -33
- package/src/ui/displays.py +27 -1
- package/src/ui/main.py +1 -4
- package/src/ui/tool_confirmation.py +16 -5
- package/src/utils/editor.py +88 -39
- package/src/utils/settings.py +25 -4
- package/src/utils/user_message_logger.py +120 -0
- 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
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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("
|
|
182
|
+
_track_validation_error("outside_repo")
|
|
121
183
|
_path_resolution_times.append(elapsed)
|
|
122
|
-
return None, f"
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
38
|
+
"""Index of plugin-tier tools and stored skills for discovery surfaces.
|
|
18
39
|
|
|
19
|
-
|
|
20
|
-
imported.
|
|
21
|
-
activate
|
|
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
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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', '')
|
package/src/tools/rg_search.py
CHANGED
|
@@ -2,25 +2,88 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
5
|
-
import
|
|
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
|
|
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": "
|
|
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:
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
args.append(f"--context={context_lines}")
|
|
128
198
|
|
|
129
199
|
# Add glob pattern
|
|
130
200
|
if glob:
|
|
131
|
-
|
|
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
|
-
|
|
206
|
+
args.append(f"--type={file_type}")
|
|
137
207
|
|
|
138
|
-
# Add
|
|
208
|
+
# Add output mode flags
|
|
139
209
|
if output_mode == "files_with_matches":
|
|
140
|
-
|
|
210
|
+
args.append("--files-with-matches")
|
|
141
211
|
elif output_mode == "count":
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
226
|
+
search_paths = [path or "."]
|
|
149
227
|
|
|
150
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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=
|
|
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")
|