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.
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/CHANGELOG.md +16 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/PKG-INFO +2 -1
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/pyproject.toml +2 -1
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/__init__.py +1 -1
- voice_vibecoder-2.9.0/src/voice_vibecoder/code_providers/openclaw.py +323 -0
- voice_vibecoder-2.8.0/src/voice_vibecoder/code_providers/openclaw.py +0 -369
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/.github/workflows/ci.yml +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/.github/workflows/release.yml +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/.gitignore +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/LICENSE +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/README.md +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/app.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/index.html +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/app_config.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/audio.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/cli_serve.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/config.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/instances.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/server.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/session.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/__init__.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/definitions.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/handlers.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/__init__.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/panels.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/screen.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/setup.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/state.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/styles.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/wizard.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
- {voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
- {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.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
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.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/claude.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/cursor.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/registry.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.8.0 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/openai.py
RENAMED
|
File without changes
|
|
File without changes
|