voice-vibecoder 2.8.0__tar.gz → 2.9.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.
Files changed (44) hide show
  1. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/CHANGELOG.md +16 -0
  2. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/PKG-INFO +2 -1
  3. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/pyproject.toml +2 -1
  4. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/__init__.py +1 -1
  5. voice_vibecoder-2.9.0/src/voice_vibecoder/code_providers/openclaw.py +323 -0
  6. voice_vibecoder-2.8.0/src/voice_vibecoder/code_providers/openclaw.py +0 -369
  7. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/.github/workflows/ci.yml +0 -0
  8. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/.github/workflows/release.yml +0 -0
  9. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/.gitignore +0 -0
  10. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/LICENSE +0 -0
  11. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/README.md +0 -0
  12. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/app.py +0 -0
  13. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/index.html +0 -0
  14. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/app_config.py +0 -0
  15. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/audio.py +0 -0
  16. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/cli_serve.py +0 -0
  17. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  18. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
  19. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  20. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  21. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/config.py +0 -0
  22. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/instances.py +0 -0
  23. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/server.py +0 -0
  24. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/session.py +0 -0
  25. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  26. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
  27. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  28. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
  29. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
  30. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/handlers.py +0 -0
  31. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  32. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  33. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  34. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  35. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/panels.py +0 -0
  36. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/screen.py +0 -0
  37. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/setup.py +0 -0
  38. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/state.py +0 -0
  39. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/styles.py +0 -0
  40. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  41. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  42. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  43. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  44. {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.9.0 (2026-02-21)
6
+
7
+ ### Features
8
+
9
+ - Add WebSocket streaming support to OpenClaw provider
10
+ ([`97e107a`](https://github.com/snokam/voice-vibecoder/commit/97e107a8371a02783b51258bc75c0d64807b3964))
11
+
12
+
13
+ ## v2.8.1 (2026-02-21)
14
+
15
+ ### Bug Fixes
16
+
17
+ - Simplify OpenClaw provider to use CLI only
18
+ ([`dfe92c8`](https://github.com/snokam/voice-vibecoder/commit/dfe92c888e390bb5b50a5594452d276f2ff2a8d8))
19
+
20
+
5
21
  ## v2.8.0 (2026-02-20)
6
22
 
7
23
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.8.0
3
+ Version: 2.9.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
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Software Development
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: claude-agent-sdk>=0.1.30
24
+ Requires-Dist: cryptography>=42.0
24
25
  Requires-Dist: numpy>=1.24
25
26
  Requires-Dist: platformdirs>=4.0
26
27
  Requires-Dist: rich>=13.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voice-vibecoder"
7
- version = "2.8.0"
7
+ version = "2.9.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"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "Topic :: Software Development",
25
25
  ]
26
26
  dependencies = [
27
+ "cryptography>=42.0",
27
28
  "textual>=0.50",
28
29
  "rich>=13.0",
29
30
  "websockets>=12",
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.8.0"
6
+ __version__ = "2.9.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -0,0 +1,323 @@
1
+ """OpenClaw Agent code provider.
2
+
3
+ Routes coding tasks to OpenClaw Gateway via WebSocket for streaming output.
4
+ Falls back to CLI if WebSocket connection fails.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import time
16
+ import uuid
17
+ from typing import Any, Awaitable, Callable
18
+
19
+ from voice_vibecoder.code_providers import AgentOutput, AgentResult
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Try to import cryptography for Ed25519 signing
24
+ try:
25
+ from cryptography.hazmat.primitives import serialization
26
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
27
+ HAS_CRYPTO = True
28
+ except ImportError:
29
+ HAS_CRYPTO = False
30
+ logger.warning("cryptography not installed, WebSocket auth will not work")
31
+
32
+
33
+ def _load_device_keys(state_dir: str) -> tuple[str, Ed25519PrivateKey | None, str | None]:
34
+ """Load device identity from OpenClaw state directory."""
35
+ device_path = os.path.join(state_dir, "identity", "device.json")
36
+ auth_path = os.path.join(state_dir, "identity", "device-auth.json")
37
+
38
+ device_id = None
39
+ private_key = None
40
+ token = None
41
+
42
+ try:
43
+ with open(device_path) as f:
44
+ device = json.load(f)
45
+ device_id = device["deviceId"]
46
+ if HAS_CRYPTO:
47
+ pem = device["privateKeyPem"].encode()
48
+ private_key = serialization.load_pem_private_key(pem, password=None)
49
+ except Exception as e:
50
+ logger.debug("Could not load device.json: %s", e)
51
+
52
+ try:
53
+ with open(auth_path) as f:
54
+ auth = json.load(f)
55
+ if "tokens" in auth and "operator" in auth["tokens"]:
56
+ token = auth["tokens"]["operator"]["token"]
57
+ except Exception as e:
58
+ logger.debug("Could not load device-auth.json: %s", e)
59
+
60
+ return device_id, private_key, token
61
+
62
+
63
+ def _sign_challenge(private_key: Ed25519PrivateKey, nonce: str, ts: int) -> tuple[str, int]:
64
+ """Sign the gateway challenge."""
65
+ message = f"{nonce}:{ts}".encode()
66
+ signature = private_key.sign(message)
67
+ return base64.urlsafe_b64encode(signature).decode().rstrip("="), ts
68
+
69
+
70
+ class OpenClawRunner:
71
+ """AgentRunner implementation using OpenClaw Gateway WebSocket."""
72
+
73
+ def __init__(
74
+ self,
75
+ session_id: str | None = None,
76
+ agent_id: str | None = None,
77
+ env: dict[str, str] | None = None,
78
+ gateway_url: str | None = None,
79
+ state_dir: str | None = None,
80
+ **_kwargs,
81
+ ) -> None:
82
+ self._session_id = session_id or f"voice-{uuid.uuid4().hex[:8]}"
83
+ self._agent_id = agent_id
84
+ self._env = env or {}
85
+ self._gateway_url = gateway_url or os.environ.get("OPENCLAW_GATEWAY_URL", "ws://127.0.0.1:18789")
86
+ self._state_dir = state_dir or os.environ.get("OPENCLAW_STATE_DIR", "/data/olaf")
87
+ self._task: asyncio.Task | None = None
88
+ self._ws = None
89
+
90
+ # Load device identity
91
+ self._device_id, self._private_key, self._device_token = _load_device_keys(self._state_dir)
92
+
93
+ async def _connect_websocket(self) -> Any:
94
+ """Connect to OpenClaw Gateway via WebSocket."""
95
+ try:
96
+ import websockets
97
+ except ImportError:
98
+ logger.warning("websockets not installed")
99
+ return None
100
+
101
+ if not self._device_id or not self._private_key:
102
+ logger.warning("No device identity found, cannot connect via WebSocket")
103
+ return None
104
+
105
+ try:
106
+ ws = await asyncio.wait_for(
107
+ websockets.connect(self._gateway_url),
108
+ timeout=5.0
109
+ )
110
+
111
+ # Wait for challenge
112
+ challenge_msg = await asyncio.wait_for(ws.recv(), timeout=5.0)
113
+ challenge = json.loads(challenge_msg)
114
+
115
+ if challenge.get("event") != "connect.challenge":
116
+ logger.warning("Unexpected challenge: %s", challenge)
117
+ await ws.close()
118
+ return None
119
+
120
+ nonce = challenge["payload"]["nonce"]
121
+ ts = challenge["payload"]["ts"]
122
+
123
+ # Sign challenge
124
+ signature, signed_at = _sign_challenge(self._private_key, nonce, ts)
125
+
126
+ # Get public key in base64
127
+ public_key_bytes = self._private_key.public_key().public_bytes(
128
+ encoding=serialization.Encoding.Raw,
129
+ format=serialization.PublicFormat.Raw
130
+ )
131
+ public_key_b64 = base64.urlsafe_b64encode(public_key_bytes).decode().rstrip("=")
132
+
133
+ # Send connect request
134
+ connect_req = {
135
+ "type": "req",
136
+ "id": str(uuid.uuid4()),
137
+ "method": "connect",
138
+ "params": {
139
+ "minProtocol": 3,
140
+ "maxProtocol": 3,
141
+ "client": {
142
+ "id": "voice-server",
143
+ "version": "1.0.0",
144
+ "platform": "linux",
145
+ "mode": "backend"
146
+ },
147
+ "role": "operator",
148
+ "scopes": ["operator.read", "operator.write"],
149
+ "caps": [],
150
+ "commands": [],
151
+ "permissions": {},
152
+ "auth": {"deviceToken": self._device_token} if self._device_token else {},
153
+ "device": {
154
+ "id": self._device_id,
155
+ "publicKey": public_key_b64,
156
+ "signature": signature,
157
+ "signedAt": signed_at,
158
+ "nonce": nonce
159
+ }
160
+ }
161
+ }
162
+
163
+ await ws.send(json.dumps(connect_req))
164
+
165
+ # Wait for response
166
+ response_msg = await asyncio.wait_for(ws.recv(), timeout=5.0)
167
+ response = json.loads(response_msg)
168
+
169
+ if not response.get("ok"):
170
+ error = response.get("error", {}).get("message", "Unknown error")
171
+ logger.warning("Gateway connect failed: %s", error)
172
+ await ws.close()
173
+ return None
174
+
175
+ logger.info("Connected to OpenClaw Gateway via WebSocket")
176
+ return ws
177
+
178
+ except Exception as e:
179
+ logger.warning("WebSocket connection failed: %s", e)
180
+ return None
181
+
182
+ async def _run_via_websocket(
183
+ self,
184
+ ws,
185
+ message: str,
186
+ cwd: str,
187
+ session_id: str,
188
+ on_output: Callable[[AgentOutput], None] | None = None,
189
+ ) -> AgentResult | None:
190
+ """Run agent via WebSocket for streaming output."""
191
+ try:
192
+ # Send agent request
193
+ req_id = str(uuid.uuid4())
194
+ agent_req = {
195
+ "type": "req",
196
+ "id": req_id,
197
+ "method": "agent.run",
198
+ "params": {
199
+ "sessionId": session_id,
200
+ "message": message,
201
+ "cwd": cwd,
202
+ "local": True
203
+ }
204
+ }
205
+
206
+ await ws.send(json.dumps(agent_req))
207
+
208
+ result_text = ""
209
+
210
+ # Read streaming responses
211
+ while True:
212
+ msg = await asyncio.wait_for(ws.recv(), timeout=300.0)
213
+ data = json.loads(msg)
214
+
215
+ # Handle events (streaming output)
216
+ if data.get("type") == "event":
217
+ event = data.get("event", "")
218
+ payload = data.get("payload", {})
219
+
220
+ if event == "agent.output":
221
+ text = payload.get("text", "")
222
+ if text and on_output:
223
+ on_output(AgentOutput(category="text", content=text))
224
+ elif event == "agent.tool":
225
+ tool = payload.get("tool", "")
226
+ if on_output:
227
+ on_output(AgentOutput(category="tool_call", content=f"[{tool}]"))
228
+
229
+ # Handle response (completion)
230
+ elif data.get("type") == "res" and data.get("id") == req_id:
231
+ if data.get("ok"):
232
+ result_text = data.get("payload", {}).get("result", "")
233
+ else:
234
+ error = data.get("error", {}).get("message", "Unknown error")
235
+ raise RuntimeError(f"Agent error: {error}")
236
+ break
237
+
238
+ return AgentResult(text=result_text.strip(), session_id=session_id)
239
+
240
+ except Exception as e:
241
+ logger.warning("WebSocket agent run failed: %s", e)
242
+ return None
243
+
244
+ async def _run_via_cli(
245
+ self,
246
+ message: str,
247
+ cwd: str,
248
+ session_id: str,
249
+ on_output: Callable[[AgentOutput], None] | None = None,
250
+ ) -> AgentResult:
251
+ """Fallback: run agent via CLI."""
252
+ if on_output:
253
+ on_output(AgentOutput(category="text", content="🦞 Running via CLI..."))
254
+
255
+ full_message = f"[Working directory: {cwd}]\n\n{message}"
256
+
257
+ cmd = [
258
+ "openclaw", "agent",
259
+ "--session-id", session_id,
260
+ "--message", full_message,
261
+ "--local",
262
+ ]
263
+
264
+ env = os.environ.copy()
265
+ env["PATH"] = f"/home/olaf/.npm-global/bin:/home/olaf/.local/bin:{env.get('PATH', '')}"
266
+ env["OPENCLAW_STATE_DIR"] = self._state_dir
267
+ if self._env:
268
+ env.update(self._env)
269
+
270
+ process = await asyncio.create_subprocess_exec(
271
+ *cmd,
272
+ stdout=asyncio.subprocess.PIPE,
273
+ stderr=asyncio.subprocess.PIPE,
274
+ env=env,
275
+ cwd=cwd,
276
+ )
277
+
278
+ stdout, stderr = await process.communicate()
279
+ result_text = stdout.decode("utf-8", errors="replace").strip()
280
+
281
+ if process.returncode != 0 and not result_text:
282
+ raise RuntimeError(f"CLI failed: {stderr.decode()}")
283
+
284
+ return AgentResult(text=result_text, session_id=session_id)
285
+
286
+ async def run(
287
+ self,
288
+ message: str,
289
+ cwd: str,
290
+ session_id: str | None = None,
291
+ on_output: Callable[[AgentOutput], None] | None = None,
292
+ can_use_tool: Callable[[str, dict, Any], Awaitable[Any]] | None = None,
293
+ ) -> AgentResult:
294
+ """Run a coding task via OpenClaw."""
295
+ self._task = asyncio.current_task()
296
+ effective_session = session_id or self._session_id
297
+
298
+ if on_output:
299
+ on_output(AgentOutput(category="text", content="🦞 Connecting to OpenClaw..."))
300
+
301
+ # Try WebSocket first
302
+ ws = await self._connect_websocket()
303
+ if ws:
304
+ try:
305
+ result = await self._run_via_websocket(ws, message, cwd, effective_session, on_output)
306
+ if result:
307
+ return result
308
+ finally:
309
+ await ws.close()
310
+
311
+ # Fallback to CLI
312
+ if on_output:
313
+ on_output(AgentOutput(category="text", content="WebSocket unavailable, using CLI..."))
314
+
315
+ return await self._run_via_cli(message, cwd, effective_session, on_output)
316
+
317
+ def cancel(self) -> bool:
318
+ """Cancel the running task."""
319
+ task = self._task
320
+ if task and not task.done():
321
+ task.cancel()
322
+ return True
323
+ return False
@@ -1,369 +0,0 @@
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
File without changes
File without changes