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.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
@@ -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)