voidx 1.0.0__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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/tools/todo.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""TodoWrite tool — stateful task list, Claude Code aligned.
|
|
2
|
+
|
|
3
|
+
Each call REPLACES the entire list. Status persists across calls via tracker.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TodoItem(BaseModel):
|
|
14
|
+
content: str = Field(description="Task description, one sentence.")
|
|
15
|
+
status: str = Field(
|
|
16
|
+
default="pending",
|
|
17
|
+
description="pending | in_progress | completed | cancelled"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TodoInput(BaseModel):
|
|
22
|
+
todos: list[TodoItem] = Field(
|
|
23
|
+
description="Full todo list — replaces the previous list entirely. Include ALL items."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TodoWriteTool(BaseTool):
|
|
28
|
+
id = "todo"
|
|
29
|
+
description = (
|
|
30
|
+
"Create and manage a task list. Each call REPLACES the entire list — "
|
|
31
|
+
"pass the full updated list. Items:[{id, status, content}] "
|
|
32
|
+
"Status: pending → in_progress → completed."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def __init__(self, tracker=None):
|
|
36
|
+
super().__init__()
|
|
37
|
+
self._tracker = tracker
|
|
38
|
+
|
|
39
|
+
def parameters_schema(self) -> dict:
|
|
40
|
+
return model_to_json_schema(TodoInput)
|
|
41
|
+
|
|
42
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
43
|
+
inp = TodoInput.model_validate(args)
|
|
44
|
+
|
|
45
|
+
total = len(inp.todos)
|
|
46
|
+
done = sum(1 for t in inp.todos if t.status == "completed")
|
|
47
|
+
in_progress = sum(1 for t in inp.todos if t.status == "in_progress")
|
|
48
|
+
pending = sum(1 for t in inp.todos if t.status == "pending")
|
|
49
|
+
cancelled = sum(1 for t in inp.todos if t.status == "cancelled")
|
|
50
|
+
|
|
51
|
+
# Store in tracker if available
|
|
52
|
+
if self._tracker:
|
|
53
|
+
self._tracker._todos = inp.todos
|
|
54
|
+
|
|
55
|
+
# Format with icons and progress
|
|
56
|
+
ICONS = {"pending": "○", "in_progress": "◐", "completed": "●", "cancelled": "✕"}
|
|
57
|
+
lines = []
|
|
58
|
+
|
|
59
|
+
# Progress bar
|
|
60
|
+
if total > 0:
|
|
61
|
+
pct = done / total
|
|
62
|
+
bar_len = 20
|
|
63
|
+
filled = int(bar_len * pct)
|
|
64
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
65
|
+
lines.append(f"[{bar}] {done}/{total} done")
|
|
66
|
+
|
|
67
|
+
# Group by status
|
|
68
|
+
for status in ["in_progress", "pending", "completed", "cancelled"]:
|
|
69
|
+
items = [t for t in inp.todos if t.status == status]
|
|
70
|
+
if not items:
|
|
71
|
+
continue
|
|
72
|
+
for item in items:
|
|
73
|
+
lines.append(f" {ICONS[item.status]} {item.content}")
|
|
74
|
+
|
|
75
|
+
return ToolResult(
|
|
76
|
+
title=f"Todo: {done}/{total} done · {in_progress} active · {pending} pending",
|
|
77
|
+
output="\n".join(lines),
|
|
78
|
+
metadata={
|
|
79
|
+
"total": total, "done": done, "in_progress": in_progress,
|
|
80
|
+
"pending": pending, "cancelled": cancelled,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Shared helpers for web search/fetch tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import html
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from html.parser import HTMLParser
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qsl, parse_qs, urlencode, unquote, urlparse, urlunparse
|
|
13
|
+
|
|
14
|
+
from voidx.tools.base import ToolResult
|
|
15
|
+
|
|
16
|
+
_TRACKING_PARAMS = {
|
|
17
|
+
"fbclid",
|
|
18
|
+
"gclid",
|
|
19
|
+
"igshid",
|
|
20
|
+
"mc_cid",
|
|
21
|
+
"mc_eid",
|
|
22
|
+
"msclkid",
|
|
23
|
+
"ref",
|
|
24
|
+
"spm",
|
|
25
|
+
}
|
|
26
|
+
_SKIP_TAGS = {"script", "style", "noscript", "svg", "nav", "footer", "header", "form"}
|
|
27
|
+
_BLOCK_TAGS = {
|
|
28
|
+
"article",
|
|
29
|
+
"aside",
|
|
30
|
+
"blockquote",
|
|
31
|
+
"dd",
|
|
32
|
+
"div",
|
|
33
|
+
"dl",
|
|
34
|
+
"dt",
|
|
35
|
+
"figcaption",
|
|
36
|
+
"figure",
|
|
37
|
+
"li",
|
|
38
|
+
"main",
|
|
39
|
+
"ol",
|
|
40
|
+
"p",
|
|
41
|
+
"pre",
|
|
42
|
+
"section",
|
|
43
|
+
"table",
|
|
44
|
+
"tbody",
|
|
45
|
+
"td",
|
|
46
|
+
"th",
|
|
47
|
+
"thead",
|
|
48
|
+
"tr",
|
|
49
|
+
"ul",
|
|
50
|
+
}
|
|
51
|
+
_STOP_WORDS = {
|
|
52
|
+
"about",
|
|
53
|
+
"after",
|
|
54
|
+
"before",
|
|
55
|
+
"content",
|
|
56
|
+
"extract",
|
|
57
|
+
"from",
|
|
58
|
+
"only",
|
|
59
|
+
"page",
|
|
60
|
+
"show",
|
|
61
|
+
"this",
|
|
62
|
+
"with",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class _CacheEntry:
|
|
68
|
+
value: Any
|
|
69
|
+
expires_at: float
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class WebToolCache:
|
|
73
|
+
def __init__(self, max_entries: int = 256) -> None:
|
|
74
|
+
self._items: dict[str, _CacheEntry] = {}
|
|
75
|
+
self._max_entries = max_entries
|
|
76
|
+
|
|
77
|
+
def get(self, key: str) -> Any | None:
|
|
78
|
+
entry = self._items.get(key)
|
|
79
|
+
if entry is None:
|
|
80
|
+
return None
|
|
81
|
+
if entry.expires_at < time.monotonic():
|
|
82
|
+
self._items.pop(key, None)
|
|
83
|
+
return None
|
|
84
|
+
return copy.deepcopy(entry.value)
|
|
85
|
+
|
|
86
|
+
def set(self, key: str, value: Any, ttl_seconds: float) -> None:
|
|
87
|
+
if len(self._items) >= self._max_entries:
|
|
88
|
+
oldest = min(self._items, key=lambda item: self._items[item].expires_at)
|
|
89
|
+
self._items.pop(oldest, None)
|
|
90
|
+
self._items[key] = _CacheEntry(copy.deepcopy(value), time.monotonic() + ttl_seconds)
|
|
91
|
+
|
|
92
|
+
def clear(self) -> None:
|
|
93
|
+
self._items.clear()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
WEB_TOOL_CACHE = WebToolCache()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cached_tool_result(result: ToolResult) -> ToolResult:
|
|
100
|
+
metadata = dict(result.metadata or {})
|
|
101
|
+
metadata["cached"] = True
|
|
102
|
+
return result.model_copy(deep=True, update={"metadata": metadata})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def canonicalize_url(url: str) -> str:
|
|
106
|
+
url = _decode_duckduckgo_url(url.strip())
|
|
107
|
+
parsed = urlparse(url)
|
|
108
|
+
scheme = (parsed.scheme or "https").lower()
|
|
109
|
+
host = (parsed.hostname or "").lower()
|
|
110
|
+
netloc = host
|
|
111
|
+
if parsed.port and not (
|
|
112
|
+
scheme == "http" and parsed.port == 80
|
|
113
|
+
or scheme == "https" and parsed.port == 443
|
|
114
|
+
):
|
|
115
|
+
netloc = f"{host}:{parsed.port}"
|
|
116
|
+
query_items = [
|
|
117
|
+
(key, value)
|
|
118
|
+
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
119
|
+
if not key.lower().startswith("utm_") and key.lower() not in _TRACKING_PARAMS
|
|
120
|
+
]
|
|
121
|
+
query = urlencode(sorted(query_items), doseq=True)
|
|
122
|
+
path = parsed.path or "/"
|
|
123
|
+
if path != "/":
|
|
124
|
+
path = path.rstrip("/")
|
|
125
|
+
return urlunparse((scheme, netloc, path, "", query, ""))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def domain_for_url(url: str) -> str:
|
|
129
|
+
host = urlparse(canonicalize_url(url)).hostname or ""
|
|
130
|
+
return host.removeprefix("www.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def matches_domain(url: str, domain: str) -> bool:
|
|
134
|
+
host = domain_for_url(url)
|
|
135
|
+
target = domain.lower().strip().removeprefix("www.")
|
|
136
|
+
return host == target or host.endswith(f".{target}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def normalize_search_results(results: list[dict[str, str]]) -> list[dict[str, Any]]:
|
|
140
|
+
seen: set[str] = set()
|
|
141
|
+
normalized: list[dict[str, str]] = []
|
|
142
|
+
for rank, result in enumerate(results, start=1):
|
|
143
|
+
raw_url = result.get("url", "").strip()
|
|
144
|
+
if not raw_url:
|
|
145
|
+
continue
|
|
146
|
+
url = canonicalize_url(raw_url)
|
|
147
|
+
if url in seen:
|
|
148
|
+
continue
|
|
149
|
+
seen.add(url)
|
|
150
|
+
title = _clean_inline_text(result.get("title", "")) or domain_for_url(url) or url
|
|
151
|
+
snippet = _clean_inline_text(result.get("snippet", ""))
|
|
152
|
+
normalized.append({
|
|
153
|
+
"rank": rank,
|
|
154
|
+
"title": title,
|
|
155
|
+
"url": url,
|
|
156
|
+
"domain": domain_for_url(url),
|
|
157
|
+
"snippet": snippet,
|
|
158
|
+
})
|
|
159
|
+
return normalized
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def extract_readable_content(
|
|
163
|
+
*,
|
|
164
|
+
url: str,
|
|
165
|
+
text: str,
|
|
166
|
+
content_type: str = "",
|
|
167
|
+
prompt: str = "",
|
|
168
|
+
max_chars: int = 12_000,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
if "html" in content_type.lower() or _looks_like_html(text):
|
|
171
|
+
parsed = _ReadableHtmlParser()
|
|
172
|
+
parsed.feed(text)
|
|
173
|
+
parsed.close()
|
|
174
|
+
title = _clean_inline_text(parsed.title)
|
|
175
|
+
blocks = _dedupe_blocks(parsed.blocks)
|
|
176
|
+
else:
|
|
177
|
+
title = ""
|
|
178
|
+
blocks = _plain_text_blocks(text)
|
|
179
|
+
|
|
180
|
+
page_text = "\n\n".join(blocks).strip()
|
|
181
|
+
relevant = _relevant_blocks(blocks, prompt)
|
|
182
|
+
if relevant:
|
|
183
|
+
page_text = "## Relevant excerpts\n\n" + "\n\n".join(relevant) + "\n\n## Page content\n\n" + page_text
|
|
184
|
+
|
|
185
|
+
total_chars = len(page_text)
|
|
186
|
+
truncated = total_chars > max_chars
|
|
187
|
+
if truncated:
|
|
188
|
+
page_text = page_text[:max_chars].rstrip()
|
|
189
|
+
page_text += f"\n\n[truncated: {total_chars} extracted chars, showing first {max_chars}]"
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"title": title,
|
|
193
|
+
"url": canonicalize_url(url),
|
|
194
|
+
"content": page_text,
|
|
195
|
+
"total_chars": total_chars,
|
|
196
|
+
"truncated": truncated,
|
|
197
|
+
"excerpt_count": len(relevant),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def search_cache_key(
|
|
202
|
+
*,
|
|
203
|
+
query: str,
|
|
204
|
+
allowed_domains: list[str] | None,
|
|
205
|
+
blocked_domains: list[str] | None,
|
|
206
|
+
max_results: int,
|
|
207
|
+
backend: str,
|
|
208
|
+
) -> str:
|
|
209
|
+
return "|".join([
|
|
210
|
+
"search:v2",
|
|
211
|
+
backend,
|
|
212
|
+
_clean_inline_text(query).lower(),
|
|
213
|
+
",".join(sorted((allowed_domains or []))),
|
|
214
|
+
",".join(sorted((blocked_domains or []))),
|
|
215
|
+
str(max_results),
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def fetch_cache_key(url: str, prompt: str, max_chars: int) -> str:
|
|
220
|
+
return "|".join(["fetch:v2", canonicalize_url(url), _clean_inline_text(prompt).lower(), str(max_chars)])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _ReadableHtmlParser(HTMLParser):
|
|
224
|
+
def __init__(self) -> None:
|
|
225
|
+
super().__init__(convert_charrefs=True)
|
|
226
|
+
self.blocks: list[str] = []
|
|
227
|
+
self.title = ""
|
|
228
|
+
self._title_parts: list[str] = []
|
|
229
|
+
self._current_parts: list[str] = []
|
|
230
|
+
self._current_tag = ""
|
|
231
|
+
self._heading_level = 0
|
|
232
|
+
self._skip_depth = 0
|
|
233
|
+
self._in_title = False
|
|
234
|
+
|
|
235
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
236
|
+
tag = tag.lower()
|
|
237
|
+
if tag in _SKIP_TAGS:
|
|
238
|
+
self._skip_depth += 1
|
|
239
|
+
return
|
|
240
|
+
if tag == "title":
|
|
241
|
+
self._in_title = True
|
|
242
|
+
self._title_parts = []
|
|
243
|
+
return
|
|
244
|
+
if self._skip_depth:
|
|
245
|
+
return
|
|
246
|
+
if tag == "br":
|
|
247
|
+
self._append_text("\n")
|
|
248
|
+
return
|
|
249
|
+
if tag in _BLOCK_TAGS or re.fullmatch(r"h[1-6]", tag):
|
|
250
|
+
self._finish_block()
|
|
251
|
+
self._current_tag = tag
|
|
252
|
+
self._heading_level = int(tag[1]) if re.fullmatch(r"h[1-6]", tag) else 0
|
|
253
|
+
|
|
254
|
+
def handle_endtag(self, tag: str) -> None:
|
|
255
|
+
tag = tag.lower()
|
|
256
|
+
if tag in _SKIP_TAGS and self._skip_depth:
|
|
257
|
+
self._skip_depth -= 1
|
|
258
|
+
return
|
|
259
|
+
if tag == "title":
|
|
260
|
+
self.title = _clean_inline_text(" ".join(self._title_parts))
|
|
261
|
+
self._in_title = False
|
|
262
|
+
return
|
|
263
|
+
if self._skip_depth:
|
|
264
|
+
return
|
|
265
|
+
if tag == self._current_tag or tag in _BLOCK_TAGS or re.fullmatch(r"h[1-6]", tag):
|
|
266
|
+
self._finish_block()
|
|
267
|
+
|
|
268
|
+
def handle_data(self, data: str) -> None:
|
|
269
|
+
if self._skip_depth:
|
|
270
|
+
return
|
|
271
|
+
if self._in_title:
|
|
272
|
+
self._title_parts.append(data)
|
|
273
|
+
return
|
|
274
|
+
if data.strip():
|
|
275
|
+
self._append_text(data)
|
|
276
|
+
|
|
277
|
+
def close(self) -> None:
|
|
278
|
+
self._finish_block()
|
|
279
|
+
super().close()
|
|
280
|
+
|
|
281
|
+
def _append_text(self, text: str) -> None:
|
|
282
|
+
if not self._current_tag:
|
|
283
|
+
self._current_tag = "p"
|
|
284
|
+
self._current_parts.append(text)
|
|
285
|
+
|
|
286
|
+
def _finish_block(self) -> None:
|
|
287
|
+
raw = " ".join(self._current_parts)
|
|
288
|
+
text = _clean_block_text(raw)
|
|
289
|
+
if text:
|
|
290
|
+
if self._heading_level:
|
|
291
|
+
text = f"{'#' * self._heading_level} {text}"
|
|
292
|
+
elif self._current_tag == "li":
|
|
293
|
+
text = f"- {text}"
|
|
294
|
+
self.blocks.append(text)
|
|
295
|
+
self._current_parts = []
|
|
296
|
+
self._current_tag = ""
|
|
297
|
+
self._heading_level = 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _decode_duckduckgo_url(url: str) -> str:
|
|
301
|
+
parsed = urlparse(url)
|
|
302
|
+
if "duckduckgo.com" not in (parsed.hostname or ""):
|
|
303
|
+
return url
|
|
304
|
+
values = parse_qs(parsed.query).get("uddg")
|
|
305
|
+
if values:
|
|
306
|
+
return unquote(values[0])
|
|
307
|
+
return url
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _looks_like_html(text: str) -> bool:
|
|
311
|
+
head = text[:500].lower()
|
|
312
|
+
return "<html" in head or "<body" in head or "<!doctype html" in head
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _plain_text_blocks(text: str) -> list[str]:
|
|
316
|
+
return [
|
|
317
|
+
_clean_block_text(block)
|
|
318
|
+
for block in re.split(r"\n\s*\n", text)
|
|
319
|
+
if _clean_block_text(block)
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _dedupe_blocks(blocks: list[str]) -> list[str]:
|
|
324
|
+
result: list[str] = []
|
|
325
|
+
seen: set[str] = set()
|
|
326
|
+
for block in blocks:
|
|
327
|
+
normalized = _clean_inline_text(block).lower()
|
|
328
|
+
if len(normalized) < 2 or normalized in seen:
|
|
329
|
+
continue
|
|
330
|
+
seen.add(normalized)
|
|
331
|
+
result.append(block)
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _relevant_blocks(blocks: list[str], prompt: str) -> list[str]:
|
|
336
|
+
terms = [
|
|
337
|
+
term.lower()
|
|
338
|
+
for term in re.findall(r"[A-Za-z0-9_.-]{3,}|[\u4e00-\u9fff]{2,}", prompt)
|
|
339
|
+
if term.lower() not in _STOP_WORDS
|
|
340
|
+
]
|
|
341
|
+
if not terms:
|
|
342
|
+
return []
|
|
343
|
+
matches = [
|
|
344
|
+
block
|
|
345
|
+
for block in blocks
|
|
346
|
+
if any(term in block.lower() for term in terms)
|
|
347
|
+
]
|
|
348
|
+
return matches[:5]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _clean_inline_text(text: str) -> str:
|
|
352
|
+
return re.sub(r"\s+", " ", html.unescape(str(text))).strip()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _clean_block_text(text: str) -> str:
|
|
356
|
+
lines = [_clean_inline_text(line) for line in str(text).splitlines()]
|
|
357
|
+
return "\n".join(line for line in lines if line).strip()
|
voidx/tools/web_mcp.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""MCP delegation helpers for web tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from voidx.tools.base import ToolContext, ToolResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def adapt_mcp_web_arguments(kind: str, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
11
|
+
if kind == "search" and tool == "tavily_search":
|
|
12
|
+
return _adapt_tavily_search_arguments(arguments)
|
|
13
|
+
if kind == "fetch" and tool == "tavily_extract":
|
|
14
|
+
return _adapt_tavily_extract_arguments(arguments)
|
|
15
|
+
return dict(arguments)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _adapt_tavily_search_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
mapped: dict[str, Any] = {"query": arguments["query"]}
|
|
20
|
+
|
|
21
|
+
allowed = arguments.get("allowed_domains")
|
|
22
|
+
if allowed:
|
|
23
|
+
mapped["include_domains"] = allowed
|
|
24
|
+
|
|
25
|
+
blocked = arguments.get("blocked_domains")
|
|
26
|
+
if blocked:
|
|
27
|
+
mapped["exclude_domains"] = blocked
|
|
28
|
+
|
|
29
|
+
max_results = arguments.get("max_results")
|
|
30
|
+
if max_results:
|
|
31
|
+
mapped["max_results"] = max_results
|
|
32
|
+
|
|
33
|
+
return mapped
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _adapt_tavily_extract_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
mapped: dict[str, Any] = {"urls": [arguments["url"]]}
|
|
38
|
+
|
|
39
|
+
prompt = arguments.get("prompt")
|
|
40
|
+
if prompt:
|
|
41
|
+
mapped["query"] = prompt
|
|
42
|
+
|
|
43
|
+
return mapped
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def call_mcp_web_tool(
|
|
47
|
+
*,
|
|
48
|
+
kind: str,
|
|
49
|
+
settings: Any,
|
|
50
|
+
ctx: ToolContext,
|
|
51
|
+
arguments: dict[str, Any],
|
|
52
|
+
title: str,
|
|
53
|
+
) -> ToolResult | None:
|
|
54
|
+
if settings is None or not hasattr(settings, "get_web_tool_route"):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
route = settings.get_web_tool_route(kind)
|
|
58
|
+
if route.backend != "mcp":
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
if not route.server or not route.tool:
|
|
62
|
+
return ToolResult(
|
|
63
|
+
output=f"Web {kind} is configured for MCP but no server/tool route is set.",
|
|
64
|
+
metadata={"backend": "mcp", "error": True, "kind": kind},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
manager = getattr(ctx, "mcp_manager", None)
|
|
68
|
+
if manager is None:
|
|
69
|
+
return ToolResult(
|
|
70
|
+
output=f"Web {kind} is configured for MCP but no MCP manager is available.",
|
|
71
|
+
metadata={
|
|
72
|
+
"backend": "mcp",
|
|
73
|
+
"error": True,
|
|
74
|
+
"kind": kind,
|
|
75
|
+
"server": route.server,
|
|
76
|
+
"tool": route.tool,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
adapted_arguments = adapt_mcp_web_arguments(kind, route.tool, arguments)
|
|
82
|
+
result = await manager.call_tool(route.server, route.tool, adapted_arguments)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
return ToolResult(
|
|
85
|
+
output=f"MCP web {kind} failed via {route.server}/{route.tool}: {exc}",
|
|
86
|
+
metadata={
|
|
87
|
+
"backend": "mcp",
|
|
88
|
+
"error": True,
|
|
89
|
+
"kind": kind,
|
|
90
|
+
"server": route.server,
|
|
91
|
+
"tool": route.tool,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
from voidx.mcp.tool import format_mcp_call_result
|
|
96
|
+
|
|
97
|
+
return ToolResult(
|
|
98
|
+
title=title,
|
|
99
|
+
output=format_mcp_call_result(result),
|
|
100
|
+
metadata={
|
|
101
|
+
"backend": "mcp",
|
|
102
|
+
"kind": kind,
|
|
103
|
+
"server": route.server,
|
|
104
|
+
"tool": route.tool,
|
|
105
|
+
"error": result.isError,
|
|
106
|
+
},
|
|
107
|
+
)
|
voidx/tools/webfetch.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""WebFetch tool — fetch web page content. SSRF-protected."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import socket
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult
|
|
14
|
+
from voidx.tools.web_content import (
|
|
15
|
+
WEB_TOOL_CACHE,
|
|
16
|
+
cached_tool_result,
|
|
17
|
+
canonicalize_url,
|
|
18
|
+
extract_readable_content,
|
|
19
|
+
fetch_cache_key,
|
|
20
|
+
)
|
|
21
|
+
from voidx.tools.web_mcp import call_mcp_web_tool
|
|
22
|
+
|
|
23
|
+
_PRIVATE_RANGES = (
|
|
24
|
+
ipaddress.IPv4Network("127.0.0.0/8"), # loopback
|
|
25
|
+
ipaddress.IPv4Network("10.0.0.0/8"), # private
|
|
26
|
+
ipaddress.IPv4Network("172.16.0.0/12"), # private
|
|
27
|
+
ipaddress.IPv4Network("192.168.0.0/16"), # private
|
|
28
|
+
ipaddress.IPv4Network("169.254.0.0/16"), # link-local
|
|
29
|
+
ipaddress.IPv4Network("0.0.0.0/8"), # current network
|
|
30
|
+
ipaddress.IPv6Network("::1/128"), # IPv6 loopback
|
|
31
|
+
ipaddress.IPv6Network("fc00::/7"), # IPv6 unique local
|
|
32
|
+
ipaddress.IPv6Network("fe80::/10"), # IPv6 link-local
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_private_host(host: str) -> bool:
|
|
37
|
+
"""Check if a hostname or IP resolves to a private/internal address."""
|
|
38
|
+
try:
|
|
39
|
+
addr = ipaddress.ip_address(host)
|
|
40
|
+
except ValueError:
|
|
41
|
+
try:
|
|
42
|
+
addr = ipaddress.ip_address(socket.gethostbyname(host))
|
|
43
|
+
except (socket.gaierror, OSError):
|
|
44
|
+
return False
|
|
45
|
+
return any(addr in net for net in _PRIVATE_RANGES)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WebFetchInput(BaseModel):
|
|
49
|
+
url: str = Field(description="URL to fetch content from")
|
|
50
|
+
prompt: str = Field(description="The prompt to run on the fetched content")
|
|
51
|
+
max_chars: int = Field(default=12000, ge=1000, le=50000, description="Maximum extracted text characters")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _FetchResponse:
|
|
56
|
+
url: str
|
|
57
|
+
status_code: int
|
|
58
|
+
text: str
|
|
59
|
+
content_type: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WebFetchTool(BaseTool):
|
|
63
|
+
id = "webfetch"
|
|
64
|
+
description = "Fetch content from a URL and convert to readable text."
|
|
65
|
+
|
|
66
|
+
def __init__(self, settings=None):
|
|
67
|
+
self._settings = settings
|
|
68
|
+
|
|
69
|
+
def parameters_schema(self) -> dict:
|
|
70
|
+
return model_to_json_schema(WebFetchInput)
|
|
71
|
+
|
|
72
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
73
|
+
inp = WebFetchInput.model_validate(args)
|
|
74
|
+
mcp_arguments = inp.model_dump(exclude_none=True)
|
|
75
|
+
if "max_chars" not in args:
|
|
76
|
+
mcp_arguments.pop("max_chars", None)
|
|
77
|
+
mcp_result = await call_mcp_web_tool(
|
|
78
|
+
kind="fetch",
|
|
79
|
+
settings=self._settings,
|
|
80
|
+
ctx=ctx,
|
|
81
|
+
arguments=mcp_arguments,
|
|
82
|
+
title=f"Fetched: {inp.url}",
|
|
83
|
+
)
|
|
84
|
+
if mcp_result is not None:
|
|
85
|
+
return mcp_result
|
|
86
|
+
|
|
87
|
+
parsed = urlparse(inp.url)
|
|
88
|
+
if parsed.scheme not in {"http", "https"}:
|
|
89
|
+
return ToolResult(
|
|
90
|
+
output=f"Blocked: unsupported URL scheme '{parsed.scheme or '(none)'}'",
|
|
91
|
+
metadata={"url": inp.url, "blocked": True, "reason": "unsupported_scheme"},
|
|
92
|
+
)
|
|
93
|
+
if parsed.hostname and _is_private_host(parsed.hostname):
|
|
94
|
+
return ToolResult(
|
|
95
|
+
output=f"Blocked: {parsed.hostname} resolves to a private/internal address",
|
|
96
|
+
metadata={"url": inp.url, "blocked": True},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
key = fetch_cache_key(inp.url, inp.prompt, inp.max_chars)
|
|
100
|
+
cached = WEB_TOOL_CACHE.get(key)
|
|
101
|
+
if isinstance(cached, ToolResult):
|
|
102
|
+
return cached_tool_result(cached)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
resp = await _fetch_url(inp.url)
|
|
106
|
+
final_host = urlparse(resp.url).hostname
|
|
107
|
+
if final_host and _is_private_host(final_host):
|
|
108
|
+
return ToolResult(
|
|
109
|
+
output=f"Blocked: redirect target {final_host} resolves to a private/internal address",
|
|
110
|
+
metadata={"url": inp.url, "final_url": resp.url, "blocked": True},
|
|
111
|
+
)
|
|
112
|
+
extracted = extract_readable_content(
|
|
113
|
+
url=resp.url,
|
|
114
|
+
text=resp.text,
|
|
115
|
+
content_type=resp.content_type,
|
|
116
|
+
prompt=inp.prompt,
|
|
117
|
+
max_chars=inp.max_chars,
|
|
118
|
+
)
|
|
119
|
+
output = extracted["content"] or resp.text[:inp.max_chars]
|
|
120
|
+
|
|
121
|
+
result = ToolResult(
|
|
122
|
+
title=f"Fetched: {canonicalize_url(resp.url)}",
|
|
123
|
+
output=output,
|
|
124
|
+
metadata={
|
|
125
|
+
"url": inp.url,
|
|
126
|
+
"canonical_url": extracted["url"],
|
|
127
|
+
"status": resp.status_code,
|
|
128
|
+
"size": len(resp.text),
|
|
129
|
+
"content_type": resp.content_type,
|
|
130
|
+
"title": extracted["title"],
|
|
131
|
+
"extracted_chars": extracted["total_chars"],
|
|
132
|
+
"truncated": extracted["truncated"],
|
|
133
|
+
"excerpt_count": extracted["excerpt_count"],
|
|
134
|
+
"cached": False,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
WEB_TOOL_CACHE.set(key, result, ttl_seconds=1800)
|
|
138
|
+
return result
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return ToolResult(
|
|
141
|
+
output=f"Failed to fetch {inp.url}: {e}",
|
|
142
|
+
metadata={"url": inp.url, "error": str(e)},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _fetch_url(url: str) -> _FetchResponse:
|
|
147
|
+
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
|
148
|
+
resp = await client.get(url, headers={"User-Agent": "voidx/0.1"})
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
return _FetchResponse(
|
|
151
|
+
url=str(resp.url),
|
|
152
|
+
status_code=resp.status_code,
|
|
153
|
+
text=resp.text,
|
|
154
|
+
content_type=resp.headers.get("content-type", ""),
|
|
155
|
+
)
|