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/config.py
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"""Configuration system — typed, JSON-backed, no .env restrictions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
SETTINGS_FILE = "voidx.json"
|
|
12
|
+
SKILLS_STATE_FILE = ".voidx/skills.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SandboxMode(str, Enum):
|
|
16
|
+
"""Filesystem boundary control — mirrors Codex CLI sandbox modes.
|
|
17
|
+
|
|
18
|
+
read-only: All write/edit/bash/lsp_format tools are denied.
|
|
19
|
+
workspace-write: Only writes inside the workspace (+ extra_paths) are allowed.
|
|
20
|
+
danger-full-access: No filesystem restrictions (current voidx behaviour).
|
|
21
|
+
"""
|
|
22
|
+
READ_ONLY = "read-only"
|
|
23
|
+
WORKSPACE_WRITE = "workspace-write"
|
|
24
|
+
DANGER_FULL_ACCESS = "danger-full-access"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ApprovalPolicy(str, Enum):
|
|
28
|
+
"""How often voidx asks for human confirmation on tool calls.
|
|
29
|
+
|
|
30
|
+
untrusted: Write/edit/write-capable bash/implement agent tools ask.
|
|
31
|
+
on-failure: Auto-allow non-bash ask tools, then report failures.
|
|
32
|
+
on-request: Auto-allow; only ask when the agent explicitly requests approval.
|
|
33
|
+
never: Full auto — no human-in-the-loop (equivalent to --full-auto).
|
|
34
|
+
"""
|
|
35
|
+
UNTRUSTED = "untrusted"
|
|
36
|
+
ON_FAILURE = "on-failure"
|
|
37
|
+
ON_REQUEST = "on-request"
|
|
38
|
+
NEVER = "never"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApprovalReviewer(str, Enum):
|
|
42
|
+
"""Who handles approval prompts when a tool call needs a decision."""
|
|
43
|
+
USER = "user"
|
|
44
|
+
AUTO_REVIEW = "auto_review"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CodeIde(str, Enum):
|
|
48
|
+
"""Preferred app for opening files from the review panel."""
|
|
49
|
+
AUTO = "auto"
|
|
50
|
+
TRAE = "trae"
|
|
51
|
+
CURSOR = "cursor"
|
|
52
|
+
CODE = "code"
|
|
53
|
+
WINDSURF = "windsurf"
|
|
54
|
+
ZED = "zed"
|
|
55
|
+
SUBLIME = "sublime"
|
|
56
|
+
JETBRAINS = "jetbrains"
|
|
57
|
+
GHOSTTY = "ghostty"
|
|
58
|
+
SYSTEM = "system"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PermissionMode(str, Enum):
|
|
62
|
+
"""User-facing presets for sandbox + approval behavior."""
|
|
63
|
+
DEFAULT = "default"
|
|
64
|
+
READ_ONLY = "read-only"
|
|
65
|
+
ACCEPT_EDITS = "accept-edits"
|
|
66
|
+
AUTO_REVIEW = "auto-review"
|
|
67
|
+
FULL_ACCESS = "full-access"
|
|
68
|
+
CUSTOM = "custom"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def permission_mode_defaults(mode: PermissionMode) -> tuple[SandboxMode, ApprovalPolicy]:
|
|
72
|
+
if mode == PermissionMode.DEFAULT:
|
|
73
|
+
return SandboxMode.WORKSPACE_WRITE, ApprovalPolicy.UNTRUSTED
|
|
74
|
+
if mode == PermissionMode.READ_ONLY:
|
|
75
|
+
return SandboxMode.READ_ONLY, ApprovalPolicy.UNTRUSTED
|
|
76
|
+
if mode == PermissionMode.ACCEPT_EDITS:
|
|
77
|
+
return SandboxMode.WORKSPACE_WRITE, ApprovalPolicy.UNTRUSTED
|
|
78
|
+
if mode == PermissionMode.AUTO_REVIEW:
|
|
79
|
+
return SandboxMode.WORKSPACE_WRITE, ApprovalPolicy.UNTRUSTED
|
|
80
|
+
if mode == PermissionMode.FULL_ACCESS:
|
|
81
|
+
return SandboxMode.DANGER_FULL_ACCESS, ApprovalPolicy.NEVER
|
|
82
|
+
return SandboxMode.WORKSPACE_WRITE, ApprovalPolicy.UNTRUSTED
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def permission_mode_reviewer_default(mode: PermissionMode) -> ApprovalReviewer:
|
|
86
|
+
if mode == PermissionMode.AUTO_REVIEW:
|
|
87
|
+
return ApprovalReviewer.AUTO_REVIEW
|
|
88
|
+
return ApprovalReviewer.USER
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _string_list(value: object) -> list[str]:
|
|
92
|
+
if not isinstance(value, list):
|
|
93
|
+
return []
|
|
94
|
+
return [str(item) for item in value]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Profile(BaseModel):
|
|
98
|
+
"""A named LLM configuration. Name is ``provider/model`` (e.g. ``mimo/mimo-v2.5-pro``)."""
|
|
99
|
+
name: str
|
|
100
|
+
api_key: str = ""
|
|
101
|
+
base_url: str | None = None
|
|
102
|
+
protocol: str | None = None
|
|
103
|
+
@property
|
|
104
|
+
def provider(self) -> str:
|
|
105
|
+
return self.name.split("/", 1)[0] if "/" in self.name else self.name
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def model(self) -> str:
|
|
109
|
+
return self.name.split("/", 1)[1] if "/" in self.name else self.name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ModelConfig(BaseModel):
|
|
113
|
+
provider: str = "anthropic"
|
|
114
|
+
model: str = "claude-sonnet-4-6"
|
|
115
|
+
base_url: str | None = None
|
|
116
|
+
protocol: str | None = None # "openai" | "anthropic" | "gemini" | None (auto-detect)
|
|
117
|
+
temperature: float = Field(default=0.3, ge=0.0, le=2.0)
|
|
118
|
+
max_tokens: int = Field(default=8192, ge=1, le=128000)
|
|
119
|
+
reasoning_effort: str | None = Field(
|
|
120
|
+
default="xhigh",
|
|
121
|
+
description="Reasoning intensity: off, low, medium, high, xhigh, or None (provider default)",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AgentConfig(BaseModel):
|
|
126
|
+
name: str
|
|
127
|
+
description: str = ""
|
|
128
|
+
model: ModelConfig | None = None
|
|
129
|
+
max_steps: int = Field(default=50, ge=1, le=500)
|
|
130
|
+
recursion_limit: int = Field(default=200, ge=1, le=1000)
|
|
131
|
+
tools: set[str] | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class McpServerConfig(BaseModel):
|
|
135
|
+
name: str
|
|
136
|
+
command: str = ""
|
|
137
|
+
args: list[str] = Field(default_factory=list)
|
|
138
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
139
|
+
disabled: bool = False
|
|
140
|
+
tools: list[str] | dict[str, object] | None = None
|
|
141
|
+
transport: str = "stdio" # "stdio" | "sse" (future)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def tool_count(self) -> int:
|
|
145
|
+
if isinstance(self.tools, dict):
|
|
146
|
+
return len(self.tools)
|
|
147
|
+
if isinstance(self.tools, list):
|
|
148
|
+
return len(self.tools)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class WebToolRoute(BaseModel):
|
|
153
|
+
backend: str = "legacy" # "legacy" | "mcp"
|
|
154
|
+
server: str = ""
|
|
155
|
+
tool: str = ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Config(BaseModel):
|
|
159
|
+
model: ModelConfig = Field(default_factory=ModelConfig)
|
|
160
|
+
agent: AgentConfig = Field(default_factory=lambda: AgentConfig(
|
|
161
|
+
name="build", description="Primary coding agent.",
|
|
162
|
+
))
|
|
163
|
+
workspace: str = "."
|
|
164
|
+
permission_mode: PermissionMode = PermissionMode.DEFAULT
|
|
165
|
+
sandbox_mode: SandboxMode = SandboxMode.WORKSPACE_WRITE
|
|
166
|
+
sandbox_workspace_write: list[str] = Field(
|
|
167
|
+
default_factory=list,
|
|
168
|
+
description="Extra paths writable under workspace-write sandbox mode.",
|
|
169
|
+
)
|
|
170
|
+
approval_policy: ApprovalPolicy = ApprovalPolicy.UNTRUSTED
|
|
171
|
+
approval_reviewer: ApprovalReviewer = ApprovalReviewer.USER
|
|
172
|
+
ask_compact: bool = False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── JSON-backed settings store ────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
class Settings:
|
|
178
|
+
"""Persistent settings backed by ``voidx.json`` in the workspace directory."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, workspace: str = ".") -> None:
|
|
181
|
+
self._workspace = Path(workspace).resolve()
|
|
182
|
+
self._path = self._workspace / SETTINGS_FILE
|
|
183
|
+
self._data: dict = self._load()
|
|
184
|
+
self._runtime_keys: dict[str, str] = {}
|
|
185
|
+
self._migrate_legacy_profiles()
|
|
186
|
+
|
|
187
|
+
def _load(self) -> dict:
|
|
188
|
+
if self._path.exists():
|
|
189
|
+
try:
|
|
190
|
+
return json.loads(self._path.read_text(encoding="utf-8"))
|
|
191
|
+
except (json.JSONDecodeError, OSError):
|
|
192
|
+
pass
|
|
193
|
+
return {}
|
|
194
|
+
|
|
195
|
+
def _save(self) -> None:
|
|
196
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
self._path.write_text(json.dumps(self._data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def path(self) -> Path:
|
|
201
|
+
return self._path
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def skills_path(self) -> Path:
|
|
205
|
+
return self._workspace / SKILLS_STATE_FILE
|
|
206
|
+
|
|
207
|
+
# ── profiles API ─────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def list_profiles(self) -> list[Profile]:
|
|
210
|
+
from voidx.memory.model_profiles import list_model_profiles
|
|
211
|
+
|
|
212
|
+
return [
|
|
213
|
+
Profile(
|
|
214
|
+
name=row.name,
|
|
215
|
+
api_key=row.api_key,
|
|
216
|
+
base_url=row.base_url,
|
|
217
|
+
protocol=row.protocol,
|
|
218
|
+
)
|
|
219
|
+
for row in list_model_profiles()
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def resolve_profile(self, name: str = "") -> Profile | None:
|
|
223
|
+
if not name:
|
|
224
|
+
name = self._data.get("current_profile", "")
|
|
225
|
+
if name:
|
|
226
|
+
profile = self._get_profile(name)
|
|
227
|
+
if profile is not None:
|
|
228
|
+
return profile
|
|
229
|
+
profiles = self.list_profiles()
|
|
230
|
+
return profiles[0] if profiles else None
|
|
231
|
+
|
|
232
|
+
def save_profile(self, profile: Profile) -> Path:
|
|
233
|
+
from voidx.memory.model_profiles import ModelProfileRow, save_model_profile
|
|
234
|
+
|
|
235
|
+
save_model_profile(ModelProfileRow(
|
|
236
|
+
name=profile.name,
|
|
237
|
+
provider=profile.provider,
|
|
238
|
+
model=profile.model,
|
|
239
|
+
api_key=profile.api_key,
|
|
240
|
+
base_url=profile.base_url,
|
|
241
|
+
protocol=profile.protocol,
|
|
242
|
+
))
|
|
243
|
+
self._data["current_profile"] = profile.name
|
|
244
|
+
self._save()
|
|
245
|
+
return self._path
|
|
246
|
+
|
|
247
|
+
def delete_profile(self, name: str) -> Path:
|
|
248
|
+
from voidx.memory.model_profiles import delete_model_profile
|
|
249
|
+
|
|
250
|
+
delete_model_profile(name)
|
|
251
|
+
if self._data.get("current_profile") == name:
|
|
252
|
+
next_profile = self.list_profiles()[0] if self.list_profiles() else None
|
|
253
|
+
if next_profile is not None:
|
|
254
|
+
self._data["current_profile"] = next_profile.name
|
|
255
|
+
else:
|
|
256
|
+
self._data.pop("current_profile", None)
|
|
257
|
+
self._save()
|
|
258
|
+
return self._path
|
|
259
|
+
|
|
260
|
+
# ── cross-profile lookups ────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def resolve_api_key(self, provider: str) -> str | None:
|
|
263
|
+
runtime = self._runtime_keys.get(provider)
|
|
264
|
+
if runtime:
|
|
265
|
+
return runtime
|
|
266
|
+
for p in self.list_profiles():
|
|
267
|
+
if p.provider == provider:
|
|
268
|
+
return p.api_key
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
def set_runtime_api_key(self, provider: str, key: str) -> None:
|
|
272
|
+
self._runtime_keys[provider] = key
|
|
273
|
+
|
|
274
|
+
def resolve_base_url(self, provider: str) -> str | None:
|
|
275
|
+
for p in self.list_profiles():
|
|
276
|
+
if p.provider == provider and p.base_url:
|
|
277
|
+
return p.base_url
|
|
278
|
+
for cp in self.list_custom_providers():
|
|
279
|
+
if cp["name"] == provider and cp["base_url"]:
|
|
280
|
+
return cp["base_url"]
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def resolve_protocol(self, provider: str) -> str | None:
|
|
284
|
+
for p in self.list_profiles():
|
|
285
|
+
if p.provider == provider and p.protocol:
|
|
286
|
+
return p.protocol
|
|
287
|
+
for cp in self.list_custom_providers():
|
|
288
|
+
if cp["name"] == provider:
|
|
289
|
+
return cp["protocol"]
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
# ── tavily API key ─────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
def get_tavily_api_key(self) -> str | None:
|
|
295
|
+
"""Get Tavily API key. Env var TAVILY_API_KEY takes priority over config file."""
|
|
296
|
+
import os
|
|
297
|
+
env_key = os.environ.get("TAVILY_API_KEY")
|
|
298
|
+
if env_key:
|
|
299
|
+
return env_key
|
|
300
|
+
return self._data.get("tavily_api_key") or None
|
|
301
|
+
|
|
302
|
+
def set_tavily_api_key(self, api_key: str) -> None:
|
|
303
|
+
self._data["tavily_api_key"] = api_key
|
|
304
|
+
self._save()
|
|
305
|
+
|
|
306
|
+
def delete_tavily_api_key(self) -> None:
|
|
307
|
+
self._data.pop("tavily_api_key", None)
|
|
308
|
+
self._save()
|
|
309
|
+
|
|
310
|
+
# ── MCP servers ─────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def list_mcp_servers(self) -> list[McpServerConfig]:
|
|
313
|
+
servers_data = self._data.get("mcpServers") or self._data.get("mcp_servers") or {}
|
|
314
|
+
if not isinstance(servers_data, dict):
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
result: list[McpServerConfig] = []
|
|
318
|
+
for name, fields in servers_data.items():
|
|
319
|
+
if not isinstance(fields, dict):
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
result.append(McpServerConfig(name=name, **fields))
|
|
323
|
+
except ValueError:
|
|
324
|
+
continue
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
def get_mcp_server(self, name: str) -> McpServerConfig | None:
|
|
328
|
+
for server in self.list_mcp_servers():
|
|
329
|
+
if server.name == name:
|
|
330
|
+
return server
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
def save_mcp_server(self, server: McpServerConfig) -> Path:
|
|
334
|
+
servers = self._mcp_servers_data()
|
|
335
|
+
servers[server.name] = server.model_dump(exclude={"name"}, exclude_none=True)
|
|
336
|
+
self._data["mcpServers"] = servers
|
|
337
|
+
self._save()
|
|
338
|
+
return self._path
|
|
339
|
+
|
|
340
|
+
def delete_mcp_server(self, name: str) -> Path:
|
|
341
|
+
servers = self._mcp_servers_data()
|
|
342
|
+
servers.pop(name, None)
|
|
343
|
+
self._data["mcpServers"] = servers
|
|
344
|
+
self.clear_web_routes_for_server(name)
|
|
345
|
+
self._save()
|
|
346
|
+
return self._path
|
|
347
|
+
|
|
348
|
+
def _mcp_servers_data(self) -> dict:
|
|
349
|
+
servers = self._data.get("mcpServers")
|
|
350
|
+
if not isinstance(servers, dict):
|
|
351
|
+
servers = self._data.get("mcp_servers")
|
|
352
|
+
if not isinstance(servers, dict):
|
|
353
|
+
servers = {}
|
|
354
|
+
return dict(servers)
|
|
355
|
+
|
|
356
|
+
# ── web tool routing ─────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
def get_web_tool_route(self, kind: str) -> WebToolRoute:
|
|
359
|
+
web = self._data.get("web", {})
|
|
360
|
+
if not isinstance(web, dict):
|
|
361
|
+
return WebToolRoute()
|
|
362
|
+
fields = web.get(kind, {})
|
|
363
|
+
if not isinstance(fields, dict):
|
|
364
|
+
return WebToolRoute()
|
|
365
|
+
try:
|
|
366
|
+
return WebToolRoute(**fields)
|
|
367
|
+
except ValueError:
|
|
368
|
+
return WebToolRoute()
|
|
369
|
+
|
|
370
|
+
def set_web_tool_route(self, kind: str, route: WebToolRoute) -> Path:
|
|
371
|
+
web = self._data.get("web", {})
|
|
372
|
+
if not isinstance(web, dict):
|
|
373
|
+
web = {}
|
|
374
|
+
web[kind] = route.model_dump()
|
|
375
|
+
self._data["web"] = web
|
|
376
|
+
self._save()
|
|
377
|
+
return self._path
|
|
378
|
+
|
|
379
|
+
def clear_web_routes_for_server(self, server: str) -> None:
|
|
380
|
+
web = self._data.get("web", {})
|
|
381
|
+
if not isinstance(web, dict):
|
|
382
|
+
return
|
|
383
|
+
for kind, fields in list(web.items()):
|
|
384
|
+
if isinstance(fields, dict) and fields.get("server") == server:
|
|
385
|
+
web[kind] = WebToolRoute().model_dump()
|
|
386
|
+
|
|
387
|
+
# ── skills ──────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
def get_skill_selection(self):
|
|
390
|
+
from voidx.skills.schema import SkillSelectionConfig
|
|
391
|
+
|
|
392
|
+
data = self._skills_data()
|
|
393
|
+
return SkillSelectionConfig(
|
|
394
|
+
enabled=set(_string_list(data.get("enabled", []))),
|
|
395
|
+
disabled=set(_string_list(data.get("disabled", []))),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def set_skill_enabled(self, name: str, enabled: bool) -> Path:
|
|
399
|
+
skills = self._skills_data()
|
|
400
|
+
enabled_list = _string_list(skills.get("enabled", []))
|
|
401
|
+
disabled_list = _string_list(skills.get("disabled", []))
|
|
402
|
+
if enabled:
|
|
403
|
+
if name not in enabled_list:
|
|
404
|
+
enabled_list.append(name)
|
|
405
|
+
disabled_list = [item for item in disabled_list if item != name]
|
|
406
|
+
else:
|
|
407
|
+
if name not in disabled_list:
|
|
408
|
+
disabled_list.append(name)
|
|
409
|
+
enabled_list = [item for item in enabled_list if item != name]
|
|
410
|
+
skills["version"] = 1
|
|
411
|
+
skills["enabled"] = sorted(enabled_list)
|
|
412
|
+
skills["disabled"] = sorted(disabled_list)
|
|
413
|
+
self._save_skills_data(skills)
|
|
414
|
+
self._data.pop("skills", None)
|
|
415
|
+
return self.skills_path
|
|
416
|
+
|
|
417
|
+
def _skills_data(self) -> dict:
|
|
418
|
+
if self.skills_path.exists():
|
|
419
|
+
try:
|
|
420
|
+
skills = json.loads(self.skills_path.read_text(encoding="utf-8"))
|
|
421
|
+
except (json.JSONDecodeError, OSError):
|
|
422
|
+
return {}
|
|
423
|
+
return dict(skills) if isinstance(skills, dict) else {}
|
|
424
|
+
skills = self._data.get("skills", {})
|
|
425
|
+
return dict(skills) if isinstance(skills, dict) else {}
|
|
426
|
+
|
|
427
|
+
def _save_skills_data(self, data: dict) -> None:
|
|
428
|
+
self.skills_path.parent.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
self.skills_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
430
|
+
|
|
431
|
+
# ── sandbox / approval ───────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
def get_permission_mode(self) -> PermissionMode:
|
|
434
|
+
raw = self._data.get("permission_mode")
|
|
435
|
+
if raw is not None:
|
|
436
|
+
try:
|
|
437
|
+
return PermissionMode(raw)
|
|
438
|
+
except ValueError:
|
|
439
|
+
return PermissionMode.CUSTOM
|
|
440
|
+
if (
|
|
441
|
+
"sandbox_mode" in self._data
|
|
442
|
+
or "approval_policy" in self._data
|
|
443
|
+
or self.get_sandbox_workspace_write()
|
|
444
|
+
):
|
|
445
|
+
return PermissionMode.CUSTOM
|
|
446
|
+
return PermissionMode.DEFAULT
|
|
447
|
+
|
|
448
|
+
def set_permission_mode(self, mode: PermissionMode) -> Path:
|
|
449
|
+
self._data["permission_mode"] = mode.value
|
|
450
|
+
if mode != PermissionMode.CUSTOM:
|
|
451
|
+
sandbox_mode, approval_policy = permission_mode_defaults(mode)
|
|
452
|
+
self._data["sandbox_mode"] = sandbox_mode.value
|
|
453
|
+
self._data["approval_policy"] = approval_policy.value
|
|
454
|
+
self._data["approval_reviewer"] = permission_mode_reviewer_default(mode).value
|
|
455
|
+
self._data.pop("sandbox_workspace_write", None)
|
|
456
|
+
self._save()
|
|
457
|
+
return self._path
|
|
458
|
+
|
|
459
|
+
def get_sandbox_mode(self) -> SandboxMode:
|
|
460
|
+
raw = self._data.get("sandbox_mode", "workspace-write")
|
|
461
|
+
try:
|
|
462
|
+
return SandboxMode(raw)
|
|
463
|
+
except ValueError:
|
|
464
|
+
return SandboxMode.WORKSPACE_WRITE
|
|
465
|
+
|
|
466
|
+
def set_sandbox_mode(self, mode: SandboxMode) -> Path:
|
|
467
|
+
self._data["permission_mode"] = PermissionMode.CUSTOM.value
|
|
468
|
+
self._data["sandbox_mode"] = mode.value
|
|
469
|
+
self._save()
|
|
470
|
+
return self._path
|
|
471
|
+
|
|
472
|
+
def get_sandbox_workspace_write(self) -> list[str]:
|
|
473
|
+
paths = self._data.get("sandbox_workspace_write", [])
|
|
474
|
+
return _string_list(paths)
|
|
475
|
+
|
|
476
|
+
def set_sandbox_workspace_write(self, paths: list[str]) -> Path:
|
|
477
|
+
self._data["permission_mode"] = PermissionMode.CUSTOM.value
|
|
478
|
+
self._data["sandbox_workspace_write"] = list(paths)
|
|
479
|
+
self._save()
|
|
480
|
+
return self._path
|
|
481
|
+
|
|
482
|
+
def get_approval_policy(self) -> ApprovalPolicy:
|
|
483
|
+
raw = self._data.get("approval_policy", "untrusted")
|
|
484
|
+
try:
|
|
485
|
+
return ApprovalPolicy(raw)
|
|
486
|
+
except ValueError:
|
|
487
|
+
return ApprovalPolicy.UNTRUSTED
|
|
488
|
+
|
|
489
|
+
def set_approval_policy(self, policy: ApprovalPolicy) -> Path:
|
|
490
|
+
self._data["permission_mode"] = PermissionMode.CUSTOM.value
|
|
491
|
+
self._data["approval_policy"] = policy.value
|
|
492
|
+
self._save()
|
|
493
|
+
return self._path
|
|
494
|
+
|
|
495
|
+
def get_approval_reviewer(self) -> ApprovalReviewer:
|
|
496
|
+
raw = self._data.get("approval_reviewer", "user")
|
|
497
|
+
try:
|
|
498
|
+
return ApprovalReviewer(raw)
|
|
499
|
+
except ValueError:
|
|
500
|
+
return ApprovalReviewer.USER
|
|
501
|
+
|
|
502
|
+
def set_approval_reviewer(self, reviewer: ApprovalReviewer) -> Path:
|
|
503
|
+
self._data["permission_mode"] = PermissionMode.CUSTOM.value
|
|
504
|
+
self._data["approval_reviewer"] = reviewer.value
|
|
505
|
+
self._save()
|
|
506
|
+
return self._path
|
|
507
|
+
|
|
508
|
+
# ── code IDE ─────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
def get_code_ide(self) -> CodeIde:
|
|
511
|
+
raw = self._data.get("codeIde", CodeIde.TRAE.value)
|
|
512
|
+
try:
|
|
513
|
+
return CodeIde(raw)
|
|
514
|
+
except ValueError:
|
|
515
|
+
return CodeIde.TRAE
|
|
516
|
+
|
|
517
|
+
def set_code_ide(self, ide: CodeIde) -> Path:
|
|
518
|
+
self._data["codeIde"] = ide.value
|
|
519
|
+
self._save()
|
|
520
|
+
return self._path
|
|
521
|
+
|
|
522
|
+
# ── custom models ─────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
def list_custom_models(self, provider: str) -> list[str]:
|
|
525
|
+
"""Return user-added custom model names for a provider."""
|
|
526
|
+
custom = self._data.get("custom_models", {})
|
|
527
|
+
result: list[str] = []
|
|
528
|
+
if not isinstance(custom, dict):
|
|
529
|
+
models = []
|
|
530
|
+
else:
|
|
531
|
+
models = custom.get(provider, [])
|
|
532
|
+
if isinstance(models, list):
|
|
533
|
+
result.extend(str(model) for model in models)
|
|
534
|
+
for profile in self.list_profiles():
|
|
535
|
+
if profile.provider == provider and profile.model not in result:
|
|
536
|
+
result.append(profile.model)
|
|
537
|
+
return result
|
|
538
|
+
|
|
539
|
+
def add_custom_model(self, provider: str, model: str) -> None:
|
|
540
|
+
"""Legacy no-op. Custom models are derived from saved DB profiles."""
|
|
541
|
+
_ = (provider, model)
|
|
542
|
+
|
|
543
|
+
def remove_custom_model(self, provider: str, model: str) -> None:
|
|
544
|
+
"""Remove a custom model name for a provider. Saves."""
|
|
545
|
+
custom = self._data.get("custom_models", {})
|
|
546
|
+
if not isinstance(custom, dict):
|
|
547
|
+
return
|
|
548
|
+
models = custom.get(provider, [])
|
|
549
|
+
if not isinstance(models, list):
|
|
550
|
+
return
|
|
551
|
+
if model in models:
|
|
552
|
+
models.remove(model)
|
|
553
|
+
if not models:
|
|
554
|
+
del custom[provider]
|
|
555
|
+
self._save()
|
|
556
|
+
|
|
557
|
+
# ── custom providers ──────────────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
def list_custom_providers(self) -> list[dict[str, str]]:
|
|
560
|
+
"""Return list of {name, protocol, base_url} for custom providers."""
|
|
561
|
+
providers = self._data.get("custom_providers", {})
|
|
562
|
+
if not isinstance(providers, dict):
|
|
563
|
+
return []
|
|
564
|
+
result: list[dict[str, str]] = []
|
|
565
|
+
for name, fields in providers.items():
|
|
566
|
+
if isinstance(fields, dict):
|
|
567
|
+
result.append({
|
|
568
|
+
"name": name,
|
|
569
|
+
"protocol": fields.get("protocol", "openai"),
|
|
570
|
+
"base_url": fields.get("base_url", ""),
|
|
571
|
+
})
|
|
572
|
+
return result
|
|
573
|
+
|
|
574
|
+
def add_custom_provider(self, name: str, protocol: str = "openai", base_url: str = "") -> None:
|
|
575
|
+
"""Legacy no-op. Provider protocol/base URL live on saved DB profiles."""
|
|
576
|
+
_ = (name, protocol, base_url)
|
|
577
|
+
|
|
578
|
+
def remove_custom_provider(self, name: str) -> None:
|
|
579
|
+
"""Remove a custom provider and its custom models. Saves."""
|
|
580
|
+
providers = self._data.get("custom_providers", {})
|
|
581
|
+
if isinstance(providers, dict) and name in providers:
|
|
582
|
+
del providers[name]
|
|
583
|
+
# Also remove custom models for this provider
|
|
584
|
+
custom = self._data.get("custom_models", {})
|
|
585
|
+
if isinstance(custom, dict) and name in custom:
|
|
586
|
+
del custom[name]
|
|
587
|
+
self._save()
|
|
588
|
+
|
|
589
|
+
# ── build config for graph ───────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
def build_config(self) -> Config:
|
|
592
|
+
profile = self.resolve_profile()
|
|
593
|
+
if profile:
|
|
594
|
+
provider = profile.provider
|
|
595
|
+
model = profile.model
|
|
596
|
+
base_url = profile.base_url
|
|
597
|
+
protocol = profile.protocol
|
|
598
|
+
else:
|
|
599
|
+
provider = "anthropic"
|
|
600
|
+
model = "claude-sonnet-4-6"
|
|
601
|
+
base_url = None
|
|
602
|
+
protocol = None
|
|
603
|
+
|
|
604
|
+
# Check if provider is a custom provider
|
|
605
|
+
if not base_url:
|
|
606
|
+
for cp in self.list_custom_providers():
|
|
607
|
+
if cp["name"] == provider:
|
|
608
|
+
protocol = protocol or cp["protocol"]
|
|
609
|
+
if cp["base_url"]:
|
|
610
|
+
base_url = cp["base_url"]
|
|
611
|
+
break
|
|
612
|
+
|
|
613
|
+
cfg = ModelConfig(provider=provider, model=model, base_url=base_url)
|
|
614
|
+
if protocol:
|
|
615
|
+
cfg.protocol = protocol
|
|
616
|
+
permission_mode = self.get_permission_mode()
|
|
617
|
+
if permission_mode == PermissionMode.CUSTOM:
|
|
618
|
+
sandbox_mode = self.get_sandbox_mode()
|
|
619
|
+
approval_policy = self.get_approval_policy()
|
|
620
|
+
approval_reviewer = self.get_approval_reviewer()
|
|
621
|
+
else:
|
|
622
|
+
sandbox_mode, approval_policy = permission_mode_defaults(permission_mode)
|
|
623
|
+
approval_reviewer = permission_mode_reviewer_default(permission_mode)
|
|
624
|
+
|
|
625
|
+
return Config(
|
|
626
|
+
model=cfg,
|
|
627
|
+
permission_mode=permission_mode,
|
|
628
|
+
sandbox_mode=sandbox_mode,
|
|
629
|
+
sandbox_workspace_write=self.get_sandbox_workspace_write(),
|
|
630
|
+
approval_policy=approval_policy,
|
|
631
|
+
approval_reviewer=approval_reviewer,
|
|
632
|
+
ask_compact=bool(self._data.get("askCompact", self._data.get("ask_compact", False))),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
def _get_profile(self, name: str) -> Profile | None:
|
|
636
|
+
from voidx.memory.model_profiles import get_model_profile
|
|
637
|
+
|
|
638
|
+
row = get_model_profile(name)
|
|
639
|
+
if row is None:
|
|
640
|
+
return None
|
|
641
|
+
return Profile(
|
|
642
|
+
name=row.name,
|
|
643
|
+
api_key=row.api_key,
|
|
644
|
+
base_url=row.base_url,
|
|
645
|
+
protocol=row.protocol,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def _migrate_legacy_profiles(self) -> None:
|
|
649
|
+
from voidx.memory.model_profiles import ModelProfileRow, save_model_profile
|
|
650
|
+
|
|
651
|
+
profiles_data = self._data.get("profiles", {})
|
|
652
|
+
if not isinstance(profiles_data, dict):
|
|
653
|
+
profiles_data = {}
|
|
654
|
+
|
|
655
|
+
custom_providers = self._data.get("custom_providers", {})
|
|
656
|
+
if not isinstance(custom_providers, dict):
|
|
657
|
+
custom_providers = {}
|
|
658
|
+
|
|
659
|
+
changed = False
|
|
660
|
+
first_imported: str | None = None
|
|
661
|
+
for name, fields in profiles_data.items():
|
|
662
|
+
if not isinstance(fields, dict) or not fields.get("api_key"):
|
|
663
|
+
continue
|
|
664
|
+
provider = name.split("/", 1)[0] if "/" in name else name
|
|
665
|
+
provider_fields = custom_providers.get(provider, {})
|
|
666
|
+
if not isinstance(provider_fields, dict):
|
|
667
|
+
provider_fields = {}
|
|
668
|
+
base_url = fields.get("base_url") or provider_fields.get("base_url") or None
|
|
669
|
+
protocol = fields.get("protocol") or provider_fields.get("protocol") or None
|
|
670
|
+
profile = Profile(
|
|
671
|
+
name=name,
|
|
672
|
+
api_key=fields["api_key"],
|
|
673
|
+
base_url=base_url,
|
|
674
|
+
protocol=protocol,
|
|
675
|
+
)
|
|
676
|
+
save_model_profile(ModelProfileRow(
|
|
677
|
+
name=profile.name,
|
|
678
|
+
provider=profile.provider,
|
|
679
|
+
model=profile.model,
|
|
680
|
+
api_key=profile.api_key,
|
|
681
|
+
base_url=profile.base_url,
|
|
682
|
+
protocol=profile.protocol,
|
|
683
|
+
))
|
|
684
|
+
first_imported = first_imported or profile.name
|
|
685
|
+
changed = True
|
|
686
|
+
|
|
687
|
+
legacy_current = self._data.get("default_profile")
|
|
688
|
+
if legacy_current:
|
|
689
|
+
self._data["current_profile"] = legacy_current
|
|
690
|
+
changed = True
|
|
691
|
+
elif first_imported and not self._data.get("current_profile"):
|
|
692
|
+
self._data["current_profile"] = first_imported
|
|
693
|
+
changed = True
|
|
694
|
+
if "profiles" in self._data:
|
|
695
|
+
self._data.pop("profiles", None)
|
|
696
|
+
changed = True
|
|
697
|
+
if "default_profile" in self._data:
|
|
698
|
+
self._data.pop("default_profile", None)
|
|
699
|
+
changed = True
|
|
700
|
+
if "custom_models" in self._data:
|
|
701
|
+
self._data.pop("custom_models", None)
|
|
702
|
+
changed = True
|
|
703
|
+
if "custom_providers" in self._data:
|
|
704
|
+
self._data.pop("custom_providers", None)
|
|
705
|
+
changed = True
|
|
706
|
+
if changed:
|
|
707
|
+
self._save()
|