voice-vibecoder 2.7.0__tar.gz → 2.8.0__tar.gz
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.
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/CHANGELOG.md +11 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/PKG-INFO +1 -1
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/pyproject.toml +1 -1
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/__init__.py +1 -1
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/app_config.py +4 -0
- voice_vibecoder-2.8.0/src/voice_vibecoder/code_providers/openclaw.py +369 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/registry.py +12 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/config.py +1 -1
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/server.py +6 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/dispatch.py +3 -1
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/callbacks.py +20 -2
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/panels.py +46 -55
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/.github/workflows/ci.yml +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/.github/workflows/release.yml +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/.gitignore +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/LICENSE +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/README.md +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/app.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/index.html +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/audio.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/cli_serve.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/instances.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/session.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/__init__.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/definitions.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/handlers.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/__init__.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/screen.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/setup.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/state.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/styles.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/wizard.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
- {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/worktrees.py +0 -0
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v2.8.0 (2026-02-20)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Add OpenClaw code provider
|
|
10
|
+
([`910dc31`](https://github.com/snokam/voice-vibecoder/commit/910dc311df6a49f0e36e6bb53c98c2ea94753a25))
|
|
11
|
+
|
|
12
|
+
- **server**: Add text message support
|
|
13
|
+
([`86873ca`](https://github.com/snokam/voice-vibecoder/commit/86873cade8ec8feaa8479d120b86222fe106abdc))
|
|
14
|
+
|
|
15
|
+
|
|
5
16
|
## v2.7.0 (2026-02-20)
|
|
6
17
|
|
|
7
18
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voice-vibecoder
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code
|
|
5
5
|
Project-URL: Homepage, https://github.com/snokam/voice-vibecoder
|
|
6
6
|
Project-URL: Repository, https://github.com/snokam/voice-vibecoder
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "voice-vibecoder"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.8.0"
|
|
8
8
|
description = "Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -72,3 +72,7 @@ class VoiceConfig:
|
|
|
72
72
|
# Raises ValueError on validation failure.
|
|
73
73
|
# When None, auth is disabled (no auth frame expected from clients).
|
|
74
74
|
authenticate: Callable[[str], Awaitable[tuple[str, str]]] | None = None
|
|
75
|
+
|
|
76
|
+
# Default agent type for new instances ("claude", "cursor", "openclaw").
|
|
77
|
+
# Can be overridden per-instance via send_to_agent/send_to_session.
|
|
78
|
+
default_agent: str = "claude"
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""OpenClaw Agent code provider.
|
|
2
|
+
|
|
3
|
+
Implements the AgentRunner protocol using the OpenClaw Gateway WebSocket
|
|
4
|
+
for streaming code agent interactions. Routes coding tasks to the
|
|
5
|
+
OpenClaw gateway which can use any configured model and tools.
|
|
6
|
+
|
|
7
|
+
This provider enables voice-vibecoder to use OpenClaw (Olaf) as the
|
|
8
|
+
coding agent instead of Claude Code CLI. Benefits:
|
|
9
|
+
- Same agent identity across all channels (Slack, voice, web)
|
|
10
|
+
- Access to all MCP tools (Jira, GitHub, CVPartner, Snøkam APIs)
|
|
11
|
+
- Persistent memory and context
|
|
12
|
+
- Any model backend (Claude, GPT-4, etc.)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import secrets
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Any, Awaitable, Callable
|
|
25
|
+
|
|
26
|
+
from voice_vibecoder.code_providers import AgentOutput, AgentResult
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Default OpenClaw Gateway URL (loopback)
|
|
31
|
+
DEFAULT_GATEWAY_URL = "ws://localhost:18789"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_tool_info(line: str) -> tuple[str, str] | None:
|
|
35
|
+
"""Extract tool name and description from agent output lines."""
|
|
36
|
+
match = re.match(r"^\[(\w+)\]\s*(.*)$", line.strip())
|
|
37
|
+
if match:
|
|
38
|
+
return match.group(1), match.group(2)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenClawRunner:
|
|
43
|
+
"""AgentRunner implementation using OpenClaw Gateway WebSocket.
|
|
44
|
+
|
|
45
|
+
This provider routes coding tasks to OpenClaw, which can:
|
|
46
|
+
- Use any configured model (Claude, GPT-4, etc.)
|
|
47
|
+
- Access all MCP tools (Jira, GitHub, CVPartner, etc.)
|
|
48
|
+
- Maintain conversation context across sessions
|
|
49
|
+
- Use the same identity/persona as the main agent
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
session_id: str | None = None,
|
|
55
|
+
agent_id: str | None = None,
|
|
56
|
+
env: dict[str, str] | None = None,
|
|
57
|
+
gateway_url: str | None = None,
|
|
58
|
+
gateway_token: str | None = None,
|
|
59
|
+
**_kwargs,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._session_id = session_id or f"voice-{uuid.uuid4().hex[:8]}"
|
|
62
|
+
self._agent_id = agent_id
|
|
63
|
+
self._env = env
|
|
64
|
+
self._gateway_url = gateway_url or os.environ.get("OPENCLAW_GATEWAY_URL", DEFAULT_GATEWAY_URL)
|
|
65
|
+
self._gateway_token = gateway_token or os.environ.get("OPENCLAW_GATEWAY_TOKEN")
|
|
66
|
+
self._task: asyncio.Task | None = None
|
|
67
|
+
self._ws = None
|
|
68
|
+
|
|
69
|
+
async def run(
|
|
70
|
+
self,
|
|
71
|
+
message: str,
|
|
72
|
+
cwd: str,
|
|
73
|
+
session_id: str | None = None,
|
|
74
|
+
on_output: Callable[[AgentOutput], None] | None = None,
|
|
75
|
+
can_use_tool: Callable[[str, dict, Any], Awaitable[Any]] | None = None,
|
|
76
|
+
) -> AgentResult:
|
|
77
|
+
"""Run a coding task via OpenClaw Gateway WebSocket."""
|
|
78
|
+
self._task = asyncio.current_task()
|
|
79
|
+
effective_session = session_id or self._session_id
|
|
80
|
+
|
|
81
|
+
# Add working directory context
|
|
82
|
+
full_message = f"[Working directory: {cwd}]\n\n{message}"
|
|
83
|
+
|
|
84
|
+
if on_output:
|
|
85
|
+
on_output(AgentOutput(
|
|
86
|
+
category="text",
|
|
87
|
+
content=f"🦞 Connecting to OpenClaw...",
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Try WebSocket first, fall back to CLI if it fails
|
|
92
|
+
result = await self._run_via_websocket(
|
|
93
|
+
full_message, effective_session, on_output, can_use_tool
|
|
94
|
+
)
|
|
95
|
+
except Exception as ws_error:
|
|
96
|
+
logger.warning("WebSocket connection failed, falling back to CLI: %s", ws_error)
|
|
97
|
+
if on_output:
|
|
98
|
+
on_output(AgentOutput(
|
|
99
|
+
category="text",
|
|
100
|
+
content="WebSocket unavailable, using CLI...",
|
|
101
|
+
))
|
|
102
|
+
result = await self._run_via_cli(
|
|
103
|
+
full_message, cwd, effective_session, on_output
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
async def _run_via_websocket(
|
|
109
|
+
self,
|
|
110
|
+
message: str,
|
|
111
|
+
session_id: str,
|
|
112
|
+
on_output: Callable[[AgentOutput], None] | None,
|
|
113
|
+
can_use_tool: Callable[[str, dict, Any], Awaitable[Any]] | None,
|
|
114
|
+
) -> AgentResult:
|
|
115
|
+
"""Connect to OpenClaw Gateway via WebSocket and stream the response."""
|
|
116
|
+
import websockets
|
|
117
|
+
|
|
118
|
+
result_text = ""
|
|
119
|
+
result_session_id = session_id
|
|
120
|
+
|
|
121
|
+
async with websockets.connect(self._gateway_url) as ws:
|
|
122
|
+
self._ws = ws
|
|
123
|
+
|
|
124
|
+
# Wait for challenge
|
|
125
|
+
challenge_raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
126
|
+
challenge = json.loads(challenge_raw)
|
|
127
|
+
|
|
128
|
+
if challenge.get("event") != "connect.challenge":
|
|
129
|
+
raise ValueError(f"Expected connect.challenge, got: {challenge}")
|
|
130
|
+
|
|
131
|
+
# Generate device identity
|
|
132
|
+
device_id = f"voice-vibecoder-{uuid.uuid4().hex[:8]}"
|
|
133
|
+
|
|
134
|
+
# Send connect request
|
|
135
|
+
connect_req = {
|
|
136
|
+
"type": "req",
|
|
137
|
+
"id": secrets.token_hex(8),
|
|
138
|
+
"method": "connect",
|
|
139
|
+
"params": {
|
|
140
|
+
"minProtocol": 3,
|
|
141
|
+
"maxProtocol": 3,
|
|
142
|
+
"client": {
|
|
143
|
+
"id": "voice-vibecoder",
|
|
144
|
+
"version": "1.0.0",
|
|
145
|
+
"platform": "linux",
|
|
146
|
+
"mode": "operator",
|
|
147
|
+
},
|
|
148
|
+
"role": "operator",
|
|
149
|
+
"scopes": ["operator.read", "operator.write"],
|
|
150
|
+
"caps": [],
|
|
151
|
+
"commands": [],
|
|
152
|
+
"permissions": {},
|
|
153
|
+
"auth": {"token": self._gateway_token} if self._gateway_token else {},
|
|
154
|
+
"locale": "en-US",
|
|
155
|
+
"userAgent": "voice-vibecoder/1.0.0",
|
|
156
|
+
"device": {
|
|
157
|
+
"id": device_id,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
await ws.send(json.dumps(connect_req))
|
|
162
|
+
|
|
163
|
+
# Wait for hello-ok
|
|
164
|
+
hello_raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
165
|
+
hello = json.loads(hello_raw)
|
|
166
|
+
|
|
167
|
+
if not hello.get("ok"):
|
|
168
|
+
error = hello.get("error", {}).get("message", "Unknown error")
|
|
169
|
+
raise ValueError(f"Connect failed: {error}")
|
|
170
|
+
|
|
171
|
+
if on_output:
|
|
172
|
+
on_output(AgentOutput(category="text", content="Connected to OpenClaw"))
|
|
173
|
+
|
|
174
|
+
# Send chat message
|
|
175
|
+
chat_req = {
|
|
176
|
+
"type": "req",
|
|
177
|
+
"id": secrets.token_hex(8),
|
|
178
|
+
"method": "chat.send",
|
|
179
|
+
"params": {
|
|
180
|
+
"message": message,
|
|
181
|
+
"sessionKey": session_id,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if self._agent_id:
|
|
186
|
+
chat_req["params"]["agentId"] = self._agent_id
|
|
187
|
+
|
|
188
|
+
await ws.send(json.dumps(chat_req))
|
|
189
|
+
|
|
190
|
+
# Stream response
|
|
191
|
+
while True:
|
|
192
|
+
try:
|
|
193
|
+
msg_raw = await asyncio.wait_for(ws.recv(), timeout=600.0)
|
|
194
|
+
msg = json.loads(msg_raw)
|
|
195
|
+
except asyncio.TimeoutError:
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
msg_type = msg.get("type")
|
|
199
|
+
|
|
200
|
+
if msg_type == "event":
|
|
201
|
+
event = msg.get("event", "")
|
|
202
|
+
payload = msg.get("payload", {})
|
|
203
|
+
|
|
204
|
+
if event == "chat.chunk":
|
|
205
|
+
# Streaming text chunk
|
|
206
|
+
text = payload.get("text", "")
|
|
207
|
+
if text and on_output:
|
|
208
|
+
for line in text.splitlines():
|
|
209
|
+
if line.strip():
|
|
210
|
+
tool_info = _extract_tool_info(line)
|
|
211
|
+
if tool_info:
|
|
212
|
+
tool_name, tool_desc = tool_info
|
|
213
|
+
category = (
|
|
214
|
+
"file_edit" if tool_name in ("Edit", "Write")
|
|
215
|
+
else "bash" if tool_name == "Bash"
|
|
216
|
+
else "tool_call"
|
|
217
|
+
)
|
|
218
|
+
on_output(AgentOutput(category=category, content=line))
|
|
219
|
+
else:
|
|
220
|
+
on_output(AgentOutput(category="text", content=line))
|
|
221
|
+
result_text += text
|
|
222
|
+
|
|
223
|
+
elif event == "chat.done":
|
|
224
|
+
# Final response
|
|
225
|
+
result_text = payload.get("text", result_text)
|
|
226
|
+
result_session_id = payload.get("sessionKey", session_id)
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
elif event == "chat.error":
|
|
230
|
+
error = payload.get("message", "Unknown error")
|
|
231
|
+
raise RuntimeError(f"OpenClaw error: {error}")
|
|
232
|
+
|
|
233
|
+
elif msg_type == "res":
|
|
234
|
+
# Response to our request
|
|
235
|
+
if not msg.get("ok"):
|
|
236
|
+
error = msg.get("error", {}).get("message", "Unknown error")
|
|
237
|
+
raise RuntimeError(f"Request failed: {error}")
|
|
238
|
+
|
|
239
|
+
# chat.send returns immediately, response comes via events
|
|
240
|
+
payload = msg.get("payload", {})
|
|
241
|
+
if "text" in payload:
|
|
242
|
+
result_text = payload["text"]
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
self._ws = None
|
|
246
|
+
|
|
247
|
+
return AgentResult(
|
|
248
|
+
text=result_text,
|
|
249
|
+
session_id=result_session_id,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def _run_via_cli(
|
|
253
|
+
self,
|
|
254
|
+
message: str,
|
|
255
|
+
cwd: str,
|
|
256
|
+
session_id: str,
|
|
257
|
+
on_output: Callable[[AgentOutput], None] | None,
|
|
258
|
+
) -> AgentResult:
|
|
259
|
+
"""Fallback: run via openclaw CLI."""
|
|
260
|
+
cmd = [
|
|
261
|
+
"openclaw", "agent",
|
|
262
|
+
"--session-id", session_id,
|
|
263
|
+
"--message", message,
|
|
264
|
+
"--json",
|
|
265
|
+
"--local",
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
if self._agent_id:
|
|
269
|
+
cmd.extend(["--agent", self._agent_id])
|
|
270
|
+
|
|
271
|
+
env = os.environ.copy()
|
|
272
|
+
if self._env:
|
|
273
|
+
env.update(self._env)
|
|
274
|
+
if self._gateway_token:
|
|
275
|
+
env["OPENCLAW_GATEWAY_TOKEN"] = self._gateway_token
|
|
276
|
+
|
|
277
|
+
process = await asyncio.create_subprocess_exec(
|
|
278
|
+
*cmd,
|
|
279
|
+
stdout=asyncio.subprocess.PIPE,
|
|
280
|
+
stderr=asyncio.subprocess.PIPE,
|
|
281
|
+
env=env,
|
|
282
|
+
cwd=cwd,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
result_text = ""
|
|
286
|
+
|
|
287
|
+
async def read_stdout():
|
|
288
|
+
nonlocal result_text
|
|
289
|
+
assert process.stdout
|
|
290
|
+
|
|
291
|
+
buffer = ""
|
|
292
|
+
while True:
|
|
293
|
+
chunk = await process.stdout.read(1024)
|
|
294
|
+
if not chunk:
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
298
|
+
buffer += text
|
|
299
|
+
|
|
300
|
+
while "\n" in buffer:
|
|
301
|
+
line, buffer = buffer.split("\n", 1)
|
|
302
|
+
line = line.strip()
|
|
303
|
+
|
|
304
|
+
if not line:
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
data = json.loads(line)
|
|
309
|
+
if isinstance(data, dict):
|
|
310
|
+
if "result" in data:
|
|
311
|
+
result_text = data["result"]
|
|
312
|
+
elif "text" in data:
|
|
313
|
+
result_text = data["text"]
|
|
314
|
+
continue
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
if on_output:
|
|
319
|
+
tool_info = _extract_tool_info(line)
|
|
320
|
+
if tool_info:
|
|
321
|
+
tool_name, tool_desc = tool_info
|
|
322
|
+
category = (
|
|
323
|
+
"file_edit" if tool_name in ("Edit", "Write")
|
|
324
|
+
else "bash" if tool_name == "Bash"
|
|
325
|
+
else "tool_call"
|
|
326
|
+
)
|
|
327
|
+
on_output(AgentOutput(category=category, content=f"[{tool_name}] {tool_desc}"))
|
|
328
|
+
else:
|
|
329
|
+
on_output(AgentOutput(category="text", content=line))
|
|
330
|
+
|
|
331
|
+
if buffer.strip():
|
|
332
|
+
try:
|
|
333
|
+
data = json.loads(buffer)
|
|
334
|
+
if isinstance(data, dict) and "result" in data:
|
|
335
|
+
result_text = data["result"]
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
if on_output:
|
|
338
|
+
on_output(AgentOutput(category="text", content=buffer.strip()))
|
|
339
|
+
|
|
340
|
+
async def read_stderr():
|
|
341
|
+
assert process.stderr
|
|
342
|
+
async for line in process.stderr:
|
|
343
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
344
|
+
if text and on_output:
|
|
345
|
+
on_output(AgentOutput(category="text", content=f"[stderr] {text}"))
|
|
346
|
+
|
|
347
|
+
await asyncio.gather(read_stdout(), read_stderr())
|
|
348
|
+
await process.wait()
|
|
349
|
+
|
|
350
|
+
if process.returncode != 0 and not result_text:
|
|
351
|
+
raise RuntimeError(f"OpenClaw CLI failed with exit code {process.returncode}")
|
|
352
|
+
|
|
353
|
+
return AgentResult(
|
|
354
|
+
text=result_text,
|
|
355
|
+
session_id=session_id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def cancel(self) -> bool:
|
|
359
|
+
"""Cancel the running task."""
|
|
360
|
+
if self._ws:
|
|
361
|
+
asyncio.create_task(self._ws.close())
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
task = self._task
|
|
365
|
+
if task and not task.done():
|
|
366
|
+
task.cancel()
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
return False
|
{voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/registry.py
RENAMED
|
@@ -34,6 +34,11 @@ def _make_cursor_runner(**_kw):
|
|
|
34
34
|
return CursorRunner()
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def _make_openclaw_runner(session_id: str | None = None, agent_id: str | None = None, env: dict | None = None, **_kw):
|
|
38
|
+
from voice_vibecoder.code_providers.openclaw import OpenClawRunner
|
|
39
|
+
return OpenClawRunner(session_id=session_id, agent_id=agent_id, env=env)
|
|
40
|
+
|
|
41
|
+
|
|
37
42
|
AGENTS: dict[str, AgentInfo] = {
|
|
38
43
|
"claude": AgentInfo(
|
|
39
44
|
id="claude",
|
|
@@ -49,6 +54,13 @@ AGENTS: dict[str, AgentInfo] = {
|
|
|
49
54
|
color="green",
|
|
50
55
|
runner_factory=_make_cursor_runner,
|
|
51
56
|
),
|
|
57
|
+
"openclaw": AgentInfo(
|
|
58
|
+
id="openclaw",
|
|
59
|
+
label="OpenClaw",
|
|
60
|
+
cli_command="openclaw",
|
|
61
|
+
color="red",
|
|
62
|
+
runner_factory=_make_openclaw_runner,
|
|
63
|
+
),
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
|
|
@@ -15,7 +15,7 @@ CHUNK_SIZE = AUDIO_SAMPLE_RATE * CHUNK_DURATION_MS // 1000
|
|
|
15
15
|
Language = Literal["no", "en", "sv"]
|
|
16
16
|
PermissionMode = Literal["bypass", "acceptEdits", "default"]
|
|
17
17
|
InputMode = Literal["vad", "ptt"]
|
|
18
|
-
AgentType = Literal["claude", "cursor"]
|
|
18
|
+
AgentType = Literal["claude", "cursor", "openclaw"]
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _agent_names(enabled: list[str] | None = None) -> str:
|
|
@@ -136,6 +136,12 @@ class WebSocketBridge:
|
|
|
136
136
|
elif msg_type == "audio.commit":
|
|
137
137
|
if self._session:
|
|
138
138
|
await self._session.commit_audio()
|
|
139
|
+
elif msg_type == "text":
|
|
140
|
+
# Text-only mode: inject user message directly
|
|
141
|
+
text = msg.get("text", "") or msg.get("message", "")
|
|
142
|
+
if text and self._session:
|
|
143
|
+
self._send({"type": "transcript", "role": "user", "text": text})
|
|
144
|
+
await self._session.inject_user_message(text)
|
|
139
145
|
elif msg_type == "sync_state":
|
|
140
146
|
# Frontend requests full state sync from backend
|
|
141
147
|
self._send({"type": "status", "status": "syncing"})
|
|
@@ -97,7 +97,9 @@ def configure(
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def _default_agent_type() -> str:
|
|
100
|
-
"""Return the first enabled agent
|
|
100
|
+
"""Return the default agent type from config, or first enabled agent."""
|
|
101
|
+
if _voice_config and _voice_config.default_agent:
|
|
102
|
+
return _voice_config.default_agent
|
|
101
103
|
return _enabled_agents[0] if _enabled_agents else "claude"
|
|
102
104
|
|
|
103
105
|
|
|
@@ -47,7 +47,7 @@ class CallbackMixin:
|
|
|
47
47
|
return
|
|
48
48
|
|
|
49
49
|
inst = self._session.registry.get_by_id(instance_id)
|
|
50
|
-
if not inst
|
|
50
|
+
if not inst:
|
|
51
51
|
return
|
|
52
52
|
|
|
53
53
|
category = None
|
|
@@ -71,9 +71,27 @@ class CallbackMixin:
|
|
|
71
71
|
styled = _format_output_line(category, display_line, inst.agent_type, show_badge=show_badge)
|
|
72
72
|
|
|
73
73
|
self._state.add_instance_line(inst.display_key, styled)
|
|
74
|
-
self.app.call_from_thread(self.
|
|
74
|
+
self.app.call_from_thread(self._ensure_output, instance_id, inst, styled)
|
|
75
75
|
self._write_log_file(f"[{inst.display_key}] {display_line}")
|
|
76
76
|
|
|
77
|
+
def _ensure_output(self, instance_id: str, inst, styled: str) -> None:
|
|
78
|
+
"""Write output to panel, recovering the widget reference if needed."""
|
|
79
|
+
recovered = False
|
|
80
|
+
if not inst.output_widget:
|
|
81
|
+
# Widget reference lost (e.g. after restore) — recover from DOM
|
|
82
|
+
try:
|
|
83
|
+
inst.output_widget = self.query_one(f"#output-{instance_id}", RichLog)
|
|
84
|
+
recovered = True
|
|
85
|
+
except Exception:
|
|
86
|
+
# Panel doesn't exist yet — create it
|
|
87
|
+
self._create_instance_panel(instance_id, inst.display_key)
|
|
88
|
+
recovered = True
|
|
89
|
+
if not inst.output_widget:
|
|
90
|
+
return
|
|
91
|
+
if recovered:
|
|
92
|
+
self._write_to_output(inst.output_widget, "[dim]─── session resumed ───[/dim]")
|
|
93
|
+
self._write_to_output(inst.output_widget, styled)
|
|
94
|
+
|
|
77
95
|
def _write_to_output(self, widget: RichLog, text: str) -> None:
|
|
78
96
|
try:
|
|
79
97
|
widget.write(text)
|
|
@@ -13,7 +13,7 @@ from textual.containers import Grid, Horizontal, Vertical
|
|
|
13
13
|
from textual.widgets import Static, RichLog, Tree
|
|
14
14
|
|
|
15
15
|
from voice_vibecoder.code_providers.registry import get_badge
|
|
16
|
-
from voice_vibecoder.instances import AgentInstance, InstanceStatus
|
|
16
|
+
from voice_vibecoder.instances import AgentInstance, InstanceStatus, InstanceType
|
|
17
17
|
from voice_vibecoder.ui.styles import _STATUS_ICONS
|
|
18
18
|
|
|
19
19
|
|
|
@@ -166,14 +166,31 @@ class PanelMixin:
|
|
|
166
166
|
classes="agent-output",
|
|
167
167
|
id=f"output-{inst.instance_id}",
|
|
168
168
|
)
|
|
169
|
+
inst.header_widget = header
|
|
170
|
+
inst.output_widget = output
|
|
171
|
+
|
|
172
|
+
if inst.instance_type == InstanceType.CODE:
|
|
173
|
+
_empty_tree = {"name": "", "path": "", "type": "folder", "children": [], "expanded": True}
|
|
174
|
+
tree_widget = FileTreeWidget(
|
|
175
|
+
_empty_tree,
|
|
176
|
+
classes="file-tree-sidebar",
|
|
177
|
+
id=f"file-tree-{inst.instance_id}",
|
|
178
|
+
)
|
|
179
|
+
body = Horizontal(
|
|
180
|
+
tree_widget,
|
|
181
|
+
output,
|
|
182
|
+
classes="file-tree-container",
|
|
183
|
+
id=f"file-tree-wrap-{inst.instance_id}",
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
body = output
|
|
187
|
+
|
|
169
188
|
panel = Vertical(
|
|
170
189
|
header,
|
|
171
|
-
|
|
190
|
+
body,
|
|
172
191
|
classes="agent-panel",
|
|
173
192
|
id=f"panel-{inst.instance_id}",
|
|
174
193
|
)
|
|
175
|
-
inst.header_widget = header
|
|
176
|
-
inst.output_widget = output
|
|
177
194
|
panels.append(panel)
|
|
178
195
|
|
|
179
196
|
try:
|
|
@@ -200,17 +217,35 @@ class PanelMixin:
|
|
|
200
217
|
classes="agent-output",
|
|
201
218
|
id=f"output-{instance_id}",
|
|
202
219
|
)
|
|
220
|
+
|
|
221
|
+
if inst:
|
|
222
|
+
inst.header_widget = header
|
|
223
|
+
inst.output_widget = output
|
|
224
|
+
|
|
225
|
+
is_code = inst and inst.instance_type == InstanceType.CODE
|
|
226
|
+
if is_code:
|
|
227
|
+
_empty_tree = {"name": "", "path": "", "type": "folder", "children": [], "expanded": True}
|
|
228
|
+
tree_widget = FileTreeWidget(
|
|
229
|
+
_empty_tree,
|
|
230
|
+
classes="file-tree-sidebar",
|
|
231
|
+
id=f"file-tree-{instance_id}",
|
|
232
|
+
)
|
|
233
|
+
body = Horizontal(
|
|
234
|
+
tree_widget,
|
|
235
|
+
output,
|
|
236
|
+
classes="file-tree-container",
|
|
237
|
+
id=f"file-tree-wrap-{instance_id}",
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
body = output
|
|
241
|
+
|
|
203
242
|
panel = Vertical(
|
|
204
243
|
header,
|
|
205
|
-
|
|
244
|
+
body,
|
|
206
245
|
classes="agent-panel",
|
|
207
246
|
id=f"panel-{instance_id}",
|
|
208
247
|
)
|
|
209
248
|
|
|
210
|
-
if inst:
|
|
211
|
-
inst.header_widget = header
|
|
212
|
-
inst.output_widget = output
|
|
213
|
-
|
|
214
249
|
try:
|
|
215
250
|
grid = self.query_one("#agent-grid", Grid)
|
|
216
251
|
grid.styles.display = "block"
|
|
@@ -325,61 +360,17 @@ class PanelMixin:
|
|
|
325
360
|
grid.styles.grid_size_rows = 1
|
|
326
361
|
|
|
327
362
|
def _update_file_tree(self, instance_id: str, data: dict) -> None:
|
|
328
|
-
"""Update
|
|
329
|
-
if not self._session:
|
|
330
|
-
return
|
|
331
|
-
inst = self._session.registry.get_by_id(instance_id)
|
|
332
|
-
if not inst:
|
|
333
|
-
return
|
|
334
|
-
|
|
363
|
+
"""Update the file tree sidebar for a code instance panel."""
|
|
335
364
|
tree_data = data.get("tree")
|
|
336
365
|
if not tree_data or not tree_data.get("children"):
|
|
337
366
|
return
|
|
338
367
|
|
|
339
|
-
sidebar_id = f"file-tree-{instance_id}"
|
|
340
|
-
|
|
341
|
-
# Try to update existing tree widget
|
|
342
368
|
try:
|
|
343
|
-
tree_widget = self.query_one(f"#{
|
|
369
|
+
tree_widget = self.query_one(f"#file-tree-{instance_id}", FileTreeWidget)
|
|
344
370
|
tree_widget.rebuild(tree_data)
|
|
345
|
-
return
|
|
346
371
|
except Exception:
|
|
347
372
|
pass
|
|
348
373
|
|
|
349
|
-
# First time — wrap output in a Horizontal with tree sidebar
|
|
350
|
-
if not inst.output_widget:
|
|
351
|
-
return
|
|
352
|
-
|
|
353
|
-
try:
|
|
354
|
-
panel = self.query_one(f"#panel-{instance_id}")
|
|
355
|
-
except Exception:
|
|
356
|
-
return
|
|
357
|
-
|
|
358
|
-
tree_widget = FileTreeWidget(
|
|
359
|
-
tree_data,
|
|
360
|
-
classes="file-tree-sidebar",
|
|
361
|
-
id=sidebar_id,
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
# Remove output from panel, wrap in Horizontal, re-mount
|
|
365
|
-
output = inst.output_widget
|
|
366
|
-
try:
|
|
367
|
-
output.remove()
|
|
368
|
-
container = Horizontal(
|
|
369
|
-
tree_widget,
|
|
370
|
-
output,
|
|
371
|
-
classes="file-tree-container",
|
|
372
|
-
id=f"file-tree-wrap-{instance_id}",
|
|
373
|
-
)
|
|
374
|
-
panel.mount(container)
|
|
375
|
-
except Exception:
|
|
376
|
-
logger.exception("Failed to mount file tree for %s", instance_id)
|
|
377
|
-
# If something fails, try to restore output directly
|
|
378
|
-
try:
|
|
379
|
-
panel.mount(output)
|
|
380
|
-
except Exception:
|
|
381
|
-
pass
|
|
382
|
-
|
|
383
374
|
def _remove_instance_panel(self, instance_id: str) -> None:
|
|
384
375
|
"""Remove a panel from the grid."""
|
|
385
376
|
if self._session:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/claude.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/cursor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/openai.py
RENAMED
|
File without changes
|
|
File without changes
|