ripperdoc 0.2.6__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 +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""File reading tool.
|
|
2
|
+
|
|
3
|
+
Allows the AI to read file contents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncGenerator, List, Optional
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from ripperdoc.core.tool import (
|
|
12
|
+
Tool,
|
|
13
|
+
ToolUseContext,
|
|
14
|
+
ToolResult,
|
|
15
|
+
ToolOutput,
|
|
16
|
+
ToolUseExample,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
)
|
|
19
|
+
from ripperdoc.utils.log import get_logger
|
|
20
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
|
+
from ripperdoc.utils.path_ignore import check_path_for_tool, is_path_ignored
|
|
22
|
+
|
|
23
|
+
logger = get_logger()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FileReadToolInput(BaseModel):
|
|
27
|
+
"""Input schema for FileReadTool."""
|
|
28
|
+
|
|
29
|
+
file_path: str = Field(description="Absolute path to the file to read")
|
|
30
|
+
offset: Optional[int] = Field(
|
|
31
|
+
default=None, description="Line number to start reading from (optional)"
|
|
32
|
+
)
|
|
33
|
+
limit: Optional[int] = Field(default=None, description="Number of lines to read (optional)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FileReadToolOutput(BaseModel):
|
|
37
|
+
"""Output from file reading."""
|
|
38
|
+
|
|
39
|
+
content: str
|
|
40
|
+
file_path: str
|
|
41
|
+
line_count: int
|
|
42
|
+
offset: int
|
|
43
|
+
limit: Optional[int]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FileReadTool(Tool[FileReadToolInput, FileReadToolOutput]):
|
|
47
|
+
"""Tool for reading file contents."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str:
|
|
51
|
+
return "View"
|
|
52
|
+
|
|
53
|
+
async def description(self) -> str:
|
|
54
|
+
return """Read the contents of a file. You can optionally specify an offset
|
|
55
|
+
and limit to read only a portion of the file."""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def input_schema(self) -> type[FileReadToolInput]:
|
|
59
|
+
return FileReadToolInput
|
|
60
|
+
|
|
61
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
62
|
+
return [
|
|
63
|
+
ToolUseExample(
|
|
64
|
+
description="Read the top of a file to understand structure",
|
|
65
|
+
example={"file_path": "/repo/src/main.py", "limit": 50},
|
|
66
|
+
),
|
|
67
|
+
ToolUseExample(
|
|
68
|
+
description="Inspect a slice of a large log without loading everything",
|
|
69
|
+
example={"file_path": "/repo/logs/server.log", "offset": 200, "limit": 40},
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
74
|
+
return (
|
|
75
|
+
"Read a file from the local filesystem.\n\n"
|
|
76
|
+
"Usage:\n"
|
|
77
|
+
"- The file_path parameter must be an absolute path (not relative).\n"
|
|
78
|
+
"- By default, the entire file is read. You can optionally specify a line offset and limit (handy for long files); offset is zero-based and output line numbers start at 1.\n"
|
|
79
|
+
"- Lines longer than 2000 characters are truncated in the output.\n"
|
|
80
|
+
"- Results are returned with cat -n style numbering: spaces + line number + tab, then the file content.\n"
|
|
81
|
+
"- You can call multiple tools in a single response—speculatively read multiple potentially useful files together.\n"
|
|
82
|
+
"- It is okay to attempt reading a non-existent file; an error will be returned if the file is missing."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def is_read_only(self) -> bool:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def is_concurrency_safe(self) -> bool:
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def needs_permissions(self, input_data: Optional[FileReadToolInput] = None) -> bool:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
async def validate_input(
|
|
95
|
+
self, input_data: FileReadToolInput, context: Optional[ToolUseContext] = None
|
|
96
|
+
) -> ValidationResult:
|
|
97
|
+
# Check if file exists
|
|
98
|
+
if not os.path.exists(input_data.file_path):
|
|
99
|
+
return ValidationResult(result=False, message=f"File not found: {input_data.file_path}")
|
|
100
|
+
|
|
101
|
+
# Check if it's a file (not a directory)
|
|
102
|
+
if not os.path.isfile(input_data.file_path):
|
|
103
|
+
return ValidationResult(
|
|
104
|
+
result=False, message=f"Path is not a file: {input_data.file_path}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Check if path is ignored (warning only for read operations)
|
|
108
|
+
file_path = Path(input_data.file_path)
|
|
109
|
+
should_proceed, warning_msg = check_path_for_tool(file_path, tool_name="Read", warn_only=True)
|
|
110
|
+
if warning_msg:
|
|
111
|
+
logger.info("[file_read_tool] %s", warning_msg)
|
|
112
|
+
|
|
113
|
+
return ValidationResult(result=True)
|
|
114
|
+
|
|
115
|
+
def render_result_for_assistant(self, output: FileReadToolOutput) -> str:
|
|
116
|
+
"""Format output for the AI."""
|
|
117
|
+
lines = output.content.split("\n")
|
|
118
|
+
numbered_lines = []
|
|
119
|
+
|
|
120
|
+
for i, line in enumerate(lines, start=output.offset + 1):
|
|
121
|
+
# Truncate very long lines
|
|
122
|
+
if len(line) > 2000:
|
|
123
|
+
line = line[:2000] + "... [truncated]"
|
|
124
|
+
numbered_lines.append(f"{i:6d}\t{line}")
|
|
125
|
+
|
|
126
|
+
return "\n".join(numbered_lines)
|
|
127
|
+
|
|
128
|
+
def render_tool_use_message(self, input_data: FileReadToolInput, verbose: bool = False) -> str:
|
|
129
|
+
"""Format the tool use for display."""
|
|
130
|
+
msg = f"Reading: {input_data.file_path}"
|
|
131
|
+
if input_data.offset or input_data.limit:
|
|
132
|
+
msg += f" (offset: {input_data.offset}, limit: {input_data.limit})"
|
|
133
|
+
return msg
|
|
134
|
+
|
|
135
|
+
async def call(
|
|
136
|
+
self, input_data: FileReadToolInput, context: ToolUseContext
|
|
137
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
138
|
+
"""Read the file."""
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with open(input_data.file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
142
|
+
lines = f.readlines()
|
|
143
|
+
|
|
144
|
+
offset = input_data.offset or 0
|
|
145
|
+
limit = input_data.limit
|
|
146
|
+
|
|
147
|
+
# Apply offset and limit
|
|
148
|
+
if limit is not None:
|
|
149
|
+
selected_lines = lines[offset : offset + limit]
|
|
150
|
+
else:
|
|
151
|
+
selected_lines = lines[offset:]
|
|
152
|
+
|
|
153
|
+
content = "".join(selected_lines)
|
|
154
|
+
|
|
155
|
+
# Remember what we read so we can detect user edits later.
|
|
156
|
+
# Use absolute path to ensure consistency with Edit tool's lookup
|
|
157
|
+
abs_file_path = os.path.abspath(input_data.file_path)
|
|
158
|
+
try:
|
|
159
|
+
record_snapshot(
|
|
160
|
+
abs_file_path,
|
|
161
|
+
content,
|
|
162
|
+
getattr(context, "file_state_cache", {}),
|
|
163
|
+
offset=offset,
|
|
164
|
+
limit=limit,
|
|
165
|
+
)
|
|
166
|
+
except (OSError, IOError, RuntimeError) as exc:
|
|
167
|
+
logger.warning(
|
|
168
|
+
"[file_read_tool] Failed to record file snapshot: %s: %s",
|
|
169
|
+
type(exc).__name__, exc,
|
|
170
|
+
extra={"file_path": input_data.file_path},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
output = FileReadToolOutput(
|
|
174
|
+
content=content,
|
|
175
|
+
file_path=input_data.file_path,
|
|
176
|
+
line_count=len(selected_lines),
|
|
177
|
+
offset=offset,
|
|
178
|
+
limit=limit,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
yield ToolResult(
|
|
182
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
except (OSError, IOError, UnicodeDecodeError, ValueError) as e:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"[file_read_tool] Error reading file: %s: %s",
|
|
188
|
+
type(e).__name__, e,
|
|
189
|
+
extra={"file_path": input_data.file_path},
|
|
190
|
+
)
|
|
191
|
+
# Create an error output
|
|
192
|
+
error_output = FileReadToolOutput(
|
|
193
|
+
content=f"Error reading file: {str(e)}",
|
|
194
|
+
file_path=input_data.file_path,
|
|
195
|
+
line_count=0,
|
|
196
|
+
offset=0,
|
|
197
|
+
limit=None,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
yield ToolResult(
|
|
201
|
+
data=error_output,
|
|
202
|
+
result_for_assistant=f"Error reading file {input_data.file_path}: {str(e)}",
|
|
203
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""File writing tool.
|
|
2
|
+
|
|
3
|
+
Allows the AI to create new files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncGenerator, List, Optional
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from ripperdoc.core.tool import (
|
|
12
|
+
Tool,
|
|
13
|
+
ToolUseContext,
|
|
14
|
+
ToolResult,
|
|
15
|
+
ToolOutput,
|
|
16
|
+
ToolUseExample,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
)
|
|
19
|
+
from ripperdoc.utils.log import get_logger
|
|
20
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
|
+
from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
22
|
+
|
|
23
|
+
logger = get_logger()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FileWriteToolInput(BaseModel):
|
|
27
|
+
"""Input schema for FileWriteTool."""
|
|
28
|
+
|
|
29
|
+
file_path: str = Field(description="Absolute path to the file to create")
|
|
30
|
+
content: str = Field(description="Content to write to the file")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FileWriteToolOutput(BaseModel):
|
|
34
|
+
"""Output from file writing."""
|
|
35
|
+
|
|
36
|
+
file_path: str
|
|
37
|
+
bytes_written: int
|
|
38
|
+
success: bool
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FileWriteTool(Tool[FileWriteToolInput, FileWriteToolOutput]):
|
|
43
|
+
"""Tool for creating new files."""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def name(self) -> str:
|
|
47
|
+
return "Write"
|
|
48
|
+
|
|
49
|
+
async def description(self) -> str:
|
|
50
|
+
return """Create a new file with the specified content. This will overwrite
|
|
51
|
+
the file if it already exists."""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def input_schema(self) -> type[FileWriteToolInput]:
|
|
55
|
+
return FileWriteToolInput
|
|
56
|
+
|
|
57
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
58
|
+
return [
|
|
59
|
+
ToolUseExample(
|
|
60
|
+
description="Create a JSON fixture file",
|
|
61
|
+
example={
|
|
62
|
+
"file_path": "/repo/tests/fixtures/sample.json",
|
|
63
|
+
"content": '{\n "items": []\n}\n',
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
ToolUseExample(
|
|
67
|
+
description="Write a short markdown note",
|
|
68
|
+
example={
|
|
69
|
+
"file_path": "/repo/docs/USAGE.md",
|
|
70
|
+
"content": "# Usage\n\nRun `make test`.\n",
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
76
|
+
prompt = """Use the Write tool to create new files. """
|
|
77
|
+
|
|
78
|
+
if safe_mode:
|
|
79
|
+
prompt += """IMPORTANT: You must ALWAYS prefer editing existing files.
|
|
80
|
+
NEVER write new files unless explicitly required by the user."""
|
|
81
|
+
|
|
82
|
+
return prompt
|
|
83
|
+
|
|
84
|
+
def is_read_only(self) -> bool:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def is_concurrency_safe(self) -> bool:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def needs_permissions(self, input_data: Optional[FileWriteToolInput] = None) -> bool:
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
async def validate_input(
|
|
94
|
+
self, input_data: FileWriteToolInput, context: Optional[ToolUseContext] = None
|
|
95
|
+
) -> ValidationResult:
|
|
96
|
+
# Check if parent directory exists
|
|
97
|
+
parent = Path(input_data.file_path).parent
|
|
98
|
+
if not parent.exists():
|
|
99
|
+
return ValidationResult(
|
|
100
|
+
result=False,
|
|
101
|
+
message=f"Parent directory does not exist: {parent}",
|
|
102
|
+
error_code=1,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
file_path = os.path.abspath(input_data.file_path)
|
|
106
|
+
|
|
107
|
+
# If file doesn't exist, it's a new file - allow without reading first
|
|
108
|
+
if not os.path.exists(file_path):
|
|
109
|
+
return ValidationResult(result=True)
|
|
110
|
+
|
|
111
|
+
# File exists - check if it has been read before writing
|
|
112
|
+
file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
|
|
113
|
+
file_snapshot = file_state_cache.get(file_path)
|
|
114
|
+
|
|
115
|
+
if not file_snapshot:
|
|
116
|
+
return ValidationResult(
|
|
117
|
+
result=False,
|
|
118
|
+
message="File has not been read yet. Read it first before writing to it.",
|
|
119
|
+
error_code=2,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Check if file has been modified since it was read
|
|
123
|
+
try:
|
|
124
|
+
current_mtime = os.path.getmtime(file_path)
|
|
125
|
+
if current_mtime > file_snapshot.timestamp:
|
|
126
|
+
return ValidationResult(
|
|
127
|
+
result=False,
|
|
128
|
+
message="File has been modified since read, either by the user or by a linter. "
|
|
129
|
+
"Read it again before attempting to write it.",
|
|
130
|
+
error_code=3,
|
|
131
|
+
)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass # File mtime check failed, proceed anyway
|
|
134
|
+
|
|
135
|
+
# Check if path is ignored (warning for write operations)
|
|
136
|
+
file_path_obj = Path(file_path)
|
|
137
|
+
should_proceed, warning_msg = check_path_for_tool(file_path_obj, tool_name="Write", warn_only=True)
|
|
138
|
+
if warning_msg:
|
|
139
|
+
logger.warning("[file_write_tool] %s", warning_msg)
|
|
140
|
+
|
|
141
|
+
return ValidationResult(result=True)
|
|
142
|
+
|
|
143
|
+
def render_result_for_assistant(self, output: FileWriteToolOutput) -> str:
|
|
144
|
+
"""Format output for the AI."""
|
|
145
|
+
return output.message
|
|
146
|
+
|
|
147
|
+
def render_tool_use_message(self, input_data: FileWriteToolInput, verbose: bool = False) -> str:
|
|
148
|
+
"""Format the tool use for display."""
|
|
149
|
+
return f"Writing: {input_data.file_path} ({len(input_data.content)} bytes)"
|
|
150
|
+
|
|
151
|
+
async def call(
|
|
152
|
+
self, input_data: FileWriteToolInput, context: ToolUseContext
|
|
153
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
154
|
+
"""Write the file."""
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
# Write the file
|
|
158
|
+
with open(input_data.file_path, "w", encoding="utf-8") as f:
|
|
159
|
+
f.write(input_data.content)
|
|
160
|
+
|
|
161
|
+
bytes_written = len(input_data.content.encode("utf-8"))
|
|
162
|
+
|
|
163
|
+
# Use absolute path to ensure consistency with validation lookup
|
|
164
|
+
abs_file_path = os.path.abspath(input_data.file_path)
|
|
165
|
+
try:
|
|
166
|
+
record_snapshot(
|
|
167
|
+
abs_file_path,
|
|
168
|
+
input_data.content,
|
|
169
|
+
getattr(context, "file_state_cache", {}),
|
|
170
|
+
)
|
|
171
|
+
except (OSError, IOError, RuntimeError) as exc:
|
|
172
|
+
logger.warning(
|
|
173
|
+
"[file_write_tool] Failed to record file snapshot: %s: %s",
|
|
174
|
+
type(exc).__name__, exc,
|
|
175
|
+
extra={"file_path": abs_file_path},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
output = FileWriteToolOutput(
|
|
179
|
+
file_path=input_data.file_path,
|
|
180
|
+
bytes_written=bytes_written,
|
|
181
|
+
success=True,
|
|
182
|
+
message=f"Successfully wrote {bytes_written} bytes to {input_data.file_path}",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
yield ToolResult(
|
|
186
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
except (OSError, IOError, PermissionError, UnicodeEncodeError) as e:
|
|
190
|
+
logger.warning(
|
|
191
|
+
"[file_write_tool] Error writing file: %s: %s",
|
|
192
|
+
type(e).__name__, e,
|
|
193
|
+
extra={"file_path": input_data.file_path},
|
|
194
|
+
)
|
|
195
|
+
error_output = FileWriteToolOutput(
|
|
196
|
+
file_path=input_data.file_path,
|
|
197
|
+
bytes_written=0,
|
|
198
|
+
success=False,
|
|
199
|
+
message=f"Error writing file: {str(e)}",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
yield ToolResult(
|
|
203
|
+
data=error_output,
|
|
204
|
+
result_for_assistant=self.render_result_for_assistant(error_output),
|
|
205
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Glob pattern matching tool.
|
|
2
|
+
|
|
3
|
+
Allows the AI to find files using glob patterns.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import AsyncGenerator, Optional, List
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from ripperdoc.core.tool import (
|
|
11
|
+
Tool,
|
|
12
|
+
ToolUseContext,
|
|
13
|
+
ToolResult,
|
|
14
|
+
ToolOutput,
|
|
15
|
+
ToolUseExample,
|
|
16
|
+
ValidationResult,
|
|
17
|
+
)
|
|
18
|
+
from ripperdoc.utils.log import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
GLOB_USAGE = (
|
|
24
|
+
"- Fast file pattern matching tool that works with any codebase size\n"
|
|
25
|
+
'- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n'
|
|
26
|
+
"- Returns matching file paths sorted by modification time\n"
|
|
27
|
+
"- Use this tool when you need to find files by name patterns\n"
|
|
28
|
+
"- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n"
|
|
29
|
+
"- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\n"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
RESULT_LIMIT = 100
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GlobToolInput(BaseModel):
|
|
36
|
+
"""Input schema for GlobTool."""
|
|
37
|
+
|
|
38
|
+
pattern: str = Field(description="Glob pattern to match files (e.g., '**/*.py')")
|
|
39
|
+
path: Optional[str] = Field(
|
|
40
|
+
default=None, description="Directory to search in (default: current working directory)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GlobToolOutput(BaseModel):
|
|
45
|
+
"""Output from glob pattern matching."""
|
|
46
|
+
|
|
47
|
+
matches: List[str]
|
|
48
|
+
pattern: str
|
|
49
|
+
count: int
|
|
50
|
+
truncated: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GlobTool(Tool[GlobToolInput, GlobToolOutput]):
|
|
54
|
+
"""Tool for finding files using glob patterns."""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def name(self) -> str:
|
|
58
|
+
return "Glob"
|
|
59
|
+
|
|
60
|
+
async def description(self) -> str:
|
|
61
|
+
return GLOB_USAGE
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def input_schema(self) -> type[GlobToolInput]:
|
|
65
|
+
return GlobToolInput
|
|
66
|
+
|
|
67
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
68
|
+
return [
|
|
69
|
+
ToolUseExample(
|
|
70
|
+
description="Find Python sources inside src",
|
|
71
|
+
example={"pattern": "src/**/*.py"},
|
|
72
|
+
),
|
|
73
|
+
ToolUseExample(
|
|
74
|
+
description="Locate snapshot files within tests",
|
|
75
|
+
example={"pattern": "tests/**/__snapshots__/*.snap", "path": "/repo"},
|
|
76
|
+
),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
80
|
+
return GLOB_USAGE
|
|
81
|
+
|
|
82
|
+
def is_read_only(self) -> bool:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def is_concurrency_safe(self) -> bool:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def needs_permissions(self, _input_data: Optional[GlobToolInput] = None) -> bool:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
async def validate_input(
|
|
92
|
+
self, _input_data: GlobToolInput, _context: Optional[ToolUseContext] = None
|
|
93
|
+
) -> ValidationResult:
|
|
94
|
+
return ValidationResult(result=True)
|
|
95
|
+
|
|
96
|
+
def render_result_for_assistant(self, output: GlobToolOutput) -> str:
|
|
97
|
+
"""Format output for the AI."""
|
|
98
|
+
if not output.matches:
|
|
99
|
+
return f"No files found matching pattern: {output.pattern}"
|
|
100
|
+
|
|
101
|
+
lines = list(output.matches)
|
|
102
|
+
if output.truncated:
|
|
103
|
+
lines.append("(Results are truncated. Consider using a more specific path or pattern.)")
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
def render_tool_use_message(self, input_data: GlobToolInput, _verbose: bool = False) -> str:
|
|
107
|
+
"""Format the tool use for display."""
|
|
108
|
+
if not input_data.pattern:
|
|
109
|
+
return "Glob"
|
|
110
|
+
|
|
111
|
+
base_path = Path.cwd()
|
|
112
|
+
rendered_path = ""
|
|
113
|
+
if input_data.path:
|
|
114
|
+
candidate_path = Path(input_data.path)
|
|
115
|
+
absolute_path = (
|
|
116
|
+
candidate_path
|
|
117
|
+
if candidate_path.is_absolute()
|
|
118
|
+
else (base_path / candidate_path).resolve()
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
relative_path = absolute_path.relative_to(base_path)
|
|
123
|
+
except ValueError:
|
|
124
|
+
relative_path = None
|
|
125
|
+
|
|
126
|
+
if _verbose or not relative_path or str(relative_path) == ".":
|
|
127
|
+
rendered_path = str(absolute_path)
|
|
128
|
+
else:
|
|
129
|
+
rendered_path = str(relative_path)
|
|
130
|
+
|
|
131
|
+
path_fragment = f', path: "{rendered_path}"' if rendered_path else ""
|
|
132
|
+
return f'pattern: "{input_data.pattern}"{path_fragment}'
|
|
133
|
+
|
|
134
|
+
async def call(
|
|
135
|
+
self, input_data: GlobToolInput, _context: ToolUseContext
|
|
136
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
137
|
+
"""Find files matching the pattern."""
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
search_path = Path(input_data.path) if input_data.path else Path.cwd()
|
|
141
|
+
if not search_path.is_absolute():
|
|
142
|
+
search_path = (Path.cwd() / search_path).resolve()
|
|
143
|
+
|
|
144
|
+
def _mtime(path: Path) -> float:
|
|
145
|
+
try:
|
|
146
|
+
return path.stat().st_mtime
|
|
147
|
+
except OSError:
|
|
148
|
+
return float("-inf")
|
|
149
|
+
|
|
150
|
+
# Find matching files, sorted by modification time
|
|
151
|
+
paths = sorted(
|
|
152
|
+
(p for p in search_path.glob(input_data.pattern) if p.is_file()),
|
|
153
|
+
key=_mtime,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
truncated = len(paths) > RESULT_LIMIT
|
|
157
|
+
paths = paths[:RESULT_LIMIT]
|
|
158
|
+
|
|
159
|
+
matches = [str(p) for p in paths]
|
|
160
|
+
|
|
161
|
+
output = GlobToolOutput(
|
|
162
|
+
matches=matches, pattern=input_data.pattern, count=len(matches), truncated=truncated
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
yield ToolResult(
|
|
166
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
170
|
+
logger.warning(
|
|
171
|
+
"[glob_tool] Error executing glob: %s: %s",
|
|
172
|
+
type(e).__name__, e,
|
|
173
|
+
extra={"pattern": input_data.pattern, "path": input_data.path},
|
|
174
|
+
)
|
|
175
|
+
error_output = GlobToolOutput(matches=[], pattern=input_data.pattern, count=0)
|
|
176
|
+
|
|
177
|
+
yield ToolResult(
|
|
178
|
+
data=error_output, result_for_assistant=f"Error executing glob: {str(e)}"
|
|
179
|
+
)
|