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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/tools/skill.py ADDED
@@ -0,0 +1,278 @@
1
+ import asyncio
2
+ import os
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from ..context.skills import (
10
+ get_config_dir,
11
+ load_builtin_cmd_skills,
12
+ load_skills,
13
+ merge_registered_skills,
14
+ )
15
+ from .base import BaseTool, ToolResult
16
+
17
+
18
+ class SkillParams(BaseModel):
19
+ action: Literal["list", "view", "create", "patch", "edit", "delete"] = Field(
20
+ description=(
21
+ "The action to perform: list (shows all loaded skills), "
22
+ "view (reads full instructions), create (creates new), "
23
+ "patch (find-and-replace), edit (overwrites file), "
24
+ "delete (deletes skill folder)."
25
+ ),
26
+ default="view",
27
+ )
28
+ name: str | None = Field(
29
+ description=(
30
+ "Name of the skill (lowercase, alphanumeric, and hyphens only). "
31
+ "Required for all actions except 'list'."
32
+ ),
33
+ default=None,
34
+ )
35
+ content: str | None = Field(
36
+ description=(
37
+ "Full SKILL.md content (YAML frontmatter + markdown body). "
38
+ "Required for 'create' and 'edit'."
39
+ ),
40
+ default=None,
41
+ )
42
+ old_string: str | None = Field(
43
+ description="Text to search for (required for 'patch'). Must be a unique match.",
44
+ default=None,
45
+ )
46
+ new_string: str | None = Field(
47
+ description="Replacement text (required for 'patch').", default=None
48
+ )
49
+ file_path: str | None = Field(
50
+ description=(
51
+ "Relative path of a supporting file to target (e.g., 'templates/prompt.md'). "
52
+ "Defaults to 'SKILL.md' if omitted."
53
+ ),
54
+ default=None,
55
+ )
56
+ scope: Literal["project", "global"] = Field(
57
+ description=(
58
+ "For 'create': whether to create project-level skill in "
59
+ ".agents/skills/ (default) or user-global skill in ~/.agents/skills/."
60
+ ),
61
+ default="project",
62
+ )
63
+
64
+
65
+ class SkillTool(BaseTool):
66
+ name = "skill"
67
+ tool_icon = "⚙"
68
+ params = SkillParams
69
+ mutating = True # Can modify skills, though list/view are read-only
70
+ prompt_guidelines = (
71
+ "Use skill to list, view, create, patch, edit, or delete skill workflows.",
72
+ )
73
+ description = (
74
+ "Manage or view the AI's skills. Actions: list, view, create, patch, edit, delete. "
75
+ "Allows progressive loading, targeting specific parts of instructions, or self-evolution."
76
+ )
77
+
78
+ def format_call(self, params: SkillParams) -> str:
79
+ name_str = f" name={params.name}" if params.name else ""
80
+ file_str = f" file={params.file_path}" if params.file_path else ""
81
+ return f"{params.action}{name_str}{file_str}"
82
+
83
+ async def execute(
84
+ self, params: SkillParams, cancel_event: asyncio.Event | None = None
85
+ ) -> ToolResult:
86
+ cwd = os.getcwd()
87
+
88
+ # Handle 'list' action
89
+ if params.action == "list":
90
+ result = load_skills(cwd)
91
+ builtin = load_builtin_cmd_skills()
92
+ all_skills = merge_registered_skills(result.skills, builtin.skills)
93
+ lines = ["Available skills:"]
94
+ for skill in sorted(all_skills, key=lambda s: s.name):
95
+ path_str = str(skill.path)
96
+ if skill.bundled:
97
+ scope = "bundled"
98
+ elif "skills" in path_str and ("~" in path_str or "/.agents/" not in path_str):
99
+ scope = "global"
100
+ else:
101
+ scope = "project"
102
+ lines.append(f"- {skill.name} [{scope}]: {skill.description}")
103
+ result_text = "\n".join(lines)
104
+ return ToolResult(
105
+ success=True,
106
+ result=result_text,
107
+ ui_summary=f"[dim]({len(all_skills)} skills)[/dim]",
108
+ )
109
+
110
+ if not params.name:
111
+ msg = "Parameter 'name' is required for this action."
112
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
113
+
114
+ # Helper to find skill directory
115
+ def find_skill_dir(name: str) -> tuple[Path | None, bool]:
116
+ # 1. Project skills
117
+ from ..context.skills import _project_skill_dirs
118
+
119
+ project_dirs = _project_skill_dirs(Path(cwd))
120
+ for skills_dir in project_dirs:
121
+ if skills_dir.exists():
122
+ skill_dir = skills_dir / name
123
+ if (skill_dir / "SKILL.md").is_file():
124
+ return skill_dir, False
125
+
126
+ # 2. User global skills
127
+ user_skills_dir = (get_config_dir() / "skills").resolve(strict=False)
128
+ skill_dir = user_skills_dir / name
129
+ if (skill_dir / "SKILL.md").is_file():
130
+ return skill_dir, False
131
+
132
+ # 3. Builtin skills
133
+ from importlib import resources
134
+
135
+ try:
136
+ builtin_resource = resources.files("vtx").joinpath("builtin_skills")
137
+ with resources.as_file(builtin_resource) as builtin_root:
138
+ skill_dir = builtin_root / name
139
+ if (skill_dir / "SKILL.md").is_file():
140
+ return skill_dir, True
141
+ except Exception:
142
+ pass
143
+
144
+ return None, False
145
+
146
+ skill_dir, is_builtin = find_skill_dir(params.name)
147
+
148
+ # Handle 'view' action
149
+ if params.action == "view":
150
+ if not skill_dir:
151
+ msg = f"Skill '{params.name}' not found."
152
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
153
+
154
+ target_file = params.file_path or "SKILL.md"
155
+ target_path = skill_dir / target_file
156
+ try:
157
+ content = target_path.read_text(encoding="utf-8")
158
+ return ToolResult(
159
+ success=True,
160
+ result=content,
161
+ ui_summary=f"[dim]({len(content.splitlines())} lines)[/dim]",
162
+ )
163
+ except Exception as e:
164
+ msg = f"Failed to read skill file: {e}"
165
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
166
+
167
+ # Mutating actions: 'create', 'edit', 'patch', 'delete'
168
+ if is_builtin:
169
+ msg = f"Cannot modify built-in skill '{params.name}'."
170
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
171
+
172
+ # Handle 'create' action
173
+ if params.action == "create":
174
+ if not params.content:
175
+ msg = "Parameter 'content' is required to create a skill."
176
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
177
+
178
+ # Validate name format
179
+ if not params.name.islower() or not params.name.replace("-", "").isalnum():
180
+ msg = "Skill name must be lowercase, alphanumeric, and hyphens only."
181
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
182
+
183
+ # Resolve target path based on scope
184
+ if params.scope == "global":
185
+ target_skills_dir = (get_config_dir() / "skills").resolve(strict=False)
186
+ else:
187
+ target_skills_dir = Path(cwd) / ".agents" / "skills"
188
+
189
+ new_skill_dir = target_skills_dir / params.name
190
+ new_skill_dir.mkdir(parents=True, exist_ok=True)
191
+ skill_md_path = new_skill_dir / "SKILL.md"
192
+
193
+ try:
194
+ skill_md_path.write_text(params.content, encoding="utf-8")
195
+ return ToolResult(
196
+ success=True,
197
+ result=f"Skill '{params.name}' created at {skill_md_path}.",
198
+ ui_summary=f"Created '{params.name}'",
199
+ )
200
+ except Exception as e:
201
+ msg = f"Failed to create skill: {e}"
202
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
203
+
204
+ # For edit, patch, delete, we need the skill to exist
205
+ if not skill_dir:
206
+ msg = f"Skill '{params.name}' not found."
207
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
208
+
209
+ # Handle 'delete' action
210
+ if params.action == "delete":
211
+ try:
212
+ shutil.rmtree(skill_dir)
213
+ return ToolResult(
214
+ success=True,
215
+ result=f"Skill '{params.name}' deleted.",
216
+ ui_summary=f"Deleted '{params.name}'",
217
+ )
218
+ except Exception as e:
219
+ msg = f"Failed to delete skill: {e}"
220
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
221
+
222
+ target_file = params.file_path or "SKILL.md"
223
+ target_path = skill_dir / target_file
224
+
225
+ # Handle 'edit' action
226
+ if params.action == "edit":
227
+ if not params.content:
228
+ msg = "Parameter 'content' is required for edit."
229
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
230
+
231
+ try:
232
+ target_path.parent.mkdir(parents=True, exist_ok=True)
233
+ target_path.write_text(params.content, encoding="utf-8")
234
+ return ToolResult(
235
+ success=True,
236
+ result=f"Skill '{params.name}' updated at {target_file}.",
237
+ ui_summary=f"Edited {target_file}",
238
+ )
239
+ except Exception as e:
240
+ msg = f"Failed to edit skill: {e}"
241
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
242
+
243
+ # Handle 'patch' action
244
+ if params.action == "patch":
245
+ if params.old_string is None or params.new_string is None:
246
+ msg = "Parameters 'old_string' and 'new_string' are required for patch."
247
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
248
+
249
+ if not target_path.is_file():
250
+ msg = f"File {target_file} not found in skill '{params.name}'."
251
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
252
+
253
+ try:
254
+ content = target_path.read_text(encoding="utf-8")
255
+ count = content.count(params.old_string)
256
+ if count == 0:
257
+ msg = f"Target string 'old_string' not found in {target_file}."
258
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
259
+ if count > 1:
260
+ msg = (
261
+ f"Target string 'old_string' is not unique in {target_file} "
262
+ f"(found {count} occurrences)."
263
+ )
264
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
265
+
266
+ new_content = content.replace(params.old_string, params.new_string)
267
+ target_path.write_text(new_content, encoding="utf-8")
268
+ return ToolResult(
269
+ success=True,
270
+ result=f"Successfully patched {target_file} in skill '{params.name}'.",
271
+ ui_summary=f"Patched {target_file}",
272
+ )
273
+ except Exception as e:
274
+ msg = f"Failed to patch skill: {e}"
275
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
276
+
277
+ msg = f"Unsupported action '{params.action}'."
278
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
vtx/tools/web.py ADDED
@@ -0,0 +1,238 @@
1
+ """Web fetch and web search tools for vtx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Annotated
8
+
9
+ import httpx
10
+ from pydantic import BaseModel, Field
11
+
12
+ from ..core.types import ToolResult
13
+ from .base import BaseTool
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Shared constants
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _FETCH_TIMEOUT = 25.0
20
+
21
+
22
+ # ===========================================================================
23
+ # WebFetchTool (Exa MCP — no API key needed)
24
+ # ===========================================================================
25
+
26
+
27
+ class FetchParams(BaseModel):
28
+ urls: Annotated[
29
+ list[str],
30
+ Field(min_length=1, description="URLs to read. Batch multiple URLs in one call."),
31
+ ]
32
+ max_characters: int = Field(
33
+ default=3000, ge=1, description="Maximum characters to extract per page (default: 3000)."
34
+ )
35
+
36
+
37
+ class WebFetchTool(BaseTool):
38
+ """Read a webpage's full content as clean markdown via the Exa MCP endpoint."""
39
+
40
+ name = "fetch_webpage"
41
+ tool_icon = "🌐"
42
+ params = FetchParams
43
+ mutating = False
44
+ description = (
45
+ "Read a webpage's full content as clean markdown via the Exa MCP endpoint. "
46
+ "Use after web_search when highlights are insufficient or to read any URL. "
47
+ "Batch multiple URLs in one call. "
48
+ "Not suitable for JavaScript-rendered pages — use web_search for those. "
49
+ "Returns up to 3000 characters per page by default."
50
+ )
51
+ prompt_guidelines = (
52
+ "Use fetch_webpage to read a specific URL you already know.",
53
+ "Use web_search first if you need to discover URLs.",
54
+ )
55
+
56
+ _MCP_URL = "https://mcp.exa.ai/mcp"
57
+
58
+ def format_call(self, params: FetchParams) -> str:
59
+ if len(params.urls) == 1:
60
+ return params.urls[0]
61
+ return f"{params.urls[0]} (+{len(params.urls) - 1} more)"
62
+
63
+ async def execute(
64
+ self, params: FetchParams, cancel_event: asyncio.Event | None = None
65
+ ) -> ToolResult:
66
+ payload = {
67
+ "jsonrpc": "2.0",
68
+ "id": 1,
69
+ "method": "tools/call",
70
+ "params": {
71
+ "name": "web_fetch_exa",
72
+ "arguments": {"urls": params.urls, "maxCharacters": params.max_characters},
73
+ },
74
+ }
75
+ headers = {
76
+ "Accept": "application/json, text/event-stream",
77
+ "Content-Type": "application/json",
78
+ }
79
+
80
+ try:
81
+ async with httpx.AsyncClient(timeout=_FETCH_TIMEOUT) as client:
82
+ resp = await client.post(self._MCP_URL, headers=headers, json=payload)
83
+ resp.raise_for_status()
84
+
85
+ for line in resp.text.splitlines():
86
+ if not line.startswith("data: "):
87
+ continue
88
+ try:
89
+ data = json.loads(line[6:])
90
+ except json.JSONDecodeError:
91
+ continue
92
+ text = self._extract_text(data)
93
+ if text:
94
+ return ToolResult(success=True, result=text, ui_summary="[dim]exa[/dim]")
95
+
96
+ try:
97
+ text = self._extract_text(resp.json())
98
+ if text:
99
+ return ToolResult(success=True, result=text, ui_summary="[dim]exa[/dim]")
100
+ except Exception:
101
+ pass
102
+
103
+ return ToolResult(
104
+ success=False,
105
+ result="No results returned from Exa API",
106
+ ui_summary="[red]No Exa results[/red]",
107
+ )
108
+
109
+ except httpx.TimeoutException:
110
+ msg = f"Exa timed out after {_FETCH_TIMEOUT}s"
111
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
112
+ except httpx.HTTPStatusError as e:
113
+ msg = f"Exa API error {e.response.status_code}"
114
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
115
+ except Exception as e:
116
+ msg = f"Exa fetch failed: {e}"
117
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
118
+
119
+ @staticmethod
120
+ def _extract_text(data: dict) -> str | None:
121
+ content = data.get("result", {}).get("content", [])
122
+ if content and isinstance(content, list):
123
+ return content[0].get("text")
124
+ return None
125
+
126
+
127
+ # ===========================================================================
128
+ # WebSearchTool (Exa neural search — no API key needed)
129
+ # ===========================================================================
130
+
131
+
132
+ class SearchParams(BaseModel):
133
+ query: str = Field(description="The search query")
134
+ num_results: int = Field(default=8, ge=1, le=20, description="Number of results")
135
+ search_type: str = Field(
136
+ default="auto", description="Search type: 'auto', 'neural', or 'keyword'"
137
+ )
138
+ livecrawl: str = Field(
139
+ default="fallback", description="Livecrawl mode: 'fallback', 'always', or 'never'"
140
+ )
141
+
142
+
143
+ class WebSearchTool(BaseTool):
144
+ """Web search via Exa MCP endpoint (no API key required)."""
145
+
146
+ name = "web_search"
147
+ tool_icon = "🔍"
148
+ params = SearchParams
149
+ mutating = False
150
+ description = (
151
+ "Search the web using the Exa neural search API (via free MCP endpoint). "
152
+ "Returns titles, URLs, and rich content snippets. "
153
+ "Better for semantic/research queries than keyword search. "
154
+ "Requires internet access."
155
+ )
156
+ prompt_guidelines = (
157
+ "Use web_search for research, semantic queries, and finding current information.",
158
+ )
159
+
160
+ _MCP_URL = "https://mcp.exa.ai/mcp"
161
+ _TIMEOUT = 25.0
162
+
163
+ def format_call(self, params: SearchParams) -> str:
164
+ return f'"{params.query}"'
165
+
166
+ async def execute(
167
+ self, params: SearchParams, cancel_event: asyncio.Event | None = None
168
+ ) -> ToolResult:
169
+ payload = {
170
+ "jsonrpc": "2.0",
171
+ "id": 1,
172
+ "method": "tools/call",
173
+ "params": {
174
+ "name": "web_search_exa",
175
+ "arguments": {
176
+ "query": params.query,
177
+ "type": params.search_type,
178
+ "numResults": params.num_results,
179
+ "livecrawl": params.livecrawl,
180
+ },
181
+ },
182
+ }
183
+ headers = {
184
+ "Accept": "application/json, text/event-stream",
185
+ "Content-Type": "application/json",
186
+ }
187
+
188
+ try:
189
+ async with httpx.AsyncClient(timeout=self._TIMEOUT) as client:
190
+ resp = await client.post(self._MCP_URL, headers=headers, json=payload)
191
+ resp.raise_for_status()
192
+
193
+ # Try SSE lines first
194
+ for line in resp.text.splitlines():
195
+ if not line.startswith("data: "):
196
+ continue
197
+ try:
198
+ data = json.loads(line[6:])
199
+ except json.JSONDecodeError:
200
+ continue
201
+ text = self._extract_text(data)
202
+ if text:
203
+ return ToolResult(
204
+ success=True, result=text, ui_summary=f"[dim]exa: {params.query!r}[/dim]"
205
+ )
206
+
207
+ # Fallback: parse whole body as JSON
208
+ try:
209
+ text = self._extract_text(resp.json())
210
+ if text:
211
+ return ToolResult(
212
+ success=True, result=text, ui_summary=f"[dim]exa: {params.query!r}[/dim]"
213
+ )
214
+ except Exception:
215
+ pass
216
+
217
+ return ToolResult(
218
+ success=False,
219
+ result="No results returned from Exa API",
220
+ ui_summary="[red]No Exa results[/red]",
221
+ )
222
+
223
+ except httpx.TimeoutException:
224
+ msg = f"Exa timed out after {self._TIMEOUT}s"
225
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
226
+ except httpx.HTTPStatusError as e:
227
+ msg = f"Exa API error {e.response.status_code}"
228
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
229
+ except Exception as e:
230
+ msg = f"Exa search failed: {e}"
231
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
232
+
233
+ @staticmethod
234
+ def _extract_text(data: dict) -> str | None:
235
+ content = data.get("result", {}).get("content", [])
236
+ if content and isinstance(content, list):
237
+ return content[0].get("text")
238
+ return None
vtx/tools/write.py ADDED
@@ -0,0 +1,88 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+
4
+ import aiofiles
5
+ from pydantic import BaseModel, Field
6
+
7
+ from vtx import config
8
+
9
+ from ..core.types import FileChanges
10
+ from ._tool_utils import shorten_path
11
+ from .base import BaseTool, ToolResult
12
+
13
+
14
+ class WriteParams(BaseModel):
15
+ path: str = Field(description="Absolute path of the file to write to")
16
+ content: str = Field(description="Content to be written to the file")
17
+
18
+
19
+ class WriteTool(BaseTool):
20
+ name = "write"
21
+ tool_icon = "+"
22
+ params = WriteParams
23
+ prompt_guidelines = (
24
+ "Use write only for new files or complete rewrites (NOT echo >/cat <<EOF)",
25
+ )
26
+ description = (
27
+ "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. "
28
+ "Automatically creates parent directories."
29
+ )
30
+
31
+ def format_call(self, params: WriteParams) -> str:
32
+ return shorten_path(params.path)
33
+
34
+ def format_preview(self, params: WriteParams) -> str | None:
35
+ from vtx.diff_display import DIFF_BG_PAD_MARKER, blend_hex
36
+
37
+ colors = config.ui.colors
38
+ bg_added = blend_hex(colors.diff_added, colors.bg)
39
+
40
+ lines = params.content.splitlines()
41
+ colored = []
42
+ for line in lines[:20]:
43
+ escaped = line.replace("[", "\\[")
44
+ content = f"+ {escaped}"
45
+ colored.append(f"[{colors.diff_added} on {bg_added}]{content}{DIFF_BG_PAD_MARKER}[/]")
46
+ if len(lines) > 20:
47
+ colored.append(f"[dim] \u22ef {len(lines) - 20} more lines \u22ef[/dim]")
48
+ return "\n".join(colored)
49
+
50
+ async def execute(
51
+ self, params: WriteParams, cancel_event: asyncio.Event | None = None
52
+ ) -> ToolResult:
53
+ file_path = Path(params.path)
54
+ file_path.parent.mkdir(parents=True, exist_ok=True)
55
+ file_existed = file_path.exists()
56
+
57
+ old_line_count = 0
58
+ if file_existed:
59
+ try:
60
+ async with aiofiles.open(file_path, encoding="utf-8") as f:
61
+ old_content = await f.read()
62
+ old_line_count = old_content.count("\n") + 1
63
+ except (OSError, UnicodeDecodeError):
64
+ pass
65
+
66
+ try:
67
+ async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
68
+ await f.write(params.content)
69
+ except OSError as e:
70
+ msg = f"Failed to write: {e}"
71
+ return ToolResult(success=False, result=msg, ui_summary=f"[red]{msg}[/red]")
72
+
73
+ n_lines = params.content.count("\n") + 1
74
+ diff_added = config.ui.colors.diff_added
75
+
76
+ if file_existed:
77
+ result = f"Overwrote {file_path} +{n_lines}"
78
+ display = f"[{diff_added}]+{n_lines}[/{diff_added}]"
79
+ else:
80
+ result = f"Created {file_path} +{n_lines}"
81
+ display = f"[{diff_added}]+{n_lines}[/{diff_added}]"
82
+
83
+ return ToolResult(
84
+ success=True,
85
+ result=result,
86
+ ui_summary=display,
87
+ file_changes=FileChanges(path=str(file_path), added=n_lines, removed=old_line_count),
88
+ )