ripperdoc 0.2.0__py3-none-any.whl → 0.2.3__py3-none-any.whl

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 (65) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +74 -9
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +30 -4
  5. ripperdoc/cli/commands/context_cmd.py +11 -1
  6. ripperdoc/cli/commands/cost_cmd.py +5 -0
  7. ripperdoc/cli/commands/doctor_cmd.py +208 -0
  8. ripperdoc/cli/commands/memory_cmd.py +202 -0
  9. ripperdoc/cli/commands/models_cmd.py +61 -6
  10. ripperdoc/cli/commands/resume_cmd.py +4 -2
  11. ripperdoc/cli/commands/status_cmd.py +1 -1
  12. ripperdoc/cli/commands/tasks_cmd.py +27 -0
  13. ripperdoc/cli/ui/rich_ui.py +258 -11
  14. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  15. ripperdoc/core/agents.py +14 -4
  16. ripperdoc/core/config.py +56 -3
  17. ripperdoc/core/default_tools.py +16 -2
  18. ripperdoc/core/permissions.py +19 -0
  19. ripperdoc/core/providers/__init__.py +31 -0
  20. ripperdoc/core/providers/anthropic.py +136 -0
  21. ripperdoc/core/providers/base.py +187 -0
  22. ripperdoc/core/providers/gemini.py +172 -0
  23. ripperdoc/core/providers/openai.py +142 -0
  24. ripperdoc/core/query.py +510 -386
  25. ripperdoc/core/query_utils.py +578 -0
  26. ripperdoc/core/system_prompt.py +2 -1
  27. ripperdoc/core/tool.py +16 -1
  28. ripperdoc/sdk/client.py +12 -1
  29. ripperdoc/tools/background_shell.py +63 -21
  30. ripperdoc/tools/bash_tool.py +48 -13
  31. ripperdoc/tools/file_edit_tool.py +20 -0
  32. ripperdoc/tools/file_read_tool.py +23 -0
  33. ripperdoc/tools/file_write_tool.py +20 -0
  34. ripperdoc/tools/glob_tool.py +59 -15
  35. ripperdoc/tools/grep_tool.py +7 -0
  36. ripperdoc/tools/ls_tool.py +246 -73
  37. ripperdoc/tools/mcp_tools.py +32 -10
  38. ripperdoc/tools/multi_edit_tool.py +23 -0
  39. ripperdoc/tools/notebook_edit_tool.py +18 -3
  40. ripperdoc/tools/task_tool.py +7 -0
  41. ripperdoc/tools/todo_tool.py +157 -25
  42. ripperdoc/tools/tool_search_tool.py +17 -4
  43. ripperdoc/utils/file_watch.py +134 -0
  44. ripperdoc/utils/git_utils.py +274 -0
  45. ripperdoc/utils/json_utils.py +27 -0
  46. ripperdoc/utils/log.py +129 -29
  47. ripperdoc/utils/mcp.py +71 -6
  48. ripperdoc/utils/memory.py +12 -1
  49. ripperdoc/utils/message_compaction.py +22 -5
  50. ripperdoc/utils/messages.py +72 -17
  51. ripperdoc/utils/output_utils.py +34 -9
  52. ripperdoc/utils/permissions/path_validation_utils.py +6 -0
  53. ripperdoc/utils/prompt.py +17 -0
  54. ripperdoc/utils/safe_get_cwd.py +4 -0
  55. ripperdoc/utils/session_history.py +27 -9
  56. ripperdoc/utils/session_usage.py +7 -0
  57. ripperdoc/utils/shell_utils.py +159 -0
  58. ripperdoc/utils/todo.py +2 -2
  59. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/METADATA +4 -2
  60. ripperdoc-0.2.3.dist-info/RECORD +95 -0
  61. ripperdoc-0.2.0.dist-info/RECORD +0 -81
  62. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/WHEEL +0 -0
  63. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/entry_points.txt +0 -0
  64. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/licenses/LICENSE +0 -0
  65. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/top_level.txt +0 -0
@@ -3,10 +3,11 @@
3
3
  Provides a safe way to inspect directory trees without executing shell commands.
4
4
  """
5
5
 
6
+ import os
6
7
  import fnmatch
7
8
  from collections import deque
8
9
  from pathlib import Path
9
- from typing import AsyncGenerator, List, Optional
10
+ from typing import AsyncGenerator, List, Optional, Dict, Any
10
11
  from pydantic import BaseModel, Field
11
12
 
12
13
  from ripperdoc.core.tool import (
@@ -17,10 +18,22 @@ from ripperdoc.core.tool import (
17
18
  ToolUseExample,
18
19
  ValidationResult,
19
20
  )
21
+ from ripperdoc.utils.safe_get_cwd import safe_get_cwd
22
+ from ripperdoc.utils.git_utils import (
23
+ build_ignore_patterns_map,
24
+ should_ignore_path,
25
+ is_git_repository,
26
+ get_git_root,
27
+ get_current_git_branch,
28
+ get_git_commit_hash,
29
+ is_working_directory_clean,
30
+ get_git_status_files,
31
+ )
20
32
 
21
33
 
22
34
  IGNORED_DIRECTORIES = {
23
35
  "node_modules",
36
+ "vendor/bundle",
24
37
  "vendor",
25
38
  "venv",
26
39
  "env",
@@ -34,18 +47,21 @@ IGNORED_DIRECTORIES = {
34
47
  "bin",
35
48
  "obj",
36
49
  ".build",
50
+ "target",
51
+ ".dart_tool",
52
+ ".pub-cache",
53
+ "build",
54
+ "target",
37
55
  "_build",
38
56
  "deps",
39
57
  "dist",
40
58
  "dist-newstyle",
41
59
  ".deno",
42
60
  "bower_components",
43
- "vendor/bundle",
44
- ".dart_tool",
45
- ".pub-cache",
46
61
  }
47
62
 
48
63
  MAX_CHARS_THRESHOLD = 40000
64
+ MAX_DEPTH = 4
49
65
  LARGE_REPO_WARNING = (
50
66
  f"There are more than {MAX_CHARS_THRESHOLD} characters in the repository "
51
67
  "(ie. either there are lots of files, or there are many long filenames). "
@@ -88,8 +104,23 @@ class LSToolOutput(BaseModel):
88
104
  entries: list[str]
89
105
  tree: str
90
106
  truncated: bool = False
107
+ aborted: bool = False
91
108
  ignored: list[str] = Field(default_factory=list)
92
109
  warning: Optional[str] = None
110
+ git_info: Optional[Dict[str, Any]] = Field(default_factory=dict)
111
+ file_count: int = 0
112
+
113
+
114
+ def _resolve_directory_path(raw_path: str) -> Path:
115
+ """Resolve a user-provided path against the current working directory."""
116
+ base_path = Path(safe_get_cwd())
117
+ candidate = Path(raw_path).expanduser()
118
+ if not candidate.is_absolute():
119
+ candidate = base_path / candidate
120
+ try:
121
+ return candidate.resolve()
122
+ except Exception:
123
+ return candidate
93
124
 
94
125
 
95
126
  def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
@@ -107,100 +138,156 @@ def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
107
138
  )
108
139
 
109
140
 
110
- def _should_skip(path: Path, root_path: Path, patterns: list[str]) -> bool:
141
+ def _should_skip(
142
+ path: Path,
143
+ root_path: Path,
144
+ patterns: list[str],
145
+ ignore_map: Optional[Dict[Optional[Path], List[str]]] = None,
146
+ ) -> bool:
111
147
  name = path.name
112
148
  if name.startswith("."):
113
149
  return True
114
150
  if "__pycache__" in path.parts:
115
151
  return True
116
- if _matches_ignore(path, root_path, patterns):
152
+
153
+ # Check against ignore patterns
154
+ if ignore_map and should_ignore_path(path, root_path, ignore_map):
155
+ return True
156
+
157
+ # Also check against direct patterns for backward compatibility
158
+ if patterns and _matches_ignore(path, root_path, patterns):
117
159
  return True
160
+
118
161
  return False
119
162
 
120
163
 
121
- def _format_relative(path: Path, root_path: Path) -> str:
122
- rel_path = path.relative_to(root_path).as_posix()
164
+ def _relative_path_for_display(path: Path, base_path: Path) -> str:
165
+ """Convert a path to a display-friendly path relative to base_path."""
166
+ resolved_path = path
167
+ try:
168
+ resolved_path = path.resolve()
169
+ except Exception:
170
+ pass
171
+
172
+ try:
173
+ rel_path = resolved_path.relative_to(base_path.resolve()).as_posix()
174
+ except Exception:
175
+ try:
176
+ rel_path = os.path.relpath(resolved_path, base_path)
177
+ except Exception:
178
+ rel_path = resolved_path.as_posix()
179
+ rel_path = rel_path.replace(os.sep, "/")
180
+
181
+ rel_path = rel_path.rstrip("/")
123
182
  return f"{rel_path}/" if path.is_dir() else rel_path
124
183
 
125
184
 
126
- def _collect_paths(root_path: Path, ignore_patterns: list[str]) -> tuple[list[str], bool]:
185
+ def _collect_paths(
186
+ root_path: Path,
187
+ base_path: Path,
188
+ ignore_patterns: list[str],
189
+ include_gitignore: bool = True,
190
+ abort_signal: Optional[Any] = None,
191
+ max_depth: Optional[int] = MAX_DEPTH,
192
+ ) -> tuple[list[str], bool, List[str], bool]:
193
+ """Collect paths under root_path relative to base_path with early-exit controls."""
127
194
  entries: list[str] = []
128
195
  total_chars = 0
129
196
  truncated = False
130
- queue = deque([root_path])
197
+ aborted = False
198
+ ignored_entries: List[str] = []
199
+ ignore_map = build_ignore_patterns_map(
200
+ root_path,
201
+ user_ignore_patterns=ignore_patterns,
202
+ include_gitignore=include_gitignore,
203
+ )
204
+
205
+ queue = deque([(root_path, 0)]) # (path, depth)
131
206
 
132
207
  while queue and not truncated:
133
- current = queue.popleft()
208
+ if abort_signal is not None and getattr(abort_signal, "is_set", lambda: False)():
209
+ aborted = True
210
+ break
211
+
212
+ current, depth = queue.popleft()
213
+
214
+ if max_depth is not None and depth > max_depth:
215
+ continue
134
216
 
135
217
  try:
136
- children = sorted(current.iterdir(), key=lambda p: p.name.lower())
137
- except (FileNotFoundError, PermissionError):
218
+ with os.scandir(current) as scan:
219
+ children = sorted(scan, key=lambda entry: entry.name.lower())
220
+ except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
138
221
  continue
139
222
 
140
223
  for child in children:
224
+ child_path = Path(current) / child.name
141
225
  try:
142
- is_dir = child.is_dir()
226
+ is_dir = child.is_dir(follow_symlinks=False)
143
227
  except OSError:
144
228
  continue
145
229
 
146
- if _should_skip(child, root_path, ignore_patterns):
230
+ if _should_skip(child_path, root_path, ignore_patterns, ignore_map):
231
+ ignored_entries.append(_relative_path_for_display(child_path, base_path))
147
232
  continue
148
233
 
149
- display = _format_relative(child, root_path)
234
+ display = _relative_path_for_display(child_path, base_path)
150
235
  entries.append(display)
151
236
  total_chars += len(display)
152
237
 
153
- if total_chars > MAX_CHARS_THRESHOLD:
238
+ if total_chars >= MAX_CHARS_THRESHOLD:
154
239
  truncated = True
155
240
  break
156
241
 
157
242
  if is_dir:
158
- if _is_ignored_directory(child, root_path):
243
+ if _is_ignored_directory(child_path, root_path):
159
244
  continue
160
245
  if child.is_symlink():
161
246
  continue
162
- queue.append(child)
163
-
164
- return entries, truncated
165
-
166
-
167
- def _add_to_tree(tree: dict, parts: list[str], is_dir: bool) -> None:
168
- node = tree
169
- for idx, part in enumerate(parts):
170
- node = node.setdefault(part, {"children": {}, "is_dir": False})
171
- if idx == len(parts) - 1:
172
- node["is_dir"] = is_dir
173
- node = node["children"]
174
-
247
+ queue.append((child_path, depth + 1))
175
248
 
176
- def _render_tree(tree: dict, indent: str = " ", current_indent: str = " ") -> str:
177
- lines: list[str] = []
178
- for name in sorted(tree):
179
- node = tree[name]
180
- suffix = "/" if node.get("is_dir") else ""
181
- lines.append(f"{current_indent}- {name}{suffix}")
182
- children = node.get("children") or {}
183
- if children:
184
- lines.append(_render_tree(children, indent, current_indent + indent))
185
- return "\n".join(lines)
186
-
187
-
188
- def _build_tree(entries: list[str], root_path: Path) -> str:
189
- root_line = f"- {root_path.resolve().as_posix()}/"
249
+ return entries, truncated, ignored_entries, aborted
190
250
 
191
- if not entries:
192
- return f"{root_line}\n (empty directory)"
193
251
 
252
+ def build_file_tree(entries: list[str]) -> dict:
253
+ """Build a nested tree structure from flat entry paths."""
194
254
  tree: dict = {}
195
255
  for entry in entries:
196
- normalized = entry[:-1] if entry.endswith("/") else entry
256
+ normalized = entry.rstrip("/")
197
257
  if not normalized:
198
258
  continue
199
- parts = normalized.split("/")
200
- _add_to_tree(tree, parts, entry.endswith("/"))
201
-
202
- body = _render_tree(tree)
203
- return f"{root_line}\n{body}"
259
+ parts = [part for part in normalized.split("/") if part]
260
+ node = tree
261
+ for idx, part in enumerate(parts):
262
+ node = node.setdefault(part, {"children": {}, "is_dir": False})
263
+ if idx == len(parts) - 1:
264
+ node["is_dir"] = node.get("is_dir", False) or entry.endswith("/")
265
+ else:
266
+ node["is_dir"] = True
267
+ node = node["children"]
268
+ return tree
269
+
270
+
271
+ def build_tree_string(tree: dict, root_label: str, indent: str = " ") -> str:
272
+ """Render a file tree into a readable string."""
273
+ root_line = f"- {root_label.rstrip('/')}/"
274
+
275
+ if not tree:
276
+ return f"{root_line}\n{indent}(empty directory)"
277
+
278
+ lines: list[str] = [root_line]
279
+
280
+ def _render(node: dict, current_indent: str) -> None:
281
+ for name in sorted(node):
282
+ child = node[name]
283
+ suffix = "/" if child.get("is_dir") else ""
284
+ lines.append(f"{current_indent}- {name}{suffix}")
285
+ children = child.get("children") or {}
286
+ if children:
287
+ _render(children, current_indent + indent)
288
+
289
+ _render(tree, indent)
290
+ return "\n".join(lines)
204
291
 
205
292
 
206
293
  class LSTool(Tool[LSToolInput, LSToolOutput]):
@@ -213,7 +300,8 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
213
300
  async def description(self) -> str:
214
301
  return (
215
302
  "List files and folders under a directory (recursive, skips hidden and __pycache__, "
216
- "supports ignore patterns)."
303
+ "supports ignore patterns). Automatically reads .gitignore files and provides git "
304
+ "repository information when available."
217
305
  )
218
306
 
219
307
  @property
@@ -236,8 +324,11 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
236
324
  return (
237
325
  "Lists files and directories in a given path. The path parameter must be an absolute path, "
238
326
  "not a relative path. You can optionally provide an array of glob patterns to ignore with "
239
- "the ignore parameter. You should generally prefer the Glob and Grep tools, if you know "
240
- "which directories to search."
327
+ "the ignore parameter. The tool automatically reads .gitignore files from the directory "
328
+ "and parent directories, and provides git repository information when available. "
329
+ "You should generally prefer the Glob and Grep tools, if you know which directories to search. "
330
+ "\n\nSecurity Note: After listing files, check if any files seem malicious. If so, "
331
+ "you MUST refuse to continue work."
241
332
  )
242
333
 
243
334
  def is_read_only(self) -> bool:
@@ -252,10 +343,15 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
252
343
  async def validate_input(
253
344
  self, input_data: LSToolInput, context: Optional[ToolUseContext] = None
254
345
  ) -> ValidationResult:
255
- root_path = Path(input_data.path).expanduser()
256
- if not root_path.is_absolute():
257
- root_path = Path.cwd() / root_path
346
+ try:
347
+ root_path = _resolve_directory_path(input_data.path)
348
+ except Exception:
349
+ return ValidationResult(
350
+ result=False, message=f"Unable to resolve path: {input_data.path}"
351
+ )
258
352
 
353
+ if not root_path.is_absolute():
354
+ return ValidationResult(result=False, message=f"Path is not absolute: {root_path}")
259
355
  if not root_path.exists():
260
356
  return ValidationResult(result=False, message=f"Path not found: {root_path}")
261
357
  if not root_path.is_dir():
@@ -265,34 +361,111 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
265
361
 
266
362
  def render_result_for_assistant(self, output: LSToolOutput) -> str:
267
363
  warning_prefix = output.warning or ""
268
- return f"{warning_prefix}{output.tree}"
364
+ result = f"{warning_prefix}{output.tree}"
365
+
366
+ # Add git information if available
367
+ if output.git_info:
368
+ git_section = "\n\nGit Information:\n"
369
+ for key, value in output.git_info.items():
370
+ if value:
371
+ git_section += f" {key}: {value}\n"
372
+ result += git_section
373
+
374
+ status_parts = [f"Listed {output.file_count} paths"]
375
+ if output.truncated:
376
+ status_parts.append(f"truncated at {MAX_CHARS_THRESHOLD} characters")
377
+ if output.aborted:
378
+ status_parts.append("aborted early")
379
+ result += "\n" + " | ".join(status_parts)
380
+
381
+ # Add security warning
382
+ result += "\n\nNOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work."
383
+
384
+ return result
269
385
 
270
386
  def render_tool_use_message(self, input_data: LSToolInput, verbose: bool = False) -> str:
271
- ignore_display = ""
272
- if input_data.ignore:
273
- ignore_display = f', ignore: "{", ".join(input_data.ignore)}"'
274
- return f'path: "{input_data.path}"{ignore_display}'
387
+ base_path = Path(safe_get_cwd())
388
+ resolved_path = _resolve_directory_path(input_data.path)
389
+
390
+ if verbose:
391
+ ignore_display = ""
392
+ if input_data.ignore:
393
+ ignore_display = f', ignore: "{", ".join(input_data.ignore)}"'
394
+ return f'path: "{input_data.path}"{ignore_display}'
395
+
396
+ try:
397
+ relative_path = (
398
+ _relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
399
+ )
400
+ except Exception:
401
+ relative_path = str(resolved_path)
402
+
403
+ return relative_path
275
404
 
276
405
  async def call(
277
406
  self, input_data: LSToolInput, context: ToolUseContext
278
407
  ) -> AsyncGenerator[ToolOutput, None]:
279
408
  """List directory contents."""
280
- root_path = Path(input_data.path).expanduser()
281
- if not root_path.is_absolute():
282
- root_path = Path.cwd() / root_path
283
- root_path = root_path.resolve()
409
+ base_path = Path(safe_get_cwd())
410
+ root_path = _resolve_directory_path(input_data.path)
411
+ abort_signal = getattr(context, "abort_signal", None)
412
+
413
+ # Collect paths with gitignore support
414
+ entries, truncated, ignored_entries, aborted = _collect_paths(
415
+ root_path,
416
+ base_path,
417
+ input_data.ignore,
418
+ include_gitignore=True,
419
+ abort_signal=abort_signal,
420
+ )
284
421
 
285
- entries, truncated = _collect_paths(root_path, input_data.ignore)
286
- tree = _build_tree(entries, root_path)
287
- warning = LARGE_REPO_WARNING if truncated else None
422
+ sorted_entries = sorted(entries)
423
+ tree = build_tree_string(build_file_tree(sorted_entries), base_path.as_posix())
424
+
425
+ warnings: list[str] = []
426
+ if aborted:
427
+ warnings.append("Listing aborted; partial results shown.\n\n")
428
+ if truncated:
429
+ warnings.append(LARGE_REPO_WARNING)
430
+ warning = "".join(warnings) or None
431
+
432
+ # Collect git information
433
+ git_info: Dict[str, Any] = {}
434
+ if is_git_repository(root_path):
435
+ git_root = get_git_root(root_path)
436
+ if git_root:
437
+ git_info["repository"] = str(git_root)
438
+
439
+ branch = get_current_git_branch(root_path)
440
+ if branch:
441
+ git_info["branch"] = branch
442
+
443
+ commit_hash = get_git_commit_hash(root_path)
444
+ if commit_hash:
445
+ git_info["commit"] = commit_hash
446
+
447
+ is_clean = is_working_directory_clean(root_path)
448
+ git_info["clean"] = "yes" if is_clean else "no (uncommitted changes)"
449
+
450
+ tracked, untracked = get_git_status_files(root_path)
451
+ if tracked or untracked:
452
+ status_info = []
453
+ if tracked:
454
+ status_info.append(f"{len(tracked)} tracked")
455
+ if untracked:
456
+ status_info.append(f"{len(untracked)} untracked")
457
+ git_info["status"] = ", ".join(status_info)
288
458
 
289
459
  output = LSToolOutput(
290
460
  root=str(root_path),
291
- entries=entries,
461
+ entries=sorted_entries,
292
462
  tree=tree,
293
463
  truncated=truncated,
294
- ignored=list(input_data.ignore),
464
+ aborted=aborted,
465
+ ignored=list(input_data.ignore) + ignored_entries,
295
466
  warning=warning,
467
+ git_info=git_info,
468
+ file_count=len(sorted_entries),
296
469
  )
297
470
 
298
471
  yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
@@ -31,13 +31,14 @@ from ripperdoc.utils.mcp import (
31
31
  shutdown_mcp_runtime,
32
32
  )
33
33
 
34
+
35
+ logger = get_logger()
36
+
34
37
  try:
35
38
  import mcp.types as mcp_types # type: ignore
36
39
  except Exception: # pragma: no cover - SDK may be missing at runtime
37
40
  mcp_types = None # type: ignore[assignment]
38
-
39
-
40
- logger = get_logger()
41
+ logger.exception("[mcp_tools] MCP SDK unavailable during import")
41
42
 
42
43
 
43
44
  def _content_block_to_text(block: Any) -> str:
@@ -272,6 +273,7 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
272
273
  try:
273
274
  return json.dumps(output.resources, indent=2, ensure_ascii=False)
274
275
  except Exception:
276
+ logger.exception("[mcp_tools] Failed to serialize MCP resources for assistant output")
275
277
  return str(output.resources)
276
278
 
277
279
  def render_tool_use_message(
@@ -313,7 +315,10 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
313
315
  for res in response.resources
314
316
  ]
315
317
  except Exception as exc: # pragma: no cover - runtime errors
316
- logger.error(f"Failed to fetch resources from {server.name}: {exc}")
318
+ logger.exception(
319
+ "Failed to fetch resources from MCP server",
320
+ extra={"server": server.name, "error": str(exc)},
321
+ )
317
322
  fetched = []
318
323
 
319
324
  candidate_resources = fetched if fetched else server.resources
@@ -494,6 +499,10 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
494
499
  try:
495
500
  raw_bytes = base64.b64decode(blob_data)
496
501
  except Exception:
502
+ logger.exception(
503
+ "[mcp_tools] Failed to decode base64 blob content",
504
+ extra={"server": input_data.server, "uri": input_data.uri},
505
+ )
497
506
  raw_bytes = None
498
507
  else:
499
508
  raw_bytes = None
@@ -521,8 +530,9 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
521
530
  text_parts = [p.text for p in parts if p.text]
522
531
  content_text = "\n".join([p for p in text_parts if p]) or None
523
532
  except Exception as exc: # pragma: no cover - runtime errors
524
- logger.error(
525
- f"Error reading MCP resource {input_data.uri} from {input_data.server}: {exc}"
533
+ logger.exception(
534
+ "Error reading MCP resource",
535
+ extra={"server": input_data.server, "uri": input_data.uri, "error": str(exc)},
526
536
  )
527
537
  content_text = f"Error reading MCP resource: {exc}"
528
538
  else:
@@ -575,6 +585,7 @@ def _annotation_flag(tool_info: Any, key: str) -> bool:
575
585
  try:
576
586
  return bool(annotations.get(key, False))
577
587
  except Exception:
588
+ logger.debug("[mcp_tools] Failed to read annotation flag", exc_info=True)
578
589
  return False
579
590
  return False
580
591
 
@@ -718,8 +729,13 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
718
729
  structured_content=None,
719
730
  is_error=True,
720
731
  )
721
- logger.error(
722
- f"Error calling MCP tool {self.tool_info.name} on {self.server_name}: {exc}"
732
+ logger.exception(
733
+ "Error calling MCP tool",
734
+ extra={
735
+ "server": self.server_name,
736
+ "tool": self.tool_info.name,
737
+ "error": str(exc),
738
+ },
723
739
  )
724
740
  yield ToolResult(
725
741
  data=output,
@@ -768,7 +784,10 @@ def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[Dyn
768
784
  try:
769
785
  return asyncio.run(_load_and_cleanup())
770
786
  except Exception as exc: # pragma: no cover - SDK/runtime failures
771
- logger.error(f"Failed to initialize MCP runtime for dynamic tools: {exc}")
787
+ logger.exception(
788
+ "Failed to initialize MCP runtime for dynamic tools (sync)",
789
+ extra={"error": str(exc)},
790
+ )
772
791
  return []
773
792
 
774
793
 
@@ -777,7 +796,10 @@ async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> L
777
796
  try:
778
797
  runtime = await ensure_mcp_runtime(project_path)
779
798
  except Exception as exc: # pragma: no cover - SDK/runtime failures
780
- logger.error(f"Failed to initialize MCP runtime for dynamic tools: {exc}")
799
+ logger.exception(
800
+ "Failed to initialize MCP runtime for dynamic tools (async)",
801
+ extra={"error": str(exc)},
802
+ )
781
803
  return []
782
804
  return _build_dynamic_mcp_tools(runtime)
783
805
 
@@ -17,6 +17,10 @@ from ripperdoc.core.tool import (
17
17
  ToolUseExample,
18
18
  ValidationResult,
19
19
  )
20
+ from ripperdoc.utils.log import get_logger
21
+ from ripperdoc.utils.file_watch import record_snapshot
22
+
23
+ logger = get_logger()
20
24
 
21
25
 
22
26
  DEFAULT_ACTION = "Edit"
@@ -307,6 +311,10 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
307
311
  if existing:
308
312
  original_content = file_path.read_text(encoding="utf-8")
309
313
  except Exception as exc: # pragma: no cover - unlikely permission issue
314
+ logger.exception(
315
+ "[multi_edit_tool] Error reading file before edits",
316
+ extra={"file_path": str(file_path)},
317
+ )
310
318
  output = MultiEditToolOutput(
311
319
  file_path=str(file_path),
312
320
  replacements_made=0,
@@ -353,7 +361,22 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
353
361
  file_path.parent.mkdir(parents=True, exist_ok=True)
354
362
  try:
355
363
  file_path.write_text(updated_content, encoding="utf-8")
364
+ try:
365
+ record_snapshot(
366
+ str(file_path),
367
+ updated_content,
368
+ getattr(context, "file_state_cache", {}),
369
+ )
370
+ except Exception:
371
+ logger.exception(
372
+ "[multi_edit_tool] Failed to record file snapshot",
373
+ extra={"file_path": str(file_path)},
374
+ )
356
375
  except Exception as exc:
376
+ logger.exception(
377
+ "[multi_edit_tool] Error writing edited file",
378
+ extra={"file_path": str(file_path)},
379
+ )
357
380
  output = MultiEditToolOutput(
358
381
  file_path=str(file_path),
359
382
  replacements_made=0,
@@ -20,6 +20,7 @@ from ripperdoc.core.tool import (
20
20
  ValidationResult,
21
21
  )
22
22
  from ripperdoc.utils.log import get_logger
23
+ from ripperdoc.utils.file_watch import record_snapshot
23
24
 
24
25
 
25
26
  logger = get_logger()
@@ -165,8 +166,8 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
165
166
  try:
166
167
  raw = path.read_text(encoding="utf-8")
167
168
  nb_json = json.loads(raw)
168
- except Exception as exc:
169
- logger.error(f"Failed to parse notebook {path}: {exc}")
169
+ except Exception:
170
+ logger.exception("Failed to parse notebook", extra={"path": str(path)})
170
171
  return ValidationResult(
171
172
  result=False, message="Notebook is not valid JSON.", error_code=6
172
173
  )
@@ -272,6 +273,17 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
272
273
  )
273
274
 
274
275
  path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
276
+ try:
277
+ record_snapshot(
278
+ input_data.notebook_path,
279
+ json.dumps(nb_json, indent=1),
280
+ getattr(context, "file_state_cache", {}),
281
+ )
282
+ except Exception:
283
+ logger.exception(
284
+ "[notebook_edit_tool] Failed to record file snapshot",
285
+ extra={"file_path": input_data.notebook_path},
286
+ )
275
287
 
276
288
  output = NotebookEditOutput(
277
289
  new_source=new_source,
@@ -285,7 +297,10 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
285
297
  data=output, result_for_assistant=self.render_result_for_assistant(output)
286
298
  )
287
299
  except Exception as exc: # pragma: no cover - error path
288
- logger.error(f"Error editing notebook {input_data.notebook_path}: {exc}")
300
+ logger.exception(
301
+ "Error editing notebook",
302
+ extra={"path": input_data.notebook_path, "error": str(exc)},
303
+ )
289
304
  output = NotebookEditOutput(
290
305
  new_source=new_source,
291
306
  cell_type=cell_type or "code",
@@ -19,6 +19,9 @@ from ripperdoc.core.query import QueryContext, query
19
19
  from ripperdoc.core.system_prompt import build_environment_prompt
20
20
  from ripperdoc.core.tool import Tool, ToolOutput, ToolProgress, ToolResult, ToolUseContext
21
21
  from ripperdoc.utils.messages import AssistantMessage, create_user_message
22
+ from ripperdoc.utils.log import get_logger
23
+
24
+ logger = get_logger()
22
25
 
23
26
 
24
27
  class TaskToolInput(BaseModel):
@@ -284,6 +287,10 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
284
287
  try:
285
288
  serialized = json.dumps(inp, ensure_ascii=False)
286
289
  except Exception:
290
+ logger.exception(
291
+ "[task_tool] Failed to serialize tool_use input",
292
+ extra={"tool_use_input": str(inp)},
293
+ )
287
294
  serialized = str(inp)
288
295
  return serialized if len(serialized) <= 120 else serialized[:117] + "..."
289
296