voice-vibecoder 2.20.3__tar.gz → 2.22.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.20.3 → voice_vibecoder-2.22.0}/CHANGELOG.md +16 -0
  2. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/PKG-INFO +1 -1
  3. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/pyproject.toml +1 -1
  4. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/config.py +5 -3
  6. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/session.py +98 -0
  7. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/voice_providers/__init__.py +3 -0
  8. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/voice_providers/openai.py +75 -38
  9. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/.github/workflows/ci.yml +0 -0
  10. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/.github/workflows/release.yml +0 -0
  11. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/.gitignore +0 -0
  12. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/LICENSE +0 -0
  13. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/README.md +0 -0
  14. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/app.py +0 -0
  15. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/index.html +0 -0
  16. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/app_config.py +0 -0
  17. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/audio.py +0 -0
  18. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/cli_serve.py +0 -0
  19. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  20. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
  21. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  22. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/code_providers/openclaw.py +0 -0
  23. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  24. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/instances.py +0 -0
  25. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/server.py +0 -0
  26. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  27. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
  28. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  29. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
  30. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
  31. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/tools/handlers.py +0 -0
  32. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  33. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  34. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  35. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  36. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/panels.py +0 -0
  37. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/screen.py +0 -0
  38. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/setup.py +0 -0
  39. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/state.py +0 -0
  40. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/styles.py +0 -0
  41. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  42. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  43. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/voice_providers/gemini.py +0 -0
  44. {voice_vibecoder-2.20.3 → voice_vibecoder-2.22.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.22.0 (2026-06-30)
6
+
7
+ ### Features
8
+
9
+ - **realtime**: Support the Azure GA realtime API (gpt-realtime / gpt-realtime-2)
10
+ ([`93703c3`](https://github.com/snokam/voice-vibecoder/commit/93703c322410e882a796b9415d663a3c446398c0))
11
+
12
+
13
+ ## v2.21.0 (2026-04-12)
14
+
15
+ ### Features
16
+
17
+ - **session**: Narrate background tool events to voice user
18
+ ([`9c28a4f`](https://github.com/snokam/voice-vibecoder/commit/9c28a4f2f6a3f2bc6e6243f11a629ab0e369d7ee))
19
+
20
+
5
21
  ## v2.20.3 (2026-02-23)
6
22
 
7
23
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.20.3
3
+ Version: 2.22.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.20.3"
7
+ version = "2.22.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"
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.20.3"
6
+ __version__ = "2.22.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -354,10 +354,12 @@ class RealtimeSettings:
354
354
  @property
355
355
  def ws_url(self) -> str:
356
356
  if self.provider == "azure":
357
+ # GA realtime API: /openai/v1/realtime?model=<deployment>, no
358
+ # api-version. The dated preview api-versions reject GA-only models
359
+ # (e.g. gpt-realtime / gpt-realtime-2 → "only available on the GA API").
357
360
  return (
358
- f"wss://{self.azure.endpoint}/openai/realtime"
359
- f"?api-version={AZURE_API_VERSION}"
360
- f"&deployment={self.azure.deployment}"
361
+ f"wss://{self.azure.endpoint}/openai/v1/realtime"
362
+ f"?model={self.azure.deployment}"
361
363
  )
362
364
  if self.provider == "gemini":
363
365
  return f"{GEMINI_WS_URL}?key={self.active_api_key}"
@@ -13,6 +13,7 @@ via the voice provider so ChatGPT can summarize it to the user.
13
13
 
14
14
  import asyncio
15
15
  import logging
16
+ import time
16
17
  from pathlib import Path
17
18
  from typing import Any, Callable
18
19
 
@@ -49,6 +50,70 @@ logger = logging.getLogger(__name__)
49
50
  RECONNECT_MAX_ATTEMPTS = 5
50
51
  RECONNECT_DELAYS = [2, 4, 8, 16, 30]
51
52
 
53
+ # Debounce between spoken tool-progress narrations. Prevents the model from
54
+ # interrupting itself mid-filler when many tool events fire in quick succession.
55
+ TOOL_NARRATION_DEBOUNCE_SEC = 6.0
56
+
57
+
58
+ # Markers used by code-provider runners to tag the kind of tool event:
59
+ # 🔧 tool invocation (tool_call)
60
+ # ✓ tool completed successfully (tool_result)
61
+ # ❌ tool failed (tool_result)
62
+ # ↳ streaming output preview, NOT a completion — skip narration
63
+ _TOOL_CALL_MARKER = "\U0001f527" # 🔧
64
+ _TOOL_OK_MARKER = "\u2713" # ✓
65
+ _TOOL_ERR_MARKER = "\u274c" # ❌
66
+
67
+
68
+ def _friendly_tool_hint(line: str) -> str | None:
69
+ """Turn a registry tool-event line into a short natural-language hint.
70
+
71
+ Lines look like `"tool_call:🔧 jira_search({...})"` or
72
+ `"tool_result:✓ jira_search: 3 issues"`. Returns None for lines that
73
+ aren't recognized tool starts/completions (e.g. streaming previews)
74
+ so the caller skips narration.
75
+ """
76
+ if not line or ":" not in line:
77
+ return None
78
+ category, rest = line.split(":", 1)
79
+ if category not in ("tool_call", "tool_result"):
80
+ return None
81
+ rest = rest.strip()
82
+ # Must be one of the known markers — otherwise it's a preview or
83
+ # something we don't want to narrate.
84
+ if category == "tool_call":
85
+ if not rest.startswith(_TOOL_CALL_MARKER):
86
+ return None
87
+ verb = "using"
88
+ else:
89
+ if rest.startswith(_TOOL_OK_MARKER):
90
+ verb = "finished"
91
+ elif rest.startswith(_TOOL_ERR_MARKER):
92
+ verb = "had trouble with"
93
+ else:
94
+ return None
95
+ rest = rest[1:].lstrip()
96
+ for sep in ("(", ":", " "):
97
+ if sep in rest:
98
+ rest = rest.split(sep, 1)[0]
99
+ raw = rest.strip()
100
+ if not raw:
101
+ return None
102
+ name = raw.split(".", 1)[-1].replace("_", " ").replace("-", " ").strip()
103
+ if not name:
104
+ return None
105
+ if verb == "using":
106
+ return f"using {name}"
107
+ if verb == "finished":
108
+ return f"finished {name}, preparing answer"
109
+ return f"had trouble with {name}, trying another way"
110
+
111
+
112
+ _TOOL_NARRATION_PROMPT = (
113
+ "[agent background status: {hint}. Let the user know naturally in "
114
+ "whatever language they are speaking. Don't guess at the results.]"
115
+ )
116
+
52
117
 
53
118
  class RealtimeSession:
54
119
  """Manages a single voice session with multi-instance support."""
@@ -89,6 +154,7 @@ class RealtimeSession:
89
154
  self._response_pending = False
90
155
  self._fallback_task: asyncio.Task | None = None
91
156
  self._loop: asyncio.AbstractEventLoop | None = None
157
+ self._last_tool_narration: float = 0.0
92
158
 
93
159
  # Create instance registry and wire callbacks
94
160
  self._registry = InstanceRegistry(user_email=user_email, user_jwt=user_jwt)
@@ -322,6 +388,38 @@ class RealtimeSession:
322
388
  def _on_registry_output(self, instance_id: str, line: str) -> None:
323
389
  if self._on_agent_output:
324
390
  self._on_agent_output(instance_id, line)
391
+ self._maybe_narrate_tool_event(line)
392
+
393
+ def _maybe_narrate_tool_event(self, line: str) -> None:
394
+ """Speak a short filler phrase when a background tool event fires.
395
+
396
+ Without this, a caller hears 30–60s of silence while the agent runs
397
+ MCP tools. Debounced to TOOL_NARRATION_DEBOUNCE_SEC so many rapid
398
+ tool events coalesce into at most one spoken hint. Called from
399
+ background threads, so the injection is bounced to the session's
400
+ asyncio loop.
401
+ """
402
+ if self.text_only or not self._running:
403
+ return
404
+ if not isinstance(line, str):
405
+ return
406
+ hint = _friendly_tool_hint(line)
407
+ if not hint:
408
+ return
409
+ now = time.monotonic()
410
+ if (now - self._last_tool_narration) < TOOL_NARRATION_DEBOUNCE_SEC:
411
+ return
412
+ loop = self._loop
413
+ if loop is None:
414
+ return
415
+ self._last_tool_narration = now
416
+ text = _TOOL_NARRATION_PROMPT.format(hint=hint)
417
+ try:
418
+ asyncio.run_coroutine_threadsafe(
419
+ self._provider.inject_message(text), loop,
420
+ )
421
+ except Exception as e:
422
+ logger.warning("Tool narration inject failed: %s", e)
325
423
 
326
424
  def _on_registry_status_change(self, instance_id: str, status: InstanceStatus) -> None:
327
425
  if self._on_instance_status:
@@ -142,4 +142,7 @@ def create_provider(settings) -> VoiceProvider:
142
142
  return OpenAIVoiceProvider(
143
143
  ws_url=settings.ws_url,
144
144
  ws_headers=settings.ws_headers,
145
+ # Azure realtime is the GA API (/openai/v1/realtime); it uses the nested
146
+ # session schema and response.output_audio.* events.
147
+ ga=settings.provider == "azure",
145
148
  )
@@ -41,12 +41,69 @@ class OpenAIVoiceProvider:
41
41
  ws_url: str,
42
42
  ws_headers: dict[str, str],
43
43
  transcription_model: str = "gpt-4o-mini-transcribe",
44
+ ga: bool = False,
44
45
  ) -> None:
45
46
  self._ws_url = ws_url
46
47
  self._ws_headers = ws_headers
47
48
  self._transcription_model = transcription_model
49
+ # GA realtime API (Azure /openai/v1/realtime): nested `audio` session
50
+ # schema + response.output_audio.* events. Preview otherwise.
51
+ self._ga = ga
48
52
  self._ws: Any = None
49
53
 
54
+ def _turn_detection(self, vad: VADSettings | None) -> dict | None:
55
+ if vad is None:
56
+ return None
57
+ return {
58
+ "type": "server_vad",
59
+ "threshold": vad.threshold,
60
+ "prefix_padding_ms": vad.prefix_padding_ms,
61
+ "silence_duration_ms": vad.silence_duration_ms,
62
+ "create_response": True,
63
+ }
64
+
65
+ def _session_body(
66
+ self,
67
+ instructions: str,
68
+ voice: str,
69
+ tools: list[dict],
70
+ vad: VADSettings | None,
71
+ ) -> dict[str, Any]:
72
+ """Build the `session` object for session.update, GA or preview schema."""
73
+ if self._ga:
74
+ audio_input: dict[str, Any] = {
75
+ "format": {"type": "audio/pcm", "rate": 24000},
76
+ "turn_detection": self._turn_detection(vad),
77
+ }
78
+ if self._transcription_model:
79
+ audio_input["transcription"] = {"model": self._transcription_model}
80
+ session: dict[str, Any] = {
81
+ "type": "realtime",
82
+ "output_modalities": ["audio"],
83
+ "instructions": instructions,
84
+ "audio": {
85
+ "input": audio_input,
86
+ "output": {
87
+ "format": {"type": "audio/pcm", "rate": 24000},
88
+ "voice": voice,
89
+ },
90
+ },
91
+ }
92
+ if tools:
93
+ session["tools"] = tools
94
+ return session
95
+
96
+ return {
97
+ "modalities": ["text", "audio"],
98
+ "instructions": instructions,
99
+ "voice": voice,
100
+ "input_audio_format": "pcm16",
101
+ "output_audio_format": "pcm16",
102
+ "input_audio_transcription": {"model": self._transcription_model},
103
+ "tools": tools,
104
+ "turn_detection": self._turn_detection(vad),
105
+ }
106
+
50
107
  async def connect(self) -> None:
51
108
  self._ws = await websockets.connect(
52
109
  self._ws_url, additional_headers=self._ws_headers
@@ -70,30 +127,10 @@ class OpenAIVoiceProvider:
70
127
  if not self._ws:
71
128
  return
72
129
 
73
- session_config: dict[str, Any] = {
130
+ session_config = {
74
131
  "type": "session.update",
75
- "session": {
76
- "modalities": ["text", "audio"],
77
- "instructions": instructions,
78
- "voice": voice,
79
- "input_audio_format": "pcm16",
80
- "output_audio_format": "pcm16",
81
- "input_audio_transcription": {"model": self._transcription_model},
82
- "tools": tools,
83
- },
132
+ "session": self._session_body(instructions, voice, tools, vad),
84
133
  }
85
-
86
- if vad is None:
87
- session_config["session"]["turn_detection"] = None
88
- else:
89
- session_config["session"]["turn_detection"] = {
90
- "type": "server_vad",
91
- "threshold": vad.threshold,
92
- "prefix_padding_ms": vad.prefix_padding_ms,
93
- "silence_duration_ms": vad.silence_duration_ms,
94
- "create_response": True,
95
- }
96
-
97
134
  await self._ws.send(json.dumps(session_config))
98
135
 
99
136
  async def send_audio(self, base64_chunk: str) -> None:
@@ -149,10 +186,10 @@ class OpenAIVoiceProvider:
149
186
  async def update_session(self, instructions: str) -> None:
150
187
  if not self._ws:
151
188
  return
152
- msg = {
153
- "type": "session.update",
154
- "session": {"instructions": instructions},
155
- }
189
+ session: dict[str, Any] = {"instructions": instructions}
190
+ if self._ga:
191
+ session["type"] = "realtime"
192
+ msg = {"type": "session.update", "session": session}
156
193
  await self._ws.send(json.dumps(msg))
157
194
 
158
195
  async def send_tool_result(self, call_id: str, output: str) -> None:
@@ -200,14 +237,12 @@ class OpenAIVoiceProvider:
200
237
  # Configure session with the selected voice
201
238
  await ws.send(json.dumps({
202
239
  "type": "session.update",
203
- "session": {
204
- "modalities": ["text", "audio"],
205
- "voice": voice,
206
- "instructions": "Always speak English. Say a short greeting in 5 words or less.",
207
- "input_audio_format": "pcm16",
208
- "output_audio_format": "pcm16",
209
- "turn_detection": None,
210
- },
240
+ "session": self._session_body(
241
+ instructions="Always speak English. Say a short greeting in 5 words or less.",
242
+ voice=voice,
243
+ tools=[],
244
+ vad=None,
245
+ ),
211
246
  }))
212
247
 
213
248
  # Send a message and request a response
@@ -227,7 +262,7 @@ class OpenAIVoiceProvider:
227
262
  while asyncio.get_event_loop().time() < deadline:
228
263
  raw = await asyncio.wait_for(ws.recv(), timeout=15)
229
264
  msg = json.loads(raw)
230
- if msg.get("type") == "response.audio.delta":
265
+ if msg.get("type") in ("response.output_audio.delta", "response.audio.delta"):
231
266
  audio_chunks.append(base64.b64decode(msg["delta"]))
232
267
  elif msg.get("type") == "response.done":
233
268
  break
@@ -245,13 +280,15 @@ class OpenAIVoiceProvider:
245
280
  def _map_event(self, msg: dict) -> VoiceEvent | None:
246
281
  msg_type = msg.get("type", "")
247
282
 
248
- if msg_type == "response.audio.delta":
283
+ # GA realtime renames the audio/transcript response events
284
+ # (response.output_audio.*); accept both so preview + GA both work.
285
+ if msg_type in ("response.output_audio.delta", "response.audio.delta"):
249
286
  return AudioDelta(delta=msg.get("delta", ""))
250
287
 
251
- elif msg_type == "response.audio_transcript.delta":
288
+ elif msg_type in ("response.output_audio_transcript.delta", "response.audio_transcript.delta"):
252
289
  return TranscriptDelta(delta=msg.get("delta", ""))
253
290
 
254
- elif msg_type == "response.audio_transcript.done":
291
+ elif msg_type in ("response.output_audio_transcript.done", "response.audio_transcript.done"):
255
292
  return TranscriptDone(transcript=msg.get("transcript", ""))
256
293
 
257
294
  elif msg_type == "conversation.item.input_audio_transcription.completed":