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,543 @@
1
+ """File editing tool with core edit operations and @tool decorators."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from exceptions import PathValidationError, FileEditError
8
+ from rich.text import Text
9
+
10
+ from .helpers.base import tool
11
+ from .helpers.path_resolver import PathResolver
12
+ from .helpers.formatters import _build_diff, _detect_newline, _normalize_search_replace_for_newlines
13
+
14
+
15
+ _WHITESPACE_RE = re.compile(r"\s+")
16
+
17
+
18
+ def _normalize_line_for_match(line, *, collapse_whitespace):
19
+ line = line.rstrip("\r\n")
20
+ if collapse_whitespace:
21
+ return _WHITESPACE_RE.sub(" ", line).strip()
22
+ return line.rstrip(" \t")
23
+
24
+
25
+ def _find_spans_by_line_normalization(content, search_text, *, collapse_whitespace):
26
+ file_lines = content.splitlines(keepends=True)
27
+ search_lines = search_text.splitlines(keepends=False)
28
+ if not search_lines:
29
+ return []
30
+
31
+ normalized_search = [
32
+ _normalize_line_for_match(line, collapse_whitespace=collapse_whitespace)
33
+ for line in search_lines
34
+ ]
35
+ normalized_file = [
36
+ _normalize_line_for_match(line, collapse_whitespace=collapse_whitespace)
37
+ for line in file_lines
38
+ ]
39
+
40
+ offsets = [0]
41
+ for line in file_lines:
42
+ offsets.append(offsets[-1] + len(line))
43
+
44
+ first = normalized_search[0]
45
+ n = len(normalized_search)
46
+ spans = []
47
+ for i, file_line in enumerate(normalized_file):
48
+ if file_line != first:
49
+ continue
50
+ if normalized_file[i:i + n] == normalized_search:
51
+ spans.append((offsets[i], offsets[i + n]))
52
+ return spans
53
+
54
+
55
+ def _build_whitespace_insensitive_pattern(search_text):
56
+ parts = []
57
+ i = 0
58
+ while i < len(search_text):
59
+ ch = search_text[i]
60
+ if ch.isspace():
61
+ while i < len(search_text) and search_text[i].isspace():
62
+ i += 1
63
+ parts.append(r"\s+")
64
+ continue
65
+ parts.append(re.escape(ch))
66
+ i += 1
67
+ return re.compile("".join(parts))
68
+
69
+
70
+ def _build_fully_whitespace_agnostic_pattern(search_text):
71
+ parts = []
72
+ for i, ch in enumerate(search_text):
73
+ if ch.isspace():
74
+ continue
75
+ parts.append(re.escape(ch))
76
+ if i != len(search_text) - 1:
77
+ parts.append(r"\s*")
78
+ return re.compile("".join(parts))
79
+
80
+
81
+ def _find_unique_span_with_fallbacks(content, search_text):
82
+ """Find unique span for search_text, collecting diagnostics along the way.
83
+
84
+ Returns:
85
+ (span_tuple, diagnostics_list) on success
86
+ (None, diagnostics_list) on failure — diagnostics explain what was tried
87
+
88
+ diagnostics is a list of strings describing each matching stage.
89
+ """
90
+ diagnostics = []
91
+
92
+ # Stage 1: Exact byte match
93
+ count = content.count(search_text)
94
+ diagnostics.append(f"exact match: {count} occurrence(s)")
95
+ if count == 1:
96
+ start = content.index(search_text)
97
+ return (start, start + len(search_text)), diagnostics
98
+ if count > 1:
99
+ raise FileEditError(
100
+ f"Search text appears {count} times in file (must be unique)",
101
+ details={"count": count, "hint": "Add more surrounding context to make it unique"}
102
+ )
103
+
104
+ # Stage 2: Line-normalized match (trailing whitespace ignored)
105
+ if "\n" in search_text or "\r" in search_text:
106
+ spans = _find_spans_by_line_normalization(
107
+ content, search_text, collapse_whitespace=False
108
+ )
109
+ diagnostics.append(f"line-normalized (trailing ws stripped): {len(spans)} match(es)")
110
+ if len(spans) == 1:
111
+ return spans[0], diagnostics
112
+ if len(spans) > 1:
113
+ raise FileEditError(
114
+ f"Search text appears {len(spans)} times in file (must be unique)",
115
+ details={"count": len(spans), "hint": "Add more surrounding context to make it unique"}
116
+ )
117
+
118
+ # Stage 3: Line-normalized + collapsed whitespace
119
+ spans = _find_spans_by_line_normalization(
120
+ content, search_text, collapse_whitespace=True
121
+ )
122
+ diagnostics.append(f"line-normalized (ws collapsed): {len(spans)} match(es)")
123
+ if len(spans) == 1:
124
+ return spans[0], diagnostics
125
+ if len(spans) > 1:
126
+ raise FileEditError(
127
+ f"Search text appears {len(spans)} times in file (must be unique)",
128
+ details={"count": len(spans), "hint": "Add more surrounding context to make it unique"}
129
+ )
130
+
131
+ # Stage 4: Regex whitespace-insensitive pattern
132
+ pattern = _build_whitespace_insensitive_pattern(search_text)
133
+ matches = list(pattern.finditer(content))
134
+ diagnostics.append(f"whitespace-insensitive regex: {len(matches)} match(es)")
135
+ if len(matches) == 1:
136
+ return matches[0].span(), diagnostics
137
+ if len(matches) > 1:
138
+ raise FileEditError(
139
+ f"Search text appears {len(matches)} times in file (must be unique)",
140
+ details={"count": len(matches), "hint": "Add more surrounding context to make it unique"}
141
+ )
142
+
143
+ # Stage 5: Fully whitespace-agnostic (only for single-line search)
144
+ if not any(ch.isspace() for ch in search_text):
145
+ pattern = _build_fully_whitespace_agnostic_pattern(search_text)
146
+ matches = list(pattern.finditer(content))
147
+ diagnostics.append(f"fully whitespace-agnostic regex: {len(matches)} match(es)")
148
+ if len(matches) == 1:
149
+ return matches[0].span(), diagnostics
150
+ if len(matches) > 1:
151
+ raise FileEditError(
152
+ f"Search text appears {len(matches)} times in file (must be unique)",
153
+ details={"count": len(matches), "hint": "Add more surrounding context to make it unique"}
154
+ )
155
+
156
+ return None, diagnostics
157
+
158
+
159
+ def _resolve_repo_path(path_str, repo_root, gitignore_spec=None, vault_root=None, skip_gitignore=False):
160
+ """Resolve and validate a path for editing.
161
+
162
+ This function wraps PathResolver.resolve_and_validate() for the edit tool's
163
+ specific needs, adding file type validation.
164
+
165
+ Args:
166
+ path_str: Path string to resolve
167
+ repo_root: Repository root directory
168
+ gitignore_spec: Optional pathspec.PathSpec for .gitignore filtering
169
+ vault_root: Optional Obsidian vault root path
170
+ skip_gitignore: If True, skip .gitignore filtering (for memory files)
171
+
172
+ Returns:
173
+ Resolved Path object
174
+
175
+ Raises:
176
+ PathValidationError: If path is invalid or blocked by .gitignore
177
+ """
178
+ from pathlib import Path
179
+ vault_path = Path(vault_root) if vault_root else None
180
+ # Use PathResolver for centralized validation
181
+ resolver = PathResolver(repo_root=repo_root, gitignore_spec=gitignore_spec, vault_path=vault_path)
182
+ resolved, error = resolver.resolve_and_validate(
183
+ path_str,
184
+ check_gitignore=not skip_gitignore,
185
+ must_exist=True,
186
+ must_be_file=False, # We'll check this separately
187
+ enforce_boundary=vault_path is not None,
188
+ )
189
+
190
+ if error:
191
+ raise PathValidationError(
192
+ error,
193
+ details={"path": path_str}
194
+ )
195
+
196
+ # Additional validation: path must be a file for editing
197
+ if not resolved.is_file():
198
+ raise PathValidationError(
199
+ f"Path is not a file: {resolved}",
200
+ details={"path": str(resolved)}
201
+ )
202
+
203
+ return resolved
204
+
205
+
206
+ def _prepare_edit(arguments, repo_root, gitignore_spec=None, vault_root=None) -> tuple[str, dict]:
207
+ """Prepare edit operation with validation.
208
+
209
+ Args:
210
+ arguments: Edit arguments dict
211
+ repo_root: Repository root
212
+ gitignore_spec: Optional PathSpec for .gitignore filtering
213
+ vault_root: Optional Obsidian vault root path
214
+
215
+ Returns:
216
+ Tuple of (status_string, payload_dict)
217
+
218
+ Raises:
219
+ PathValidationError: If path is invalid or blocked by .gitignore
220
+ FileEditError: If file cannot be read or edit is invalid
221
+ """
222
+ path = arguments.get("path")
223
+ if not path or not isinstance(path, str) or not path.strip():
224
+ raise FileEditError("Missing or invalid 'path' parameter")
225
+
226
+ # Memory files (.bone/ and user_memory.md) are auto-approved writes that the
227
+ # system itself adds to .gitignore, so gitignore filtering would block them.
228
+ is_memory = Path(path).parent.name == ".bone" or Path(path).name == "user_memory.md"
229
+
230
+ # Resolve and validate path using PathResolver
231
+ try:
232
+ file_path = _resolve_repo_path(path, repo_root, gitignore_spec, vault_root=vault_root,
233
+ skip_gitignore=is_memory)
234
+ except PathValidationError as e:
235
+ # Re-raise with additional context
236
+ raise FileEditError(str(e), details=e.details)
237
+
238
+ if not file_path.exists():
239
+ # Auto-create memory files (.bone/) with default header on first write.
240
+ # These are already auto-approved, so directory+file creation is safe.
241
+ if file_path.parent.name == ".bone" or file_path.name == "user_memory.md":
242
+ file_path.parent.mkdir(parents=True, exist_ok=True)
243
+ header = "# Project Memory\n\n" if file_path.name == "agents.md" else "# User Memory\n\n"
244
+ file_path.write_text(header, encoding="utf-8")
245
+ else:
246
+ raise FileEditError(
247
+ f"File not found",
248
+ details={"path": str(file_path)}
249
+ )
250
+
251
+ search = arguments.get("search")
252
+ replace = arguments.get("replace")
253
+
254
+ if search is None:
255
+ raise FileEditError("'search' parameter is required")
256
+ if replace is None:
257
+ raise FileEditError("'replace' parameter is required")
258
+ if not isinstance(search, str):
259
+ raise FileEditError("'search' must be a string")
260
+ if not isinstance(replace, str):
261
+ raise FileEditError("'replace' must be a string")
262
+ if search == "":
263
+ raise FileEditError("'search' must be non-empty")
264
+
265
+ try:
266
+ with file_path.open("r", encoding="utf-8", newline="") as f:
267
+ original_content = f.read()
268
+ except UnicodeDecodeError as e:
269
+ raise FileEditError(
270
+ "File contains non-UTF-8 bytes and cannot be edited safely",
271
+ details={
272
+ "path": str(file_path),
273
+ "encoding_error": str(e),
274
+ "hint": "This file contains bytes that are not valid UTF-8. "
275
+ "Use execute_command to inspect or edit it with a tool like sed or xxd."
276
+ }
277
+ )
278
+ except Exception as e:
279
+ raise FileEditError(
280
+ f"Failed to read file",
281
+ details={"path": str(file_path), "original_error": str(e)}
282
+ )
283
+
284
+ file_newline = _detect_newline(original_content)
285
+ search, replace, _ = _normalize_search_replace_for_newlines(
286
+ search, replace, file_newline
287
+ )
288
+
289
+ search_span, match_diagnostics = _find_unique_span_with_fallbacks(original_content, search)
290
+ if search_span is None:
291
+ search_preview = search[:200] + "..." if len(search) > 200 else search
292
+ diagnostics_summary = "\n".join(f" {d}" for d in match_diagnostics)
293
+ raise FileEditError(
294
+ "Search text not found in file",
295
+ details={
296
+ "search_preview": search_preview,
297
+ "diagnostics": diagnostics_summary,
298
+ "hint": "Try adding more surrounding context (including nearby lines) to disambiguate whitespace/indentation differences. Check that blank-line counts in your search match the file exactly."
299
+ }
300
+ )
301
+
302
+ context_lines = arguments.get("context_lines", 3)
303
+ if not isinstance(context_lines, int) or context_lines < 0:
304
+ context_lines = 3
305
+
306
+ return "exit_code=0", {
307
+ "file_path": file_path,
308
+ "original_content": original_content,
309
+ "search_span": search_span,
310
+ "replace": replace,
311
+ "context_lines": context_lines,
312
+ }
313
+
314
+
315
+ def preview_edit_file(arguments, repo_root, gitignore_spec=None, vault_root=None) -> tuple[str, Text]:
316
+ """Build a line-numbered diff preview without writing changes.
317
+
318
+ Returns:
319
+ Tuple of (status_string, diff_text)
320
+
321
+ Raises:
322
+ FileEditError: If edit validation fails
323
+ """
324
+ status, payload = _prepare_edit(arguments, repo_root, gitignore_spec, vault_root=vault_root)
325
+
326
+ start, end = payload["search_span"]
327
+ new_content = (
328
+ payload["original_content"][:start]
329
+ + payload["replace"]
330
+ + payload["original_content"][end:]
331
+ )
332
+
333
+ # Early exit for no-op edits (e.g. fuzzy match produced identical content)
334
+ if new_content == payload["original_content"]:
335
+ raise FileEditError(
336
+ "Edit is a no-op: replacement produces identical content",
337
+ details={"hint": "Check that your search/replace text actually differs."}
338
+ )
339
+
340
+ diff_text = _build_diff(
341
+ payload["original_content"],
342
+ new_content,
343
+ payload["file_path"],
344
+ payload["context_lines"],
345
+ show_header=True,
346
+ repo_root=repo_root,
347
+ )
348
+ return "exit_code=0", diff_text
349
+
350
+
351
+ def run_edit_file(arguments, repo_root, console, gitignore_spec=None, vault_root=None) -> str | Text:
352
+ """Apply search/replace edit to a file.
353
+
354
+ Returns:
355
+ Rich Text with diff for success, str with exit_code for errors
356
+ """
357
+ try:
358
+ status, payload = _prepare_edit(arguments, repo_root, gitignore_spec, vault_root=vault_root)
359
+
360
+ start, end = payload["search_span"]
361
+ new_content = (
362
+ payload["original_content"][:start]
363
+ + payload["replace"]
364
+ + payload["original_content"][end:]
365
+ )
366
+
367
+ # Generate diff for preview
368
+ diff_text = _build_diff(
369
+ payload["original_content"],
370
+ new_content,
371
+ payload["file_path"],
372
+ payload["context_lines"],
373
+ )
374
+
375
+ # Skip write for no-op edits (safety net — preview should have caught this,
376
+ # but guards against race conditions or direct calls to run_edit_file)
377
+ if new_content == payload["original_content"]:
378
+ raise FileEditError(
379
+ "Edit is a no-op: replacement produces identical content",
380
+ details={"hint": "Check that your search/replace text actually differs."}
381
+ )
382
+
383
+ # Write to file
384
+ try:
385
+ with payload["file_path"].open("w", encoding="utf-8", newline="") as f:
386
+ f.write(new_content)
387
+ except Exception as e:
388
+ raise FileEditError(
389
+ f"Failed to write file",
390
+ details={"path": str(payload["file_path"]), "original_error": str(e)}
391
+ )
392
+
393
+ # Success - return Rich Text object with styled diff (no exit_code prefix)
394
+ result = Text()
395
+ result.append(diff_text)
396
+ result.append("\n")
397
+ return result
398
+
399
+ except FileEditError as e:
400
+ # Return formatted error string for backward compatibility
401
+ error_msg = str(e)
402
+ if e.details:
403
+ details_str = "\n".join(f" {k}: {v}" for k, v in e.details.items())
404
+ return f"exit_code=1\n{error_msg}\n{details_str}\n\n"
405
+ return f"exit_code=1\n{error_msg}\n\n"
406
+ except Exception as exc:
407
+ return f"exit_code=1\n{exc}\n\n"
408
+
409
+
410
+ # =============================================================================
411
+ # @tool decorated functions
412
+ # =============================================================================
413
+
414
+ @tool(
415
+ name="edit_file",
416
+ description="Apply search/replace edit to file. Search text must appear exactly once.",
417
+ parameters={
418
+ "type": "object",
419
+ "properties": {
420
+ "path": {
421
+ "type": "string",
422
+ "description": "Path to edit"
423
+ },
424
+ "search": {
425
+ "type": "string",
426
+ "description": "Exact text to find. Must be unique. Multi-line supported."
427
+ },
428
+ "replace": {
429
+ "type": "string",
430
+ "description": "Replacement text. Multi-line supported."
431
+ },
432
+ "context_lines": {
433
+ "type": "integer",
434
+ "description": "Context lines in diff (default: 3)"
435
+ },
436
+ "reason": {
437
+ "type": "string",
438
+ "description": "Brief explanation (shown during confirmation)"
439
+ }
440
+ },
441
+ "required": ["path", "search", "replace"]
442
+ },
443
+ requires_approval=True,
444
+ terminal_policy="stop"
445
+ )
446
+ def edit_file(
447
+ path: str,
448
+ search: str,
449
+ replace: str,
450
+ repo_root: Path,
451
+ console,
452
+ chat_manager,
453
+ gitignore_spec = None,
454
+ context_lines: int = 3,
455
+ vault_root: str = None,
456
+ ) -> str | Text:
457
+ """Apply search/replace edit to a file.
458
+
459
+ Args:
460
+ path: Path to the file to edit
461
+ search: Exact text to find (must be unique)
462
+ replace: Replacement text
463
+ repo_root: Repository root directory (injected by context)
464
+ console: Rich console for output (injected by context)
465
+ chat_manager: ChatManager instance (injected by context)
466
+ gitignore_spec: PathSpec for .gitignore filtering (injected by context)
467
+ context_lines: Number of context lines in diff
468
+ vault_root: Obsidian vault root path (injected by context)
469
+
470
+ Returns:
471
+ Edit result with diff
472
+ """
473
+ # Validate path doesn't contain JSON-like syntax or invalid characters
474
+ invalid_chars = '[]{}"\n\r\t'
475
+ if any(char in path for char in invalid_chars):
476
+ return f"exit_code=1\nedit_file 'path' contains invalid characters. Got: {path}"
477
+
478
+ # Prepare arguments
479
+ arguments = {
480
+ "path": path,
481
+ "search": search,
482
+ "replace": replace,
483
+ "context_lines": context_lines,
484
+ }
485
+
486
+ # Preview edit (confirmation workflow handled by orchestrator)
487
+ try:
488
+ preview_status, preview_diff = preview_edit_file(arguments, repo_root, gitignore_spec, vault_root=vault_root)
489
+ if preview_status != "exit_code=0":
490
+ return preview_status
491
+
492
+ # Build a Rich Text object with diff only (exit_code is for agent, not user display)
493
+ result = Text()
494
+ result.append(preview_diff)
495
+ return result
496
+
497
+ except FileEditError as e:
498
+ return f"exit_code=1\n{e}"
499
+ except Exception as e:
500
+ return f"exit_code=1\nEdit failed: {str(e)}"
501
+
502
+
503
+ def _execute_edit_file(
504
+ path: str,
505
+ search: str,
506
+ replace: str,
507
+ repo_root: Path,
508
+ console,
509
+ gitignore_spec = None,
510
+ context_lines: int = 3,
511
+ vault_root: str = None
512
+ ) -> str | Text:
513
+ """Execute a confirmed edit operation (internal function).
514
+
515
+ Called after user confirmation to actually apply the edit.
516
+ The main edit_file tool generates the preview first.
517
+
518
+ Args:
519
+ path: Path to the file to edit
520
+ search: Exact text to find (must be unique)
521
+ replace: Replacement text
522
+ repo_root: Repository root directory
523
+ console: Rich console for output
524
+ gitignore_spec: PathSpec for .gitignore filtering
525
+ context_lines: Number of context lines in diff
526
+ vault_root: Obsidian vault root path
527
+
528
+ Returns:
529
+ Edit result with diff (Rich Text for success, str with exit_code for errors)
530
+ """
531
+ arguments = {
532
+ "path": path,
533
+ "search": search,
534
+ "replace": replace,
535
+ "context_lines": context_lines,
536
+ }
537
+
538
+ try:
539
+ return run_edit_file(arguments, repo_root, console, gitignore_spec, vault_root=vault_root)
540
+ except FileEditError as e:
541
+ return f"exit_code=1\n{e}"
542
+ except Exception as e:
543
+ return f"exit_code=1\nEdit failed: {str(e)}"