返回 JoyAI-Echo
filesystem.py
1 """File system tools: read, write, edit, list."""
2
3 import difflib
4 import mimetypes
5 import os
6 from dataclasses import dataclass
7 from pathlib import Path
8 from typing import Any
9
10 from nanobot.agent.tools.base import Tool, tool_parameters
11 from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
12 from nanobot.agent.tools import file_state
13 from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
14 from nanobot.config.paths import get_media_dir
15
16
17 def _resolve_path(
18 path: str,
19 workspace: Path | None = None,
20 allowed_dir: Path | None = None,
21 extra_allowed_dirs: list[Path] | None = None,
22 ) -> Path:
23 """Resolve path against workspace (if relative) and enforce directory restriction."""
24 p = Path(path).expanduser()
25 if not p.is_absolute() and workspace:
26 p = workspace / p
27 resolved = p.resolve()
28 if allowed_dir:
29 media_path = get_media_dir().resolve()
30 all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
31 if not any(_is_under(resolved, d) for d in all_dirs):
32 raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
33 return resolved
34
35
36 def _is_under(path: Path, directory: Path) -> bool:
37 try:
38 path.relative_to(directory.resolve())
39 return True
40 except ValueError:
41 return False
42
43
44 class _FsTool(Tool):
45 """Shared base for filesystem tools — common init and path resolution."""
46
47 def __init__(
48 self,
49 workspace: Path | None = None,
50 allowed_dir: Path | None = None,
51 extra_allowed_dirs: list[Path] | None = None,
52 ):
53 self._workspace = workspace
54 self._allowed_dir = allowed_dir
55 self._extra_allowed_dirs = extra_allowed_dirs
56
57 def _resolve(self, path: str) -> Path:
58 return _resolve_path(path, self._workspace, self._allowed_dir, self._extra_allowed_dirs)
59
60
61 # ---------------------------------------------------------------------------
62 # read_file
63 # ---------------------------------------------------------------------------
64
65
66 _BLOCKED_DEVICE_PATHS = frozenset({
67 "/dev/zero", "/dev/random", "/dev/urandom", "/dev/full",
68 "/dev/stdin", "/dev/stdout", "/dev/stderr",
69 "/dev/tty", "/dev/console",
70 "/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
71 })
72
73
74 def _is_blocked_device(path: str | Path) -> bool:
75 """Check if path is a blocked device that could hang or produce infinite output."""
76 import re
77 raw = str(path)
78
79 # Resolve symlinks to check the actual target
80 try:
81 resolved = str(Path(raw).resolve())
82 except (OSError, ValueError):
83 resolved = raw
84
85 if raw in _BLOCKED_DEVICE_PATHS or resolved in _BLOCKED_DEVICE_PATHS:
86 return True
87 if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw):
88 return True
89 if re.match(r"/proc/\d+/fd/[012]$", resolved) or re.match(r"/proc/self/fd/[012]$", resolved):
90 return True
91
92 # Check if resolved path starts with /dev/ (covers symlinks to devices)
93 if resolved.startswith("/dev/"):
94 return True
95 return False
96
97
98 def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
99 """Parse a page range like '2-5' into 0-based (start, end) inclusive."""
100 parts = pages.strip().split("-")
101 if len(parts) == 1:
102 p = int(parts[0])
103 return max(0, p - 1), min(p - 1, total - 1)
104 start = int(parts[0])
105 end = int(parts[1])
106 return max(0, start - 1), min(end - 1, total - 1)
107
108
109 @tool_parameters(
110 tool_parameters_schema(
111 path=StringSchema("The file path to read"),
112 offset=IntegerSchema(
113 1,
114 description="Line number to start reading from (1-indexed, default 1)",
115 minimum=1,
116 ),
117 limit=IntegerSchema(
118 2000,
119 description="Maximum number of lines to read (default 2000)",
120 minimum=1,
121 ),
122 pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
123 required=["path"],
124 )
125 )
126 class ReadFileTool(_FsTool):
127 """Read file contents with optional line-based pagination."""
128
129 _MAX_CHARS = 128_000
130 _DEFAULT_LIMIT = 2000
131 _MAX_PDF_PAGES = 20
132
133 @property
134 def name(self) -> str:
135 return "read_file"
136
137 @property
138 def description(self) -> str:
139 return (
140 "Read a file (text, image, or document). "
141 "Text output format: LINE_NUM|CONTENT. "
142 "Images return visual content for analysis. "
143 "Supports PDF, DOCX, XLSX, PPTX documents. "
144 "Use offset and limit for large text files. "
145 "Reads exceeding ~128K chars are truncated."
146 )
147
148 @property
149 def read_only(self) -> bool:
150 return True
151
152 async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any:
153 try:
154 if not path:
155 return "Error reading file: Unknown path"
156
157 # Device path blacklist
158 if _is_blocked_device(path):
159 return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
160
161 fp = self._resolve(path)
162 if _is_blocked_device(fp):
163 return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
164 if not fp.exists():
165 return f"Error: File not found: {path}"
166 if not fp.is_file():
167 return f"Error: Not a file: {path}"
168
169 # PDF support
170 if fp.suffix.lower() == ".pdf":
171 return self._read_pdf(fp, pages)
172
173 # Office document support
174 if fp.suffix.lower() in {".docx", ".xlsx", ".pptx"}:
175 return self._read_office_doc(fp)
176
177 raw = fp.read_bytes()
178 if not raw:
179 return f"(Empty file: {path})"
180
181 mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
182 if mime and mime.startswith("image/"):
183 return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
184
185 # Read dedup: same path + offset + limit + unchanged mtime → stub
186 # Always check for external modifications before dedup
187 entry = file_state._state.get(str(fp.resolve()))
188 try:
189 current_mtime = os.path.getmtime(fp)
190 except OSError:
191 current_mtime = 0.0
192 if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
193 if current_mtime != entry.mtime:
194 # File was modified externally - force full read and mark as not dedupable
195 entry.can_dedup = False
196 file_state.record_read(fp, offset=offset, limit=limit) # Update state with new mtime
197 # Continue to read full content (don't return dedup message)
198 else:
199 # File unchanged - return dedup message
200 # But only if content is actually unchanged (not just mtime)
201 current_hash = file_state._hash_file(str(fp))
202 if current_hash == entry.content_hash:
203 return f"[File unchanged since last read: {path}]"
204 else:
205 # Content changed despite same mtime - force full read
206 entry.can_dedup = False
207 file_state.record_read(fp, offset=offset, limit=limit)
208 else:
209 # No previous state or marked as not dedupable - read full content
210 file_state.record_read(fp, offset=offset, limit=limit)
211 # Force full read by setting can_dedup to False for this read
212 if entry:
213 entry.can_dedup = False
214
215 # Read the file content after dedup check
216 raw = fp.read_bytes()
217 try:
218 text_content = raw.decode("utf-8")
219 except UnicodeDecodeError:
220 # Binary file - return error message
221 mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
222 if mime and mime.startswith("image/"):
223 return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
224 return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported."
225
226 # Normalize CRLF -> LF before line-splitting. Primarily a Windows
227 # concern (git checkouts with autocrlf, editors saving CRLF) but
228 # applied on all platforms so downstream StrReplace/Grep behavior
229 # is consistent regardless of where the file was written.
230 text_content = text_content.replace("\r\n", "\n")
231
232 all_lines = text_content.splitlines()
233 total = len(all_lines)
234
235 if offset < 1:
236 offset = 1
237 if offset > total:
238 return f"Error: offset {offset} is beyond end of file ({total} lines)"
239
240 start = offset - 1
241 end = min(start + (limit or self._DEFAULT_LIMIT), total)
242 numbered = [f"{start + i + 1}| {line}" for i, line in enumerate(all_lines[start:end])]
243 result = "\n".join(numbered)
244
245 if len(result) > self._MAX_CHARS:
246 trimmed, chars = [], 0
247 for line in numbered:
248 chars += len(line) + 1
249 if chars > self._MAX_CHARS:
250 break
251 trimmed.append(line)
252 end = start + len(trimmed)
253 result = "\n".join(trimmed)
254
255 if end < total:
256 result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
257 else:
258 result += f"\n\n(End of file — {total} lines total)"
259 file_state.record_read(fp, offset=offset, limit=limit)
260 return result
261 except PermissionError as e:
262 return f"Error: {e}"
263 except Exception as e:
264 return f"Error reading file: {e}"
265
266 def _read_pdf(self, fp: Path, pages: str | None) -> str:
267 try:
268 import fitz # pymupdf
269 except ImportError:
270 return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf"
271
272 try:
273 doc = fitz.open(str(fp))
274 except Exception as e:
275 return f"Error reading PDF: {e}"
276
277 total_pages = len(doc)
278 if pages:
279 try:
280 start, end = _parse_page_range(pages, total_pages)
281 except (ValueError, IndexError):
282 doc.close()
283 return f"Error: Invalid page range '{pages}'. Use format like '1-5'."
284 if start > end or start >= total_pages:
285 doc.close()
286 return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)."
287 else:
288 start = 0
289 end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
290
291 if end - start + 1 > self._MAX_PDF_PAGES:
292 end = start + self._MAX_PDF_PAGES - 1
293
294 parts: list[str] = []
295 for i in range(start, end + 1):
296 page = doc[i]
297 text = page.get_text().strip()
298 if text:
299 parts.append(f"--- Page {i + 1} ---\n{text}")
300 doc.close()
301
302 if not parts:
303 return f"(PDF has no extractable text: {fp})"
304
305 result = "\n\n".join(parts)
306 if end < total_pages - 1:
307 result += f"\n\n(Showing pages {start + 1}-{end + 1} of {total_pages}. Use pages='{end + 2}-{min(end + 1 + self._MAX_PDF_PAGES, total_pages)}' to continue.)"
308 if len(result) > self._MAX_CHARS:
309 result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
310 return result
311
312 def _read_office_doc(self, fp: Path) -> str:
313 from nanobot.utils.document import extract_text
314
315 result = extract_text(fp)
316
317 if result is None:
318 return f"Error: Unsupported file format: {fp.suffix}"
319
320 if result.startswith("[error:"):
321 return f"Error reading {fp.suffix.upper()} file: {result}"
322
323 if not result:
324 return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
325
326 if len(result) > self._MAX_CHARS:
327 result = result[:self._MAX_CHARS] + "\n\n(Document text truncated at ~128K chars)"
328
329 return result
330
331
332 # ---------------------------------------------------------------------------
333 # write_file
334 # ---------------------------------------------------------------------------
335
336
337 @tool_parameters(
338 tool_parameters_schema(
339 path=StringSchema("The file path to write to"),
340 content=StringSchema("The content to write"),
341 required=["path", "content"],
342 )
343 )
344 class WriteFileTool(_FsTool):
345 """Write content to a file."""
346
347 @property
348 def name(self) -> str:
349 return "write_file"
350
351 @property
352 def description(self) -> str:
353 return (
354 "Write content to a file. Overwrites if the file already exists; "
355 "creates parent directories as needed. "
356 "For partial edits, prefer edit_file instead."
357 )
358
359 async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
360 try:
361 if not path:
362 raise ValueError("Unknown path")
363 if content is None:
364 raise ValueError("Unknown content")
365 fp = self._resolve(path)
366 fp.parent.mkdir(parents=True, exist_ok=True)
367 fp.write_text(content, encoding="utf-8")
368 file_state.record_write(fp)
369 return f"Successfully wrote {len(content)} characters to {fp}"
370 except PermissionError as e:
371 return f"Error: {e}"
372 except Exception as e:
373 return f"Error writing file: {e}"
374
375
376 # ---------------------------------------------------------------------------
377 # edit_file
378 # ---------------------------------------------------------------------------
379
380 _QUOTE_TABLE = str.maketrans({
381 "\u2018": "'", "\u2019": "'", # curly single → straight
382 "\u201c": '"', "\u201d": '"', # curly double → straight
383 "'": "'", '"': '"', # identity (kept for completeness)
384 })
385
386
387 def _normalize_quotes(s: str) -> str:
388 return s.translate(_QUOTE_TABLE)
389
390
391 def _curly_double_quotes(text: str) -> str:
392 parts: list[str] = []
393 opening = True
394 for ch in text:
395 if ch == '"':
396 parts.append("\u201c" if opening else "\u201d")
397 opening = not opening
398 else:
399 parts.append(ch)
400 return "".join(parts)
401
402
403 def _curly_single_quotes(text: str) -> str:
404 parts: list[str] = []
405 opening = True
406 for i, ch in enumerate(text):
407 if ch != "'":
408 parts.append(ch)
409 continue
410 prev_ch = text[i - 1] if i > 0 else ""
411 next_ch = text[i + 1] if i + 1 < len(text) else ""
412 if prev_ch.isalnum() and next_ch.isalnum():
413 parts.append("\u2019")
414 continue
415 parts.append("\u2018" if opening else "\u2019")
416 opening = not opening
417 return "".join(parts)
418
419
420 def _preserve_quote_style(old_text: str, actual_text: str, new_text: str) -> str:
421 """Preserve curly quote style when a quote-normalized fallback matched."""
422 if _normalize_quotes(old_text.strip()) != _normalize_quotes(actual_text.strip()) or old_text == actual_text:
423 return new_text
424
425 styled = new_text
426 if any(ch in actual_text for ch in ("\u201c", "\u201d")) and '"' in styled:
427 styled = _curly_double_quotes(styled)
428 if any(ch in actual_text for ch in ("\u2018", "\u2019")) and "'" in styled:
429 styled = _curly_single_quotes(styled)
430 return styled
431
432
433 def _leading_ws(line: str) -> str:
434 return line[: len(line) - len(line.lstrip(" \t"))]
435
436
437 def _reindent_like_match(old_text: str, actual_text: str, new_text: str) -> str:
438 """Preserve the outer indentation from the actual matched block."""
439 old_lines = old_text.split("\n")
440 actual_lines = actual_text.split("\n")
441 if len(old_lines) != len(actual_lines):
442 return new_text
443
444 comparable = [
445 (old_line, actual_line)
446 for old_line, actual_line in zip(old_lines, actual_lines)
447 if old_line.strip() and actual_line.strip()
448 ]
449 if not comparable or any(
450 _normalize_quotes(old_line.strip()) != _normalize_quotes(actual_line.strip())
451 for old_line, actual_line in comparable
452 ):
453 return new_text
454
455 old_ws = _leading_ws(comparable[0][0])
456 actual_ws = _leading_ws(comparable[0][1])
457 if actual_ws == old_ws:
458 return new_text
459
460 if old_ws:
461 if not actual_ws.startswith(old_ws):
462 return new_text
463 delta = actual_ws[len(old_ws):]
464 else:
465 delta = actual_ws
466
467 if not delta:
468 return new_text
469
470 return "\n".join((delta + line) if line else line for line in new_text.split("\n"))
471
472
473 @dataclass(slots=True)
474 class _MatchSpan:
475 start: int
476 end: int
477 text: str
478 line: int
479
480
481 def _find_exact_matches(content: str, old_text: str) -> list[_MatchSpan]:
482 matches: list[_MatchSpan] = []
483 start = 0
484 while True:
485 idx = content.find(old_text, start)
486 if idx == -1:
487 break
488 matches.append(
489 _MatchSpan(
490 start=idx,
491 end=idx + len(old_text),
492 text=content[idx : idx + len(old_text)],
493 line=content.count("\n", 0, idx) + 1,
494 )
495 )
496 start = idx + max(1, len(old_text))
497 return matches
498
499
500 def _find_trim_matches(content: str, old_text: str, *, normalize_quotes: bool = False) -> list[_MatchSpan]:
501 old_lines = old_text.splitlines()
502 if not old_lines:
503 return []
504
505 content_lines = content.splitlines()
506 content_lines_keepends = content.splitlines(keepends=True)
507 if len(content_lines) < len(old_lines):
508 return []
509
510 offsets: list[int] = []
511 pos = 0
512 for line in content_lines_keepends:
513 offsets.append(pos)
514 pos += len(line)
515 offsets.append(pos)
516
517 if normalize_quotes:
518 stripped_old = [_normalize_quotes(line.strip()) for line in old_lines]
519 else:
520 stripped_old = [line.strip() for line in old_lines]
521
522 matches: list[_MatchSpan] = []
523 window_size = len(stripped_old)
524 for i in range(len(content_lines) - window_size + 1):
525 window = content_lines[i : i + window_size]
526 if normalize_quotes:
527 comparable = [_normalize_quotes(line.strip()) for line in window]
528 else:
529 comparable = [line.strip() for line in window]
530 if comparable != stripped_old:
531 continue
532
533 start = offsets[i]
534 end = offsets[i + window_size]
535 if content_lines_keepends[i + window_size - 1].endswith("\n"):
536 end -= 1
537 matches.append(
538 _MatchSpan(
539 start=start,
540 end=end,
541 text=content[start:end],
542 line=i + 1,
543 )
544 )
545 return matches
546
547
548 def _find_quote_matches(content: str, old_text: str) -> list[_MatchSpan]:
549 norm_content = _normalize_quotes(content)
550 norm_old = _normalize_quotes(old_text)
551 matches: list[_MatchSpan] = []
552 start = 0
553 while True:
554 idx = norm_content.find(norm_old, start)
555 if idx == -1:
556 break
557 matches.append(
558 _MatchSpan(
559 start=idx,
560 end=idx + len(old_text),
561 text=content[idx : idx + len(old_text)],
562 line=content.count("\n", 0, idx) + 1,
563 )
564 )
565 start = idx + max(1, len(norm_old))
566 return matches
567
568
569 def _find_matches(content: str, old_text: str) -> list[_MatchSpan]:
570 """Locate all matches using progressively looser strategies."""
571 for matcher in (
572 lambda: _find_exact_matches(content, old_text),
573 lambda: _find_trim_matches(content, old_text),
574 lambda: _find_trim_matches(content, old_text, normalize_quotes=True),
575 lambda: _find_quote_matches(content, old_text),
576 ):
577 matches = matcher()
578 if matches:
579 return matches
580 return []
581
582
583 def _find_match_line_numbers(content: str, old_text: str) -> list[int]:
584 """Return 1-based starting line numbers for the current matching strategies."""
585 return [match.line for match in _find_matches(content, old_text)]
586
587
588 def _collapse_internal_whitespace(text: str) -> str:
589 return "\n".join(" ".join(line.split()) for line in text.splitlines())
590
591
592 def _diagnose_near_match(old_text: str, actual_text: str) -> list[str]:
593 """Return actionable hints describing why text was close but not exact."""
594 hints: list[str] = []
595
596 if old_text.lower() == actual_text.lower() and old_text != actual_text:
597 hints.append("letter case differs")
598 if _collapse_internal_whitespace(old_text) == _collapse_internal_whitespace(actual_text) and old_text != actual_text:
599 hints.append("whitespace differs")
600 if old_text.rstrip("\n") == actual_text.rstrip("\n") and old_text != actual_text:
601 hints.append("trailing newline differs")
602 if _normalize_quotes(old_text) == _normalize_quotes(actual_text) and old_text != actual_text:
603 hints.append("quote style differs")
604
605 return hints
606
607
608 def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], list[str]]:
609 """Find the closest line-window match and return ratio/start/snippet/hints."""
610 lines = content.splitlines(keepends=True)
611 old_lines = old_text.splitlines(keepends=True)
612 window = max(1, len(old_lines))
613
614 best_ratio, best_start = -1.0, 0
615 best_window_lines: list[str] = []
616
617 for i in range(max(1, len(lines) - window + 1)):
618 current = lines[i : i + window]
619 ratio = difflib.SequenceMatcher(None, old_lines, current).ratio()
620 if ratio > best_ratio:
621 best_ratio, best_start = ratio, i
622 best_window_lines = current
623
624 actual_text = "".join(best_window_lines).replace("\r\n", "\n").rstrip("\n")
625 hints = _diagnose_near_match(old_text.replace("\r\n", "\n").rstrip("\n"), actual_text)
626 return best_ratio, best_start, best_window_lines, hints
627
628
629 def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
630 """Locate old_text in content with a multi-level fallback chain:
631
632 1. Exact substring match
633 2. Line-trimmed sliding window (handles indentation differences)
634 3. Smart quote normalization (curly ↔ straight quotes)
635
636 Both inputs should use LF line endings (caller normalises CRLF).
637 Returns (matched_fragment, count) or (None, 0).
638 """
639 matches = _find_matches(content, old_text)
640 if not matches:
641 return None, 0
642 return matches[0].text, len(matches)
643
644
645 @tool_parameters(
646 tool_parameters_schema(
647 path=StringSchema("The file path to edit"),
648 old_text=StringSchema("The text to find and replace"),
649 new_text=StringSchema("The text to replace with"),
650 replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
651 required=["path", "old_text", "new_text"],
652 )
653 )
654 class EditFileTool(_FsTool):
655 """Edit a file by replacing text with fallback matching."""
656
657 _MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 # 1 GiB
658 _MARKDOWN_EXTS = frozenset({".md", ".mdx", ".markdown"})
659
660 @property
661 def name(self) -> str:
662 return "edit_file"
663
664 @property
665 def description(self) -> str:
666 return (
667 "Edit a file by replacing old_text with new_text. "
668 "Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
669 "If old_text matches multiple times, you must provide more context "
670 "or set replace_all=true. Shows a diff of the closest match on failure."
671 )
672
673 @staticmethod
674 def _strip_trailing_ws(text: str) -> str:
675 """Strip trailing whitespace from each line."""
676 return "\n".join(line.rstrip() for line in text.split("\n"))
677
678 async def execute(
679 self, path: str | None = None, old_text: str | None = None,
680 new_text: str | None = None,
681 replace_all: bool = False, **kwargs: Any,
682 ) -> str:
683 try:
684 if not path:
685 raise ValueError("Unknown path")
686 if old_text is None:
687 raise ValueError("Unknown old_text")
688 if new_text is None:
689 raise ValueError("Unknown new_text")
690
691 # .ipynb detection
692 if path.endswith(".ipynb"):
693 return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
694
695 fp = self._resolve(path)
696
697 # Create-file semantics: old_text='' + file doesn't exist → create
698 if not fp.exists():
699 if old_text == "":
700 fp.parent.mkdir(parents=True, exist_ok=True)
701 fp.write_text(new_text, encoding="utf-8")
702 file_state.record_write(fp)
703 return f"Successfully created {fp}"
704 return self._file_not_found_msg(path, fp)
705
706 # File size protection
707 try:
708 fsize = fp.stat().st_size
709 except OSError:
710 fsize = 0
711 if fsize > self._MAX_EDIT_FILE_SIZE:
712 return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB."
713
714 # Create-file: old_text='' but file exists and not empty → reject
715 if old_text == "":
716 raw = fp.read_bytes()
717 content = raw.decode("utf-8")
718 if content.strip():
719 return f"Error: Cannot create file — {path} already exists and is not empty."
720 fp.write_text(new_text, encoding="utf-8")
721 file_state.record_write(fp)
722 return f"Successfully edited {fp}"
723
724 # Read-before-edit check
725 warning = file_state.check_read(fp)
726
727 raw = fp.read_bytes()
728 uses_crlf = b"\r\n" in raw
729 content = raw.decode("utf-8").replace("\r\n", "\n")
730 norm_old = old_text.replace("\r\n", "\n")
731 matches = _find_matches(content, norm_old)
732
733 if not matches:
734 return self._not_found_msg(old_text, content, path)
735 count = len(matches)
736 if count > 1 and not replace_all:
737 line_numbers = [match.line for match in matches]
738 preview = ", ".join(f"line {n}" for n in line_numbers[:3])
739 if len(line_numbers) > 3:
740 preview += ", ..."
741 location_hint = f" at {preview}" if preview else ""
742 return (
743 f"Warning: old_text appears {count} times{location_hint}. "
744 "Provide more context to make it unique, or set replace_all=true."
745 )
746
747 norm_new = new_text.replace("\r\n", "\n")
748
749 # Trailing whitespace stripping (skip markdown to preserve double-space line breaks)
750 if fp.suffix.lower() not in self._MARKDOWN_EXTS:
751 norm_new = self._strip_trailing_ws(norm_new)
752
753 selected = matches if replace_all else matches[:1]
754 new_content = content
755 for match in reversed(selected):
756 replacement = _preserve_quote_style(norm_old, match.text, norm_new)
757 replacement = _reindent_like_match(norm_old, match.text, replacement)
758
759 # Delete-line cleanup: when deleting text (new_text=''), consume trailing
760 # newline to avoid leaving a blank line
761 end = match.end
762 if replacement == "" and not match.text.endswith("\n") and content[end:end + 1] == "\n":
763 end += 1
764
765 new_content = new_content[: match.start] + replacement + new_content[end:]
766 if uses_crlf:
767 new_content = new_content.replace("\n", "\r\n")
768
769 fp.write_bytes(new_content.encode("utf-8"))
770 file_state.record_write(fp)
771 msg = f"Successfully edited {fp}"
772 if warning:
773 msg = f"{warning}\n{msg}"
774 return msg
775 except PermissionError as e:
776 return f"Error: {e}"
777 except Exception as e:
778 return f"Error editing file: {e}"
779
780 def _file_not_found_msg(self, path: str, fp: Path) -> str:
781 """Build an error message with 'Did you mean ...?' suggestions."""
782 parent = fp.parent
783 suggestions: list[str] = []
784 if parent.is_dir():
785 siblings = [f.name for f in parent.iterdir() if f.is_file()]
786 close = difflib.get_close_matches(fp.name, siblings, n=3, cutoff=0.6)
787 suggestions = [str(parent / c) for c in close]
788 parts = [f"Error: File not found: {path}"]
789 if suggestions:
790 parts.append("Did you mean: " + ", ".join(suggestions) + "?")
791 return "\n".join(parts)
792
793 @staticmethod
794 def _not_found_msg(old_text: str, content: str, path: str) -> str:
795 best_ratio, best_start, best_window_lines, hints = _best_window(old_text, content)
796 if best_ratio > 0.5:
797 diff = "\n".join(difflib.unified_diff(
798 old_text.splitlines(keepends=True),
799 best_window_lines,
800 fromfile="old_text (provided)",
801 tofile=f"{path} (actual, line {best_start + 1})",
802 lineterm="",
803 ))
804 hint_text = ""
805 if hints:
806 hint_text = "\nPossible cause: " + ", ".join(hints) + "."
807 return (
808 f"Error: old_text not found in {path}."
809 f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
810 )
811
812 if hints:
813 return (
814 f"Error: old_text not found in {path}. "
815 f"Possible cause: {', '.join(hints)}. "
816 "Copy the exact text from read_file and try again."
817 )
818 return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
819
820
821 # ---------------------------------------------------------------------------
822 # list_dir
823 # ---------------------------------------------------------------------------
824
825 @tool_parameters(
826 tool_parameters_schema(
827 path=StringSchema("The directory path to list"),
828 recursive=BooleanSchema(description="Recursively list all files (default false)"),
829 max_entries=IntegerSchema(
830 200,
831 description="Maximum entries to return (default 200)",
832 minimum=1,
833 ),
834 required=["path"],
835 )
836 )
837 class ListDirTool(_FsTool):
838 """List directory contents with optional recursion."""
839
840 _DEFAULT_MAX = 200
841 _IGNORE_DIRS = {
842 ".git", "node_modules", "__pycache__", ".venv", "venv",
843 "dist", "build", ".tox", ".mypy_cache", ".pytest_cache",
844 ".ruff_cache", ".coverage", "htmlcov",
845 }
846
847 @property
848 def name(self) -> str:
849 return "list_dir"
850
851 @property
852 def description(self) -> str:
853 return (
854 "List the contents of a directory. "
855 "Set recursive=true to explore nested structure. "
856 "Common noise directories (.git, node_modules, __pycache__, etc.) are auto-ignored."
857 )
858
859 @property
860 def read_only(self) -> bool:
861 return True
862
863 async def execute(
864 self, path: str | None = None, recursive: bool = False,
865 max_entries: int | None = None, **kwargs: Any,
866 ) -> str:
867 try:
868 if path is None:
869 raise ValueError("Unknown path")
870 dp = self._resolve(path)
871 if not dp.exists():
872 return f"Error: Directory not found: {path}"
873 if not dp.is_dir():
874 return f"Error: Not a directory: {path}"
875
876 cap = max_entries or self._DEFAULT_MAX
877 items: list[str] = []
878 total = 0
879
880 if recursive:
881 for item in sorted(dp.rglob("*")):
882 if any(p in self._IGNORE_DIRS for p in item.parts):
883 continue
884 total += 1
885 if len(items) < cap:
886 rel = item.relative_to(dp)
887 items.append(f"{rel}/" if item.is_dir() else str(rel))
888 else:
889 for item in sorted(dp.iterdir()):
890 if item.name in self._IGNORE_DIRS:
891 continue
892 total += 1
893 if len(items) < cap:
894 pfx = "📁 " if item.is_dir() else "📄 "
895 items.append(f"{pfx}{item.name}")
896
897 if not items and total == 0:
898 return f"Directory {path} is empty"
899
900 result = "\n".join(items)
901 if total > cap:
902 result += f"\n\n(truncated, showing first {cap} of {total} entries)"
903 return result
904 except PermissionError as e:
905 return f"Error: {e}"
906 except Exception as e:
907 return f"Error listing directory: {e}"
908
908 lines PYTHON