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
package/src/tools/edit.py DELETED
@@ -1,549 +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=True,
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
- reason: str = None,
459
- ) -> str | Text:
460
- """Apply search/replace edit to a file.
461
-
462
- Args:
463
- path: Path to the file to edit
464
- search: Exact text to find (must be unique)
465
- replace: Replacement text
466
- repo_root: Repository root directory (injected by context)
467
- console: Rich console for output (injected by context)
468
- chat_manager: ChatManager instance (injected by context)
469
- gitignore_spec: PathSpec for .gitignore filtering (injected by context)
470
- context_lines: Number of context lines in diff
471
- vault_root: Obsidian vault root path (injected by context)
472
- reason: Brief explanation shown during confirmation
473
-
474
- Returns:
475
- Edit result with diff
476
- """
477
- # Validate path doesn't contain JSON-like syntax or invalid characters
478
- invalid_chars = '[]{}"\n\r\t'
479
- if any(char in path for char in invalid_chars):
480
- return f"exit_code=1\nedit_file 'path' contains invalid characters. Got: {path}"
481
-
482
- # Prepare arguments
483
- arguments = {
484
- "path": path,
485
- "search": search,
486
- "replace": replace,
487
- "context_lines": context_lines,
488
- }
489
- if reason:
490
- arguments["reason"] = reason
491
-
492
- # Preview edit (confirmation workflow handled by orchestrator)
493
- try:
494
- preview_status, preview_diff = preview_edit_file(arguments, repo_root, gitignore_spec, vault_root=vault_root)
495
- if preview_status != "exit_code=0":
496
- return preview_status
497
-
498
- # Build a Rich Text object with diff only (exit_code is for agent, not user display)
499
- result = Text()
500
- result.append(preview_diff)
501
- return result
502
-
503
- except FileEditError as e:
504
- return f"exit_code=1\n{e}"
505
- except Exception as e:
506
- return f"exit_code=1\nEdit failed: {str(e)}"
507
-
508
-
509
- def _execute_edit_file(
510
- path: str,
511
- search: str,
512
- replace: str,
513
- repo_root: Path,
514
- console,
515
- gitignore_spec = None,
516
- context_lines: int = 3,
517
- vault_root: str = None
518
- ) -> str | Text:
519
- """Execute a confirmed edit operation (internal function).
520
-
521
- Called after user confirmation to actually apply the edit.
522
- The main edit_file tool generates the preview first.
523
-
524
- Args:
525
- path: Path to the file to edit
526
- search: Exact text to find (must be unique)
527
- replace: Replacement text
528
- repo_root: Repository root directory
529
- console: Rich console for output
530
- gitignore_spec: PathSpec for .gitignore filtering
531
- context_lines: Number of context lines in diff
532
- vault_root: Obsidian vault root path
533
-
534
- Returns:
535
- Edit result with diff (Rich Text for success, str with exit_code for errors)
536
- """
537
- arguments = {
538
- "path": path,
539
- "search": search,
540
- "replace": replace,
541
- "context_lines": context_lines,
542
- }
543
-
544
- try:
545
- return run_edit_file(arguments, repo_root, console, gitignore_spec, vault_root=vault_root)
546
- except FileEditError as e:
547
- return f"exit_code=1\n{e}"
548
- except Exception as e:
549
- return f"exit_code=1\nEdit failed: {str(e)}"