bone-agent 1.3.3 → 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 (121) 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 -184
  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 -141
  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 -36
  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/targeted_searching.md +0 -10
  25. package/prompts/main/task_lists_pattern.md +0 -8
  26. package/prompts/main/temp_folder.md +0 -9
  27. package/prompts/main/think_before_acting.md +0 -10
  28. package/prompts/main/tone_and_style.md +0 -4
  29. package/prompts/main/tool_preferences.md +0 -24
  30. package/prompts/main/trust_subagent_context.md +0 -21
  31. package/prompts/main/when_to_use_sub_agent.md +0 -7
  32. package/prompts/micro/ask_questions.md +0 -1
  33. package/prompts/micro/batch_independent_calls.md +0 -1
  34. package/prompts/micro/casual_interactions.md +0 -1
  35. package/prompts/micro/code_references.md +0 -1
  36. package/prompts/micro/communication_style.md +0 -1
  37. package/prompts/micro/context_reliability.md +0 -1
  38. package/prompts/micro/conversational_tool_calling.md +0 -1
  39. package/prompts/micro/editing_pattern.md +0 -1
  40. package/prompts/micro/error_handling.md +0 -1
  41. package/prompts/micro/exploration_pattern.md +0 -1
  42. package/prompts/micro/intro.md +0 -1
  43. package/prompts/micro/obsidian.md +0 -4
  44. package/prompts/micro/obsidian_project.md +0 -5
  45. package/prompts/micro/professional_objectivity.md +0 -1
  46. package/prompts/micro/targeted_searching.md +0 -1
  47. package/prompts/micro/task_lists_pattern.md +0 -1
  48. package/prompts/micro/temp_folder.md +0 -1
  49. package/prompts/micro/think_before_acting.md +0 -5
  50. package/prompts/micro/tone_and_style.md +0 -1
  51. package/prompts/micro/tool_preferences.md +0 -1
  52. package/prompts/micro/trust_subagent_context.md +0 -1
  53. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  54. package/requirements.txt +0 -9
  55. package/src/__init__.py +0 -11
  56. package/src/core/__init__.py +0 -1
  57. package/src/core/agentic.py +0 -985
  58. package/src/core/chat_manager.py +0 -1564
  59. package/src/core/config_manager.py +0 -253
  60. package/src/core/cron.py +0 -582
  61. package/src/core/cron_allowlist.py +0 -118
  62. package/src/core/memory.py +0 -145
  63. package/src/core/retry.py +0 -71
  64. package/src/core/sub_agent.py +0 -326
  65. package/src/core/tool_approval.py +0 -220
  66. package/src/core/tool_feedback.py +0 -778
  67. package/src/exceptions.py +0 -79
  68. package/src/llm/__init__.py +0 -1
  69. package/src/llm/client.py +0 -171
  70. package/src/llm/config.py +0 -492
  71. package/src/llm/prompts.py +0 -489
  72. package/src/llm/providers.py +0 -436
  73. package/src/llm/streaming.py +0 -163
  74. package/src/llm/token_tracker.py +0 -384
  75. package/src/tools/__init__.py +0 -212
  76. package/src/tools/constants.py +0 -59
  77. package/src/tools/create_file.py +0 -136
  78. package/src/tools/directory.py +0 -389
  79. package/src/tools/edit.py +0 -545
  80. package/src/tools/file_reader.py +0 -322
  81. package/src/tools/helpers/__init__.py +0 -105
  82. package/src/tools/helpers/base.py +0 -550
  83. package/src/tools/helpers/converters.py +0 -44
  84. package/src/tools/helpers/file_helpers.py +0 -189
  85. package/src/tools/helpers/formatters.py +0 -411
  86. package/src/tools/helpers/loader.py +0 -231
  87. package/src/tools/helpers/parallel_executor.py +0 -231
  88. package/src/tools/helpers/path_resolver.py +0 -232
  89. package/src/tools/helpers/plugin_manifest.py +0 -156
  90. package/src/tools/obsidian.py +0 -96
  91. package/src/tools/review_sub_agent.py +0 -189
  92. package/src/tools/rg_search.py +0 -460
  93. package/src/tools/search_plugins.py +0 -109
  94. package/src/tools/select_option.py +0 -600
  95. package/src/tools/shell.py +0 -302
  96. package/src/tools/sub_agent.py +0 -139
  97. package/src/tools/task_list.py +0 -269
  98. package/src/tools/web_search.py +0 -61
  99. package/src/ui/__init__.py +0 -1
  100. package/src/ui/banner.py +0 -87
  101. package/src/ui/commands.py +0 -2809
  102. package/src/ui/displays.py +0 -214
  103. package/src/ui/loader.py +0 -284
  104. package/src/ui/main.py +0 -647
  105. package/src/ui/prompt_utils.py +0 -113
  106. package/src/ui/setting_selector.py +0 -590
  107. package/src/ui/setup_wizard.py +0 -294
  108. package/src/ui/sub_agent_panel.py +0 -234
  109. package/src/ui/tool_confirmation.py +0 -215
  110. package/src/utils/__init__.py +0 -1
  111. package/src/utils/citation_parser.py +0 -199
  112. package/src/utils/editor.py +0 -158
  113. package/src/utils/gitignore_filter.py +0 -149
  114. package/src/utils/logger.py +0 -254
  115. package/src/utils/paths.py +0 -30
  116. package/src/utils/result_parsers.py +0 -108
  117. package/src/utils/safe_commands.py +0 -243
  118. package/src/utils/settings.py +0 -191
  119. package/src/utils/user_message_logger.py +0 -120
  120. package/src/utils/validation.py +0 -191
  121. package/src/utils/web_search.py +0 -173
package/src/tools/edit.py DELETED
@@ -1,545 +0,0 @@
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/ under repo root and user_memory.md) are auto-approved
227
- # writes that the system itself adds to .gitignore, so gitignore filtering
228
- # would block them. Must anchor to repo_root to avoid matching any .bone/ dir.
229
- _resolved = (repo_root / path).resolve()
230
- is_memory = str(_resolved).startswith(str((repo_root / ".bone").resolve()) + os.sep) or Path(path).name == "user_memory.md"
231
-
232
- # Resolve and validate path using PathResolver
233
- try:
234
- file_path = _resolve_repo_path(path, repo_root, gitignore_spec, vault_root=vault_root,
235
- skip_gitignore=is_memory)
236
- except PathValidationError as e:
237
- # Re-raise with additional context
238
- raise FileEditError(str(e), details=e.details)
239
-
240
- if not file_path.exists():
241
- # Auto-create memory files (.bone/ under repo root) with default header
242
- # on first write. Already auto-approved, so creation is safe.
243
- if is_memory:
244
- file_path.parent.mkdir(parents=True, exist_ok=True)
245
- header = "# Project Memory\n\n" if file_path.name == "agents.md" else "# User Memory\n\n"
246
- file_path.write_text(header, encoding="utf-8")
247
- else:
248
- raise FileEditError(
249
- f"File not found",
250
- details={"path": str(file_path)}
251
- )
252
-
253
- search = arguments.get("search")
254
- replace = arguments.get("replace")
255
-
256
- if search is None:
257
- raise FileEditError("'search' parameter is required")
258
- if replace is None:
259
- raise FileEditError("'replace' parameter is required")
260
- if not isinstance(search, str):
261
- raise FileEditError("'search' must be a string")
262
- if not isinstance(replace, str):
263
- raise FileEditError("'replace' must be a string")
264
- if search == "":
265
- raise FileEditError("'search' must be non-empty")
266
-
267
- try:
268
- with file_path.open("r", encoding="utf-8", newline="") as f:
269
- original_content = f.read()
270
- except UnicodeDecodeError as e:
271
- raise FileEditError(
272
- "File contains non-UTF-8 bytes and cannot be edited safely",
273
- details={
274
- "path": str(file_path),
275
- "encoding_error": str(e),
276
- "hint": "This file contains bytes that are not valid UTF-8. "
277
- "Use execute_command to inspect or edit it with a tool like sed or xxd."
278
- }
279
- )
280
- except Exception as e:
281
- raise FileEditError(
282
- f"Failed to read file",
283
- details={"path": str(file_path), "original_error": str(e)}
284
- )
285
-
286
- file_newline = _detect_newline(original_content)
287
- search, replace, _ = _normalize_search_replace_for_newlines(
288
- search, replace, file_newline
289
- )
290
-
291
- search_span, match_diagnostics = _find_unique_span_with_fallbacks(original_content, search)
292
- if search_span is None:
293
- search_preview = search[:200] + "..." if len(search) > 200 else search
294
- diagnostics_summary = "\n".join(f" {d}" for d in match_diagnostics)
295
- raise FileEditError(
296
- "Search text not found in file",
297
- details={
298
- "search_preview": search_preview,
299
- "diagnostics": diagnostics_summary,
300
- "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."
301
- }
302
- )
303
-
304
- context_lines = arguments.get("context_lines", 3)
305
- if not isinstance(context_lines, int) or context_lines < 0:
306
- context_lines = 3
307
-
308
- return "exit_code=0", {
309
- "file_path": file_path,
310
- "original_content": original_content,
311
- "search_span": search_span,
312
- "replace": replace,
313
- "context_lines": context_lines,
314
- }
315
-
316
-
317
- def preview_edit_file(arguments, repo_root, gitignore_spec=None, vault_root=None) -> tuple[str, Text]:
318
- """Build a line-numbered diff preview without writing changes.
319
-
320
- Returns:
321
- Tuple of (status_string, diff_text)
322
-
323
- Raises:
324
- FileEditError: If edit validation fails
325
- """
326
- status, payload = _prepare_edit(arguments, repo_root, gitignore_spec, vault_root=vault_root)
327
-
328
- start, end = payload["search_span"]
329
- new_content = (
330
- payload["original_content"][:start]
331
- + payload["replace"]
332
- + payload["original_content"][end:]
333
- )
334
-
335
- # Early exit for no-op edits (e.g. fuzzy match produced identical content)
336
- if new_content == payload["original_content"]:
337
- raise FileEditError(
338
- "Edit is a no-op: replacement produces identical content",
339
- details={"hint": "Check that your search/replace text actually differs."}
340
- )
341
-
342
- diff_text = _build_diff(
343
- payload["original_content"],
344
- new_content,
345
- payload["file_path"],
346
- payload["context_lines"],
347
- show_header=True,
348
- repo_root=repo_root,
349
- )
350
- return "exit_code=0", diff_text
351
-
352
-
353
- def run_edit_file(arguments, repo_root, console, gitignore_spec=None, vault_root=None) -> str | Text:
354
- """Apply search/replace edit to a file.
355
-
356
- Returns:
357
- Rich Text with diff for success, str with exit_code for errors
358
- """
359
- try:
360
- status, payload = _prepare_edit(arguments, repo_root, gitignore_spec, vault_root=vault_root)
361
-
362
- start, end = payload["search_span"]
363
- new_content = (
364
- payload["original_content"][:start]
365
- + payload["replace"]
366
- + payload["original_content"][end:]
367
- )
368
-
369
- # Generate diff for preview
370
- diff_text = _build_diff(
371
- payload["original_content"],
372
- new_content,
373
- payload["file_path"],
374
- payload["context_lines"],
375
- )
376
-
377
- # Skip write for no-op edits (safety net — preview should have caught this,
378
- # but guards against race conditions or direct calls to run_edit_file)
379
- if new_content == payload["original_content"]:
380
- raise FileEditError(
381
- "Edit is a no-op: replacement produces identical content",
382
- details={"hint": "Check that your search/replace text actually differs."}
383
- )
384
-
385
- # Write to file
386
- try:
387
- with payload["file_path"].open("w", encoding="utf-8", newline="") as f:
388
- f.write(new_content)
389
- except Exception as e:
390
- raise FileEditError(
391
- f"Failed to write file",
392
- details={"path": str(payload["file_path"]), "original_error": str(e)}
393
- )
394
-
395
- # Success - return Rich Text object with styled diff (no exit_code prefix)
396
- result = Text()
397
- result.append(diff_text)
398
- result.append("\n")
399
- return result
400
-
401
- except FileEditError as e:
402
- # Return formatted error string for backward compatibility
403
- error_msg = str(e)
404
- if e.details:
405
- details_str = "\n".join(f" {k}: {v}" for k, v in e.details.items())
406
- return f"exit_code=1\n{error_msg}\n{details_str}\n\n"
407
- return f"exit_code=1\n{error_msg}\n\n"
408
- except Exception as exc:
409
- return f"exit_code=1\n{exc}\n\n"
410
-
411
-
412
- # =============================================================================
413
- # @tool decorated functions
414
- # =============================================================================
415
-
416
- @tool(
417
- name="edit_file",
418
- description="Apply search/replace edit to file. Search text must appear exactly once.",
419
- parameters={
420
- "type": "object",
421
- "properties": {
422
- "path": {
423
- "type": "string",
424
- "description": "Path to edit"
425
- },
426
- "search": {
427
- "type": "string",
428
- "description": "Exact text to find. Must be unique. Multi-line supported."
429
- },
430
- "replace": {
431
- "type": "string",
432
- "description": "Replacement text. Multi-line supported."
433
- },
434
- "context_lines": {
435
- "type": "integer",
436
- "description": "Context lines in diff (default: 3)"
437
- },
438
- "reason": {
439
- "type": "string",
440
- "description": "Brief explanation (shown during confirmation)"
441
- }
442
- },
443
- "required": ["path", "search", "replace"]
444
- },
445
- requires_approval=True,
446
- terminal_policy="stop"
447
- )
448
- def edit_file(
449
- path: str,
450
- search: str,
451
- replace: str,
452
- repo_root: Path,
453
- console,
454
- chat_manager,
455
- gitignore_spec = None,
456
- context_lines: int = 3,
457
- vault_root: str = None,
458
- ) -> str | Text:
459
- """Apply search/replace edit to a file.
460
-
461
- Args:
462
- path: Path to the file to edit
463
- search: Exact text to find (must be unique)
464
- replace: Replacement text
465
- repo_root: Repository root directory (injected by context)
466
- console: Rich console for output (injected by context)
467
- chat_manager: ChatManager instance (injected by context)
468
- gitignore_spec: PathSpec for .gitignore filtering (injected by context)
469
- context_lines: Number of context lines in diff
470
- vault_root: Obsidian vault root path (injected by context)
471
-
472
- Returns:
473
- Edit result with diff
474
- """
475
- # Validate path doesn't contain JSON-like syntax or invalid characters
476
- invalid_chars = '[]{}"\n\r\t'
477
- if any(char in path for char in invalid_chars):
478
- return f"exit_code=1\nedit_file 'path' contains invalid characters. Got: {path}"
479
-
480
- # Prepare arguments
481
- arguments = {
482
- "path": path,
483
- "search": search,
484
- "replace": replace,
485
- "context_lines": context_lines,
486
- }
487
-
488
- # Preview edit (confirmation workflow handled by orchestrator)
489
- try:
490
- preview_status, preview_diff = preview_edit_file(arguments, repo_root, gitignore_spec, vault_root=vault_root)
491
- if preview_status != "exit_code=0":
492
- return preview_status
493
-
494
- # Build a Rich Text object with diff only (exit_code is for agent, not user display)
495
- result = Text()
496
- result.append(preview_diff)
497
- return result
498
-
499
- except FileEditError as e:
500
- return f"exit_code=1\n{e}"
501
- except Exception as e:
502
- return f"exit_code=1\nEdit failed: {str(e)}"
503
-
504
-
505
- def _execute_edit_file(
506
- path: str,
507
- search: str,
508
- replace: str,
509
- repo_root: Path,
510
- console,
511
- gitignore_spec = None,
512
- context_lines: int = 3,
513
- vault_root: str = None
514
- ) -> str | Text:
515
- """Execute a confirmed edit operation (internal function).
516
-
517
- Called after user confirmation to actually apply the edit.
518
- The main edit_file tool generates the preview first.
519
-
520
- Args:
521
- path: Path to the file to edit
522
- search: Exact text to find (must be unique)
523
- replace: Replacement text
524
- repo_root: Repository root directory
525
- console: Rich console for output
526
- gitignore_spec: PathSpec for .gitignore filtering
527
- context_lines: Number of context lines in diff
528
- vault_root: Obsidian vault root path
529
-
530
- Returns:
531
- Edit result with diff (Rich Text for success, str with exit_code for errors)
532
- """
533
- arguments = {
534
- "path": path,
535
- "search": search,
536
- "replace": replace,
537
- "context_lines": context_lines,
538
- }
539
-
540
- try:
541
- return run_edit_file(arguments, repo_root, console, gitignore_spec, vault_root=vault_root)
542
- except FileEditError as e:
543
- return f"exit_code=1\n{e}"
544
- except Exception as e:
545
- return f"exit_code=1\nEdit failed: {str(e)}"