vibecore 0.2.0a1__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.
Potentially problematic release.
This version of vibecore might be problematic. Click here for more details.
- vibecore/__init__.py +0 -0
- vibecore/agents/default.py +79 -0
- vibecore/agents/prompts.py +12 -0
- vibecore/agents/task_agent.py +66 -0
- vibecore/cli.py +131 -0
- vibecore/context.py +24 -0
- vibecore/handlers/__init__.py +5 -0
- vibecore/handlers/stream_handler.py +231 -0
- vibecore/main.py +506 -0
- vibecore/main.tcss +0 -0
- vibecore/mcp/__init__.py +6 -0
- vibecore/mcp/manager.py +167 -0
- vibecore/mcp/server_wrapper.py +109 -0
- vibecore/models/__init__.py +5 -0
- vibecore/models/anthropic.py +239 -0
- vibecore/prompts/common_system_prompt.txt +64 -0
- vibecore/py.typed +0 -0
- vibecore/session/__init__.py +5 -0
- vibecore/session/file_lock.py +127 -0
- vibecore/session/jsonl_session.py +236 -0
- vibecore/session/loader.py +193 -0
- vibecore/session/path_utils.py +81 -0
- vibecore/settings.py +161 -0
- vibecore/tools/__init__.py +1 -0
- vibecore/tools/base.py +27 -0
- vibecore/tools/file/__init__.py +5 -0
- vibecore/tools/file/executor.py +282 -0
- vibecore/tools/file/tools.py +184 -0
- vibecore/tools/file/utils.py +78 -0
- vibecore/tools/python/__init__.py +1 -0
- vibecore/tools/python/backends/__init__.py +1 -0
- vibecore/tools/python/backends/terminal_backend.py +58 -0
- vibecore/tools/python/helpers.py +80 -0
- vibecore/tools/python/manager.py +208 -0
- vibecore/tools/python/tools.py +27 -0
- vibecore/tools/shell/__init__.py +5 -0
- vibecore/tools/shell/executor.py +223 -0
- vibecore/tools/shell/tools.py +156 -0
- vibecore/tools/task/__init__.py +5 -0
- vibecore/tools/task/executor.py +51 -0
- vibecore/tools/task/tools.py +51 -0
- vibecore/tools/todo/__init__.py +1 -0
- vibecore/tools/todo/manager.py +31 -0
- vibecore/tools/todo/models.py +36 -0
- vibecore/tools/todo/tools.py +111 -0
- vibecore/utils/__init__.py +5 -0
- vibecore/utils/text.py +28 -0
- vibecore/widgets/core.py +332 -0
- vibecore/widgets/core.tcss +63 -0
- vibecore/widgets/expandable.py +121 -0
- vibecore/widgets/expandable.tcss +69 -0
- vibecore/widgets/info.py +25 -0
- vibecore/widgets/info.tcss +17 -0
- vibecore/widgets/messages.py +232 -0
- vibecore/widgets/messages.tcss +85 -0
- vibecore/widgets/tool_message_factory.py +121 -0
- vibecore/widgets/tool_messages.py +483 -0
- vibecore/widgets/tool_messages.tcss +289 -0
- vibecore-0.2.0a1.dist-info/METADATA +407 -0
- vibecore-0.2.0a1.dist-info/RECORD +63 -0
- vibecore-0.2.0a1.dist-info/WHEEL +4 -0
- vibecore-0.2.0a1.dist-info/entry_points.txt +2 -0
- vibecore-0.2.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""File reading execution logic."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .utils import PathValidationError, format_line_with_number, validate_file_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def read_file(file_path: str, offset: int | None = None, limit: int | None = None) -> str:
|
|
9
|
+
"""Read a file and return its contents in cat -n format.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
file_path: The path to the file to read
|
|
13
|
+
offset: The line number to start reading from (1-based)
|
|
14
|
+
limit: The maximum number of lines to read
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The file contents with line numbers, or an error message
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
# Validate the file path
|
|
21
|
+
validated_path = validate_file_path(file_path)
|
|
22
|
+
|
|
23
|
+
# Check if file exists
|
|
24
|
+
if not validated_path.exists():
|
|
25
|
+
return f"Error: File does not exist: {file_path}"
|
|
26
|
+
|
|
27
|
+
# Check if it's a file (not a directory)
|
|
28
|
+
if not validated_path.is_file():
|
|
29
|
+
return f"Error: Path is not a file: {file_path}"
|
|
30
|
+
|
|
31
|
+
# Check for Jupyter notebooks
|
|
32
|
+
if validated_path.suffix == ".ipynb":
|
|
33
|
+
return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookRead tool instead"
|
|
34
|
+
|
|
35
|
+
# Set defaults
|
|
36
|
+
if offset is None:
|
|
37
|
+
offset = 1 # Line numbers start at 1
|
|
38
|
+
if limit is None:
|
|
39
|
+
limit = 2000
|
|
40
|
+
|
|
41
|
+
# Validate offset and limit
|
|
42
|
+
if offset < 1:
|
|
43
|
+
return "Error: Offset must be 1 or greater (line numbers start at 1)"
|
|
44
|
+
if limit < 1:
|
|
45
|
+
return "Error: Limit must be 1 or greater"
|
|
46
|
+
|
|
47
|
+
# Read the file
|
|
48
|
+
try:
|
|
49
|
+
with validated_path.open("r", encoding="utf-8", errors="replace") as f:
|
|
50
|
+
# Skip to the offset
|
|
51
|
+
for _ in range(offset - 1):
|
|
52
|
+
line = f.readline()
|
|
53
|
+
if not line:
|
|
54
|
+
return f"Error: Offset {offset} is beyond the end of file"
|
|
55
|
+
|
|
56
|
+
# Read the requested lines
|
|
57
|
+
lines = []
|
|
58
|
+
line_num = offset
|
|
59
|
+
for _ in range(limit):
|
|
60
|
+
line = f.readline()
|
|
61
|
+
if not line:
|
|
62
|
+
break
|
|
63
|
+
lines.append(format_line_with_number(line_num, line))
|
|
64
|
+
line_num += 1
|
|
65
|
+
|
|
66
|
+
# Handle empty file or no content in range
|
|
67
|
+
if not lines:
|
|
68
|
+
if offset == 1:
|
|
69
|
+
# Empty file
|
|
70
|
+
return "<system-reminder>Warning: The file exists but has empty contents</system-reminder>"
|
|
71
|
+
else:
|
|
72
|
+
return f"Error: No content found starting from line {offset}"
|
|
73
|
+
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
except PermissionError:
|
|
77
|
+
return f"Error: Permission denied reading file: {file_path}"
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return f"Error reading file: {e}"
|
|
80
|
+
|
|
81
|
+
except PathValidationError as e:
|
|
82
|
+
return f"Error: {e}"
|
|
83
|
+
except Exception as e:
|
|
84
|
+
return f"Error: Unexpected error reading file: {e}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def edit_file(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
88
|
+
"""Edit a file by replacing strings.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
file_path: The path to the file to edit
|
|
92
|
+
old_string: The text to replace
|
|
93
|
+
new_string: The text to replace it with
|
|
94
|
+
replace_all: Replace all occurrences (default: False)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Success message or error message
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
# Validate the file path
|
|
101
|
+
validated_path = validate_file_path(file_path)
|
|
102
|
+
|
|
103
|
+
# Check if file exists
|
|
104
|
+
if not validated_path.exists():
|
|
105
|
+
return f"Error: File does not exist: {file_path}"
|
|
106
|
+
|
|
107
|
+
# Check if it's a file (not a directory)
|
|
108
|
+
if not validated_path.is_file():
|
|
109
|
+
return f"Error: Path is not a file: {file_path}"
|
|
110
|
+
|
|
111
|
+
# Check for Jupyter notebooks
|
|
112
|
+
if validated_path.suffix == ".ipynb":
|
|
113
|
+
return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookEdit tool instead"
|
|
114
|
+
|
|
115
|
+
# Validate old_string != new_string
|
|
116
|
+
if old_string == new_string:
|
|
117
|
+
return "Error: old_string and new_string cannot be the same"
|
|
118
|
+
|
|
119
|
+
# Read the file
|
|
120
|
+
try:
|
|
121
|
+
with validated_path.open("r", encoding="utf-8") as f:
|
|
122
|
+
content = f.read()
|
|
123
|
+
|
|
124
|
+
# Check if old_string exists in the file
|
|
125
|
+
occurrences = content.count(old_string)
|
|
126
|
+
if occurrences == 0:
|
|
127
|
+
return f"Error: String not found in file: {old_string!r}"
|
|
128
|
+
|
|
129
|
+
# Check uniqueness if not replace_all
|
|
130
|
+
if not replace_all and occurrences > 1:
|
|
131
|
+
return (
|
|
132
|
+
f"Error: Multiple occurrences ({occurrences}) of old_string found. "
|
|
133
|
+
f"Use replace_all=True or provide more context to make the string unique"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Perform the replacement
|
|
137
|
+
if replace_all:
|
|
138
|
+
new_content = content.replace(old_string, new_string)
|
|
139
|
+
replaced = occurrences
|
|
140
|
+
else:
|
|
141
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
142
|
+
replaced = 1
|
|
143
|
+
|
|
144
|
+
# Write the file back
|
|
145
|
+
with validated_path.open("w", encoding="utf-8") as f:
|
|
146
|
+
f.write(new_content)
|
|
147
|
+
|
|
148
|
+
return f"Successfully replaced {replaced} occurrence(s) in {file_path}"
|
|
149
|
+
|
|
150
|
+
except PermissionError:
|
|
151
|
+
return f"Error: Permission denied accessing file: {file_path}"
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return f"Error editing file: {e}"
|
|
154
|
+
|
|
155
|
+
except PathValidationError as e:
|
|
156
|
+
return f"Error: {e}"
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return f"Error: Unexpected error editing file: {e}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def multi_edit_file(file_path: str, edits: list[dict[str, Any]]) -> str:
|
|
162
|
+
"""Edit a file by applying multiple replacements sequentially.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
file_path: The path to the file to edit
|
|
166
|
+
edits: List of edit operations, each containing old_string, new_string, and optional replace_all
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Success message or error message
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
# Validate the file path
|
|
173
|
+
validated_path = validate_file_path(file_path)
|
|
174
|
+
|
|
175
|
+
# Check if file exists
|
|
176
|
+
if not validated_path.exists():
|
|
177
|
+
return f"Error: File does not exist: {file_path}"
|
|
178
|
+
|
|
179
|
+
# Check if it's a file (not a directory)
|
|
180
|
+
if not validated_path.is_file():
|
|
181
|
+
return f"Error: Path is not a file: {file_path}"
|
|
182
|
+
|
|
183
|
+
# Check for Jupyter notebooks
|
|
184
|
+
if validated_path.suffix == ".ipynb":
|
|
185
|
+
return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookEdit tool instead"
|
|
186
|
+
|
|
187
|
+
# Read the file
|
|
188
|
+
try:
|
|
189
|
+
with validated_path.open("r", encoding="utf-8") as f:
|
|
190
|
+
content = f.read()
|
|
191
|
+
|
|
192
|
+
# Apply each edit sequentially
|
|
193
|
+
total_replacements = 0
|
|
194
|
+
for i, edit in enumerate(edits):
|
|
195
|
+
old_string = str(edit["old_string"])
|
|
196
|
+
new_string = str(edit["new_string"])
|
|
197
|
+
replace_all = bool(edit.get("replace_all", False))
|
|
198
|
+
|
|
199
|
+
# Validate old_string != new_string
|
|
200
|
+
if old_string == new_string:
|
|
201
|
+
return f"Error: Edit {i + 1}: old_string and new_string cannot be the same"
|
|
202
|
+
|
|
203
|
+
# Check if old_string exists in the current content
|
|
204
|
+
occurrences = content.count(old_string)
|
|
205
|
+
if occurrences == 0:
|
|
206
|
+
return f"Error: Edit {i + 1}: String not found: {old_string!r}"
|
|
207
|
+
|
|
208
|
+
# Check uniqueness if not replace_all
|
|
209
|
+
if not replace_all and occurrences > 1:
|
|
210
|
+
return (
|
|
211
|
+
f"Error: Edit {i + 1}: Multiple occurrences ({occurrences}) of old_string found. "
|
|
212
|
+
f"Use replace_all=True or provide more context to make the string unique"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Perform the replacement
|
|
216
|
+
if replace_all:
|
|
217
|
+
content = content.replace(old_string, new_string)
|
|
218
|
+
total_replacements += occurrences
|
|
219
|
+
else:
|
|
220
|
+
content = content.replace(old_string, new_string, 1)
|
|
221
|
+
total_replacements += 1
|
|
222
|
+
|
|
223
|
+
# Write the file back
|
|
224
|
+
with validated_path.open("w", encoding="utf-8") as f:
|
|
225
|
+
f.write(content)
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
f"Successfully applied {len(edits)} edits with {total_replacements} total replacements in {file_path}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
except PermissionError:
|
|
232
|
+
return f"Error: Permission denied accessing file: {file_path}"
|
|
233
|
+
except Exception as e:
|
|
234
|
+
return f"Error editing file: {e}"
|
|
235
|
+
|
|
236
|
+
except PathValidationError as e:
|
|
237
|
+
return f"Error: {e}"
|
|
238
|
+
except Exception as e:
|
|
239
|
+
return f"Error: Unexpected error editing file: {e}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def write_file(file_path: str, content: str) -> str:
|
|
243
|
+
"""Write content to a file.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
file_path: The path to the file to write
|
|
247
|
+
content: The content to write to the file
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Success message or error message
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
# Validate the file path
|
|
254
|
+
validated_path = validate_file_path(file_path)
|
|
255
|
+
|
|
256
|
+
# Check if it's a directory
|
|
257
|
+
if validated_path.exists() and validated_path.is_dir():
|
|
258
|
+
return f"Error: Path is a directory: {file_path}"
|
|
259
|
+
|
|
260
|
+
# Check for Jupyter notebooks
|
|
261
|
+
if validated_path.suffix == ".ipynb":
|
|
262
|
+
return "Error: For Jupyter notebooks (.ipynb files), please use the NotebookEdit tool instead"
|
|
263
|
+
|
|
264
|
+
# Create parent directories if they don't exist
|
|
265
|
+
validated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
|
|
267
|
+
# Write the file
|
|
268
|
+
try:
|
|
269
|
+
with validated_path.open("w", encoding="utf-8") as f:
|
|
270
|
+
f.write(content)
|
|
271
|
+
|
|
272
|
+
return f"Successfully wrote {len(content)} bytes to {file_path}"
|
|
273
|
+
|
|
274
|
+
except PermissionError:
|
|
275
|
+
return f"Error: Permission denied writing file: {file_path}"
|
|
276
|
+
except Exception as e:
|
|
277
|
+
return f"Error writing file: {e}"
|
|
278
|
+
|
|
279
|
+
except PathValidationError as e:
|
|
280
|
+
return f"Error: {e}"
|
|
281
|
+
except Exception as e:
|
|
282
|
+
return f"Error: Unexpected error writing file: {e}"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""File reading tool for Vibecore agents."""
|
|
2
|
+
|
|
3
|
+
from agents import RunContextWrapper, function_tool
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from vibecore.context import VibecoreContext
|
|
7
|
+
|
|
8
|
+
from .executor import edit_file, multi_edit_file, read_file, write_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EditOperation(BaseModel):
|
|
12
|
+
"""Represents a single edit operation."""
|
|
13
|
+
|
|
14
|
+
old_string: str
|
|
15
|
+
new_string: str
|
|
16
|
+
replace_all: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@function_tool
|
|
20
|
+
async def read(
|
|
21
|
+
ctx: RunContextWrapper[VibecoreContext],
|
|
22
|
+
file_path: str,
|
|
23
|
+
offset: int | None = None,
|
|
24
|
+
limit: int | None = None,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Reads a file from the local filesystem. You can access any file directly by using this tool.
|
|
27
|
+
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume
|
|
28
|
+
that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
- The file_path parameter must be an absolute path, not a relative path
|
|
32
|
+
- By default, it reads up to 2000 lines starting from the beginning of the file
|
|
33
|
+
- You can optionally specify a line offset and limit (especially handy for long files), but it's
|
|
34
|
+
recommended to read the whole file by not providing these parameters
|
|
35
|
+
- Any lines longer than 2000 characters will be truncated
|
|
36
|
+
- Results are returned using cat -n format, with line numbers starting at 1
|
|
37
|
+
- For Jupyter notebooks (.ipynb files), use the NotebookRead instead
|
|
38
|
+
- You have the capability to call multiple tools in a single response. It is always better to
|
|
39
|
+
speculatively read multiple files as a batch that are potentially useful.
|
|
40
|
+
- If you read a file that exists but has empty contents you will receive a system reminder warning
|
|
41
|
+
in place of file contents.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
ctx: The run context wrapper
|
|
45
|
+
file_path: The absolute path to the file to read
|
|
46
|
+
offset: The line number to start reading from. Only provide if the file is too large to read at once
|
|
47
|
+
limit: The number of lines to read. Only provide if the file is too large to read at once.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The file contents with line numbers in cat -n format, or an error message
|
|
51
|
+
"""
|
|
52
|
+
return await read_file(file_path, offset, limit)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@function_tool
|
|
56
|
+
async def edit(
|
|
57
|
+
ctx: RunContextWrapper[VibecoreContext],
|
|
58
|
+
file_path: str,
|
|
59
|
+
old_string: str,
|
|
60
|
+
new_string: str,
|
|
61
|
+
replace_all: bool = False,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Performs exact string replacements in files.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you
|
|
67
|
+
attempt an edit without reading the file.
|
|
68
|
+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears
|
|
69
|
+
AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after
|
|
70
|
+
that tab is the actual file content to match. Never include any part of the line number prefix in the
|
|
71
|
+
old_string or new_string.
|
|
72
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
73
|
+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
|
74
|
+
- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more
|
|
75
|
+
surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
|
|
76
|
+
- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to
|
|
77
|
+
rename a variable for instance.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
ctx: The run context wrapper
|
|
81
|
+
file_path: The absolute path to the file to modify
|
|
82
|
+
old_string: The text to replace
|
|
83
|
+
new_string: The text to replace it with (must be different from old_string)
|
|
84
|
+
replace_all: Replace all occurences of old_string (default false)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Success message or error message
|
|
88
|
+
"""
|
|
89
|
+
return await edit_file(file_path, old_string, new_string, replace_all)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@function_tool
|
|
93
|
+
async def multi_edit(
|
|
94
|
+
ctx: RunContextWrapper[VibecoreContext],
|
|
95
|
+
file_path: str,
|
|
96
|
+
edits: list[EditOperation],
|
|
97
|
+
) -> str:
|
|
98
|
+
"""This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit
|
|
99
|
+
tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit
|
|
100
|
+
tool when you need to make multiple edits to the same file.
|
|
101
|
+
|
|
102
|
+
Before using this tool:
|
|
103
|
+
|
|
104
|
+
1. Use the Read tool to understand the file's contents and context
|
|
105
|
+
2. Verify the directory path is correct
|
|
106
|
+
|
|
107
|
+
To make multiple file edits, provide the following:
|
|
108
|
+
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
|
109
|
+
2. edits: An array of edit operations to perform, where each edit contains:
|
|
110
|
+
- old_string: The text to replace (must match the file contents exactly, including all whitespace and
|
|
111
|
+
indentation)
|
|
112
|
+
- new_string: The edited text to replace the old_string
|
|
113
|
+
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
|
|
114
|
+
|
|
115
|
+
IMPORTANT:
|
|
116
|
+
- All edits are applied in sequence, in the order they are provided
|
|
117
|
+
- Each edit operates on the result of the previous edit
|
|
118
|
+
- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
|
|
119
|
+
- This tool is ideal when you need to make several changes to different parts of the same file
|
|
120
|
+
- For Jupyter notebooks (.ipynb files), use the NotebookEdit instead
|
|
121
|
+
|
|
122
|
+
CRITICAL REQUIREMENTS:
|
|
123
|
+
1. All edits follow the same requirements as the single Edit tool
|
|
124
|
+
2. The edits are atomic - either all succeed or none are applied
|
|
125
|
+
3. Plan your edits carefully to avoid conflicts between sequential operations
|
|
126
|
+
|
|
127
|
+
WARNING:
|
|
128
|
+
- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
|
|
129
|
+
- The tool will fail if edits.old_string and edits.new_string are the same
|
|
130
|
+
- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are
|
|
131
|
+
trying to find
|
|
132
|
+
|
|
133
|
+
When making edits:
|
|
134
|
+
- Ensure all edits result in idiomatic, correct code
|
|
135
|
+
- Do not leave the code in a broken state
|
|
136
|
+
- Always use absolute file paths (starting with /)
|
|
137
|
+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
|
138
|
+
- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to
|
|
139
|
+
rename a variable for instance.
|
|
140
|
+
|
|
141
|
+
If you want to create a new file, use:
|
|
142
|
+
- A new file path, including dir name if needed
|
|
143
|
+
- First edit: empty old_string and the new file's contents as new_string
|
|
144
|
+
- Subsequent edits: normal edit operations on the created content
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
ctx: The run context wrapper
|
|
148
|
+
file_path: The absolute path to the file to modify
|
|
149
|
+
edits: Array of edit operations to perform sequentially on the file
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Success message or error message
|
|
153
|
+
"""
|
|
154
|
+
# Convert EditOperation objects to dictionaries
|
|
155
|
+
edit_dicts = [edit.model_dump() for edit in edits]
|
|
156
|
+
return await multi_edit_file(file_path, edit_dicts)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@function_tool
|
|
160
|
+
async def write(
|
|
161
|
+
ctx: RunContextWrapper[VibecoreContext],
|
|
162
|
+
file_path: str,
|
|
163
|
+
content: str,
|
|
164
|
+
) -> str:
|
|
165
|
+
"""Writes a file to the local filesystem.
|
|
166
|
+
|
|
167
|
+
Usage:
|
|
168
|
+
- This tool will overwrite the existing file if there is one at the provided path.
|
|
169
|
+
- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail
|
|
170
|
+
if you did not read the file first.
|
|
171
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
172
|
+
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if
|
|
173
|
+
explicitly requested by the User.
|
|
174
|
+
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
ctx: The run context wrapper
|
|
178
|
+
file_path: The absolute path to the file to write (must be absolute, not relative)
|
|
179
|
+
content: The content to write to the file
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Success message or error message
|
|
183
|
+
"""
|
|
184
|
+
return await write_file(file_path, content)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Path validation utilities for file tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PathValidationError(Exception):
|
|
7
|
+
"""Raised when a path validation fails."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_file_path(file_path: str, base_dir: Path | None = None) -> Path:
|
|
13
|
+
"""Validate and resolve a file path, ensuring it's within the allowed directory.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
file_path: The file path to validate (can be relative or absolute)
|
|
17
|
+
base_dir: The base directory to restrict access to (defaults to CWD)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The validated absolute Path object
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
PathValidationError: If the path is invalid or outside the allowed directory
|
|
24
|
+
"""
|
|
25
|
+
if base_dir is None:
|
|
26
|
+
base_dir = Path.cwd()
|
|
27
|
+
|
|
28
|
+
base_dir = base_dir.resolve()
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
# Convert to Path object
|
|
32
|
+
path = Path(file_path)
|
|
33
|
+
|
|
34
|
+
# If relative, make it absolute relative to base_dir
|
|
35
|
+
if not path.is_absolute():
|
|
36
|
+
path = base_dir / path
|
|
37
|
+
|
|
38
|
+
# Resolve to get the canonical path (resolves symlinks, .., etc)
|
|
39
|
+
resolved_path = path.resolve()
|
|
40
|
+
|
|
41
|
+
# Check if the resolved path is within the base directory
|
|
42
|
+
# This prevents directory traversal attacks
|
|
43
|
+
try:
|
|
44
|
+
resolved_path.relative_to(base_dir)
|
|
45
|
+
except ValueError:
|
|
46
|
+
raise PathValidationError(
|
|
47
|
+
f"Path '{file_path}' is outside the allowed directory. "
|
|
48
|
+
f"Access is restricted to '{base_dir}' and its subdirectories."
|
|
49
|
+
) from None
|
|
50
|
+
|
|
51
|
+
return resolved_path
|
|
52
|
+
|
|
53
|
+
except Exception as e:
|
|
54
|
+
if isinstance(e, PathValidationError):
|
|
55
|
+
raise
|
|
56
|
+
raise PathValidationError(f"Invalid path '{file_path}': {e}") from e
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_line_with_number(line_num: int, line: str, max_length: int = 2000) -> str:
|
|
60
|
+
"""Format a line with line number in cat -n style.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
line_num: The line number (1-based)
|
|
64
|
+
line: The line content
|
|
65
|
+
max_length: Maximum length for the line content
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Formatted line with line number
|
|
69
|
+
"""
|
|
70
|
+
# Truncate line if too long
|
|
71
|
+
if len(line) > max_length:
|
|
72
|
+
line = line[:max_length] + "... (truncated)"
|
|
73
|
+
|
|
74
|
+
# Remove trailing newline for consistent formatting
|
|
75
|
+
line = line.rstrip("\n")
|
|
76
|
+
|
|
77
|
+
# Format with right-aligned line number (6 spaces wide) followed by tab
|
|
78
|
+
return f"{line_num:6d}\t{line}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python code execution tools."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Matplotlib backends for terminal rendering."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Terminal backend for matplotlib that captures images for later display."""
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from matplotlib.backend_bases import FigureManagerBase # type: ignore[import-not-found]
|
|
8
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg # type: ignore[import-not-found]
|
|
9
|
+
else:
|
|
10
|
+
try:
|
|
11
|
+
from matplotlib.backend_bases import FigureManagerBase
|
|
12
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
13
|
+
except ImportError:
|
|
14
|
+
# Create dummy classes if matplotlib is not installed
|
|
15
|
+
FigureManagerBase = object # type: ignore[misc,assignment]
|
|
16
|
+
FigureCanvasAgg = object # type: ignore[misc,assignment]
|
|
17
|
+
|
|
18
|
+
__all__ = ["FigureCanvas", "FigureManager", "clear_captured_images", "get_captured_images"]
|
|
19
|
+
|
|
20
|
+
# Global list to store captured images
|
|
21
|
+
_captured_images: list[bytes] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TerminalImageFigureManager(FigureManagerBase):
|
|
25
|
+
"""Figure manager that captures plots for later display."""
|
|
26
|
+
|
|
27
|
+
def show(self):
|
|
28
|
+
global _captured_images
|
|
29
|
+
|
|
30
|
+
# Save figure to BytesIO buffer
|
|
31
|
+
buf = BytesIO()
|
|
32
|
+
self.canvas.figure.savefig(buf, format="png", bbox_inches="tight", dpi=150)
|
|
33
|
+
buf.seek(0)
|
|
34
|
+
|
|
35
|
+
# Store the image buffer in the global list
|
|
36
|
+
_captured_images.append(buf.getvalue())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TerminalImageFigureCanvas(FigureCanvasAgg):
|
|
40
|
+
"""Figure canvas for terminal image backend."""
|
|
41
|
+
|
|
42
|
+
manager_class = TerminalImageFigureManager # type: ignore[assignment]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Provide the standard names that matplotlib is expecting
|
|
46
|
+
FigureCanvas = TerminalImageFigureCanvas
|
|
47
|
+
FigureManager = TerminalImageFigureManager
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_captured_images() -> list[bytes]:
|
|
51
|
+
"""Get the list of captured images."""
|
|
52
|
+
return _captured_images.copy()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clear_captured_images() -> None:
|
|
56
|
+
"""Clear the list of captured images."""
|
|
57
|
+
global _captured_images
|
|
58
|
+
_captured_images.clear()
|