bone-agent 1.4.0 → 2.0.1
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.
- package/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -201
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -144
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -50
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/skills.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/skills.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -1085
- package/src/core/chat_manager.py +0 -1577
- package/src/core/config_manager.py +0 -260
- package/src/core/cron.py +0 -578
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/metadata.py +0 -75
- package/src/core/retry.py +0 -71
- package/src/core/skills.py +0 -463
- package/src/core/sub_agent.py +0 -376
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -789
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -176
- package/src/llm/codex_provider.py +0 -350
- package/src/llm/config.py +0 -536
- package/src/llm/prompts.py +0 -494
- package/src/llm/providers.py +0 -438
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -399
- package/src/tools/__init__.py +0 -151
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -549
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -99
- package/src/tools/helpers/base.py +0 -599
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -145
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -283
- package/src/tools/helpers/plugin_manifest.py +0 -185
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -190
- package/src/tools/rg_search.py +0 -477
- package/src/tools/search_plugins.py +0 -177
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -3131
- package/src/ui/displays.py +0 -239
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -643
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -226
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -207
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -195
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -201
- 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)}"
|