bone-agent 1.4.0 → 2.0.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 (126) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -201
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -144
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -50
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. 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