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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +74 -9
- ripperdoc/cli/commands/__init__.py +4 -0
- ripperdoc/cli/commands/agents_cmd.py +30 -4
- ripperdoc/cli/commands/context_cmd.py +11 -1
- ripperdoc/cli/commands/cost_cmd.py +5 -0
- ripperdoc/cli/commands/doctor_cmd.py +208 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +61 -6
- ripperdoc/cli/commands/resume_cmd.py +4 -2
- ripperdoc/cli/commands/status_cmd.py +1 -1
- ripperdoc/cli/commands/tasks_cmd.py +27 -0
- ripperdoc/cli/ui/rich_ui.py +258 -11
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/core/agents.py +14 -4
- ripperdoc/core/config.py +56 -3
- ripperdoc/core/default_tools.py +16 -2
- ripperdoc/core/permissions.py +19 -0
- ripperdoc/core/providers/__init__.py +31 -0
- ripperdoc/core/providers/anthropic.py +136 -0
- ripperdoc/core/providers/base.py +187 -0
- ripperdoc/core/providers/gemini.py +172 -0
- ripperdoc/core/providers/openai.py +142 -0
- ripperdoc/core/query.py +510 -386
- ripperdoc/core/query_utils.py +578 -0
- ripperdoc/core/system_prompt.py +2 -1
- ripperdoc/core/tool.py +16 -1
- ripperdoc/sdk/client.py +12 -1
- ripperdoc/tools/background_shell.py +63 -21
- ripperdoc/tools/bash_tool.py +48 -13
- ripperdoc/tools/file_edit_tool.py +20 -0
- ripperdoc/tools/file_read_tool.py +23 -0
- ripperdoc/tools/file_write_tool.py +20 -0
- ripperdoc/tools/glob_tool.py +59 -15
- ripperdoc/tools/grep_tool.py +7 -0
- ripperdoc/tools/ls_tool.py +246 -73
- ripperdoc/tools/mcp_tools.py +32 -10
- ripperdoc/tools/multi_edit_tool.py +23 -0
- ripperdoc/tools/notebook_edit_tool.py +18 -3
- ripperdoc/tools/task_tool.py +7 -0
- ripperdoc/tools/todo_tool.py +157 -25
- ripperdoc/tools/tool_search_tool.py +17 -4
- ripperdoc/utils/file_watch.py +134 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +129 -29
- ripperdoc/utils/mcp.py +71 -6
- ripperdoc/utils/memory.py +12 -1
- ripperdoc/utils/message_compaction.py +22 -5
- ripperdoc/utils/messages.py +72 -17
- ripperdoc/utils/output_utils.py +34 -9
- ripperdoc/utils/permissions/path_validation_utils.py +6 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +4 -0
- ripperdoc/utils/session_history.py +27 -9
- ripperdoc/utils/session_usage.py +7 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +2 -2
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/METADATA +4 -2
- ripperdoc-0.2.3.dist-info/RECORD +95 -0
- ripperdoc-0.2.0.dist-info/RECORD +0 -81
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/top_level.txt +0 -0
ripperdoc/tools/ls_tool.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
122
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
243
|
+
if _is_ignored_directory(child_path, root_path):
|
|
159
244
|
continue
|
|
160
245
|
if child.is_symlink():
|
|
161
246
|
continue
|
|
162
|
-
queue.append(
|
|
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
|
-
|
|
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
|
|
256
|
+
normalized = entry.rstrip("/")
|
|
197
257
|
if not normalized:
|
|
198
258
|
continue
|
|
199
|
-
parts = normalized.split("/")
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
240
|
-
"
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
tree =
|
|
287
|
-
|
|
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=
|
|
461
|
+
entries=sorted_entries,
|
|
292
462
|
tree=tree,
|
|
293
463
|
truncated=truncated,
|
|
294
|
-
|
|
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))
|
ripperdoc/tools/mcp_tools.py
CHANGED
|
@@ -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.
|
|
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.
|
|
525
|
-
|
|
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.
|
|
722
|
-
|
|
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.
|
|
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.
|
|
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
|
|
169
|
-
logger.
|
|
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.
|
|
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",
|
ripperdoc/tools/task_tool.py
CHANGED
|
@@ -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
|
|