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
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
"""Shared utilities for file operations."""
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
from functools import lru_cache
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Optional
|
|
7
|
-
|
|
8
|
-
_GITIGNORE_SPEC_REGISTRY = {}
|
|
9
|
-
|
|
10
|
-
# Performance metrics for gitignore filtering
|
|
11
|
-
_gitignore_filter_times = []
|
|
12
|
-
_gitignore_spec_hits = 0
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _register_gitignore_spec(gitignore_spec) -> int:
|
|
16
|
-
"""Register a PathSpec for cached lookups and return its key.
|
|
17
|
-
|
|
18
|
-
Args:
|
|
19
|
-
gitignore_spec: PathSpec object to register
|
|
20
|
-
|
|
21
|
-
Returns:
|
|
22
|
-
Registry key for the PathSpec object
|
|
23
|
-
"""
|
|
24
|
-
if gitignore_spec is None:
|
|
25
|
-
return 0
|
|
26
|
-
key = id(gitignore_spec)
|
|
27
|
-
_GITIGNORE_SPEC_REGISTRY[key] = gitignore_spec
|
|
28
|
-
return key
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@lru_cache(maxsize=1000)
|
|
32
|
-
def _is_ignored_cached(path_str: str, repo_root_str: str, spec_key: int) -> bool:
|
|
33
|
-
"""Cached version of gitignore check.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
path_str: String representation of path to check
|
|
37
|
-
repo_root_str: String representation of repository root
|
|
38
|
-
spec_key: Registry key for the PathSpec object
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
True if path is ignored by gitignore spec
|
|
42
|
-
"""
|
|
43
|
-
gitignore_spec = _GITIGNORE_SPEC_REGISTRY.get(spec_key)
|
|
44
|
-
if gitignore_spec is None:
|
|
45
|
-
return False
|
|
46
|
-
|
|
47
|
-
from utils.gitignore_filter import is_path_ignored
|
|
48
|
-
|
|
49
|
-
path = Path(path_str)
|
|
50
|
-
repo_root = Path(repo_root_str)
|
|
51
|
-
is_ignored, _ = is_path_ignored(path, repo_root, gitignore_spec)
|
|
52
|
-
return is_ignored
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _is_reserved_windows_name(name: str) -> bool:
|
|
56
|
-
"""Check if filename is a reserved Windows device name.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
name: Filename to check (without path)
|
|
60
|
-
|
|
61
|
-
Returns:
|
|
62
|
-
True if name is reserved (e.g., CON, PRN, NUL)
|
|
63
|
-
"""
|
|
64
|
-
if not name:
|
|
65
|
-
return False
|
|
66
|
-
base = name.upper().split('.')[0]
|
|
67
|
-
return base in {
|
|
68
|
-
'CON', 'PRN', 'AUX', 'NUL',
|
|
69
|
-
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
|
70
|
-
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
class GitignoreFilter:
|
|
75
|
-
"""Centralized gitignore filtering logic.
|
|
76
|
-
|
|
77
|
-
This class provides a single interface for checking if paths should be
|
|
78
|
-
excluded based on .gitignore rules, combining fast-path checks with
|
|
79
|
-
full gitignore spec evaluation.
|
|
80
|
-
|
|
81
|
-
Example:
|
|
82
|
-
filter = GitignoreFilter(repo_root=Path("/project"), gitignore_spec=spec)
|
|
83
|
-
if filter.is_ignored(path):
|
|
84
|
-
continue # Skip this path
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
def __init__(
|
|
88
|
-
self,
|
|
89
|
-
repo_root: Path,
|
|
90
|
-
gitignore_spec = None
|
|
91
|
-
):
|
|
92
|
-
"""Initialize the gitignore filter.
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
repo_root: Repository root directory
|
|
96
|
-
gitignore_spec: Optional PathSpec for .gitignore filtering
|
|
97
|
-
"""
|
|
98
|
-
self.repo_root = repo_root
|
|
99
|
-
self.gitignore_spec = gitignore_spec
|
|
100
|
-
self._spec_key = _register_gitignore_spec(gitignore_spec) if gitignore_spec else None
|
|
101
|
-
|
|
102
|
-
def is_ignored(self, path: Path) -> bool:
|
|
103
|
-
"""Check if a path should be ignored by gitignore rules.
|
|
104
|
-
|
|
105
|
-
Args:
|
|
106
|
-
path: Path object to check
|
|
107
|
-
|
|
108
|
-
Returns:
|
|
109
|
-
True if path should be ignored, False otherwise
|
|
110
|
-
"""
|
|
111
|
-
global _gitignore_spec_hits
|
|
112
|
-
start_time = time.time()
|
|
113
|
-
|
|
114
|
-
# Full gitignore check (only if spec is provided)
|
|
115
|
-
if self.gitignore_spec is not None and self._spec_key is not None:
|
|
116
|
-
# Only check paths within the repo
|
|
117
|
-
try:
|
|
118
|
-
path.relative_to(self.repo_root)
|
|
119
|
-
is_ignored = _is_ignored_cached(str(path), str(self.repo_root), self._spec_key)
|
|
120
|
-
if is_ignored:
|
|
121
|
-
_gitignore_spec_hits += 1
|
|
122
|
-
_gitignore_filter_times.append(time.time() - start_time)
|
|
123
|
-
return is_ignored
|
|
124
|
-
except ValueError:
|
|
125
|
-
# Path is outside repo, don't filter
|
|
126
|
-
pass
|
|
127
|
-
|
|
128
|
-
_gitignore_filter_times.append(time.time() - start_time)
|
|
129
|
-
return False
|
|
130
|
-
|
|
131
|
-
def should_include(self, path: Path) -> bool:
|
|
132
|
-
"""Check if a path should be included (inverse of is_ignored).
|
|
133
|
-
|
|
134
|
-
This is provided for readability when filtering:
|
|
135
|
-
files = [f for f in files if filter.should_include(f)]
|
|
136
|
-
|
|
137
|
-
Args:
|
|
138
|
-
path: Path object to check
|
|
139
|
-
|
|
140
|
-
Returns:
|
|
141
|
-
True if path should be included, False if ignored
|
|
142
|
-
"""
|
|
143
|
-
return not self.is_ignored(path)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def get_gitignore_filter_metrics() -> dict:
|
|
147
|
-
"""Get performance metrics for gitignore filtering operations.
|
|
148
|
-
|
|
149
|
-
Returns:
|
|
150
|
-
Dictionary with metrics:
|
|
151
|
-
- total_checks: Total number of filter checks
|
|
152
|
-
- spec_hits: Number of matches by gitignore spec
|
|
153
|
-
- avg_filter_time: Average filter time in seconds
|
|
154
|
-
- cache_hit_rate: Estimated LRU cache hit rate
|
|
155
|
-
"""
|
|
156
|
-
if not _gitignore_filter_times:
|
|
157
|
-
return {
|
|
158
|
-
"total_checks": 0,
|
|
159
|
-
"spec_hits": 0,
|
|
160
|
-
"avg_filter_time": 0,
|
|
161
|
-
"cache_hit_rate": 0
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
total_checks = len(_gitignore_filter_times)
|
|
165
|
-
total_hits = _gitignore_spec_hits
|
|
166
|
-
|
|
167
|
-
# Estimate cache hit rate from _is_ignored_cached
|
|
168
|
-
try:
|
|
169
|
-
from functools import _is_ignored_cached as cached_func
|
|
170
|
-
cache_info = cached_func.cache_info() if hasattr(cached_func, 'cache_info') else None
|
|
171
|
-
cache_hit_rate = cache_info.hits / (cache_info.hits + cache_info.misses) if cache_info and (cache_info.hits + cache_info.misses) > 0 else 0
|
|
172
|
-
except:
|
|
173
|
-
cache_hit_rate = 0
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
"total_checks": total_checks,
|
|
177
|
-
"spec_hits": _gitignore_spec_hits,
|
|
178
|
-
"avg_filter_time": sum(_gitignore_filter_times) / total_checks,
|
|
179
|
-
"cache_hit_rate": cache_hit_rate
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def clear_gitignore_filter_metrics():
|
|
184
|
-
"""Clear all accumulated metrics for testing or monitoring reset."""
|
|
185
|
-
global _gitignore_spec_hits
|
|
186
|
-
_gitignore_filter_times.clear()
|
|
187
|
-
_gitignore_spec_hits = 0
|
|
188
|
-
# Clear LRU cache for _is_ignored_cached
|
|
189
|
-
_is_ignored_cached.cache_clear()
|
|
@@ -1,411 +0,0 @@
|
|
|
1
|
-
"""Result formatting utilities for tool output and diffs."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
import difflib
|
|
6
|
-
from rich.text import Text
|
|
7
|
-
|
|
8
|
-
# Import constants module to access centralized values
|
|
9
|
-
try:
|
|
10
|
-
from ..constants import (
|
|
11
|
-
DEFAULT_TERMINAL_WIDTH,
|
|
12
|
-
FORMATTER_MAX_LINES,
|
|
13
|
-
)
|
|
14
|
-
except ImportError:
|
|
15
|
-
# Fallback for standalone usage
|
|
16
|
-
DEFAULT_TERMINAL_WIDTH = 80
|
|
17
|
-
FORMATTER_MAX_LINES = 100
|
|
18
|
-
|
|
19
|
-
# Shell output truncation (lazy import to avoid circular dependency)
|
|
20
|
-
_SHELL_MAX_LINES = None
|
|
21
|
-
|
|
22
|
-
def _get_shell_max_lines():
|
|
23
|
-
"""Get shell output line limit from settings (lazy import)."""
|
|
24
|
-
global _SHELL_MAX_LINES
|
|
25
|
-
if _SHELL_MAX_LINES is None:
|
|
26
|
-
try:
|
|
27
|
-
from utils.settings import MAX_SHELL_OUTPUT_LINES
|
|
28
|
-
_SHELL_MAX_LINES = MAX_SHELL_OUTPUT_LINES
|
|
29
|
-
except ImportError:
|
|
30
|
-
_SHELL_MAX_LINES = 200
|
|
31
|
-
return _SHELL_MAX_LINES
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _detect_newline(text):
|
|
35
|
-
"""Detect the newline character used in text."""
|
|
36
|
-
if "\r\n" in text:
|
|
37
|
-
return "\r\n"
|
|
38
|
-
if "\n" in text:
|
|
39
|
-
return "\n"
|
|
40
|
-
return os.linesep
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _colorize_numbered_lines(lines, file_path=None):
|
|
44
|
-
"""Apply color highlighting to diff lines.
|
|
45
|
-
|
|
46
|
-
- Removed lines (-): bold white text on red background, full width
|
|
47
|
-
- Added lines (+): bold white text on green background, full width
|
|
48
|
-
- Unchanged lines: dim grey text
|
|
49
|
-
|
|
50
|
-
Returns:
|
|
51
|
-
Rich Text object with styled content
|
|
52
|
-
"""
|
|
53
|
-
# Get terminal width with fallback for non-TTY environments
|
|
54
|
-
try:
|
|
55
|
-
terminal_width = os.get_terminal_size().columns
|
|
56
|
-
except (OSError, AttributeError):
|
|
57
|
-
terminal_width = DEFAULT_TERMINAL_WIDTH # Fallback default
|
|
58
|
-
|
|
59
|
-
result = Text()
|
|
60
|
-
for line in lines:
|
|
61
|
-
# Check the sign character (7th character, index 6)
|
|
62
|
-
# Format: " 5 - text" or " 6 + text" or " 7 text"
|
|
63
|
-
# Where indices 0-4 are line number, 5 is space, 6 is sign
|
|
64
|
-
if len(line) >= 7:
|
|
65
|
-
sign = line[6]
|
|
66
|
-
|
|
67
|
-
if sign == "-":
|
|
68
|
-
# Removed line - red background
|
|
69
|
-
padded = line.ljust(terminal_width)
|
|
70
|
-
result.append(padded, style="on #870101")
|
|
71
|
-
elif sign == "+":
|
|
72
|
-
# Added line - green background
|
|
73
|
-
padded = line.ljust(terminal_width)
|
|
74
|
-
result.append(padded, style="on #005f00")
|
|
75
|
-
else:
|
|
76
|
-
# Unchanged line - dim grey
|
|
77
|
-
result.append(line, style="dim")
|
|
78
|
-
else:
|
|
79
|
-
result.append(line)
|
|
80
|
-
|
|
81
|
-
result.append("\n")
|
|
82
|
-
|
|
83
|
-
return result
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _build_numbered_diff_lines(original_content, new_content, context_lines):
|
|
87
|
-
"""Build numbered diff lines from original and new content."""
|
|
88
|
-
if original_content == new_content:
|
|
89
|
-
return [], 0, 0
|
|
90
|
-
|
|
91
|
-
diff_lines = list(difflib.unified_diff(
|
|
92
|
-
original_content.splitlines(keepends=False),
|
|
93
|
-
new_content.splitlines(keepends=False),
|
|
94
|
-
fromfile="old",
|
|
95
|
-
tofile="new",
|
|
96
|
-
n=context_lines,
|
|
97
|
-
lineterm="",
|
|
98
|
-
))
|
|
99
|
-
|
|
100
|
-
hunk_re = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
101
|
-
old_line = None
|
|
102
|
-
new_line = None
|
|
103
|
-
removed = 0
|
|
104
|
-
added = 0
|
|
105
|
-
formatted_lines = []
|
|
106
|
-
|
|
107
|
-
for line in diff_lines:
|
|
108
|
-
if line.startswith("--- ") or line.startswith("+++ "):
|
|
109
|
-
continue
|
|
110
|
-
if line.startswith("@@ "):
|
|
111
|
-
match = hunk_re.match(line)
|
|
112
|
-
if match:
|
|
113
|
-
old_line = int(match.group(1))
|
|
114
|
-
new_line = int(match.group(3))
|
|
115
|
-
continue
|
|
116
|
-
|
|
117
|
-
if old_line is None or new_line is None:
|
|
118
|
-
continue
|
|
119
|
-
|
|
120
|
-
sign = line[:1]
|
|
121
|
-
text = line[1:]
|
|
122
|
-
if sign == " ":
|
|
123
|
-
line_no = old_line
|
|
124
|
-
old_line += 1
|
|
125
|
-
new_line += 1
|
|
126
|
-
elif sign == "-":
|
|
127
|
-
line_no = old_line
|
|
128
|
-
old_line += 1
|
|
129
|
-
removed += 1
|
|
130
|
-
elif sign == "+":
|
|
131
|
-
line_no = new_line
|
|
132
|
-
new_line += 1
|
|
133
|
-
added += 1
|
|
134
|
-
else:
|
|
135
|
-
continue
|
|
136
|
-
|
|
137
|
-
formatted_lines.append(f"{line_no:>5} {sign} {text}")
|
|
138
|
-
|
|
139
|
-
return formatted_lines, added, removed
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _build_diff(
|
|
143
|
-
original_content,
|
|
144
|
-
new_content,
|
|
145
|
-
file_path,
|
|
146
|
-
context_lines,
|
|
147
|
-
show_header=False,
|
|
148
|
-
repo_root=None
|
|
149
|
-
):
|
|
150
|
-
"""Build a diff with optional header or summary line.
|
|
151
|
-
|
|
152
|
-
Args:
|
|
153
|
-
original_content: Original file content
|
|
154
|
-
new_content: Modified file content
|
|
155
|
-
file_path: Path to the file being edited
|
|
156
|
-
context_lines: Number of context lines for diff
|
|
157
|
-
show_header: If True, show filename header (used in preview)
|
|
158
|
-
repo_root: Required when show_header=True to compute relative path
|
|
159
|
-
|
|
160
|
-
Returns:
|
|
161
|
-
Rich Text object with styled diff and optional header/summary
|
|
162
|
-
"""
|
|
163
|
-
formatted_lines, added, removed = _build_numbered_diff_lines(
|
|
164
|
-
original_content, new_content, context_lines,
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
# Build header or summary based on mode
|
|
168
|
-
if show_header:
|
|
169
|
-
try:
|
|
170
|
-
rel_path = file_path.relative_to(repo_root)
|
|
171
|
-
except (ValueError, TypeError):
|
|
172
|
-
rel_path = file_path
|
|
173
|
-
header = f"{rel_path} | -{removed} | +{added}"
|
|
174
|
-
else:
|
|
175
|
-
header = None
|
|
176
|
-
summary = f"Changes: +{added}, -{removed}"
|
|
177
|
-
|
|
178
|
-
# Handle empty case
|
|
179
|
-
if not formatted_lines:
|
|
180
|
-
result = Text()
|
|
181
|
-
if show_header:
|
|
182
|
-
result.append(f"\n{header}\n")
|
|
183
|
-
result.append("(no changes)\n", style="dim")
|
|
184
|
-
else:
|
|
185
|
-
result.append("(no changes)\n", style="dim")
|
|
186
|
-
return result
|
|
187
|
-
|
|
188
|
-
# Get colored diff as Text object
|
|
189
|
-
diff_text = _colorize_numbered_lines(formatted_lines, file_path)
|
|
190
|
-
|
|
191
|
-
# Build output based on mode
|
|
192
|
-
result = Text()
|
|
193
|
-
if show_header:
|
|
194
|
-
result.append(f"\n{header}\n")
|
|
195
|
-
result.append(diff_text)
|
|
196
|
-
else:
|
|
197
|
-
result.append(diff_text)
|
|
198
|
-
result.append(f"{summary}\n")
|
|
199
|
-
|
|
200
|
-
return result
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def _normalize_search_replace_for_newlines(search, replace, newline):
|
|
204
|
-
"""Normalize search/replace text to match file's newline characters."""
|
|
205
|
-
if newline == "\n":
|
|
206
|
-
return search, replace, False
|
|
207
|
-
if "\n" not in search or "\r\n" in search:
|
|
208
|
-
return search, replace, False
|
|
209
|
-
normalized_search = search.replace("\n", newline)
|
|
210
|
-
normalized_replace = replace.replace("\n", newline)
|
|
211
|
-
return normalized_search, normalized_replace, True
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _is_rg_match_line(line):
|
|
215
|
-
"""Return True if an rg output line is a match (not context/separator).
|
|
216
|
-
|
|
217
|
-
Match formats:
|
|
218
|
-
path:line:content (colon before line number)
|
|
219
|
-
line:content (single-file search, line starts with number)
|
|
220
|
-
Context/separators are excluded (path-line:content uses hyphen, not colon).
|
|
221
|
-
"""
|
|
222
|
-
if re.search(r':\d+:', line):
|
|
223
|
-
return True
|
|
224
|
-
return bool(re.match(r'^\d+:', line))
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def format_tool_result(result, command=None, is_rg=False, debug_mode=False, max_matches=None):
|
|
228
|
-
"""Format subprocess result for model consumption.
|
|
229
|
-
|
|
230
|
-
Args:
|
|
231
|
-
result: subprocess.CompletedProcess result
|
|
232
|
-
command: The command that was executed (for display and mode detection)
|
|
233
|
-
is_rg: Whether this was an rg command (affects empty output and counting)
|
|
234
|
-
debug_mode: If True, show full output; if False, show summary only
|
|
235
|
-
max_matches: Maximum number of matches to return (0 = use line-based limit, >0 = match limit)
|
|
236
|
-
|
|
237
|
-
Returns:
|
|
238
|
-
str: Formatted result with exit code
|
|
239
|
-
"""
|
|
240
|
-
# Number of trailing context lines to include after the last match when truncating
|
|
241
|
-
_RG_TRAILING_CONTEXT_LINES = 5
|
|
242
|
-
|
|
243
|
-
output = (result.stdout or "") + (result.stderr or "")
|
|
244
|
-
output = output.strip()
|
|
245
|
-
|
|
246
|
-
if not output:
|
|
247
|
-
if is_rg and result.returncode == 1:
|
|
248
|
-
output = "no matches found"
|
|
249
|
-
else:
|
|
250
|
-
output = "(no output)"
|
|
251
|
-
|
|
252
|
-
# For rg commands, apply smart truncation to prevent context explosion
|
|
253
|
-
if is_rg:
|
|
254
|
-
label = "files" if command and "--files-with-matches" in command.lower() else "matches"
|
|
255
|
-
MAX_LINES = FORMATTER_MAX_LINES
|
|
256
|
-
|
|
257
|
-
# Exit code 0: found matches, Exit code 1: no matches
|
|
258
|
-
if result.returncode == 1:
|
|
259
|
-
# No matches found - rg returns 1 in this case
|
|
260
|
-
count = 0
|
|
261
|
-
elif result.returncode == 0:
|
|
262
|
-
# Count actual matches (lines with ':number:' pattern), not context lines
|
|
263
|
-
if "--files-with-matches" in (command or "").lower():
|
|
264
|
-
# files-with-matches mode: count lines (each line is a file)
|
|
265
|
-
lines = [line for line in output.splitlines() if line.strip()]
|
|
266
|
-
count = len(lines)
|
|
267
|
-
else:
|
|
268
|
-
# Normal mode: count match lines (lines with ':number:' pattern), not context
|
|
269
|
-
count = sum(1 for line in output.splitlines() if _is_rg_match_line(line))
|
|
270
|
-
else:
|
|
271
|
-
# Error occurred (exit code 2 or higher)
|
|
272
|
-
count = 0
|
|
273
|
-
|
|
274
|
-
# Handle no matches
|
|
275
|
-
if result.returncode == 1:
|
|
276
|
-
return f"exit_code={result.returncode}\n{label}=0\nNo matches found\n\n"
|
|
277
|
-
elif count == 0:
|
|
278
|
-
# Exit code 0 but no output - unusual but possible
|
|
279
|
-
return f"exit_code={result.returncode}\n{output}\n\n"
|
|
280
|
-
|
|
281
|
-
# Truncate output
|
|
282
|
-
output_lines = output.splitlines()
|
|
283
|
-
|
|
284
|
-
# Use max_matches directly if positive (>0 means match limit, 0/None means line-based fallback)
|
|
285
|
-
effective_max_matches = max_matches if max_matches and max_matches > 0 else None
|
|
286
|
-
|
|
287
|
-
if effective_max_matches is not None and "--files-with-matches" in (command or "").lower():
|
|
288
|
-
# files_with_matches mode: truncate by file count (each line is a file)
|
|
289
|
-
if len(output_lines) > effective_max_matches:
|
|
290
|
-
truncated = "\n".join(output_lines[:effective_max_matches])
|
|
291
|
-
omitted = len(output_lines) - effective_max_matches
|
|
292
|
-
output = f"{truncated}\n\n... ({omitted} more {label} truncated)"
|
|
293
|
-
return f"exit_code={result.returncode}\n{label}={len(output_lines)}\n{output}\n\n"
|
|
294
|
-
else:
|
|
295
|
-
output = "\n".join(output_lines)
|
|
296
|
-
return f"exit_code={result.returncode}\n{label}={count}\n{output}\n\n"
|
|
297
|
-
|
|
298
|
-
if effective_max_matches is not None:
|
|
299
|
-
# Match-based truncation: find lines that are actual matches (contain :number:)
|
|
300
|
-
# and cut after the Nth match including its trailing context
|
|
301
|
-
is_match = [_is_rg_match_line(line) for line in output_lines]
|
|
302
|
-
|
|
303
|
-
# Compute total match count once upfront
|
|
304
|
-
total_matches = sum(is_match)
|
|
305
|
-
|
|
306
|
-
# Find cutoff point after Nth match
|
|
307
|
-
match_count = 0
|
|
308
|
-
cutoff = len(output_lines)
|
|
309
|
-
for i, match in enumerate(is_match):
|
|
310
|
-
if match:
|
|
311
|
-
match_count += 1
|
|
312
|
-
if match_count >= effective_max_matches:
|
|
313
|
-
# Include up to _RG_TRAILING_CONTEXT_LINES trailing context lines after this match
|
|
314
|
-
cutoff = i + 1
|
|
315
|
-
for j in range(i + 1, min(i + 1 + _RG_TRAILING_CONTEXT_LINES, len(is_match))):
|
|
316
|
-
if is_match[j]:
|
|
317
|
-
break
|
|
318
|
-
cutoff = j + 1
|
|
319
|
-
break
|
|
320
|
-
|
|
321
|
-
if cutoff < len(output_lines):
|
|
322
|
-
truncated = "\n".join(output_lines[:cutoff])
|
|
323
|
-
omitted_matches = max(0, total_matches - effective_max_matches)
|
|
324
|
-
output = f"{truncated}\n\n... ({omitted_matches} more {label} truncated)"
|
|
325
|
-
return f"exit_code={result.returncode}\n{label}={total_matches}\n{output}\n\n"
|
|
326
|
-
else:
|
|
327
|
-
output = "\n".join(output_lines)
|
|
328
|
-
return f"exit_code={result.returncode}\n{label}={total_matches}\n{output}\n\n"
|
|
329
|
-
|
|
330
|
-
# Fallback: line-based truncation
|
|
331
|
-
if len(output_lines) > MAX_LINES:
|
|
332
|
-
truncated = "\n".join(output_lines[:MAX_LINES])
|
|
333
|
-
omitted = len(output_lines) - MAX_LINES
|
|
334
|
-
output = f"{truncated}\n\n... ({omitted} more {label} truncated)"
|
|
335
|
-
else:
|
|
336
|
-
output = "\n".join(output_lines)
|
|
337
|
-
|
|
338
|
-
return f"exit_code={result.returncode}\n{label}={count}\n{output}\n\n"
|
|
339
|
-
|
|
340
|
-
# For non-rg shell commands: apply head+tail truncation
|
|
341
|
-
output_lines = output.splitlines()
|
|
342
|
-
max_lines = _get_shell_max_lines()
|
|
343
|
-
|
|
344
|
-
if len(output_lines) > max_lines:
|
|
345
|
-
head_count = max_lines // 2
|
|
346
|
-
tail_count = max_lines - head_count
|
|
347
|
-
omitted = len(output_lines) - max_lines
|
|
348
|
-
head = "\n".join(output_lines[:head_count])
|
|
349
|
-
tail = "\n".join(output_lines[-tail_count:])
|
|
350
|
-
output = f"{head}\n\n... ({omitted} lines omitted) ...\n\n{tail}"
|
|
351
|
-
else:
|
|
352
|
-
output = "\n".join(output_lines)
|
|
353
|
-
|
|
354
|
-
return f"exit_code={result.returncode}\n{output}\n\n"
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
def format_file_result(exit_code, content=None, error=None, path=None,
|
|
359
|
-
lines_read=None, start_line=None, truncated=False, items_count=None,
|
|
360
|
-
truncation_info=None):
|
|
361
|
-
"""Format file operation result for model consumption.
|
|
362
|
-
|
|
363
|
-
Args:
|
|
364
|
-
exit_code: Exit code (0 for success, 1 for error)
|
|
365
|
-
content: Optional content string (for successful reads)
|
|
366
|
-
error: Optional error message (for failures)
|
|
367
|
-
path: Path to the file/directory
|
|
368
|
-
lines_read: Number of lines read (for file reads)
|
|
369
|
-
start_line: 1-based starting line number for file reads
|
|
370
|
-
truncated: Whether content was truncated (for file reads)
|
|
371
|
-
items_count: Number of items (for directory listings)
|
|
372
|
-
truncation_info: Optional dict with truncation metadata (total, shown, omitted)
|
|
373
|
-
|
|
374
|
-
Returns:
|
|
375
|
-
str: Formatted result with exit code and metadata
|
|
376
|
-
"""
|
|
377
|
-
metadata_parts = [f"exit_code={exit_code}"]
|
|
378
|
-
|
|
379
|
-
if path is not None:
|
|
380
|
-
metadata_parts.append(f"path={path}")
|
|
381
|
-
|
|
382
|
-
if lines_read is not None:
|
|
383
|
-
metadata_parts.append(f"lines_read={lines_read}")
|
|
384
|
-
|
|
385
|
-
if start_line is not None:
|
|
386
|
-
metadata_parts.append(f"start_line={start_line}")
|
|
387
|
-
|
|
388
|
-
if truncated:
|
|
389
|
-
metadata_parts.append("truncated=true")
|
|
390
|
-
if truncation_info:
|
|
391
|
-
metadata_parts.append(
|
|
392
|
-
f"truncation_info=total:{truncation_info['total']},"
|
|
393
|
-
f"shown:{truncation_info['shown']},"
|
|
394
|
-
f"omitted:{truncation_info['omitted']}"
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
if items_count is not None:
|
|
398
|
-
metadata_parts.append(f"items_count={items_count}")
|
|
399
|
-
|
|
400
|
-
metadata = " ".join(metadata_parts)
|
|
401
|
-
|
|
402
|
-
if error:
|
|
403
|
-
return f"{metadata}\nerror: {error}"
|
|
404
|
-
|
|
405
|
-
if content is not None:
|
|
406
|
-
return f"{metadata}\n{content}\n\n"
|
|
407
|
-
|
|
408
|
-
return f"{metadata}\n\n"
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
# format_file_preview removed - now using Rich Syntax directly in agentic.py
|