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/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()