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
voidx/tools/agent.py ADDED
@@ -0,0 +1,81 @@
1
+ """Agent tool — start an isolated child agent with filtered context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from voidx.agent.agents import child_agent_descriptions_for_llm, get_agent
8
+ from voidx.tools.base import BaseTool, ToolContext, ToolResult, model_to_json_schema
9
+
10
+
11
+ class AgentInput(BaseModel):
12
+ agent: str = Field(
13
+ description=(
14
+ "Child agent to run: explore, plan, implement, or review. "
15
+ "Use implement only when the user explicitly asked to modify code."
16
+ )
17
+ )
18
+ description: str = Field(
19
+ description=(
20
+ "Complete, self-contained task description for the child agent. "
21
+ "Include all context it needs because it receives only a filtered "
22
+ "slice of the parent conversation."
23
+ )
24
+ )
25
+ model: str | None = Field(
26
+ default=None,
27
+ description="Optional model override for this child agent.",
28
+ )
29
+
30
+
31
+ class AgentTool(BaseTool):
32
+ id = "agent"
33
+ description = (
34
+ "Start an isolated child agent for a delegated task. Use this for true "
35
+ "sub-agent work only: broad codebase exploration, implementation, review, "
36
+ "or planning that should run in its own context.\n\n"
37
+ + child_agent_descriptions_for_llm()
38
+ + "\n\nIMPORTANT: Provide a complete, self-contained task description. "
39
+ "The child agent receives only a filtered slice of the parent context plus "
40
+ "your task description."
41
+ )
42
+
43
+ def __init__(self, runner=None):
44
+ super().__init__()
45
+ self._run_child_agent = runner
46
+
47
+ def parameters_schema(self) -> dict:
48
+ return model_to_json_schema(AgentInput)
49
+
50
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
51
+ inp = AgentInput.model_validate(args)
52
+
53
+ agent_def = get_agent(inp.agent)
54
+ if not agent_def:
55
+ available = [
56
+ a.name
57
+ for a in [get_agent(n) for n in ["explore", "plan", "implement", "review"]]
58
+ if a
59
+ ]
60
+ return ToolResult(output=f"Unknown child agent: {inp.agent}. Available: {available}")
61
+
62
+ if agent_def.name == "orchestrator":
63
+ return ToolResult(output="Cannot run orchestrator as a child agent.")
64
+
65
+ if not self._run_child_agent:
66
+ return ToolResult(
67
+ output=f"Child agent execution not available. Task: {inp.description[:200]}"
68
+ )
69
+
70
+ try:
71
+ output = await self._run_child_agent(agent_def, inp.description, inp.model)
72
+ return ToolResult(
73
+ title=f"{agent_def.name}: {inp.description[:60]}",
74
+ output=output,
75
+ metadata={"agent": agent_def.name, "model": inp.model or agent_def.model or "default"},
76
+ )
77
+ except Exception as exc:
78
+ return ToolResult(
79
+ output=f"Child agent '{agent_def.name}' failed: {exc}",
80
+ metadata={"agent": agent_def.name, "error": str(exc)},
81
+ )
voidx/tools/base.py ADDED
@@ -0,0 +1,86 @@
1
+ """Tool base — abstract contract, result types, context. No circular imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from collections.abc import Callable
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ def resolve_safe(workspace: str, file_path: str, extra_paths: list[str] | None = None) -> Path | None:
14
+ """Resolve file path and verify it stays inside workspace (+ optional extra paths).
15
+
16
+ Returns the resolved path if safe, or None if blocked.
17
+ """
18
+ ws = Path(workspace).resolve()
19
+ resolved = (ws / file_path).resolve()
20
+
21
+ allowed = [ws]
22
+ if extra_paths:
23
+ for ep in extra_paths:
24
+ allowed.append(Path(ep).expanduser().resolve())
25
+
26
+ for base in allowed:
27
+ try:
28
+ resolved.relative_to(base)
29
+ return resolved
30
+ except ValueError:
31
+ continue
32
+ return None
33
+
34
+
35
+ class ToolResult(BaseModel):
36
+ """Result from tool execution. Typed so the agent can reason about it."""
37
+ title: str = ""
38
+ output: str
39
+ metadata: dict = {}
40
+ diff: str | None = None # unified diff for edit/write tools
41
+
42
+
43
+ class ToolContext(BaseModel):
44
+ """Context passed to every tool execution. Mutable file_mtimes for staleness guard."""
45
+ workspace: str
46
+ session_id: str = "default"
47
+ agent: str = "build"
48
+ file_mtimes: dict[str, float] = Field(default_factory=dict)
49
+ mcp_manager: Any | None = None
50
+ lsp_manager: Any | None = None
51
+ sandbox_extra_paths: list[str] = Field(default_factory=list)
52
+
53
+ model_config = {"arbitrary_types_allowed": True}
54
+
55
+
56
+ class BaseTool(ABC):
57
+ """Every tool has: id, description, typed parameters, deterministic execute."""
58
+
59
+ @property
60
+ @abstractmethod
61
+ def id(self) -> str: ...
62
+
63
+ @property
64
+ @abstractmethod
65
+ def description(self) -> str: ...
66
+
67
+ @abstractmethod
68
+ def parameters_schema(self) -> dict[str, Any]:
69
+ """Return JSON Schema dict generated from the tool's Pydantic input model."""
70
+ ...
71
+
72
+ @abstractmethod
73
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
74
+ """Execute the tool with typed inputs. Returns typed result."""
75
+ ...
76
+
77
+
78
+ def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
79
+ """Convert a Pydantic model to JSON Schema dict."""
80
+ schema = model.model_json_schema()
81
+ return {
82
+ "type": "object",
83
+ "properties": schema.get("properties", {}),
84
+ "required": schema.get("required", []),
85
+ "additionalProperties": False,
86
+ }
voidx/tools/bash.py ADDED
@@ -0,0 +1,105 @@
1
+ """Bash tool — execute shell commands, capture output. Exact, measurable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import subprocess
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult
12
+
13
+ # Patterns that are always blocked regardless of permission
14
+ _BLOCKED = [
15
+ (r"\bsudo\b", "sudo is blocked — privilege escalation"),
16
+ (r"\bchmod\s+.*[0]*7\d{2}\b", "chmod 7xx is blocked — world-writable permissions"),
17
+ (r"\bchmod\s+[0]*\d*7\b", "chmod with 7 is blocked"),
18
+ (r"\bchown\b", "chown is blocked"),
19
+ (r"\bchgrp\b", "chgrp is blocked"),
20
+ (r"\bmkfs\b", "mkfs is blocked — filesystem formatting"),
21
+ (r"\bdd\s+if=.*of=/dev/", "dd to /dev is blocked — raw disk write"),
22
+ (r">\s*/dev/sd", "write to /dev/sd* is blocked"),
23
+ (r"\breboot\b", "reboot is blocked"),
24
+ (r"\bshutdown\b", "shutdown is blocked"),
25
+ (r"\bpoweroff\b", "poweroff is blocked"),
26
+ (r"\binit\s+[06]\b", "init runlevel change is blocked"),
27
+ (r":\(\)\s*\{", "fork bomb pattern is blocked"),
28
+ (r"\bgit\s+push\s+.*(-f|--force).*(main|master)\b", "force push to main/master is blocked"),
29
+ (r"\bcurl\b.*\|\s*(bash|sh|/bin/bash|/bin/sh)\b", "curl piped to shell is blocked"),
30
+ (r"\bwget\b.*\|\s*(bash|sh|/bin/bash|/bin/sh)\b", "wget piped to shell is blocked"),
31
+ ]
32
+
33
+
34
+ def _normalize_command(command: str) -> str:
35
+ """Strip common shell escapes so blocked patterns can't be bypassed."""
36
+ import re as _re
37
+ s = command.strip()
38
+ s = _re.sub(r"\\\s*\n", " ", s)
39
+ s = _re.sub(r"\\(.)", r"\1", s)
40
+ s = _re.sub(r"\$\([^)]*\)", "SUB", s)
41
+ s = _re.sub(r"`[^`]*`", "SUB", s)
42
+ s = _re.sub(r"''", "", s)
43
+ return s
44
+
45
+
46
+ def _check_command(command: str) -> str | None:
47
+ """Return block reason if command matches a dangerous pattern, else None."""
48
+ normalized = _normalize_command(command)
49
+ for pattern, reason in _BLOCKED:
50
+ if re.search(pattern, normalized):
51
+ return f"Blocked: {reason}\n command: {command.strip()[:120]}"
52
+ return None
53
+
54
+
55
+ class BashInput(BaseModel):
56
+ command: str = Field(description="Shell command to execute")
57
+ timeout: int = Field(default=120, description="Timeout in seconds")
58
+
59
+
60
+ class BashTool(BaseTool):
61
+ id = "bash"
62
+ description = "Execute a shell command in the workspace directory. Returns stdout, stderr, and exit code."
63
+
64
+ def parameters_schema(self) -> dict:
65
+ return model_to_json_schema(BashInput)
66
+
67
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
68
+ inp = BashInput.model_validate(args)
69
+
70
+ blocked = _check_command(inp.command)
71
+ if blocked:
72
+ return ToolResult(output=blocked, metadata={"command": inp.command, "blocked": True})
73
+
74
+ try:
75
+ proc = await asyncio.create_subprocess_shell(
76
+ inp.command,
77
+ stdout=subprocess.PIPE,
78
+ stderr=subprocess.PIPE,
79
+ cwd=ctx.workspace,
80
+ )
81
+ stdout, stderr = await asyncio.wait_for(
82
+ proc.communicate(), timeout=inp.timeout
83
+ )
84
+ except asyncio.TimeoutError:
85
+ return ToolResult(
86
+ output=f"Command timed out after {inp.timeout}s: {inp.command}",
87
+ metadata={"command": inp.command, "exit_code": -1, "timeout": True},
88
+ )
89
+
90
+ output_parts = []
91
+ if stdout:
92
+ output_parts.append(stdout.decode("utf-8", errors="replace"))
93
+ if stderr:
94
+ output_parts.append(f"[stderr]\n{stderr.decode('utf-8', errors='replace')}")
95
+
96
+ return ToolResult(
97
+ title=f"Bash: {inp.command}",
98
+ output="\n".join(output_parts) or "(no output)",
99
+ metadata={
100
+ "command": inp.command,
101
+ "exit_code": proc.returncode or 0,
102
+ "stdout_size": len(stdout) if stdout else 0,
103
+ "stderr_size": len(stderr) if stderr else 0,
104
+ },
105
+ )
@@ -0,0 +1,193 @@
1
+ """File operation tools — read, write, edit. Deterministic, typed I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from voidx.tools.base import BaseTool, model_to_json_schema, ToolContext, ToolResult, resolve_safe
10
+
11
+
12
+ def _check_staleness(ctx: ToolContext, resolved: Path) -> str | None:
13
+ """Return error message if file was modified since last read, else None."""
14
+ key = str(resolved.resolve())
15
+ if key not in ctx.file_mtimes:
16
+ return None
17
+ if not resolved.exists():
18
+ return f"File deleted since last read: {resolved}"
19
+ current_mtime = resolved.stat().st_mtime
20
+ if current_mtime != ctx.file_mtimes[key]:
21
+ return (
22
+ f"File was modified since last read: {resolved}. "
23
+ "Please re-read the file before editing."
24
+ )
25
+ return None
26
+
27
+
28
+ def _record_mtime(ctx: ToolContext, resolved: Path) -> None:
29
+ """Record file mtime after successful read or write."""
30
+ if resolved.exists():
31
+ ctx.file_mtimes[str(resolved.resolve())] = resolved.stat().st_mtime
32
+
33
+
34
+ class FileReadInput(BaseModel):
35
+ file_path: str = Field(description="Absolute or relative path to the file")
36
+ offset: int | None = Field(default=None, description="Line number to start reading from (1-based)")
37
+ limit: int | None = Field(default=None, description="Maximum number of lines to read")
38
+
39
+
40
+ class FileReadTool(BaseTool):
41
+ id = "read"
42
+ description = "Read a file. Returns content with line numbers. Use offset/limit for large files."
43
+
44
+ def parameters_schema(self) -> dict:
45
+ return model_to_json_schema(FileReadInput)
46
+
47
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
48
+ inp = FileReadInput.model_validate(args)
49
+ path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
50
+ if path is None:
51
+ return ToolResult(output=f"Path traversal blocked: {inp.file_path}", metadata={"error": True})
52
+ if not path.exists():
53
+ return ToolResult(output=f"File not found: {inp.file_path}", metadata={"error": True})
54
+ if path.is_dir():
55
+ return ToolResult(output=f"Path is a directory: {inp.file_path}", metadata={"error": True})
56
+
57
+ text = path.read_text(encoding="utf-8", errors="replace")
58
+ if text.endswith("\n"):
59
+ text = text[:-1]
60
+ lines = text.split("\n")
61
+ start = (inp.offset or 1) - 1
62
+ end = start + (inp.limit or len(lines))
63
+ sliced = lines[start:end]
64
+
65
+ numbered = []
66
+ for i, line in enumerate(sliced, start=start + 1):
67
+ numbered.append(f"{i}\t{line}")
68
+
69
+ _record_mtime(ctx, path)
70
+
71
+ return ToolResult(
72
+ title=f"Read {len(sliced)} lines from {inp.file_path}",
73
+ output="\n".join(numbered),
74
+ metadata={"file": inp.file_path, "lines": len(sliced), "total_lines": len(lines)},
75
+ )
76
+
77
+
78
+ class FileWriteInput(BaseModel):
79
+ file_path: str = Field(description="Path to write the file to")
80
+ content: str = Field(description="Content to write")
81
+
82
+
83
+ class FileWriteTool(BaseTool):
84
+ id = "write"
85
+ description = "Write content to a file. Creates parent directories. Overwrites existing files."
86
+
87
+ def parameters_schema(self) -> dict:
88
+ return model_to_json_schema(FileWriteInput)
89
+
90
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
91
+ inp = FileWriteInput.model_validate(args)
92
+ path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
93
+ if path is None:
94
+ return ToolResult(output=f"Path traversal blocked: {inp.file_path}", metadata={"error": True})
95
+ if path.exists():
96
+ stale = _check_staleness(ctx, path)
97
+ if stale:
98
+ return ToolResult(output=stale, metadata={"error": True})
99
+
100
+ old_content = ""
101
+ if path.exists():
102
+ old_content = path.read_text(encoding="utf-8", errors="replace")
103
+
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_text(inp.content, encoding="utf-8")
106
+ size = len(inp.content)
107
+ _record_mtime(ctx, path)
108
+
109
+ from voidx.ui.diff import make_file_diff
110
+ diff = make_file_diff(
111
+ inp.file_path,
112
+ old_content,
113
+ inp.content,
114
+ old_label=f"a/{inp.file_path}" if old_content else "/dev/null",
115
+ new_label=f"b/{inp.file_path}",
116
+ )
117
+
118
+ return ToolResult(
119
+ title=f"Wrote {size} bytes to {inp.file_path}",
120
+ output=f"File written: {inp.file_path} ({size} bytes)",
121
+ metadata={"file": inp.file_path, "size": size},
122
+ diff=diff,
123
+ )
124
+
125
+
126
+ class EditEntry(BaseModel):
127
+ old_string: str = Field(description="Exact string to replace. Must exist at least once in the file.")
128
+ new_string: str = Field(description="String to replace with")
129
+
130
+
131
+ class FileEditInput(BaseModel):
132
+ file_path: str = Field(description="Path to edit")
133
+ edits: list[EditEntry] = Field(description="List of edits to apply atomically. Use a single entry for one change.")
134
+
135
+
136
+ class FileEditTool(BaseTool):
137
+ id = "edit"
138
+ description = (
139
+ "Replace strings in a single file atomically. Replaces ALL occurrences "
140
+ "of each old_string. Edits apply in order — later edits see earlier results."
141
+ )
142
+
143
+ def parameters_schema(self) -> dict:
144
+ return model_to_json_schema(FileEditInput)
145
+
146
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
147
+ inp = FileEditInput.model_validate(args)
148
+ path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
149
+ if path is None:
150
+ return ToolResult(output=f"Path traversal blocked: {inp.file_path}", metadata={"error": True})
151
+ if not path.exists():
152
+ return ToolResult(output=f"File not found: {inp.file_path}", metadata={"error": True})
153
+
154
+ if not inp.edits:
155
+ return ToolResult(
156
+ output="No edits provided. The 'edits' array must contain at least one entry.",
157
+ metadata={"error": True},
158
+ )
159
+
160
+ stale = _check_staleness(ctx, path)
161
+ if stale:
162
+ return ToolResult(output=stale, metadata={"error": True})
163
+
164
+ original = path.read_text(encoding="utf-8", errors="replace")
165
+ content = original
166
+
167
+ for i, edit in enumerate(inp.edits):
168
+ if not edit.old_string:
169
+ return ToolResult(output=f"Edit {i}: old_string must not be empty")
170
+ if edit.old_string == edit.new_string:
171
+ return ToolResult(output=f"Edit {i}: old_string and new_string must differ")
172
+
173
+ count = content.count(edit.old_string)
174
+ if count == 0:
175
+ return ToolResult(
176
+ output=f"Edit {i}: old_string not found in {inp.file_path}",
177
+ metadata={"error": True},
178
+ )
179
+
180
+ content = content.replace(edit.old_string, edit.new_string)
181
+
182
+ path.write_text(content, encoding="utf-8")
183
+ _record_mtime(ctx, path)
184
+
185
+ from voidx.ui.diff import make_file_diff
186
+ diff = make_file_diff(inp.file_path, original, content)
187
+
188
+ return ToolResult(
189
+ title=f"Edited {inp.file_path} ({len(inp.edits)} edits)",
190
+ output=f"File edited: {inp.file_path} ({len(inp.edits)} replacements)",
191
+ metadata={"file": inp.file_path, "replacements": len(inp.edits)},
192
+ diff=diff,
193
+ )
voidx/tools/lsp.py ADDED
@@ -0,0 +1,155 @@
1
+ """LSP-backed code intelligence tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from voidx.lsp.errors import LspError
8
+ from voidx.lsp.service import LspService
9
+ from voidx.tools.base import BaseTool, ToolContext, ToolResult, model_to_json_schema, resolve_safe
10
+
11
+
12
+ class LspDiagnosticsInput(BaseModel):
13
+ file_path: str | None = Field(
14
+ default=None,
15
+ description="File to check. If omitted, returns cached diagnostics for already opened files.",
16
+ )
17
+
18
+
19
+ class LspSymbolsInput(BaseModel):
20
+ file_path: str | None = Field(default=None, description="File for document symbols.")
21
+ query: str = Field(default="", description="Workspace symbol query when file_path is omitted.")
22
+
23
+
24
+ class LspPositionInput(BaseModel):
25
+ file_path: str = Field(description="File containing the position.")
26
+ line: int = Field(description="1-based line number.", ge=1)
27
+ character: int = Field(default=0, description="0-based character offset.", ge=0)
28
+
29
+
30
+ class LspReferencesInput(LspPositionInput):
31
+ include_declaration: bool = Field(default=True, description="Include the symbol declaration in results.")
32
+
33
+
34
+ class LspFormatInput(BaseModel):
35
+ file_path: str = Field(description="File to format with the configured language server.")
36
+
37
+
38
+ class LspDiagnosticsTool(BaseTool):
39
+ id = "lsp_diagnostics"
40
+ description = "Get LSP diagnostics for a file, or cached diagnostics for opened files when file_path is omitted."
41
+
42
+ def parameters_schema(self) -> dict:
43
+ return model_to_json_schema(LspDiagnosticsInput)
44
+
45
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
46
+ inp = LspDiagnosticsInput.model_validate(args)
47
+ service = _service(ctx)
48
+ if service is None:
49
+ return ToolResult(output="LSP manager not available.", metadata={"error": True})
50
+ try:
51
+ output = await service.diagnostics(inp.file_path)
52
+ return ToolResult(title="LSP diagnostics", output=output)
53
+ except LspError as exc:
54
+ return ToolResult(output=f"LSP diagnostics failed: {exc}", metadata={"error": True})
55
+
56
+
57
+ class LspSymbolsTool(BaseTool):
58
+ id = "lsp_symbols"
59
+ description = "List document symbols for file_path, or workspace symbols for query."
60
+
61
+ def parameters_schema(self) -> dict:
62
+ return model_to_json_schema(LspSymbolsInput)
63
+
64
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
65
+ inp = LspSymbolsInput.model_validate(args)
66
+ service = _service(ctx)
67
+ if service is None:
68
+ return ToolResult(output="LSP manager not available.", metadata={"error": True})
69
+ try:
70
+ output = await service.symbols(inp.file_path, inp.query)
71
+ return ToolResult(title="LSP symbols", output=output)
72
+ except LspError as exc:
73
+ return ToolResult(output=f"LSP symbols failed: {exc}", metadata={"error": True})
74
+
75
+
76
+ class LspDefinitionTool(BaseTool):
77
+ id = "lsp_definition"
78
+ description = "Find definition locations for a symbol at file_path:line:character."
79
+
80
+ def parameters_schema(self) -> dict:
81
+ return model_to_json_schema(LspPositionInput)
82
+
83
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
84
+ inp = LspPositionInput.model_validate(args)
85
+ service = _service(ctx)
86
+ if service is None:
87
+ return ToolResult(output="LSP manager not available.", metadata={"error": True})
88
+ try:
89
+ output = await service.definition(inp.file_path, inp.line, inp.character)
90
+ return ToolResult(title="LSP definition", output=output)
91
+ except LspError as exc:
92
+ return ToolResult(output=f"LSP definition failed: {exc}", metadata={"error": True})
93
+
94
+
95
+ class LspReferencesTool(BaseTool):
96
+ id = "lsp_references"
97
+ description = "Find references for a symbol at file_path:line:character."
98
+
99
+ def parameters_schema(self) -> dict:
100
+ return model_to_json_schema(LspReferencesInput)
101
+
102
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
103
+ inp = LspReferencesInput.model_validate(args)
104
+ service = _service(ctx)
105
+ if service is None:
106
+ return ToolResult(output="LSP manager not available.", metadata={"error": True})
107
+ try:
108
+ output = await service.references(
109
+ inp.file_path,
110
+ inp.line,
111
+ inp.character,
112
+ include_declaration=inp.include_declaration,
113
+ )
114
+ return ToolResult(title="LSP references", output=output)
115
+ except LspError as exc:
116
+ return ToolResult(output=f"LSP references failed: {exc}", metadata={"error": True})
117
+
118
+
119
+ class LspFormatTool(BaseTool):
120
+ id = "lsp_format"
121
+ description = "Format a file with its configured language server. Writes the formatted content back to disk."
122
+
123
+ def parameters_schema(self) -> dict:
124
+ return model_to_json_schema(LspFormatInput)
125
+
126
+ async def execute(self, args: dict, ctx: ToolContext) -> ToolResult:
127
+ inp = LspFormatInput.model_validate(args)
128
+ manager = getattr(ctx, "lsp_manager", None)
129
+ if manager is None:
130
+ return ToolResult(output="LSP manager not available.", metadata={"error": True})
131
+ try:
132
+ changed, old_text, new_text = await manager.format_document(inp.file_path)
133
+ except LspError as exc:
134
+ return ToolResult(output=f"LSP format failed: {exc}", metadata={"error": True})
135
+ path = resolve_safe(ctx.workspace, inp.file_path, ctx.sandbox_extra_paths)
136
+ if path is not None and path.exists():
137
+ ctx.file_mtimes[str(path.resolve())] = path.stat().st_mtime
138
+ if not changed:
139
+ return ToolResult(output=f"No formatting changes for {inp.file_path}.")
140
+
141
+ from voidx.ui.diff import make_file_diff
142
+ diff = make_file_diff(inp.file_path, old_text, new_text)
143
+ return ToolResult(
144
+ title=f"Formatted {inp.file_path}",
145
+ output=f"File formatted: {inp.file_path}",
146
+ metadata={"file": inp.file_path, "size": len(new_text)},
147
+ diff=diff,
148
+ )
149
+
150
+
151
+ def _service(ctx: ToolContext) -> LspService | None:
152
+ manager = getattr(ctx, "lsp_manager", None)
153
+ if manager is None:
154
+ return None
155
+ return LspService(manager)