vtx-coding-agent 0.1.1__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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/tools/edit.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import difflib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import aiofiles
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
|
|
9
|
+
from vtx import config
|
|
10
|
+
from vtx.diff_display import DIFF_BG_PAD_MARKER, blend_hex
|
|
11
|
+
|
|
12
|
+
from ..core.types import FileChanges
|
|
13
|
+
from ._tool_utils import shorten_path
|
|
14
|
+
from .base import BaseTool, ToolResult
|
|
15
|
+
|
|
16
|
+
CONTEXT_LINES = 4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EditParams(BaseModel):
|
|
20
|
+
path: str = Field(description="Absolute path of the file to edit")
|
|
21
|
+
old_string: str = Field(description="The text to replace")
|
|
22
|
+
new_string: str = Field(
|
|
23
|
+
description="The text to replace it with (must be different from old_string)"
|
|
24
|
+
)
|
|
25
|
+
replace_all: bool = Field(
|
|
26
|
+
description="Replace all occurrences of old_string (default false)", default=False
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ellipsis(line_num_width: int, skipped: int) -> str:
|
|
31
|
+
return f" {''.rjust(line_num_width)} \u22ef {skipped} lines \u22ef" # ⋯ N lines ⋯
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_diff(
|
|
35
|
+
old_content: str, new_content: str, context_lines: int = CONTEXT_LINES
|
|
36
|
+
) -> tuple[str, int, int]:
|
|
37
|
+
"""
|
|
38
|
+
Generate a diff with line numbers and context.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
tuple: (diff_string, added_count, removed_count)
|
|
42
|
+
|
|
43
|
+
Format:
|
|
44
|
+
" 42 context line" (space, num, three spaces = empty change marker)
|
|
45
|
+
" 42 - removed line" (space, num, space-minus-space = removed)
|
|
46
|
+
" 42 + added line" (space, num, space-plus-space = added)
|
|
47
|
+
" ⋯ N lines ⋯" (ellipsis = skipped lines with count)
|
|
48
|
+
"""
|
|
49
|
+
old_lines = old_content.splitlines()
|
|
50
|
+
new_lines = new_content.splitlines()
|
|
51
|
+
|
|
52
|
+
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
|
53
|
+
opcodes = matcher.get_opcodes()
|
|
54
|
+
|
|
55
|
+
max_line_num = max(len(old_lines), len(new_lines))
|
|
56
|
+
line_num_width = len(str(max_line_num))
|
|
57
|
+
|
|
58
|
+
def _num(n: int) -> str:
|
|
59
|
+
return str(n).rjust(line_num_width)
|
|
60
|
+
|
|
61
|
+
output: list[str] = []
|
|
62
|
+
added, removed = 0, 0
|
|
63
|
+
last_was_change = False
|
|
64
|
+
|
|
65
|
+
for i, (tag, i1, i2, j1, j2) in enumerate(opcodes):
|
|
66
|
+
if tag == "equal":
|
|
67
|
+
equal_lines = old_lines[i1:i2]
|
|
68
|
+
next_is_change = i < len(opcodes) - 1 and opcodes[i + 1][0] != "equal"
|
|
69
|
+
|
|
70
|
+
if last_was_change or next_is_change:
|
|
71
|
+
if last_was_change and next_is_change:
|
|
72
|
+
if len(equal_lines) > context_lines * 2:
|
|
73
|
+
for idx, line in enumerate(equal_lines[:context_lines]):
|
|
74
|
+
line_num = i1 + idx + 1
|
|
75
|
+
output.append(f" {_num(line_num)} {line}")
|
|
76
|
+
skipped = len(equal_lines) - context_lines * 2
|
|
77
|
+
output.append(_ellipsis(line_num_width, skipped))
|
|
78
|
+
for idx, line in enumerate(equal_lines[-context_lines:]):
|
|
79
|
+
line_num = i1 + len(equal_lines) - context_lines + idx + 1
|
|
80
|
+
output.append(f" {_num(line_num)} {line}")
|
|
81
|
+
else:
|
|
82
|
+
for idx, line in enumerate(equal_lines):
|
|
83
|
+
line_num = i1 + idx + 1
|
|
84
|
+
output.append(f" {_num(line_num)} {line}")
|
|
85
|
+
elif last_was_change:
|
|
86
|
+
if len(equal_lines) > context_lines:
|
|
87
|
+
for idx, line in enumerate(equal_lines[:context_lines]):
|
|
88
|
+
line_num = i1 + idx + 1
|
|
89
|
+
output.append(f" {_num(line_num)} {line}")
|
|
90
|
+
skipped = len(equal_lines) - context_lines
|
|
91
|
+
output.append(_ellipsis(line_num_width, skipped))
|
|
92
|
+
else:
|
|
93
|
+
for idx, line in enumerate(equal_lines):
|
|
94
|
+
line_num = i1 + idx + 1
|
|
95
|
+
output.append(f" {_num(line_num)} {line}")
|
|
96
|
+
else:
|
|
97
|
+
if len(equal_lines) > context_lines:
|
|
98
|
+
skipped = len(equal_lines) - context_lines
|
|
99
|
+
output.append(_ellipsis(line_num_width, skipped))
|
|
100
|
+
for idx, line in enumerate(equal_lines[-context_lines:]):
|
|
101
|
+
line_num = i1 + len(equal_lines) - context_lines + idx + 1
|
|
102
|
+
output.append(f" {_num(line_num)} {line}")
|
|
103
|
+
else:
|
|
104
|
+
for idx, line in enumerate(equal_lines):
|
|
105
|
+
line_num = i1 + idx + 1
|
|
106
|
+
output.append(f" {_num(line_num)} {line}")
|
|
107
|
+
|
|
108
|
+
last_was_change = False
|
|
109
|
+
|
|
110
|
+
elif tag == "replace":
|
|
111
|
+
for idx, line in enumerate(old_lines[i1:i2]):
|
|
112
|
+
line_num = i1 + idx + 1
|
|
113
|
+
output.append(f" {_num(line_num)} - {line}")
|
|
114
|
+
removed += 1
|
|
115
|
+
for idx, line in enumerate(new_lines[j1:j2]):
|
|
116
|
+
line_num = j1 + idx + 1
|
|
117
|
+
output.append(f" {_num(line_num)} + {line}")
|
|
118
|
+
added += 1
|
|
119
|
+
last_was_change = True
|
|
120
|
+
|
|
121
|
+
elif tag == "delete":
|
|
122
|
+
for idx, line in enumerate(old_lines[i1:i2]):
|
|
123
|
+
line_num = i1 + idx + 1
|
|
124
|
+
output.append(f" {_num(line_num)} - {line}")
|
|
125
|
+
removed += 1
|
|
126
|
+
last_was_change = True
|
|
127
|
+
|
|
128
|
+
elif tag == "insert":
|
|
129
|
+
for idx, line in enumerate(new_lines[j1:j2]):
|
|
130
|
+
line_num = j1 + idx + 1
|
|
131
|
+
output.append(f" {_num(line_num)} + {line}")
|
|
132
|
+
added += 1
|
|
133
|
+
last_was_change = True
|
|
134
|
+
|
|
135
|
+
return "\n".join(output), added, removed
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_diff_line(line: str) -> tuple[str, str, str] | None:
|
|
139
|
+
"""Parse a formatted diff line into (line_number_part, sign, content_part)."""
|
|
140
|
+
num_start = next((i for i, char in enumerate(line) if char.isdigit()), -1)
|
|
141
|
+
if num_start == -1:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
num_end = num_start
|
|
145
|
+
while num_end < len(line) and line[num_end].isdigit():
|
|
146
|
+
num_end += 1
|
|
147
|
+
|
|
148
|
+
sign_index = num_end + 1
|
|
149
|
+
if sign_index >= len(line):
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Includes leading padding and the separator space after the line number.
|
|
153
|
+
line_number_part = line[:sign_index]
|
|
154
|
+
sign = line[sign_index]
|
|
155
|
+
content_part = line[sign_index + 1 :]
|
|
156
|
+
return line_number_part, sign, content_part
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def format_diff_display(diff: str) -> str:
|
|
160
|
+
colors = config.ui.colors
|
|
161
|
+
lines = diff.split("\n")
|
|
162
|
+
formatted = []
|
|
163
|
+
|
|
164
|
+
bg_added = blend_hex(colors.diff_added, colors.bg)
|
|
165
|
+
bg_removed = blend_hex(colors.diff_removed, colors.bg)
|
|
166
|
+
|
|
167
|
+
for line in lines:
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
truncated = line[:200] + "\u2026" if len(line) > 203 else line # … ellipsis
|
|
172
|
+
parsed = _parse_diff_line(truncated)
|
|
173
|
+
|
|
174
|
+
if parsed and parsed[1] == "-":
|
|
175
|
+
line_num, sign, content_part = parsed
|
|
176
|
+
content = (
|
|
177
|
+
f" [{colors.dim}]{escape(line_num)}[/{colors.dim}]"
|
|
178
|
+
f"[{colors.diff_removed}]{sign}{escape(content_part)}[/{colors.diff_removed}]"
|
|
179
|
+
)
|
|
180
|
+
formatted.append(f"[on {bg_removed}]{content}{DIFF_BG_PAD_MARKER}[/]")
|
|
181
|
+
elif parsed and parsed[1] == "+":
|
|
182
|
+
line_num, sign, content_part = parsed
|
|
183
|
+
content = (
|
|
184
|
+
f" [{colors.dim}]{escape(line_num)}[/{colors.dim}]"
|
|
185
|
+
f"[{colors.diff_added}]{sign}{escape(content_part)}[/{colors.diff_added}]"
|
|
186
|
+
)
|
|
187
|
+
formatted.append(f"[on {bg_added}]{content}{DIFF_BG_PAD_MARKER}[/]")
|
|
188
|
+
elif "\u22ef" in line:
|
|
189
|
+
escaped = escape(truncated)
|
|
190
|
+
formatted.append(f"[{colors.dim}]{escaped}[/{colors.dim}]")
|
|
191
|
+
else:
|
|
192
|
+
escaped = escape(truncated)
|
|
193
|
+
formatted.append(f"[{colors.dim}] {escaped}[/{colors.dim}]")
|
|
194
|
+
|
|
195
|
+
return "\n".join(formatted)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class EditTool(BaseTool):
|
|
199
|
+
name = "edit"
|
|
200
|
+
tool_icon = "←"
|
|
201
|
+
params = EditParams
|
|
202
|
+
prompt_guidelines = ("Use edit for precise changes (NOT sed/awk)",)
|
|
203
|
+
description = (
|
|
204
|
+
"Edit a file by replacing exact text. The old_string must match exactly "
|
|
205
|
+
"(including whitespaces). Use this for precise, surgical edits."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def format_call(self, params: EditParams) -> str:
|
|
209
|
+
return shorten_path(params.path)
|
|
210
|
+
|
|
211
|
+
def format_preview(self, params: EditParams) -> str | None:
|
|
212
|
+
diff, _, _ = generate_diff(params.old_string, params.new_string)
|
|
213
|
+
return format_diff_display(diff)
|
|
214
|
+
|
|
215
|
+
async def execute(
|
|
216
|
+
self, params: EditParams, cancel_event: asyncio.Event | None = None
|
|
217
|
+
) -> ToolResult:
|
|
218
|
+
file_path = Path(params.path)
|
|
219
|
+
|
|
220
|
+
if not file_path.exists():
|
|
221
|
+
msg = f"File not found: {file_path}"
|
|
222
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
223
|
+
|
|
224
|
+
async with aiofiles.open(file_path, encoding="utf-8") as f:
|
|
225
|
+
content = await f.read()
|
|
226
|
+
|
|
227
|
+
if params.old_string not in content:
|
|
228
|
+
msg = "old_string not found in file"
|
|
229
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
230
|
+
|
|
231
|
+
if params.replace_all:
|
|
232
|
+
new_content = content.replace(params.old_string, params.new_string)
|
|
233
|
+
else:
|
|
234
|
+
new_content = content.replace(params.old_string, params.new_string, 1)
|
|
235
|
+
|
|
236
|
+
async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
|
|
237
|
+
await f.write(new_content)
|
|
238
|
+
|
|
239
|
+
diff, added, removed = generate_diff(content, new_content)
|
|
240
|
+
diff_display = format_diff_display(diff)
|
|
241
|
+
|
|
242
|
+
# Full diff for expanded view (no context truncation)
|
|
243
|
+
total_lines = max(content.count("\n"), new_content.count("\n")) + 1
|
|
244
|
+
diff_full, _, _ = generate_diff(content, new_content, context_lines=total_lines)
|
|
245
|
+
diff_full_display = format_diff_display(diff_full)
|
|
246
|
+
|
|
247
|
+
colors = config.ui.colors
|
|
248
|
+
result = f"Updated {file_path} +{added} -{removed}"
|
|
249
|
+
ui_summary = (
|
|
250
|
+
f"[{colors.diff_added}]+{added}[/{colors.diff_added}] "
|
|
251
|
+
f"[{colors.diff_removed}]-{removed}[/{colors.diff_removed}]"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return ToolResult(
|
|
255
|
+
success=True,
|
|
256
|
+
result=result,
|
|
257
|
+
ui_summary=ui_summary,
|
|
258
|
+
ui_details=diff_display,
|
|
259
|
+
ui_details_full=diff_full_display,
|
|
260
|
+
file_changes=FileChanges(path=str(file_path), added=added, removed=removed),
|
|
261
|
+
)
|
vtx/tools/find.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from ..core.types import ToolResult
|
|
7
|
+
from ..tools_manager import ensure_tool
|
|
8
|
+
from ._tool_utils import (
|
|
9
|
+
ToolCancelledError,
|
|
10
|
+
communicate_or_cancel,
|
|
11
|
+
shorten_path,
|
|
12
|
+
truncate_lines_by_bytes,
|
|
13
|
+
)
|
|
14
|
+
from .base import BaseTool
|
|
15
|
+
|
|
16
|
+
MAX_RESULTS = 100
|
|
17
|
+
MAX_OUTPUT_BYTES = 20 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FindParams(BaseModel):
|
|
21
|
+
pattern: str = Field(
|
|
22
|
+
description="Glob pattern to match files, e.g. '*.py', '**/*.json', or 'src/**/*.spec.ts'"
|
|
23
|
+
)
|
|
24
|
+
path: str | None = Field(
|
|
25
|
+
description="Directory to search in (default: current directory)", default=None
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FindTool(BaseTool):
|
|
30
|
+
name = "find"
|
|
31
|
+
tool_icon = "*"
|
|
32
|
+
params = FindParams
|
|
33
|
+
mutating = False
|
|
34
|
+
prompt_guidelines = ("Use find to search for files by name/glob (NOT find or ls via bash)",)
|
|
35
|
+
description = (
|
|
36
|
+
"Search for files by glob pattern using fd. "
|
|
37
|
+
"Returns matching file paths relative to the search directory, "
|
|
38
|
+
"sorted by modification time."
|
|
39
|
+
f"Respects .gitignore. Truncated to {MAX_RESULTS} results."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def format_call(self, params: FindParams) -> str:
|
|
43
|
+
pattern = params.pattern.replace('"', '\\"')
|
|
44
|
+
parts = [f'"{pattern}"']
|
|
45
|
+
if params.path:
|
|
46
|
+
parts.append(f"in {shorten_path(params.path)}")
|
|
47
|
+
return " ".join(parts)
|
|
48
|
+
|
|
49
|
+
async def execute(
|
|
50
|
+
self, params: FindParams, cancel_event: asyncio.Event | None = None
|
|
51
|
+
) -> ToolResult:
|
|
52
|
+
fd_path = await ensure_tool("fd", silent=True)
|
|
53
|
+
if not fd_path:
|
|
54
|
+
msg = "fd is not available and could not be downloaded"
|
|
55
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
56
|
+
|
|
57
|
+
search_path = params.path or os.getcwd()
|
|
58
|
+
if not os.path.isabs(search_path):
|
|
59
|
+
search_path = os.path.join(os.getcwd(), search_path)
|
|
60
|
+
|
|
61
|
+
if not os.path.exists(search_path):
|
|
62
|
+
msg = f"Path not found: {search_path}"
|
|
63
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
64
|
+
|
|
65
|
+
args = [
|
|
66
|
+
fd_path,
|
|
67
|
+
"--glob",
|
|
68
|
+
"--color=never",
|
|
69
|
+
"--hidden",
|
|
70
|
+
"--max-results",
|
|
71
|
+
str(MAX_RESULTS),
|
|
72
|
+
params.pattern,
|
|
73
|
+
search_path,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
proc = await asyncio.create_subprocess_exec(
|
|
77
|
+
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
stdout, stderr = await communicate_or_cancel(proc, cancel_event)
|
|
82
|
+
except ToolCancelledError:
|
|
83
|
+
return ToolResult(success=False, result="Search aborted")
|
|
84
|
+
|
|
85
|
+
exit_code = proc.returncode
|
|
86
|
+
output = stdout.decode("utf-8", errors="replace").strip()
|
|
87
|
+
error_output = stderr.decode("utf-8", errors="replace").strip()
|
|
88
|
+
|
|
89
|
+
if exit_code not in (0, 1) and not output:
|
|
90
|
+
msg = f"fd failed: {error_output}"
|
|
91
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
92
|
+
|
|
93
|
+
if not output:
|
|
94
|
+
return ToolResult(
|
|
95
|
+
success=True,
|
|
96
|
+
result="No files found matching pattern",
|
|
97
|
+
ui_summary="[dim]No files found[/dim]",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
lines = [line.strip() for line in output.split("\n") if line.strip()]
|
|
101
|
+
|
|
102
|
+
# Relativize and collect mtime for sorting
|
|
103
|
+
files: list[tuple[str, float]] = []
|
|
104
|
+
for line in lines:
|
|
105
|
+
if line.startswith(search_path):
|
|
106
|
+
rel = line[len(search_path) :].lstrip(os.sep)
|
|
107
|
+
rel = rel if rel else line
|
|
108
|
+
else:
|
|
109
|
+
rel = os.path.relpath(line, search_path)
|
|
110
|
+
try:
|
|
111
|
+
mtime = os.path.getmtime(line)
|
|
112
|
+
except OSError:
|
|
113
|
+
mtime = 0.0
|
|
114
|
+
files.append((rel, mtime))
|
|
115
|
+
|
|
116
|
+
files.sort(key=lambda f: f[1], reverse=True)
|
|
117
|
+
|
|
118
|
+
relativized = [f[0] for f in files]
|
|
119
|
+
truncated = len(relativized) >= MAX_RESULTS
|
|
120
|
+
|
|
121
|
+
result_text, _ = truncate_lines_by_bytes(relativized, MAX_OUTPUT_BYTES)
|
|
122
|
+
|
|
123
|
+
if truncated:
|
|
124
|
+
result_text += (
|
|
125
|
+
f"\n\n[{MAX_RESULTS} results limit reached; "
|
|
126
|
+
"refine the pattern or path for more specific results]"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
count = len(relativized)
|
|
130
|
+
display = f"[dim]({count} files)[/dim]"
|
|
131
|
+
|
|
132
|
+
return ToolResult(success=True, result=result_text, ui_summary=display)
|
vtx/tools/read.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import aiofiles
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from ..core.types import ImageContent
|
|
9
|
+
from ..tools_manager import ensure_tool
|
|
10
|
+
from ._read_image import is_image_file, read_and_process_image
|
|
11
|
+
from ._tool_utils import ToolCancelledError, communicate_or_cancel, shorten_path
|
|
12
|
+
from .base import BaseTool, ToolResult
|
|
13
|
+
|
|
14
|
+
MAX_CHARS_PER_LINE = 2000
|
|
15
|
+
MAX_LINES_PER_FILE = 2000
|
|
16
|
+
DIRECTORY_DEPTH_ROW_LIMIT = 200
|
|
17
|
+
MAX_DIRECTORY_ROWS = 1000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ReadParams(BaseModel):
|
|
21
|
+
path: str = Field(description="Absolute path of the file or directory to read")
|
|
22
|
+
offset: int | None = Field(
|
|
23
|
+
description="Line number to start reading from. "
|
|
24
|
+
"Only provide if the file is too large to read at once.",
|
|
25
|
+
default=None,
|
|
26
|
+
)
|
|
27
|
+
limit: int | None = Field(
|
|
28
|
+
description="Number of lines to read. "
|
|
29
|
+
"Only provide if the file is too large to read at once.",
|
|
30
|
+
default=None,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReadTool(BaseTool):
|
|
35
|
+
name = "read"
|
|
36
|
+
tool_icon = "→"
|
|
37
|
+
params = ReadParams
|
|
38
|
+
mutating = False
|
|
39
|
+
prompt_guidelines = ("Use read to view files (NOT cat/head/tail)",)
|
|
40
|
+
description = (
|
|
41
|
+
"Read the contents of a file or directory. "
|
|
42
|
+
f"File reads truncate to {MAX_LINES_PER_FILE} lines and "
|
|
43
|
+
f"{MAX_CHARS_PER_LINE} chars per line. "
|
|
44
|
+
"Use offset/limit to paginate large files. "
|
|
45
|
+
"Supports reading jpg/jpeg/png/gif/webp images."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def format_call(self, params: ReadParams) -> str:
|
|
49
|
+
path = shorten_path(params.path)
|
|
50
|
+
if params.offset or params.limit:
|
|
51
|
+
start = params.offset or 1
|
|
52
|
+
end = (start + params.limit - 1) if params.limit else "?"
|
|
53
|
+
return f"{path}:{start}-{end}"
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
async def read_file(self, file_path: Path, offset: int | None, limit: int | None) -> str:
|
|
57
|
+
lines = []
|
|
58
|
+
start = (offset - 1) if offset else 0
|
|
59
|
+
effective_limit = min(limit, MAX_LINES_PER_FILE) if limit else MAX_LINES_PER_FILE
|
|
60
|
+
line_number = 0
|
|
61
|
+
|
|
62
|
+
async with aiofiles.open(file_path, encoding="utf-8") as f:
|
|
63
|
+
async for line in f:
|
|
64
|
+
line_number += 1
|
|
65
|
+
if line_number <= start:
|
|
66
|
+
continue
|
|
67
|
+
if len(lines) == effective_limit:
|
|
68
|
+
if effective_limit == MAX_LINES_PER_FILE:
|
|
69
|
+
lines.append(f"[output truncated after {MAX_LINES_PER_FILE} lines]")
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
if len(line) > MAX_CHARS_PER_LINE:
|
|
73
|
+
line = (
|
|
74
|
+
line[:MAX_CHARS_PER_LINE]
|
|
75
|
+
+ f" [output truncated after {MAX_CHARS_PER_LINE} chars]\n"
|
|
76
|
+
)
|
|
77
|
+
lines.append(f"{line_number:6d}\t{line}")
|
|
78
|
+
|
|
79
|
+
return "".join(lines)
|
|
80
|
+
|
|
81
|
+
def _format_directory_entry(self, entry_path: Path, relative: Path) -> str:
|
|
82
|
+
modified = datetime.fromtimestamp(entry_path.stat().st_mtime)
|
|
83
|
+
timestamp = f"{modified.day:2d} {modified.strftime('%b %H:%M')}"
|
|
84
|
+
display = relative.as_posix()
|
|
85
|
+
if entry_path.is_dir():
|
|
86
|
+
display += "/"
|
|
87
|
+
return f"{timestamp} {display}"
|
|
88
|
+
|
|
89
|
+
async def _list_directory_entries(
|
|
90
|
+
self,
|
|
91
|
+
fd_path: str,
|
|
92
|
+
dir_path: Path,
|
|
93
|
+
max_depth: int,
|
|
94
|
+
max_results: int,
|
|
95
|
+
cancel_event: asyncio.Event | None,
|
|
96
|
+
) -> list[str]:
|
|
97
|
+
proc = await asyncio.create_subprocess_exec(
|
|
98
|
+
fd_path,
|
|
99
|
+
"--hidden",
|
|
100
|
+
"--color=never",
|
|
101
|
+
"--max-depth",
|
|
102
|
+
str(max_depth),
|
|
103
|
+
"--max-results",
|
|
104
|
+
str(max_results),
|
|
105
|
+
".",
|
|
106
|
+
str(dir_path),
|
|
107
|
+
stdout=asyncio.subprocess.PIPE,
|
|
108
|
+
stderr=asyncio.subprocess.PIPE,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
stdout, stderr = await communicate_or_cancel(proc, cancel_event)
|
|
112
|
+
output = stdout.decode("utf-8", errors="replace").strip()
|
|
113
|
+
error_output = stderr.decode("utf-8", errors="replace").strip()
|
|
114
|
+
|
|
115
|
+
if proc.returncode not in (0, 1):
|
|
116
|
+
raise RuntimeError(f"fd failed: {error_output or 'unknown error'}")
|
|
117
|
+
|
|
118
|
+
if not output:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
entries: list[str] = []
|
|
122
|
+
for line in output.split("\n"):
|
|
123
|
+
if not line.strip():
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
entry_path = Path(line)
|
|
127
|
+
if entry_path.is_absolute():
|
|
128
|
+
try:
|
|
129
|
+
relative = entry_path.relative_to(dir_path)
|
|
130
|
+
except ValueError:
|
|
131
|
+
relative = entry_path
|
|
132
|
+
else:
|
|
133
|
+
relative = entry_path
|
|
134
|
+
entry_path = dir_path / entry_path
|
|
135
|
+
|
|
136
|
+
entries.append(self._format_directory_entry(entry_path, relative))
|
|
137
|
+
|
|
138
|
+
return entries
|
|
139
|
+
|
|
140
|
+
async def read_directory(
|
|
141
|
+
self, dir_path: Path, cancel_event: asyncio.Event | None = None
|
|
142
|
+
) -> ToolResult:
|
|
143
|
+
fd_path = await ensure_tool("fd", silent=True)
|
|
144
|
+
if not fd_path:
|
|
145
|
+
msg = "fd is not available and could not be downloaded"
|
|
146
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
for max_depth in (3, 2):
|
|
150
|
+
entries = await self._list_directory_entries(
|
|
151
|
+
fd_path,
|
|
152
|
+
dir_path,
|
|
153
|
+
max_depth=max_depth,
|
|
154
|
+
max_results=DIRECTORY_DEPTH_ROW_LIMIT + 1,
|
|
155
|
+
cancel_event=cancel_event,
|
|
156
|
+
)
|
|
157
|
+
if len(entries) <= DIRECTORY_DEPTH_ROW_LIMIT:
|
|
158
|
+
result = "\n".join(entries) if entries else "(empty directory)"
|
|
159
|
+
return ToolResult(
|
|
160
|
+
success=True,
|
|
161
|
+
result=result,
|
|
162
|
+
ui_summary=f"[dim]({len(entries)} entries)[/dim]",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
entries = await self._list_directory_entries(
|
|
166
|
+
fd_path,
|
|
167
|
+
dir_path,
|
|
168
|
+
max_depth=1,
|
|
169
|
+
max_results=MAX_DIRECTORY_ROWS + 1,
|
|
170
|
+
cancel_event=cancel_event,
|
|
171
|
+
)
|
|
172
|
+
except ToolCancelledError:
|
|
173
|
+
return ToolResult(
|
|
174
|
+
success=False, result="Read aborted", ui_summary="[yellow]Read aborted[/yellow]"
|
|
175
|
+
)
|
|
176
|
+
except RuntimeError as e:
|
|
177
|
+
msg = str(e)
|
|
178
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
179
|
+
|
|
180
|
+
truncated = len(entries) > MAX_DIRECTORY_ROWS
|
|
181
|
+
visible_entries = entries[:MAX_DIRECTORY_ROWS]
|
|
182
|
+
result = "\n".join(visible_entries) if visible_entries else "(empty directory)"
|
|
183
|
+
if truncated:
|
|
184
|
+
result += f"\n[output truncated after {MAX_DIRECTORY_ROWS} lines]"
|
|
185
|
+
|
|
186
|
+
shown = min(len(entries), MAX_DIRECTORY_ROWS)
|
|
187
|
+
return ToolResult(
|
|
188
|
+
success=True, result=result, ui_summary=f"[dim]({shown} entries shown)[/dim]"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def execute(
|
|
192
|
+
self, params: ReadParams, cancel_event: asyncio.Event | None = None
|
|
193
|
+
) -> ToolResult:
|
|
194
|
+
file_path = Path(params.path)
|
|
195
|
+
|
|
196
|
+
if not file_path.exists():
|
|
197
|
+
msg = "Path not found"
|
|
198
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
199
|
+
|
|
200
|
+
if file_path.is_dir():
|
|
201
|
+
return await self.read_directory(file_path, cancel_event)
|
|
202
|
+
|
|
203
|
+
if not file_path.is_file():
|
|
204
|
+
msg = "Path is not a file or directory"
|
|
205
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
206
|
+
|
|
207
|
+
if is_image_file(str(file_path)):
|
|
208
|
+
try:
|
|
209
|
+
base64_data, mime_type, resize_note = read_and_process_image(str(file_path))
|
|
210
|
+
|
|
211
|
+
text_note = f"Read image file [{mime_type}]"
|
|
212
|
+
if resize_note:
|
|
213
|
+
text_note += f" {resize_note}"
|
|
214
|
+
|
|
215
|
+
display_note = "[dim]Read image[/dim]"
|
|
216
|
+
if resize_note:
|
|
217
|
+
display_note = f"{display_note} {resize_note}"
|
|
218
|
+
|
|
219
|
+
return ToolResult(
|
|
220
|
+
success=True,
|
|
221
|
+
result=text_note,
|
|
222
|
+
images=[ImageContent(data=base64_data, mime_type=mime_type)],
|
|
223
|
+
ui_summary=display_note,
|
|
224
|
+
)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
msg = f"Failed to read image: {e}"
|
|
227
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
content = await self.read_file(file_path, params.offset, params.limit)
|
|
231
|
+
except OSError as e:
|
|
232
|
+
msg = f"Failed to read: {e}"
|
|
233
|
+
return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
|
|
234
|
+
|
|
235
|
+
lines_read = len(content.splitlines()) if content else 0
|
|
236
|
+
return ToolResult(
|
|
237
|
+
success=True, result=content, ui_summary=f"[dim]({lines_read} lines)[/dim]"
|
|
238
|
+
)
|