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/registry.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tool registry — every tool typed, all dispatch quantified."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from voidx.tools.base import ToolContext, ToolResult # noqa: F401 — re-export
|
|
8
|
+
from voidx.tools.file_ops import FileReadTool, FileWriteTool, FileEditTool
|
|
9
|
+
from voidx.tools.lsp import (
|
|
10
|
+
LspDefinitionTool,
|
|
11
|
+
LspDiagnosticsTool,
|
|
12
|
+
LspFormatTool,
|
|
13
|
+
LspReferencesTool,
|
|
14
|
+
LspSymbolsTool,
|
|
15
|
+
)
|
|
16
|
+
from voidx.tools.repomap import RepoMapTool
|
|
17
|
+
from voidx.tools.search import GlobTool, GrepTool
|
|
18
|
+
from voidx.tools.bash import BashTool
|
|
19
|
+
from voidx.tools.task_status import TaskStatusTool
|
|
20
|
+
from voidx.tools.todo import TodoWriteTool
|
|
21
|
+
from voidx.tools.webfetch import WebFetchTool
|
|
22
|
+
from voidx.tools.websearch import WebSearchTool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolDef(BaseModel):
|
|
26
|
+
"""A registered tool definition — everything the LLM needs to call it."""
|
|
27
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
description: str
|
|
31
|
+
parameters: dict # JSON Schema for the LLM
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolRegistry:
|
|
35
|
+
"""Manages all available tools. No dynamic discovery — everything explicit."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, settings=None) -> None:
|
|
38
|
+
self._tools: dict[str, ToolDef] = {}
|
|
39
|
+
self._instances: dict[str, object] = {}
|
|
40
|
+
self._settings = settings
|
|
41
|
+
self._register_builtins()
|
|
42
|
+
|
|
43
|
+
def _register_builtins(self) -> None:
|
|
44
|
+
for cls in [
|
|
45
|
+
FileReadTool, FileWriteTool, FileEditTool,
|
|
46
|
+
RepoMapTool,
|
|
47
|
+
GlobTool, GrepTool, BashTool,
|
|
48
|
+
TodoWriteTool,
|
|
49
|
+
LspDiagnosticsTool, LspSymbolsTool,
|
|
50
|
+
LspDefinitionTool, LspReferencesTool, LspFormatTool,
|
|
51
|
+
]:
|
|
52
|
+
instance = cls()
|
|
53
|
+
self.register(instance.id, instance, instance.description, instance.parameters_schema())
|
|
54
|
+
wf = WebFetchTool(settings=self._settings)
|
|
55
|
+
self.register(wf.id, wf, wf.description, wf.parameters_schema())
|
|
56
|
+
# WebSearchTool needs settings for Tavily API key
|
|
57
|
+
ws = WebSearchTool(settings=self._settings)
|
|
58
|
+
self.register(ws.id, ws, ws.description, ws.parameters_schema())
|
|
59
|
+
|
|
60
|
+
def register(self, tool_id: str, instance: object, description: str, parameters: dict) -> None:
|
|
61
|
+
"""Register a tool dynamically (e.g. agent tool injected at runtime)."""
|
|
62
|
+
self._tools[tool_id] = ToolDef(
|
|
63
|
+
id=tool_id, description=description, parameters=parameters,
|
|
64
|
+
)
|
|
65
|
+
self._instances[tool_id] = instance
|
|
66
|
+
|
|
67
|
+
def list(self) -> list[ToolDef]:
|
|
68
|
+
return list(self._tools.values())
|
|
69
|
+
|
|
70
|
+
def get(self, tool_id: str):
|
|
71
|
+
return self._instances.get(tool_id)
|
|
72
|
+
|
|
73
|
+
def get_def(self, tool_id: str) -> ToolDef | None:
|
|
74
|
+
return self._tools.get(tool_id)
|
|
75
|
+
|
|
76
|
+
def unregister_prefix(self, prefix: str) -> None:
|
|
77
|
+
for tool_id in [tid for tid in self._tools if tid.startswith(prefix)]:
|
|
78
|
+
self._tools.pop(tool_id, None)
|
|
79
|
+
self._instances.pop(tool_id, None)
|
|
80
|
+
|
|
81
|
+
def ids(self) -> list[str]:
|
|
82
|
+
return list(self._tools.keys())
|
|
83
|
+
|
|
84
|
+
def tools_for_llm(self) -> list[dict]:
|
|
85
|
+
"""Generate OpenAI/Anthropic-compatible tool definitions."""
|
|
86
|
+
result = []
|
|
87
|
+
for t in self._tools.values():
|
|
88
|
+
result.append({
|
|
89
|
+
"type": "function",
|
|
90
|
+
"function": {
|
|
91
|
+
"name": t.id,
|
|
92
|
+
"description": t.description,
|
|
93
|
+
"parameters": t.parameters,
|
|
94
|
+
"strict": True,
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
async def execute_tool(self, tool_id: str, args: dict, ctx: ToolContext) -> ToolResult:
|
|
100
|
+
"""Execute a tool by id. Returns typed result."""
|
|
101
|
+
tool = self.get(tool_id)
|
|
102
|
+
if not tool:
|
|
103
|
+
return ToolResult(output=f"Unknown tool: {tool_id}. Available: {self.ids()}")
|
|
104
|
+
return await tool.execute(args, ctx)
|
voidx/tools/repomap.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Repo map tool — structural overview of the codebase for LLM context.
|
|
2
|
+
|
|
3
|
+
Extracts function/class signatures from source files using regex-based
|
|
4
|
+
parsing. Groups by file, limits output to a token budget (~4000 tokens).
|
|
5
|
+
|
|
6
|
+
Supports Python by default. Extensible to other languages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult, resolve_safe
|
|
17
|
+
|
|
18
|
+
OUTPUT_TOKEN_BUDGET = 4000
|
|
19
|
+
DIR_ENTRY_LIMIT = 200
|
|
20
|
+
|
|
21
|
+
SKIP_DIRS = {
|
|
22
|
+
".git", ".venv", "venv", "node_modules", "__pycache__",
|
|
23
|
+
".pytest_cache", ".mypy_cache", ".tox", ".eggs",
|
|
24
|
+
".idea", ".vscode", "dist", "build", "opencode",
|
|
25
|
+
".claude", ".ruff_cache",
|
|
26
|
+
}
|
|
27
|
+
SKIP_SUFFIXES = {
|
|
28
|
+
".pyc", ".pyo", ".so", ".dll", ".exe", ".bin",
|
|
29
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf",
|
|
30
|
+
".zip", ".tar", ".gz", ".whl", ".egg",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RepoMapInput(BaseModel):
|
|
35
|
+
path: str | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Subdirectory to focus on. Defaults to workspace root."
|
|
38
|
+
)
|
|
39
|
+
detail: str = Field(
|
|
40
|
+
default="overview",
|
|
41
|
+
description="'overview' = top-level symbols only, 'signatures' = all function/class signatures"
|
|
42
|
+
)
|
|
43
|
+
pattern: str | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="Glob pattern to filter files, e.g. '*.py' or 'src/**/*.ts'"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RepoMapTool(BaseTool):
|
|
50
|
+
id = "repo_map"
|
|
51
|
+
description = (
|
|
52
|
+
"Structural map of the codebase: file tree with function/class signatures. "
|
|
53
|
+
"detail='overview' returns top-level symbols only. detail='signatures' "
|
|
54
|
+
"returns all symbols including methods. Narrow with path or pattern."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def parameters_schema(self) -> dict:
|
|
58
|
+
return model_to_json_schema(RepoMapInput)
|
|
59
|
+
|
|
60
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
61
|
+
inp = RepoMapInput.model_validate(args)
|
|
62
|
+
base = Path(ctx.workspace)
|
|
63
|
+
if inp.path:
|
|
64
|
+
root = resolve_safe(ctx.workspace, inp.path, ctx.sandbox_extra_paths)
|
|
65
|
+
if root is None:
|
|
66
|
+
return ToolResult(output=f"Path traversal blocked: {inp.path}")
|
|
67
|
+
else:
|
|
68
|
+
root = base
|
|
69
|
+
|
|
70
|
+
if not root.exists():
|
|
71
|
+
return ToolResult(output=f"Path not found: {root}")
|
|
72
|
+
|
|
73
|
+
files = _collect_files(root, inp.pattern)
|
|
74
|
+
if not files:
|
|
75
|
+
return ToolResult(output=f"No source files found in: {root}")
|
|
76
|
+
|
|
77
|
+
detail = inp.detail
|
|
78
|
+
entries: list[str] = []
|
|
79
|
+
total_tokens = 0
|
|
80
|
+
|
|
81
|
+
for f in files:
|
|
82
|
+
rel = str(f.relative_to(base)).replace("\\", "/")
|
|
83
|
+
|
|
84
|
+
if detail == "overview":
|
|
85
|
+
symbols = _extract_top_level(f)
|
|
86
|
+
else:
|
|
87
|
+
symbols = _extract_all_symbols(f)
|
|
88
|
+
|
|
89
|
+
if symbols:
|
|
90
|
+
entry = f"{rel}:\n" + "".join(symbols)
|
|
91
|
+
else:
|
|
92
|
+
entry = f"{rel}: (no symbols) " if detail == "signatures" else ""
|
|
93
|
+
|
|
94
|
+
est = _estimate_tokens(entry)
|
|
95
|
+
if total_tokens + est > OUTPUT_TOKEN_BUDGET:
|
|
96
|
+
remaining = len(files) - len(entries)
|
|
97
|
+
entries.append(f"\n... ({remaining} more files omitted — token limit)")
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if entry:
|
|
101
|
+
entries.append(entry)
|
|
102
|
+
total_tokens += est
|
|
103
|
+
|
|
104
|
+
if not entries:
|
|
105
|
+
return ToolResult(output=f"No recognizable symbols found in: {root}")
|
|
106
|
+
|
|
107
|
+
return ToolResult(
|
|
108
|
+
title=f"Repo map: {root.relative_to(base)} ({len(entries)} files)",
|
|
109
|
+
output="\n".join(entries),
|
|
110
|
+
metadata={
|
|
111
|
+
"root": str(root.relative_to(base)),
|
|
112
|
+
"files": len(entries),
|
|
113
|
+
"detail": detail,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── file collection ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _collect_files(root: Path, pattern: str | None) -> list[Path]:
|
|
121
|
+
files: list[Path] = []
|
|
122
|
+
|
|
123
|
+
def _scan(dir_path: Path):
|
|
124
|
+
try:
|
|
125
|
+
entries = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
126
|
+
except PermissionError:
|
|
127
|
+
return
|
|
128
|
+
for entry in entries:
|
|
129
|
+
if entry.name in SKIP_DIRS or entry.name.startswith("."):
|
|
130
|
+
continue
|
|
131
|
+
if entry.is_dir():
|
|
132
|
+
_scan(entry)
|
|
133
|
+
elif entry.is_file():
|
|
134
|
+
if entry.suffix in SKIP_SUFFIXES:
|
|
135
|
+
continue
|
|
136
|
+
if pattern and not entry.match(pattern):
|
|
137
|
+
continue
|
|
138
|
+
if _is_source(entry):
|
|
139
|
+
files.append(entry)
|
|
140
|
+
|
|
141
|
+
_scan(root)
|
|
142
|
+
return files
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_source(path: Path) -> bool:
|
|
146
|
+
ext_map = {
|
|
147
|
+
".py": True, ".ts": True, ".tsx": True, ".js": True, ".jsx": True,
|
|
148
|
+
".go": True, ".rs": True, ".java": True, ".kt": True, ".swift": True,
|
|
149
|
+
".c": True, ".h": True, ".cpp": True, ".hpp": True, ".rb": True,
|
|
150
|
+
".php": True, ".css": True, ".scss": True, ".less": True,
|
|
151
|
+
".sql": True, ".sh": True, ".bash": True, ".zsh": True,
|
|
152
|
+
".yaml": True, ".yml": True, ".toml": True, ".json": True,
|
|
153
|
+
".md": True, ".txt": True, ".cfg": True, ".ini": True,
|
|
154
|
+
".dockerfile": True, ".makefile": True, ".mk": True,
|
|
155
|
+
}
|
|
156
|
+
name = path.name.lower()
|
|
157
|
+
if name == "dockerfile" or name == "makefile":
|
|
158
|
+
return True
|
|
159
|
+
return ext_map.get(path.suffix, False)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Python symbol extraction ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _extract_all_symbols(f: Path) -> list[str]:
|
|
166
|
+
if f.suffix == ".py":
|
|
167
|
+
return _extract_python_symbols(f)
|
|
168
|
+
return _extract_generic(f)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _extract_top_level(f: Path) -> list[str]:
|
|
172
|
+
if f.suffix == ".py":
|
|
173
|
+
return _extract_python_symbols(f, top_level_only=True)
|
|
174
|
+
return _extract_generic(f)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_python_symbols(f: Path, top_level_only: bool = False) -> list[str]:
|
|
178
|
+
try:
|
|
179
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
180
|
+
except Exception:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
lines: list[str] = []
|
|
184
|
+
decorator: str | None = None
|
|
185
|
+
|
|
186
|
+
_RE_COMBINED = re.compile(
|
|
187
|
+
r"^(\s*)class\s+(\w+)\s*(?:\(([^)]*?)\))?\s*:"
|
|
188
|
+
r"|^(\s*)(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)\s*(?:->[^:]+?)?\s*:"
|
|
189
|
+
r"|^[ \t]*@(\w+)",
|
|
190
|
+
re.MULTILINE,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
for match in _RE_COMBINED.finditer(text):
|
|
194
|
+
indent = match.group(1) or match.group(4) or ""
|
|
195
|
+
level = len(indent) // 4 if indent else 0
|
|
196
|
+
|
|
197
|
+
if match.group(0).startswith("@"):
|
|
198
|
+
decorator = match.group(7) or ""
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
is_class = match.group(2) is not None
|
|
202
|
+
func_name = match.group(5)
|
|
203
|
+
name = match.group(2) or func_name
|
|
204
|
+
args = match.group(3) or match.group(6) or ""
|
|
205
|
+
|
|
206
|
+
if top_level_only and level > 0:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
prefix = " " * level
|
|
210
|
+
if is_class:
|
|
211
|
+
tag = ""
|
|
212
|
+
if decorator:
|
|
213
|
+
tag = f"@{decorator}\n{prefix}"
|
|
214
|
+
decorator = None
|
|
215
|
+
base = f"({match.group(3)})" if match.group(3) else ""
|
|
216
|
+
lines.append(f"{prefix}{tag}class {name}{base}\n")
|
|
217
|
+
else:
|
|
218
|
+
tag = ""
|
|
219
|
+
if decorator:
|
|
220
|
+
tag = f"@{decorator} "
|
|
221
|
+
decorator = None
|
|
222
|
+
if match.group(0).lstrip().startswith("async"):
|
|
223
|
+
tag = "async "
|
|
224
|
+
lines.append(f"{prefix}{tag}def {name}({args})\n")
|
|
225
|
+
|
|
226
|
+
return lines
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _extract_generic(f: Path) -> list[str]:
|
|
230
|
+
"""Minimal extraction for non-Python files: just report the file."""
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── token estimation ─────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def _estimate_tokens(text: str) -> int:
|
|
237
|
+
"""Quick token estimate: ~4 chars per token for code."""
|
|
238
|
+
return max(1, len(text) // 4)
|
voidx/tools/search.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Search tools — glob pattern matching, grep content search. Fast, deterministic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult, resolve_safe
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GlobInput(BaseModel):
|
|
14
|
+
pattern: str = Field(description="Glob pattern to match files, e.g. '**/*.py' or 'src/**/*.ts'")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GlobTool(BaseTool):
|
|
18
|
+
id = "glob"
|
|
19
|
+
description = (
|
|
20
|
+
"Find files by glob pattern (e.g. '**/*.py'). Returns sorted relative paths. "
|
|
21
|
+
"Skips .git, node_modules, .venv, __pycache__, and other build/dot directories."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def parameters_schema(self) -> dict:
|
|
25
|
+
return model_to_json_schema(GlobInput)
|
|
26
|
+
|
|
27
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
28
|
+
inp = GlobInput.model_validate(args)
|
|
29
|
+
base = Path(ctx.workspace)
|
|
30
|
+
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "opencode",
|
|
31
|
+
".pytest_cache", ".mypy_cache", ".tox", ".idea", ".vscode",
|
|
32
|
+
"dist", "build", ".eggs"}
|
|
33
|
+
|
|
34
|
+
def _visible_path(p: Path) -> bool:
|
|
35
|
+
parts = p.relative_to(base).parts
|
|
36
|
+
for i, part in enumerate(parts):
|
|
37
|
+
if part in SKIP_DIRS:
|
|
38
|
+
return False
|
|
39
|
+
# Allow dot-files at root level (e.g. .env, .gitignore)
|
|
40
|
+
# but skip content inside hidden directories
|
|
41
|
+
if part.startswith(".") and i < len(parts) - 1:
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
matches = sorted(
|
|
46
|
+
str(p.relative_to(base)).replace("\\", "/")
|
|
47
|
+
for p in base.glob(inp.pattern)
|
|
48
|
+
if _visible_path(p)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if not matches:
|
|
52
|
+
return ToolResult(
|
|
53
|
+
output=f"No files matched pattern: {inp.pattern}",
|
|
54
|
+
metadata={"pattern": inp.pattern, "matches": 0},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return ToolResult(
|
|
58
|
+
title=f"Glob: {inp.pattern} → {len(matches)} files",
|
|
59
|
+
output="\n".join(matches[:200]), # cap at 200 results
|
|
60
|
+
metadata={"pattern": inp.pattern, "matches": len(matches)},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GrepInput(BaseModel):
|
|
65
|
+
pattern: str = Field(description="Regular expression to search for")
|
|
66
|
+
path: str | None = Field(default=None, description="File or directory to search. Defaults to workspace root.")
|
|
67
|
+
include: str | None = Field(default=None, description="Glob pattern to filter files, e.g. '*.py'")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GrepTool(BaseTool):
|
|
71
|
+
id = "grep"
|
|
72
|
+
description = (
|
|
73
|
+
"Search file contents with regex. Returns file:line:content matches. "
|
|
74
|
+
"Skips .git, node_modules, binary files, and build/dot directories."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def parameters_schema(self) -> dict:
|
|
78
|
+
return model_to_json_schema(GrepInput)
|
|
79
|
+
|
|
80
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
81
|
+
inp = GrepInput.model_validate(args)
|
|
82
|
+
base = Path(ctx.workspace)
|
|
83
|
+
search_dir = resolve_safe(ctx.workspace, inp.path, ctx.sandbox_extra_paths) if inp.path else base
|
|
84
|
+
if search_dir is None:
|
|
85
|
+
return ToolResult(output=f"Path traversal blocked: {inp.path}")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
regex = re.compile(inp.pattern)
|
|
89
|
+
except re.error as e:
|
|
90
|
+
return ToolResult(output=f"Invalid regex: {e}")
|
|
91
|
+
|
|
92
|
+
results: list[str] = []
|
|
93
|
+
count = 0
|
|
94
|
+
scanned = 0
|
|
95
|
+
|
|
96
|
+
# Directories to never enter
|
|
97
|
+
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__",
|
|
98
|
+
".pytest_cache", ".mypy_cache", ".tox", "opencode",
|
|
99
|
+
".idea", ".vscode", "dist", "build", ".eggs"}
|
|
100
|
+
|
|
101
|
+
# Binary/text suffixes to skip
|
|
102
|
+
SKIP_SUFFIXES = {".pyc", ".pyo", ".so", ".dll", ".exe", ".bin",
|
|
103
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf",
|
|
104
|
+
".zip", ".tar", ".gz", ".whl", ".egg"}
|
|
105
|
+
|
|
106
|
+
def should_skip_dir(p: Path) -> bool:
|
|
107
|
+
return p.name in SKIP_DIRS or p.name.startswith(".")
|
|
108
|
+
|
|
109
|
+
def iter_files(dir_path: Path):
|
|
110
|
+
nonlocal scanned
|
|
111
|
+
try:
|
|
112
|
+
for entry in dir_path.iterdir():
|
|
113
|
+
if should_skip_dir(entry):
|
|
114
|
+
continue
|
|
115
|
+
if entry.is_dir():
|
|
116
|
+
yield from iter_files(entry)
|
|
117
|
+
elif entry.is_file():
|
|
118
|
+
if entry.suffix in SKIP_SUFFIXES:
|
|
119
|
+
continue
|
|
120
|
+
if inp.include and not entry.match(inp.include):
|
|
121
|
+
continue
|
|
122
|
+
scanned += 1
|
|
123
|
+
yield entry
|
|
124
|
+
except PermissionError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
if search_dir.is_file():
|
|
128
|
+
files = [search_dir]
|
|
129
|
+
elif search_dir.is_dir():
|
|
130
|
+
files = iter_files(search_dir)
|
|
131
|
+
else:
|
|
132
|
+
return ToolResult(output=f"Path not found: {inp.path}")
|
|
133
|
+
|
|
134
|
+
for f in files:
|
|
135
|
+
if scanned > 5000:
|
|
136
|
+
results.append(f"... (stopped after scanning {scanned} files)")
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").split("\n"), 1):
|
|
141
|
+
if regex.search(line):
|
|
142
|
+
rel = str(f.relative_to(base)).replace("\\", "/")
|
|
143
|
+
results.append(f"{rel}:{i}:{line.strip()[:200]}")
|
|
144
|
+
count += 1
|
|
145
|
+
if count >= 100:
|
|
146
|
+
break
|
|
147
|
+
if count >= 100:
|
|
148
|
+
break
|
|
149
|
+
except Exception:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if not results:
|
|
153
|
+
return ToolResult(
|
|
154
|
+
output=f"No matches found for: {inp.pattern}",
|
|
155
|
+
metadata={"pattern": inp.pattern, "matches": 0},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return ToolResult(
|
|
159
|
+
title=f"Grep: {inp.pattern} → {count} matches",
|
|
160
|
+
output="\n".join(results),
|
|
161
|
+
metadata={"pattern": inp.pattern, "matches": count},
|
|
162
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""TaskStatus tool — check worker-role progress. Claude Code aligned."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskStatusInput(BaseModel):
|
|
11
|
+
task_id: str | None = Field(
|
|
12
|
+
default=None,
|
|
13
|
+
description="Specific task ID to check. If omitted, lists all tasks."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskStatusTool(BaseTool):
|
|
18
|
+
id = "task_status"
|
|
19
|
+
description = (
|
|
20
|
+
"Check child-agent task status. Returns status (pending/running/completed/error), "
|
|
21
|
+
"current step, elapsed time, and recent output preview. "
|
|
22
|
+
"Without task_id, lists all tasks."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def __init__(self, tracker=None):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self._tracker = tracker
|
|
28
|
+
|
|
29
|
+
def parameters_schema(self) -> dict:
|
|
30
|
+
return model_to_json_schema(TaskStatusInput)
|
|
31
|
+
|
|
32
|
+
async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
33
|
+
inp = TaskStatusInput.model_validate(args)
|
|
34
|
+
|
|
35
|
+
if not self._tracker:
|
|
36
|
+
return ToolResult(output="Task tracker not available.")
|
|
37
|
+
|
|
38
|
+
if inp.task_id:
|
|
39
|
+
task = self._tracker.get(inp.task_id)
|
|
40
|
+
if not task:
|
|
41
|
+
return ToolResult(output=f"Task not found: {inp.task_id}")
|
|
42
|
+
return ToolResult(
|
|
43
|
+
title=f"Task {inp.task_id}: {task.status}",
|
|
44
|
+
output=self._tracker.format_status(),
|
|
45
|
+
metadata={
|
|
46
|
+
"task_id": task.id, "agent": task.agent,
|
|
47
|
+
"status": task.status, "step": task.step,
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
output = self._tracker.format_status()
|
|
52
|
+
running = self._tracker.list_running()
|
|
53
|
+
return ToolResult(
|
|
54
|
+
title=f"Tasks: {len(running)} running, {len(self._tracker._tasks)} total",
|
|
55
|
+
output=output,
|
|
56
|
+
metadata={"running": len(running), "total": len(self._tracker._tasks)},
|
|
57
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Task tracker — shared state for running worker-role status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
TaskStatus = Literal["pending", "running", "completed", "error", "cancelled"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TaskState:
|
|
14
|
+
id: str
|
|
15
|
+
agent: str
|
|
16
|
+
description: str
|
|
17
|
+
status: TaskStatus = "pending"
|
|
18
|
+
step: int = 0
|
|
19
|
+
max_steps: int = 0
|
|
20
|
+
last_output: str = "" # most recent text preview
|
|
21
|
+
created_at: float = field(default_factory=time.time)
|
|
22
|
+
updated_at: float = field(default_factory=time.time)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TaskTracker:
|
|
26
|
+
"""Thread-safe registry for running worker-role tasks."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self._tasks: dict[str, TaskState] = {}
|
|
30
|
+
self._todos: list = [] # type: ignore[type-arg]
|
|
31
|
+
|
|
32
|
+
def start(self, task_id: str, agent: str, description: str, max_steps: int = 25) -> TaskState:
|
|
33
|
+
state = TaskState(
|
|
34
|
+
id=task_id, agent=agent, description=description,
|
|
35
|
+
status="running", max_steps=max_steps,
|
|
36
|
+
)
|
|
37
|
+
self._tasks[task_id] = state
|
|
38
|
+
return state
|
|
39
|
+
|
|
40
|
+
def update(self, task_id: str, **kwargs):
|
|
41
|
+
if task_id in self._tasks:
|
|
42
|
+
for k, v in kwargs.items():
|
|
43
|
+
if hasattr(self._tasks[task_id], k):
|
|
44
|
+
setattr(self._tasks[task_id], k, v)
|
|
45
|
+
self._tasks[task_id].updated_at = time.time()
|
|
46
|
+
|
|
47
|
+
def finish(self, task_id: str, status: TaskStatus = "completed"):
|
|
48
|
+
if task_id in self._tasks:
|
|
49
|
+
self._tasks[task_id].status = status
|
|
50
|
+
self._tasks[task_id].updated_at = time.time()
|
|
51
|
+
|
|
52
|
+
def get(self, task_id: str) -> TaskState | None:
|
|
53
|
+
return self._tasks.get(task_id)
|
|
54
|
+
|
|
55
|
+
def list_all(self) -> list[TaskState]:
|
|
56
|
+
return list(self._tasks.values())
|
|
57
|
+
|
|
58
|
+
def list_running(self) -> list[TaskState]:
|
|
59
|
+
return [t for t in self._tasks.values() if t.status in ("pending", "running")]
|
|
60
|
+
|
|
61
|
+
def remove(self, task_id: str):
|
|
62
|
+
self._tasks.pop(task_id, None)
|
|
63
|
+
|
|
64
|
+
def format_status(self) -> str:
|
|
65
|
+
"""Format all tasks as a status report string."""
|
|
66
|
+
tasks = self.list_all()
|
|
67
|
+
if not tasks:
|
|
68
|
+
return "No worker-role tasks."
|
|
69
|
+
|
|
70
|
+
lines = []
|
|
71
|
+
for t in tasks:
|
|
72
|
+
icon = {"pending": "○", "running": "◐", "completed": "●", "error": "✕", "cancelled": "◌"}[t.status]
|
|
73
|
+
elapsed = int(time.time() - t.created_at)
|
|
74
|
+
step_info = f"step {t.step}/{t.max_steps}" if t.max_steps else ""
|
|
75
|
+
preview = t.last_output[:100] if t.last_output else ""
|
|
76
|
+
lines.append(
|
|
77
|
+
f"{icon} [{t.id}] {t.agent} — {t.status} ({step_info}, {elapsed}s)\n"
|
|
78
|
+
f" {t.description[:120]}\n"
|
|
79
|
+
+ (f" last: {preview}" if preview else "")
|
|
80
|
+
)
|
|
81
|
+
return "\n".join(lines)
|