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
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Slash command support for /model operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from voidx.agent.slash_components.runtime import PROVIDERS, get_providers, _select_from_list, ui
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SlashModelMixin:
|
|
11
|
+
async def _model_new(self) -> None:
|
|
12
|
+
"""Interactive model configuration — create or update a named profile."""
|
|
13
|
+
from voidx.config import Profile
|
|
14
|
+
from voidx.llm.provider import create_chat_model
|
|
15
|
+
|
|
16
|
+
ui.print("[bold]Configure LLM[/bold]")
|
|
17
|
+
|
|
18
|
+
# Step 1: choose provider via arrow keys
|
|
19
|
+
providers = get_providers(self._g._settings)
|
|
20
|
+
provider_choices = providers + ["Add custom provider..."]
|
|
21
|
+
idx = await _select_from_list(self._g._app, "Provider", provider_choices)
|
|
22
|
+
if idx is None:
|
|
23
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
24
|
+
return
|
|
25
|
+
if provider_choices[idx] == "Add custom provider...":
|
|
26
|
+
new_provider = await self._prompt("Provider name")
|
|
27
|
+
if not new_provider or not new_provider.strip():
|
|
28
|
+
ui.error("Provider name is required.")
|
|
29
|
+
return
|
|
30
|
+
new_provider = new_provider.strip()
|
|
31
|
+
protocol_choices = ["openai", "anthropic"]
|
|
32
|
+
proto_idx = await _select_from_list(self._g._app, "Protocol", protocol_choices)
|
|
33
|
+
if proto_idx is None:
|
|
34
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
35
|
+
return
|
|
36
|
+
protocol = protocol_choices[proto_idx]
|
|
37
|
+
custom_base_url = await self._prompt("Base URL (optional)", default="")
|
|
38
|
+
if custom_base_url is None:
|
|
39
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
40
|
+
return
|
|
41
|
+
custom_base_url = custom_base_url.strip()
|
|
42
|
+
ui.print(f"[dim] Custom provider: {new_provider} (protocol={protocol})[/dim]")
|
|
43
|
+
else:
|
|
44
|
+
new_provider = provider_choices[idx]
|
|
45
|
+
protocol = self._g._settings.resolve_protocol(new_provider) if self._g._settings else None
|
|
46
|
+
custom_base_url = ""
|
|
47
|
+
ui.print(f"[dim] Provider: {new_provider}[/dim]")
|
|
48
|
+
|
|
49
|
+
# Step 2: choose model from known list or enter manually
|
|
50
|
+
from voidx.llm.catalog import list_models as list_provider_models
|
|
51
|
+
known = await list_provider_models(new_provider)
|
|
52
|
+
model_choices = known + ["Other (enter manually)"]
|
|
53
|
+
ui.print()
|
|
54
|
+
model_idx = await _select_from_list(self._g._app, "Model", model_choices)
|
|
55
|
+
if model_idx is None:
|
|
56
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
57
|
+
return
|
|
58
|
+
if model_choices[model_idx] == "Other (enter manually)":
|
|
59
|
+
new_model = await self._prompt(
|
|
60
|
+
f"Model name",
|
|
61
|
+
default=self._g.config.model.model,
|
|
62
|
+
)
|
|
63
|
+
if new_model is None:
|
|
64
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
65
|
+
return
|
|
66
|
+
if not new_model.strip():
|
|
67
|
+
ui.error("Model name is required.")
|
|
68
|
+
return
|
|
69
|
+
new_model = new_model.strip()
|
|
70
|
+
else:
|
|
71
|
+
new_model = model_choices[model_idx]
|
|
72
|
+
ui.print(f"[dim] Model: {new_model}[/dim]")
|
|
73
|
+
|
|
74
|
+
# Step 3: API key
|
|
75
|
+
current_key = ""
|
|
76
|
+
if self._g._settings:
|
|
77
|
+
current_key = self._g._settings.resolve_api_key(new_provider) or ""
|
|
78
|
+
masked = self._mask_key(current_key) if current_key else "(not set)"
|
|
79
|
+
ui.print(f"[dim]Current: {masked}[/dim]")
|
|
80
|
+
new_key = await self._prompt("API key", default="", secret=True)
|
|
81
|
+
if new_key is None:
|
|
82
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
83
|
+
return
|
|
84
|
+
if new_key.strip():
|
|
85
|
+
api_key = new_key.strip()
|
|
86
|
+
else:
|
|
87
|
+
if self._g._settings:
|
|
88
|
+
key = self._g._settings.resolve_api_key(new_provider)
|
|
89
|
+
if not key:
|
|
90
|
+
ui.error(
|
|
91
|
+
f"No API key found for '{new_provider}'. Provide one now."
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
api_key = key
|
|
95
|
+
else:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Step 4: build and validate
|
|
99
|
+
base_url = custom_base_url or (self._g._settings.resolve_base_url(new_provider) if self._g._settings else None)
|
|
100
|
+
test_cfg = self._g.config.model.model_copy()
|
|
101
|
+
test_cfg.provider = new_provider
|
|
102
|
+
test_cfg.model = new_model
|
|
103
|
+
test_cfg.base_url = base_url
|
|
104
|
+
test_cfg.protocol = protocol
|
|
105
|
+
|
|
106
|
+
test_model = create_chat_model(api_key, test_cfg)
|
|
107
|
+
|
|
108
|
+
ui.print()
|
|
109
|
+
ui.print(f"[dim] Testing connection to {new_provider}/{new_model}...[/dim]")
|
|
110
|
+
|
|
111
|
+
ok, err_msg = await self._test_connection(test_model)
|
|
112
|
+
if not ok:
|
|
113
|
+
ui.error(f"Connection failed: {err_msg}")
|
|
114
|
+
ui.print("[dim]Configuration not saved. Check your API key and try again.[/dim]")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Step 5: save profile (key = provider/model) and activate
|
|
118
|
+
profile_key = f"{new_provider}/{new_model}"
|
|
119
|
+
profile = Profile(
|
|
120
|
+
name=profile_key,
|
|
121
|
+
api_key=api_key,
|
|
122
|
+
base_url=base_url,
|
|
123
|
+
protocol=protocol,
|
|
124
|
+
)
|
|
125
|
+
env_path = self._g._settings.save_profile(profile)
|
|
126
|
+
|
|
127
|
+
self._g.config.model.provider = new_provider
|
|
128
|
+
self._g.config.model.model = new_model
|
|
129
|
+
self._g.config.model.base_url = base_url
|
|
130
|
+
self._g.config.model.protocol = protocol
|
|
131
|
+
self._sync_context_limit()
|
|
132
|
+
self._g.api_key = api_key
|
|
133
|
+
self._g.model = test_model
|
|
134
|
+
|
|
135
|
+
ui.print(f" [cyan]{profile_key}[/cyan] [green]✓ configured[/green]")
|
|
136
|
+
ui.print(f"[dim]Saved to {env_path}[/dim]")
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
async def _test_connection(model) -> tuple[bool, str]:
|
|
140
|
+
"""Test an LLM connection with a minimal prompt. Returns (ok, error_msg)."""
|
|
141
|
+
from langchain_core.messages import HumanMessage
|
|
142
|
+
try:
|
|
143
|
+
resp = await model.ainvoke([HumanMessage(content="hi")])
|
|
144
|
+
if resp and getattr(resp, "content", None):
|
|
145
|
+
return True, ""
|
|
146
|
+
return False, "empty response"
|
|
147
|
+
except Exception as e:
|
|
148
|
+
msg = str(e)
|
|
149
|
+
# Extract the most useful part of the error
|
|
150
|
+
if len(msg) > 300:
|
|
151
|
+
msg = msg[:300] + "..."
|
|
152
|
+
return False, msg
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _mask_key(key: str) -> str:
|
|
156
|
+
if len(key) <= 8:
|
|
157
|
+
return "*" * len(key)
|
|
158
|
+
return key[:4] + "****" + key[-4:]
|
|
159
|
+
|
|
160
|
+
async def _prompt(self, text: str, default: str = "", secret: bool = False) -> str | None:
|
|
161
|
+
app = getattr(self._g, "_app", None)
|
|
162
|
+
if app is not None and hasattr(app, "ask_text"):
|
|
163
|
+
return await app.ask_text(text, default=default, secret=secret)
|
|
164
|
+
|
|
165
|
+
loop = asyncio.get_event_loop()
|
|
166
|
+
result = await loop.run_in_executor(
|
|
167
|
+
None,
|
|
168
|
+
lambda: input(f" {text}: ").strip(),
|
|
169
|
+
)
|
|
170
|
+
return result if result else default
|
|
171
|
+
|
|
172
|
+
async def _list_models(self) -> None:
|
|
173
|
+
from voidx.llm.catalog import list_models
|
|
174
|
+
|
|
175
|
+
current = f"{self._g.config.model.provider}/{self._g.config.model.model}"
|
|
176
|
+
ui.print(f"[bold]Current:[/bold] [cyan]{current}[/cyan]\n")
|
|
177
|
+
|
|
178
|
+
for provider in get_providers(self._g._settings):
|
|
179
|
+
ui.print(f" [bold]{provider}[/bold] ", end="")
|
|
180
|
+
models = await list_models(provider)
|
|
181
|
+
if models:
|
|
182
|
+
shown = models[:8]
|
|
183
|
+
suffix = f" [dim](+{len(models) - 8} more)[/dim]" if len(models) > 8 else ""
|
|
184
|
+
ui.print(f"{' '.join(shown)}{suffix}")
|
|
185
|
+
else:
|
|
186
|
+
ui.print("[dim](none)[/dim]")
|
|
187
|
+
ui.print()
|
|
188
|
+
ui.print("[dim]Usage: /model list|new|reasoning|test|del|switch|<name>[/dim]")
|
|
189
|
+
|
|
190
|
+
async def _model_list(self) -> None:
|
|
191
|
+
cfg = self._g.config
|
|
192
|
+
if self._g._settings is None:
|
|
193
|
+
ui.error("No Settings reference.")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
current = f"{cfg.model.provider}/{cfg.model.model}"
|
|
197
|
+
ui.print(f"[bold]Current:[/bold] [cyan]{current}[/cyan]")
|
|
198
|
+
|
|
199
|
+
profiles = self._g._settings.list_profiles()
|
|
200
|
+
if not profiles:
|
|
201
|
+
ui.print("[dim]No profiles configured. Use /model new.[/dim]")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
ui.print()
|
|
205
|
+
for p in profiles:
|
|
206
|
+
is_active = p.name == current
|
|
207
|
+
marker = " *" if is_active else " "
|
|
208
|
+
masked = self._mask_key(p.api_key) if p.api_key else "(env)"
|
|
209
|
+
ui.print(f" {marker} [cyan]{p.name}[/cyan] {masked}")
|
|
210
|
+
|
|
211
|
+
# ── /model action helpers ─────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def _profile_names(self) -> list[str]:
|
|
214
|
+
"""Return names of configured profiles."""
|
|
215
|
+
if self._g._settings is None:
|
|
216
|
+
return []
|
|
217
|
+
return [p.name for p in self._g._settings.list_profiles()]
|
|
218
|
+
|
|
219
|
+
async def _pick_or_act(self, action: str, target: str, callback) -> None:
|
|
220
|
+
"""If *target* is a profile name, call callback(target).
|
|
221
|
+
Otherwise show profiles for arrow-key selection, then call callback."""
|
|
222
|
+
import sys as _sys
|
|
223
|
+
|
|
224
|
+
if target:
|
|
225
|
+
await callback(target)
|
|
226
|
+
_sys.stdout.flush()
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
names = self._profile_names()
|
|
230
|
+
if not names:
|
|
231
|
+
ui.print("[yellow]No profiles configured. Use /model new first.[/yellow]")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
ui.print(f"[bold]{action}[/bold] — select profile (↑↓ Enter, ESC cancel):")
|
|
235
|
+
idx = await _select_from_list(self._g._app, action, names)
|
|
236
|
+
if idx is None:
|
|
237
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
238
|
+
return
|
|
239
|
+
await callback(names[idx])
|
|
240
|
+
_sys.stdout.flush()
|
|
241
|
+
|
|
242
|
+
async def _model_test(self, target: str) -> None:
|
|
243
|
+
async def _do_test(profile_name: str) -> None:
|
|
244
|
+
from voidx.llm.provider import create_chat_model
|
|
245
|
+
settings = self._g._settings
|
|
246
|
+
if settings is None:
|
|
247
|
+
ui.error("No Settings reference.")
|
|
248
|
+
return
|
|
249
|
+
profile = settings.resolve_profile(profile_name)
|
|
250
|
+
if not profile:
|
|
251
|
+
ui.error(f"Profile not found: {profile_name}")
|
|
252
|
+
return
|
|
253
|
+
cfg = self._g.config.model.model_copy()
|
|
254
|
+
cfg.provider = profile.provider
|
|
255
|
+
cfg.model = profile.model
|
|
256
|
+
cfg.base_url = profile.base_url or settings.resolve_base_url(profile.provider)
|
|
257
|
+
cfg.protocol = profile.protocol or settings.resolve_protocol(profile.provider)
|
|
258
|
+
model = create_chat_model(profile.api_key, cfg)
|
|
259
|
+
ui.print(f"[dim]Testing {profile.name} ({profile.provider}/{profile.model})...[/dim]")
|
|
260
|
+
ok, err_msg = await self._test_connection(model)
|
|
261
|
+
if ok:
|
|
262
|
+
ui.print(f"[green]✓ {profile.name} — connection successful[/green]")
|
|
263
|
+
else:
|
|
264
|
+
ui.print(f"[red]✗ {profile.name} — {err_msg}[/red]")
|
|
265
|
+
|
|
266
|
+
await self._pick_or_act("Test", target, _do_test)
|
|
267
|
+
|
|
268
|
+
async def _model_del(self, target: str) -> None:
|
|
269
|
+
async def _do_delete(profile_name: str) -> None:
|
|
270
|
+
if self._g._settings is None:
|
|
271
|
+
ui.error("No Settings reference.")
|
|
272
|
+
return
|
|
273
|
+
profile = self._g._settings.resolve_profile(profile_name)
|
|
274
|
+
if not profile:
|
|
275
|
+
ui.error(f"Profile not found: {profile_name}")
|
|
276
|
+
return
|
|
277
|
+
env_path = self._g._settings.delete_profile(profile_name)
|
|
278
|
+
was_active = (self._g.config.model.provider == profile.provider
|
|
279
|
+
and self._g.config.model.model == profile.model)
|
|
280
|
+
if was_active:
|
|
281
|
+
self._g.model = None
|
|
282
|
+
self._g.api_key = None
|
|
283
|
+
ui.print(f"[yellow]'{profile_name}' removed. Model disconnected.[/yellow]")
|
|
284
|
+
else:
|
|
285
|
+
ui.print(f"[dim]'{profile_name}' removed.[/dim]")
|
|
286
|
+
ui.print(f"[dim]Cleaned {env_path}[/dim]")
|
|
287
|
+
|
|
288
|
+
await self._pick_or_act("Delete", target, _do_delete)
|
|
289
|
+
|
|
290
|
+
async def _model_switch(self, target: str) -> None:
|
|
291
|
+
async def _do_switch(profile_name: str) -> None:
|
|
292
|
+
from voidx.llm.provider import create_chat_model
|
|
293
|
+
settings = self._g._settings
|
|
294
|
+
if settings is None:
|
|
295
|
+
ui.error("No Settings reference.")
|
|
296
|
+
return
|
|
297
|
+
profile = settings.resolve_profile(profile_name)
|
|
298
|
+
if not profile:
|
|
299
|
+
ui.error(f"Profile not found: {profile_name}")
|
|
300
|
+
return
|
|
301
|
+
self._g.config.model.provider = profile.provider
|
|
302
|
+
self._g.config.model.model = profile.model
|
|
303
|
+
self._g.config.model.base_url = profile.base_url or settings.resolve_base_url(profile.provider)
|
|
304
|
+
self._g.config.model.protocol = profile.protocol or settings.resolve_protocol(profile.provider)
|
|
305
|
+
self._sync_context_limit()
|
|
306
|
+
self._g.api_key = profile.api_key
|
|
307
|
+
self._g.model = create_chat_model(profile.api_key, self._g.config.model)
|
|
308
|
+
settings.save_profile(profile)
|
|
309
|
+
ui.print(f"[cyan]{profile.name}[/cyan] ({profile.provider}/{profile.model}) [green]✓ switched[/green]")
|
|
310
|
+
|
|
311
|
+
await self._pick_or_act("Switch", target, _do_switch)
|
|
312
|
+
|
|
313
|
+
async def _model_reasoning(self, effort: str) -> None:
|
|
314
|
+
valid = ("off", "low", "medium", "high", "xhigh")
|
|
315
|
+
|
|
316
|
+
if effort and effort in valid:
|
|
317
|
+
new_effort = effort
|
|
318
|
+
elif not effort:
|
|
319
|
+
current = self._g.config.model.reasoning_effort or "xhigh"
|
|
320
|
+
choices = list(valid)
|
|
321
|
+
idx = await _select_from_list(self._g._app, "Select effort", choices)
|
|
322
|
+
if idx is None:
|
|
323
|
+
ui.print("[dim]Cancelled.[/dim]")
|
|
324
|
+
return
|
|
325
|
+
new_effort = choices[idx]
|
|
326
|
+
else:
|
|
327
|
+
ui.error(f"Invalid effort: '{effort}'. Use: {', '.join(valid)}")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
self._g.config.model.reasoning_effort = new_effort
|
|
331
|
+
self._sync_context_limit()
|
|
332
|
+
|
|
333
|
+
if self._g.api_key:
|
|
334
|
+
from voidx.llm.provider import create_chat_model
|
|
335
|
+
self._g.model = create_chat_model(self._g.api_key, self._g.config.model)
|
|
336
|
+
|
|
337
|
+
ui.print(f"Reasoning effort: [cyan]{new_effort}[/cyan] [green]✓[/green]")
|
|
338
|
+
|
|
339
|
+
async def _switch_model(self, model_spec: str) -> None:
|
|
340
|
+
from voidx.llm.provider import create_chat_model
|
|
341
|
+
from voidx.memory.session import update_session_model
|
|
342
|
+
|
|
343
|
+
if not model_spec:
|
|
344
|
+
await self._list_models()
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
spec = model_spec.strip()
|
|
348
|
+
|
|
349
|
+
if " " in spec:
|
|
350
|
+
parts = spec.split(None, 1)
|
|
351
|
+
new_provider = parts[0].lower()
|
|
352
|
+
new_model = parts[1]
|
|
353
|
+
elif "/" in spec:
|
|
354
|
+
new_provider, new_model = spec.split("/", 1)
|
|
355
|
+
new_provider = new_provider.lower()
|
|
356
|
+
else:
|
|
357
|
+
new_provider = self._g.config.model.provider
|
|
358
|
+
new_model = spec
|
|
359
|
+
|
|
360
|
+
# Resolve API key for the target provider
|
|
361
|
+
if self._g._settings is None:
|
|
362
|
+
ui.error("No Settings reference available.")
|
|
363
|
+
return
|
|
364
|
+
new_key = self._g._settings.resolve_api_key(new_provider)
|
|
365
|
+
if not new_key:
|
|
366
|
+
ui.error(
|
|
367
|
+
f"No API key found for '{new_provider}'. Use /model new."
|
|
368
|
+
)
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
self._g.api_key = new_key
|
|
372
|
+
|
|
373
|
+
old = f"{self._g.config.model.provider}/{self._g.config.model.model}"
|
|
374
|
+
self._g.config.model.provider = new_provider
|
|
375
|
+
self._g.config.model.model = new_model
|
|
376
|
+
self._g.config.model.base_url = (
|
|
377
|
+
self._g._settings.resolve_base_url(new_provider) if self._g._settings else None
|
|
378
|
+
)
|
|
379
|
+
self._g.config.model.protocol = (
|
|
380
|
+
self._g._settings.resolve_protocol(new_provider) if self._g._settings else None
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
from voidx.config import Profile
|
|
384
|
+
existing = self._g._settings.resolve_profile(f"{new_provider}/{new_model}")
|
|
385
|
+
if existing:
|
|
386
|
+
self._g.config.model.base_url = existing.base_url or self._g.config.model.base_url
|
|
387
|
+
self._g.config.model.protocol = existing.protocol or self._g.config.model.protocol
|
|
388
|
+
|
|
389
|
+
self._sync_context_limit()
|
|
390
|
+
|
|
391
|
+
self._g.model = create_chat_model(self._g.api_key, self._g.config.model)
|
|
392
|
+
|
|
393
|
+
new_profile = Profile(
|
|
394
|
+
name=f"{new_provider}/{new_model}",
|
|
395
|
+
api_key=new_key,
|
|
396
|
+
base_url=self._g.config.model.base_url,
|
|
397
|
+
protocol=self._g.config.model.protocol,
|
|
398
|
+
)
|
|
399
|
+
self._g._settings.save_profile(new_profile)
|
|
400
|
+
|
|
401
|
+
if self._g._session:
|
|
402
|
+
await update_session_model(self._g._session.id, new_provider, new_model)
|
|
403
|
+
|
|
404
|
+
ui.print(f"[dim] {old}[/dim]")
|
|
405
|
+
ui.print(f" [cyan]→ {new_provider}/{new_model}[/cyan] [green]✓[/green]")
|
|
406
|
+
|
|
407
|
+
def _sync_context_limit(self) -> None:
|
|
408
|
+
from voidx.llm.provider import get_context_limit
|
|
409
|
+
|
|
410
|
+
limit = get_context_limit(self._g.config.model.provider)
|
|
411
|
+
stats = getattr(self._g, "_usage_stats", None)
|
|
412
|
+
if stats is not None:
|
|
413
|
+
stats.context_limit = limit
|
|
414
|
+
app = getattr(self._g, "_app", None)
|
|
415
|
+
if app is not None and hasattr(app, "status"):
|
|
416
|
+
app.status.context_limit = limit
|
|
417
|
+
app.status.provider = self._g.config.model.provider
|
|
418
|
+
app.status.model = self._g.config.model.model
|
|
419
|
+
app.status.reasoning_effort = self._g.config.model.reasoning_effort or "xhigh"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared runtime helpers for slash commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from voidx.ui.console import VoidConsole
|
|
8
|
+
|
|
9
|
+
ui = VoidConsole()
|
|
10
|
+
|
|
11
|
+
_STATIC_PROVIDERS = [
|
|
12
|
+
"anthropic",
|
|
13
|
+
"openai",
|
|
14
|
+
"deepseek",
|
|
15
|
+
"openrouter",
|
|
16
|
+
"mimo",
|
|
17
|
+
"mimo-token-plan",
|
|
18
|
+
"qwen",
|
|
19
|
+
"zhipu",
|
|
20
|
+
"kimi",
|
|
21
|
+
"doubao",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_providers(settings=None) -> list[str]:
|
|
26
|
+
"""Return providers list, merging static + custom providers from settings."""
|
|
27
|
+
base = list(_STATIC_PROVIDERS)
|
|
28
|
+
if settings:
|
|
29
|
+
for profile in settings.list_profiles():
|
|
30
|
+
if profile.provider not in base:
|
|
31
|
+
base.append(profile.provider)
|
|
32
|
+
for cp in settings.list_custom_providers():
|
|
33
|
+
name = cp["name"]
|
|
34
|
+
if name not in base:
|
|
35
|
+
base.append(name)
|
|
36
|
+
return base
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Backward-compatible alias (static list only).
|
|
40
|
+
PROVIDERS = list(_STATIC_PROVIDERS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _w(text: str) -> None:
|
|
44
|
+
sys.stdout.write(text)
|
|
45
|
+
sys.stdout.flush()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _select_from_list(app, prompt: str, items: list[str]) -> int | None:
|
|
49
|
+
if not app or not items:
|
|
50
|
+
return None
|
|
51
|
+
choices = [(item, str(i), "") for i, item in enumerate(items)]
|
|
52
|
+
res = await app.ask_choice(prompt, choices)
|
|
53
|
+
if res is not None:
|
|
54
|
+
return int(res)
|
|
55
|
+
return None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Slash command support for /skills operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from voidx.agent.slash_components.runtime import ui
|
|
6
|
+
from voidx.skills.registry import SkillRegistry
|
|
7
|
+
from voidx.skills.service import SkillService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SlashSkillsMixin:
|
|
11
|
+
async def _skills(self, args: str) -> None:
|
|
12
|
+
parts = args.split(None, 1)
|
|
13
|
+
action = parts[0] if parts else ""
|
|
14
|
+
target = parts[1].strip() if len(parts) > 1 else ""
|
|
15
|
+
|
|
16
|
+
if action in ("", "list"):
|
|
17
|
+
self._skills_list()
|
|
18
|
+
elif action == "show":
|
|
19
|
+
self._skills_show(target)
|
|
20
|
+
elif action == "enable":
|
|
21
|
+
self._skills_set_enabled(target, True)
|
|
22
|
+
elif action == "disable":
|
|
23
|
+
self._skills_set_enabled(target, False)
|
|
24
|
+
elif action == "paths":
|
|
25
|
+
self._skills_paths()
|
|
26
|
+
else:
|
|
27
|
+
ui.error("Usage: /skills [list|show|enable|disable|paths]")
|
|
28
|
+
|
|
29
|
+
def _skill_service(self) -> SkillService:
|
|
30
|
+
selection = (
|
|
31
|
+
self._g._settings.get_skill_selection()
|
|
32
|
+
if getattr(self._g, "_settings", None) is not None
|
|
33
|
+
else None
|
|
34
|
+
)
|
|
35
|
+
return SkillService(
|
|
36
|
+
SkillRegistry(getattr(self._g, "_workspace", ".")),
|
|
37
|
+
selection=selection,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _skills_list(self) -> None:
|
|
41
|
+
service = self._skill_service()
|
|
42
|
+
skills = service.list_skills()
|
|
43
|
+
ui.print("[bold]Skills:[/bold]")
|
|
44
|
+
if not skills:
|
|
45
|
+
ui.print("[dim]No skills found. Add SKILL.md files under ~/.voidx/skills or .voidx/skills.[/dim]")
|
|
46
|
+
return
|
|
47
|
+
for skill in skills:
|
|
48
|
+
state = "[green]enabled[/green]" if service.is_enabled(skill) else "[dim]disabled[/dim]"
|
|
49
|
+
scope = skill.meta.scope
|
|
50
|
+
desc = f" — {skill.meta.description}" if skill.meta.description else ""
|
|
51
|
+
ui.print(f" [cyan]{skill.name}[/cyan] · {state} · [dim]{scope}[/dim]{desc}")
|
|
52
|
+
ui.print("[dim]Usage: /skills show|enable|disable|paths[/dim]")
|
|
53
|
+
|
|
54
|
+
def _skills_show(self, name: str) -> None:
|
|
55
|
+
if not name:
|
|
56
|
+
ui.error("Usage: /skills show <name>")
|
|
57
|
+
return
|
|
58
|
+
service = self._skill_service()
|
|
59
|
+
skill = service.get(name)
|
|
60
|
+
if skill is None:
|
|
61
|
+
ui.error(f"Skill not found: {name}")
|
|
62
|
+
return
|
|
63
|
+
state = "enabled" if service.is_enabled(skill) else "disabled"
|
|
64
|
+
ui.print(f"[bold]{skill.name}[/bold] [{state}]")
|
|
65
|
+
ui.print(f"[dim]{skill.path}[/dim]")
|
|
66
|
+
if skill.meta.description:
|
|
67
|
+
ui.print(skill.meta.description)
|
|
68
|
+
if skill.meta.triggers:
|
|
69
|
+
ui.print(f"[dim]Triggers: {', '.join(skill.meta.triggers)}[/dim]")
|
|
70
|
+
ui.print()
|
|
71
|
+
ui.print(skill.body or "[dim](empty skill body)[/dim]")
|
|
72
|
+
|
|
73
|
+
def _skills_set_enabled(self, name: str, enabled: bool) -> None:
|
|
74
|
+
if not name:
|
|
75
|
+
command = "enable" if enabled else "disable"
|
|
76
|
+
ui.error(f"Usage: /skills {command} <name>")
|
|
77
|
+
return
|
|
78
|
+
if getattr(self._g, "_settings", None) is None:
|
|
79
|
+
ui.error("No settings file available.")
|
|
80
|
+
return
|
|
81
|
+
service = self._skill_service()
|
|
82
|
+
if service.get(name) is None:
|
|
83
|
+
ui.error(f"Skill not found: {name}")
|
|
84
|
+
return
|
|
85
|
+
path = self._g._settings.set_skill_enabled(name, enabled)
|
|
86
|
+
state = "enabled" if enabled else "disabled"
|
|
87
|
+
ui.print(f"[dim]{name} {state}. Saved to {path}[/dim]")
|
|
88
|
+
|
|
89
|
+
def _skills_paths(self) -> None:
|
|
90
|
+
registry = SkillRegistry(getattr(self._g, "_workspace", "."))
|
|
91
|
+
ui.print("[bold]Skill paths:[/bold]")
|
|
92
|
+
ui.print(f" bundled [dim]{registry.bundled_dir}[/dim]")
|
|
93
|
+
ui.print(f" global [dim]{registry.global_dir}[/dim]")
|
|
94
|
+
ui.print(f" project [dim]{registry.project_dir}[/dim]")
|
voidx/agent/state.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Agent state — typed, explicit, every field has a known type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from langgraph.graph.message import add_messages
|
|
8
|
+
from langchain_core.messages import BaseMessage
|
|
9
|
+
from typing_extensions import NotRequired, TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentState(TypedDict):
|
|
13
|
+
"""Complete agent state. LangGraph manages this across the graph."""
|
|
14
|
+
messages: Annotated[list[BaseMessage], add_messages] # LangGraph auto-merge
|
|
15
|
+
workspace: str # absolute path to working directory
|
|
16
|
+
agent: str # current agent name (orchestrator/explore/plan/implement/review)
|
|
17
|
+
plan_mode: bool # when True, write/edit are denied — plan→implement→review enforced
|
|
18
|
+
interaction_mode: NotRequired[str] # auto/plan/goal
|
|
19
|
+
task_intent: NotRequired[str] # chat/inspect/design/review/implement/debug/ambiguous
|
|
20
|
+
implementation_allowed: NotRequired[bool] # intent hint for context, not a permission gate
|
|
21
|
+
intent_resolution_reason: NotRequired[str]
|
|
22
|
+
awaiting_implementation_approval: NotRequired[bool]
|
|
23
|
+
approved_scope: NotRequired[str]
|
|
24
|
+
goal: NotRequired[str]
|
|
25
|
+
goal_phase: NotRequired[str]
|
|
26
|
+
goal_status: NotRequired[str]
|
|
27
|
+
goal_turn_count: NotRequired[int]
|
|
28
|
+
user_message_id: NotRequired[int]
|
|
29
|
+
tool_results: dict[str, str] # tool_call_id → result text
|
|
30
|
+
step_count: int # current step number
|
|
31
|
+
max_steps: int # safety limit
|
|
32
|
+
should_continue: bool # router flag
|