bone-agent 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,322 @@
1
+ """File reading operations."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional, Dict, Tuple
6
+
7
+ from .helpers.base import tool
8
+ from .helpers.path_resolver import PathResolver
9
+ from .helpers.formatters import format_file_result
10
+ from . import constants
11
+
12
+
13
+ def _validate_read_path(
14
+ path_str: str,
15
+ repo_root: Path,
16
+ gitignore_spec,
17
+ vault_root: str = None,
18
+ ) -> Tuple[Optional[Path], Optional[str]]:
19
+ """Validate and resolve path for reading.
20
+
21
+ Args:
22
+ path_str: Path string to validate
23
+ repo_root: Repository root directory
24
+ gitignore_spec: Optional PathSpec for .gitignore filtering
25
+ vault_root: Optional Obsidian vault root path
26
+
27
+ Returns:
28
+ (resolved_path, error_message) - error_message is None if valid
29
+ """
30
+ vault_path = Path(vault_root) if vault_root else None
31
+ resolver = PathResolver(repo_root=repo_root, gitignore_spec=gitignore_spec, vault_path=vault_path)
32
+ return resolver.resolve_and_validate(
33
+ path_str,
34
+ check_gitignore=True,
35
+ must_exist=True,
36
+ must_be_file=True,
37
+ enforce_boundary=vault_path is not None,
38
+ )
39
+
40
+
41
+ def _validate_start_line(start_line: Optional[int]) -> int:
42
+ """Validate and normalize start_line parameter.
43
+
44
+ Args:
45
+ start_line: Optional 1-based starting line number
46
+
47
+ Returns:
48
+ Normalized start_line (1 or greater)
49
+ """
50
+ if start_line is None:
51
+ return 1
52
+ try:
53
+ start_line = int(start_line)
54
+ except (TypeError, ValueError):
55
+ raise ValueError("start_line must be an integer (1-based).")
56
+ if start_line < 1:
57
+ start_line = 1
58
+ return start_line
59
+
60
+
61
+ def _skip_lines(file_obj, lines_to_skip: int) -> bool:
62
+ """Advance file_obj by lines_to_skip lines.
63
+
64
+ Args:
65
+ file_obj: File object to advance
66
+ lines_to_skip: Number of lines to skip
67
+
68
+ Returns:
69
+ True if EOF reached early
70
+ """
71
+ if lines_to_skip <= 0:
72
+ return False
73
+ remaining = lines_to_skip
74
+ while remaining > 0:
75
+ if file_obj.readline() == "":
76
+ return True
77
+ remaining -= 1
78
+ return False
79
+
80
+
81
+ def _read_full_file(file_path: Path, start_line: int) -> Dict[str, any]:
82
+ """Read entire file, optionally starting from specific line.
83
+
84
+ Args:
85
+ file_path: Path to file to read
86
+ start_line: 1-based starting line number
87
+
88
+ Returns:
89
+ dict with keys: content, lines_read, truncated=False
90
+ """
91
+ if start_line == 1:
92
+ # Use newline=None (universal newlines) to normalize \r\n → \n,
93
+ # matching the behavior of all other read paths and edit_file's matcher.
94
+ with file_path.open("r", encoding="utf-8", errors="replace", newline=None) as f:
95
+ content = f.read()
96
+ lines_read = len(content.splitlines())
97
+ else:
98
+ lines = []
99
+ with file_path.open("r", encoding="utf-8", errors="replace", newline=None) as f:
100
+ eof_early = _skip_lines(f, start_line - 1)
101
+ if not eof_early:
102
+ lines = f.readlines()
103
+ content = "".join(lines)
104
+ lines_read = len(content.splitlines())
105
+
106
+ return {"content": content, "lines_read": lines_read, "truncated": False}
107
+
108
+
109
+ def _read_partial_file(file_path: Path, start_line: int, max_lines: int) -> Dict[str, any]:
110
+ """Read partial file content with streaming for large files.
111
+
112
+ Args:
113
+ file_path: Path to file to read
114
+ start_line: 1-based starting line number
115
+ max_lines: Maximum number of lines to read
116
+
117
+ Returns:
118
+ dict with keys: content, lines_read, truncated
119
+
120
+ Strategy:
121
+ - Stream in 8KB chunks
122
+ - Extract complete lines as we go
123
+ - Stop at max_lines
124
+ - Handle pathological long lines (>10MB buffer)
125
+ """
126
+ lines = []
127
+ truncated = False
128
+ lines_read = 0
129
+ chunk_size = constants.FILE_READ_CHUNK_SIZE
130
+ max_buffer_size = constants.FILE_READ_MAX_BUFFER_SIZE
131
+
132
+ # Use universal newlines so all newline types normalize to '\n' for parsing.
133
+ with file_path.open("r", encoding="utf-8", errors="replace", newline=None) as f:
134
+ eof_early = _skip_lines(f, start_line - 1)
135
+ if eof_early:
136
+ return {"content": "", "lines_read": 0, "truncated": False}
137
+
138
+ if max_lines == 0:
139
+ # Check if file has any content without loading it all
140
+ if f.read(1):
141
+ truncated = True
142
+ else:
143
+ # Streaming read: read in chunks, stop when we have enough lines
144
+ buffer = ""
145
+ eof_reached = False
146
+ while lines_read < max_lines:
147
+ chunk = f.read(chunk_size)
148
+ if not chunk: # EOF reached
149
+ eof_reached = True
150
+ break
151
+
152
+ buffer += chunk
153
+
154
+ parts = buffer.split("\n")
155
+ complete_lines = len(parts) - 1
156
+ remaining_capacity = max_lines - lines_read
157
+
158
+ if complete_lines:
159
+ to_take = min(remaining_capacity, complete_lines)
160
+ for i in range(to_take):
161
+ lines.append(parts[i] + "\n")
162
+ lines_read += to_take
163
+
164
+ if to_take < complete_lines:
165
+ truncated = True
166
+ buffer = ""
167
+ break
168
+
169
+ buffer = parts[-1]
170
+
171
+ # If we've read enough lines and have leftover content, mark as truncated
172
+ if lines_read >= max_lines:
173
+ if buffer:
174
+ truncated = True
175
+ break
176
+
177
+ # Safeguard against extremely long single lines (pathological case)
178
+ if len(buffer) > max_buffer_size:
179
+ lines.append(buffer[:max_buffer_size])
180
+ lines_read += 1
181
+ truncated = True
182
+ buffer = ""
183
+ break
184
+
185
+ if eof_reached and not truncated and buffer and lines_read < max_lines:
186
+ lines.append(buffer)
187
+ lines_read += 1
188
+ buffer = ""
189
+
190
+ if lines_read >= max_lines and not truncated:
191
+ # We may have stopped exactly at a chunk boundary; peek for more content.
192
+ if f.read(1):
193
+ truncated = True
194
+
195
+ content = "".join(lines)
196
+ return {"content": content, "lines_read": lines_read, "truncated": truncated}
197
+
198
+
199
+ def _read_file_content(
200
+ file_path: Path,
201
+ start_line: int,
202
+ max_lines: Optional[int]
203
+ ) -> Dict[str, any]:
204
+ """Read file content with optional line range.
205
+
206
+ Args:
207
+ file_path: Path to file to read
208
+ start_line: 1-based starting line number
209
+ max_lines: Optional maximum number of lines to read
210
+
211
+ Returns:
212
+ dict with keys: content, lines_read, truncated
213
+
214
+ Logic:
215
+ - If max_lines is None: call _read_full_file()
216
+ - Else: call _read_partial_file()
217
+ """
218
+ if max_lines is None:
219
+ return _read_full_file(file_path, start_line)
220
+ return _read_partial_file(file_path, start_line, max_lines)
221
+
222
+
223
+ @tool(
224
+ name="read_file",
225
+ description="Read file contents. Prefer over rg when you know the file path.",
226
+ parameters={
227
+ "type": "object",
228
+ "properties": {
229
+ "path_str": {"type": "string", "description": "Path to read"},
230
+ "max_lines": {"type": "integer", "description": "Max lines to read (omit for full file)"},
231
+ "start_line": {"type": "integer", "description": "1-based start line (default: 1)"}
232
+ },
233
+ "required": ["path_str"]
234
+ },
235
+ )
236
+ def read_file(
237
+ path_str: str,
238
+ repo_root: Path,
239
+ max_lines: Optional[int] = None,
240
+ start_line: Optional[int] = None,
241
+ gitignore_spec = None,
242
+ vault_root: str = None,
243
+ ) -> str:
244
+ """Read a file's contents.
245
+
246
+ Fast file reader that respects .gitignore, supports partial reads via
247
+ max_lines/start_line, and provides consistent output format.
248
+
249
+ Args:
250
+ path_str: Path string to the file to read
251
+ repo_root: Repository root directory (for path resolution)
252
+ max_lines: Optional limit on number of lines to read
253
+ start_line: Optional 1-based starting line number (default: 1)
254
+ gitignore_spec: Optional PathSpec for .gitignore filtering
255
+ vault_root: Optional Obsidian vault root path
256
+
257
+ Returns:
258
+ str: Formatted result with exit_code, lines_read, and file content
259
+ """
260
+ try:
261
+ # Validate path
262
+ resolved, error = _validate_read_path(path_str, repo_root, gitignore_spec, vault_root=vault_root)
263
+ if error:
264
+ return format_file_result(
265
+ exit_code=1,
266
+ error=error,
267
+ path=path_str
268
+ )
269
+
270
+ # Validate start_line
271
+ try:
272
+ start_line = _validate_start_line(start_line)
273
+ except ValueError as e:
274
+ try:
275
+ rel_path = resolved.relative_to(repo_root)
276
+ except ValueError:
277
+ rel_path = resolved
278
+ return format_file_result(
279
+ exit_code=1,
280
+ error=str(e),
281
+ path=str(rel_path)
282
+ )
283
+
284
+ # Normalize max_lines
285
+ if max_lines is not None and max_lines < 0:
286
+ max_lines = 0
287
+
288
+ # Read file content
289
+ result = _read_file_content(resolved, start_line, max_lines)
290
+
291
+ try:
292
+ rel_path = resolved.relative_to(repo_root)
293
+ except ValueError:
294
+ rel_path = resolved
295
+
296
+ return format_file_result(
297
+ exit_code=0,
298
+ content=result["content"],
299
+ path=str(rel_path),
300
+ lines_read=result["lines_read"],
301
+ start_line=start_line,
302
+ truncated=result["truncated"]
303
+ )
304
+
305
+ except FileNotFoundError:
306
+ return format_file_result(
307
+ exit_code=1,
308
+ error="File not found",
309
+ path=path_str
310
+ )
311
+ except PermissionError:
312
+ return format_file_result(
313
+ exit_code=1,
314
+ error="Permission denied",
315
+ path=path_str
316
+ )
317
+ except Exception as e:
318
+ return format_file_result(
319
+ exit_code=1,
320
+ error=str(e),
321
+ path=path_str
322
+ )
@@ -0,0 +1,105 @@
1
+ """Tool infrastructure and helper utilities.
2
+
3
+ This subpackage provides the core infrastructure for tool registration,
4
+ execution, and supporting utilities. It is not intended to be imported
5
+ directly by end users - import from tools/ instead for backward compatibility.
6
+
7
+ For creating custom tools, use:
8
+ from tools import tool # or from tools.base import tool
9
+ """
10
+
11
+ # Core infrastructure
12
+ from .base import (
13
+ ToolDefinition,
14
+ ToolRegistry,
15
+ tool,
16
+ build_context,
17
+ get_tool_schemas,
18
+ get_terminal_policy,
19
+ TERMINAL_NONE,
20
+ TERMINAL_YIELD,
21
+ TERMINAL_STOP,
22
+ TOOLS,
23
+ )
24
+
25
+ # File operation helpers
26
+ from .file_helpers import (
27
+ _is_reserved_windows_name,
28
+ GitignoreFilter,
29
+ )
30
+
31
+ # Path resolution helpers
32
+ from .path_resolver import PathResolver
33
+
34
+ # Result formatting helpers
35
+ from .formatters import (
36
+ format_tool_result,
37
+ format_file_result,
38
+ _build_diff,
39
+ _detect_newline,
40
+ )
41
+
42
+ # Type conversion helpers
43
+ from .converters import (
44
+ coerce_int,
45
+ coerce_bool,
46
+ )
47
+
48
+ # Tool loading and discovery
49
+ from .loader import (
50
+ discover_tools,
51
+ load_builtin_tools,
52
+ load_plugin_tools,
53
+ load_all_tools,
54
+ list_registered_tools,
55
+ )
56
+
57
+ # Plugin manifest for on-demand tool discovery
58
+ from .plugin_manifest import PluginManifest, plugin_manifest
59
+
60
+ # Parallel execution
61
+ from .parallel_executor import (
62
+ ToolCall,
63
+ ToolResult,
64
+ ParallelToolExecutor,
65
+ )
66
+
67
+ __all__ = [
68
+ # Core infrastructure
69
+ 'ToolDefinition',
70
+ 'ToolRegistry',
71
+ 'tool',
72
+ 'build_context',
73
+ 'get_tool_schemas',
74
+ 'get_terminal_policy',
75
+ 'TERMINAL_NONE',
76
+ 'TERMINAL_YIELD',
77
+ 'TERMINAL_STOP',
78
+ 'TOOLS',
79
+ # File operation helpers
80
+ '_is_reserved_windows_name',
81
+ 'GitignoreFilter',
82
+ # Path resolution helpers
83
+ 'PathResolver',
84
+ # Result formatting helpers
85
+ 'format_tool_result',
86
+ 'format_file_result',
87
+ '_build_diff',
88
+ '_detect_newline',
89
+ # Type conversion helpers
90
+ 'coerce_int',
91
+ 'coerce_bool',
92
+ # Tool loading and discovery
93
+ 'discover_tools',
94
+ 'load_builtin_tools',
95
+ 'load_plugin_tools',
96
+ 'load_all_tools',
97
+ 'list_registered_tools',
98
+ # Plugin manifest
99
+ 'PluginManifest',
100
+ 'plugin_manifest',
101
+ # Parallel execution
102
+ 'ToolCall',
103
+ 'ToolResult',
104
+ 'ParallelToolExecutor',
105
+ ]