{"repo":"NousResearch/hermes-agent","public_id":"314","display_label":"Voice mode feature requests and implementation PRs","summary":"A collection of feature requests and pull requests centered on adding voice interaction capabilities, including speech input/output, real-time conversation, and various interface integrations (CLI, Telegram, Discord, Web UI). Members cover both design proposals and implementation attempts across multiple platforms.","subsystem":"agent","counts":{"members":6,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":0},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":327},"resolution":{"state":"partially_resolved","by":"pr-327"},"freshness":{"state":"stale","last_verified_at":"2026-03-14T13:45:22+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2053333333333333},"fix_edges":[{"issue_number":314,"pr_number":327,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":314,"pr_number":1299,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":314,"title":"Feature: Voice Mode — Speech Input/Output for CLI and Gateway Platforms","url":null,"state":"closed"},{"kind":"issue","number":26949,"title":"[Feature Request]: Hold-to-Record Voice Mode (Alternative to Push-to-Toggle)","url":null,"state":"open"},{"kind":"issue","number":35750,"title":"[Feature]: Real-time Voice Conversation Mode (voice-in, voice-out, low-latency)","url":null,"state":"closed"},{"kind":"issue","number":53836,"title":"[Feature]: Add a Live Multimodal Voice Mode with Real-Time Interaction and Agent Integration","url":null,"state":"open"},{"kind":"pr","number":327,"title":"feat: Voice Mode — CLI, Telegram, Discord (text + VC), and Web UI with full voice support (Issue #314)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".env.example","cli.py","gateway/platforms/base.py","gateway/platforms/discord.py","gateway/platforms/slack.py","gateway/platforms/telegram.py","gateway/run.py","gateway/session.py","hermes_cli/commands.py","hermes_cli/config.py","pyproject.toml","run_agent.py","tests/fakes/fake_ha_server.py","tests/gateway/test_background_command.py","tests/gateway/test_discord_free_response.py","tests/gateway/test_discord_opus.py","tests/gateway/test_discord_slash_commands.py","tests/gateway/test_resume_command.py","tests/gateway/test_run_progress_topics.py","tests/gateway/test_session_hygiene.py","tests/gateway/test_title_command.py","tests/gateway/test_update_command.py","tests/gateway/test_voice_command.py","tests/hermes_cli/test_commands.py","tests/test_cli_skin_integration.py","tests/test_run_agent.py","tests/tools/test_transcription.py","tests/tools/test_transcription_tools.py","tests/tools/test_voice_cli_integration.py","tests/tools/test_voice_mode.py","tools/browser_tool.py","tools/transcription_tools.py","tools/tts_tool.py","tools/voice_mode.py","website/docs/user-guide/features/voice-mode.md","website/docs/user-guide/messaging/discord.md","website/docs/user-guide/messaging/index.md"],"fix_hunks":[{"file":".env.example","hunks":["@@ -275,3 +275,27 @@ WANDB_API_KEY=\n # GITHUB_APP_ID=\n # GITHUB_APP_PRIVATE_KEY_PATH=\n # GITHUB_APP_INSTALLATION_ID=\n+\n+# Groq API key (free tier — used for Whisper STT in voice mode)\n+# GROQ_API_KEY=\n+\n+# =============================================================================\n+# STT PROVIDER SELECTION\n+# =============================================================================\n+# Default STT provider is \"local\" (faster-whisper) — runs on your machine, no API key needed.\n+# Install with: pip install faster-whisper\n+# Model downloads automatically on first use (~150 MB for \"base\").\n+# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.\n+# Provider priority: local > groq > openai\n+# Configure in config.yaml: stt.provider: local | groq | openai\n+\n+# =============================================================================\n+# STT ADVANCED OVERRIDES (optional)\n+# =============================================================================\n+# Override default STT models per provider (normally set via stt.model in config.yaml)\n+# STT_GROQ_MODEL=whisper-large-v3-turbo\n+# STT_OPENAI_MODEL=whisper-1\n+\n+# Override STT provider endpoints (for proxies or self-hosted instances)\n+# GROQ_BASE_URL=https://api.groq.com/openai/v1\n+# STT_OPENAI_BASE_URL=https://api.openai.com/v1"]},{"file":"cli.py","hunks":["@@ -18,6 +18,8 @@\n import sys\n import json\n import atexit\n+import tempfile\n+import time\n import uuid\n import textwrap\n from contextlib import contextmanager","@@ -1548,6 +1550,7 @@ def _init_agent(self) -> bool:\n                 checkpoints_enabled=self.checkpoints_enabled,\n                 checkpoint_max_snapshots=self.checkpoint_max_snapshots,\n                 pass_session_id=self.pass_session_id,\n+                tool_progress_callback=self._on_tool_progress,\n             )\n             # Apply any pending title now that the session exists in the DB\n             if self._pending_title and self._session_db:","@@ -3017,6 +3020,8 @@ def process_command(self, command: str) -> bool:\n             self._handle_background_command(cmd_original)\n         elif cmd_lower.startswith(\"/skin\"):\n             self._handle_skin_command(cmd_original)\n+        elif cmd_lower.startswith(\"/voice\"):\n+            self._handle_voice_command(cmd_original)\n         else:\n             # Check for user-defined quick commands (bypass agent loop, no LLM call)\n             base_cmd = cmd_lower.split()[0]","@@ -3511,6 +3516,407 @@ def _reload_mcp(self):\n         except Exception as e:\n             print(f\"  ❌ MCP reload failed: {e}\")\n \n+    # ====================================================================\n+    # Tool progress callback (audio cues for voice mode)\n+    # ====================================================================\n+\n+    def _on_tool_progress(self, function_name: str, preview: str, function_args: dict):\n+        \"\"\"Called when a tool starts executing. Plays audio cue in voice mode.\"\"\"\n+        if not self._voice_mode:\n+            return\n+        # Skip internal/thinking tools\n+        if function_name.startswith(\"_\"):\n+            return\n+        try:\n+            from tools.voice_mode import play_beep\n+            # Short, subtle tick sound (higher pitch, very brief)\n+            threading.Thread(\n+                target=play_beep,\n+                kwargs={\"frequency\": 1200, \"duration\": 0.06, \"count\": 1},\n+                daemon=True,\n+            ).start()\n+        except Exception:\n+            pass\n+\n+    # ====================================================================\n+    # Voice mode methods\n+    # ====================================================================\n+\n+    def _voice_start_recording(self):\n+        \"\"\"Start capturing audio from the microphone.\"\"\"\n+        if getattr(self, '_should_exit', False):\n+            return\n+        from tools.voice_mode import AudioRecorder, check_voice_requirements\n+\n+        reqs = check_voice_requirements()\n+        if not reqs[\"audio_available\"]:\n+            raise RuntimeError(\n+                \"Voice mode requires sounddevice and numpy.\\n\"\n+                \"Install with: pip install sounddevice numpy\\n\"\n+                \"Or: pip install hermes-agent[voice]\"\n+            )\n+        if not reqs.get(\"stt_available\", reqs.get(\"stt_key_set\")):\n+            raise RuntimeError(\n+                \"Voice mode requires an STT provider for transcription.\\n\"\n+                \"Option 1: pip install faster-whisper  (free, local)\\n\"\n+                \"Option 2: Set GROQ_API_KEY (free tier)\\n\"\n+                \"Option 3: Set VOICE_TOOLS_OPENAI_KEY (paid)\"\n+            )\n+\n+        # Prevent double-start from concurrent threads (atomic check-and-set)\n+        with self._voice_lock:\n+            if self._voice_recording:\n+                return\n+            self._voice_recording = True\n+\n+        # Load silence detection params from config\n+        voice_cfg = {}\n+        try:\n+            from hermes_cli.config import load_config\n+            voice_cfg = load_config().get(\"voice\", {})\n+        except Exception:\n+            pass\n+\n+        if self._voice_recorder is None:\n+            self._voice_recorder = AudioRecorder()\n+\n+        # Apply config-driven silence params\n+        self._voice_recorder._silence_threshold = voice_cfg.get(\"silence_threshold\", 200)\n+        self._voice_recorder._silence_duration = voice_cfg.get(\"silence_duration\", 3.0)\n+\n+        def _on_silence():\n+            \"\"\"Called by AudioRecorder when silence is detected after speech.\"\"\"\n+            with self._voice_lock:\n+                if not self._voice_recording:\n+                    return\n+            _cprint(f\"\\n{_DIM}Silence detected, auto-stopping...{_RST}\")\n+            if hasattr(self, '_app') and self._app:\n+                self._app.invalidate()\n+            self._voice_stop_and_transcribe()\n+\n+        # Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict)\n+        try:\n+            from tools.voice_mode import play_beep\n+            play_beep(frequency=880, count=1)\n+        except Exception:\n+            pass\n+\n+        try:\n+            self._voice_recorder.start(on_silence_stop=_on_silence)\n+        except Exception:\n+            with self._voice_lock:\n+                self._voice_recording = False\n+            raise\n+        _cprint(f\"\\n{_GOLD}● Recording...{_RST} {_DIM}(auto-stops on silence | Ctrl+B to stop & exit continuous){_RST}\")\n+\n+        # Periodically refresh prompt to update audio level indicator\n+        def _refresh_level():\n+            while True:\n+                with self._voice_lock:\n+                    still_recording = self._voice_recording\n+                if not still_recording:\n+                    break\n+                if hasattr(self, '_app') and self._app:\n+                    self._app.invalidate()\n+                time.sleep(0.15)\n+        threading.Thread(target=_refresh_level, daemon=True).start()\n+\n+    def _voice_stop_and_transcribe(self):\n+        \"\"\"Stop recording, transcribe via STT, and queue the transcript as input.\"\"\"\n+        # Atomic guard: only one thread can enter stop-and-transcribe.\n+        # Set _voice_processing immediately so concurrent Ctrl+B presses\n+        # don't race into the START path while recorder.stop() holds its lock.\n+        with self._voice_lock:\n+            if not self._voice_recording:\n+                return\n+            self._voice_recording = False\n+            self._voice_processing = True\n+\n+        submitted = False\n+        wav_path = None\n+        try:\n+            if self._voice_recorder is None:\n+                return\n+\n+            wav_path = self._voice_recorder.stop()\n+\n+            # Audio cue: double beep after stream stopped (no CoreAudio conflict)\n+            try:\n+                from tools.voice_mode import play_beep\n+                play_beep(frequency=660, count=2)\n+            except Exception:\n+                pass\n+\n+            if wav_path is None:\n+                _cprint(f\"{_DIM}No speech detected.{_RST}\")\n+                return\n+\n+            # _voice_processing is already True (set atomically above)\n+            if hasattr(self, '_app') and self._app:\n+                self._app.invalidate()\n+            _cprint(f\"{_DIM}Transcribing...{_RST}\")\n+\n+            # Get STT model from config\n+            stt_model = None\n+            try:\n+                from hermes_cli.config import load_config\n+                stt_config = load_config().get(\"stt\", {})\n+                stt_model = stt_config.get(\"model\")\n+            except Exception:\n+                pass\n+\n+            from tools.voice_mode import transcribe_recording\n+            result = transcribe_recording(wav_path, model=stt_model)\n+\n+            if result.get(\"success\") and result.get(\"transcript\", \"\").strip():\n+                transcript = result[\"transcript\"].strip()\n+                self._pending_input.put(transcript)\n+                submitted = True\n+            elif result.get(\"success\"):\n+                _cprint(f\"{_DIM}No speech detected.{_RST}\")\n+            else:\n+                error = result.get(\"error\", \"Unknown error\")\n+                _cprint(f\"\\n{_DIM}Transcription failed: {error}{_RST}\")\n+\n+        except Exception as e:\n+            _cprint(f\"\\n{_DIM}Voice processing error: {e}{_RST}\")\n+        finally:\n+            with self._voice_lock:\n+                self._voice_processing = False\n+            if hasattr(self, '_app') and self._app:\n+                self._app.invalidate()\n+            # Clean up temp file\n+            try:\n+                if wav_path and os.path.isfile(wav_path):\n+                    os.unlink(wav_path)\n+            except Exception:\n+                pass\n+\n+            # Track consecutive no-speech cycles to avoid infinite restart loops.\n+            if not submitted:\n+                self._no_speech_count = getattr(self, '_no_speech_count', 0) + 1\n+                if self._no_speech_count >= 3:\n+                    self._voice_continuous = False\n+                    self._no_speech_count = 0\n+                    _cprint(f\"{_DIM}No speech detected 3 times, continuous mode stopped.{_RST}\")\n+                    return\n+            else:\n+                self._no_speech_count = 0\n+\n+            # If no transcript was submitted but continuous mode is active,\n+            # restart recording so the user can keep talking.\n+            # (When transcript IS submitted, process_loop handles restart\n+            # after chat() completes.)\n+            if self._voice_continuous and not submitted and not self._voice_recording:\n+                def _restart_recording():\n+                    try:\n+                        self._voice_start_recording()\n+                        if hasattr(self, '_app') and self._app:\n+                            self._app.invalidate()\n+                    except Exception as e:\n+                        _cprint(f\"{_DIM}Voice auto-restart failed: {e}{_RST}\")\n+                threading.Thread(target=_restart_recording, daemon=True).start()\n+\n+    def _voice_speak_response(self, text: str):\n+        \"\"\"Speak the agent's response aloud using TTS (runs in background thread).\"\"\"\n+        if not self._voice_tts:\n+            return\n+        self._voice_tts_done.clear()\n+        try:\n+            from tools.tts_tool import text_to_speech_tool\n+            from tools.voice_mode import play_audio_file\n+            import json\n+            import re\n+\n+            # Strip markdown and non-speech content for cleaner TTS\n+            tts_text = text[:4000] if len(text) > 4000 else text\n+            tts_text = re.sub(r'```[\\s\\S]*?```', ' ', tts_text)   # fenced code blocks\n+            tts_text = re.sub(r'\\[([^\\]]+)\\]\\([^)]+\\)', r'\\1', tts_text)  # [text](url) -> text\n+            tts_text = re.sub(r'https?://\\S+', '', tts_text)      # URLs\n+            tts_text = re.sub(r'\\*\\*(.+?)\\*\\*', r'\\1', tts_text)  # bold\n+            tts_text = re.sub(r'\\*(.+?)\\*', r'\\1', tts_text)      # italic\n+            tts_text = re.sub(r'`(.+?)`', r'\\1', tts_text)        # inline code\n+            tts_text = re.sub(r'^#+\\s*', '', tts_text, flags=re.MULTILINE)  # headers\n+            tts_text = re.sub(r'^\\s*[-*]\\s+', '', tts_text, flags=re.MULTILINE)  # list items\n+            tts_text = re.sub(r'---+', '', tts_text)              # horizontal rules\n+            tts_text = re.sub(r'\\n{3,}', '\\n\\n', tts_text)        # excessive newlines\n+            tts_text = tts_text.strip()\n+            if not tts_text:\n+                return\n+\n+            # Use MP3 output for CLI playback (afplay doesn't handle OGG well).\n+            # The TTS tool may auto-convert MP3->OGG, but the original MP3 remains.\n+            os.makedirs(os.path.join(tempfile.gettempdir(), \"hermes_voice\"), exist_ok=True)\n+            mp3_path = os.path.join(\n+                tempfile.gettempdir(), \"hermes_voice\",\n+                f\"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3\",\n+            )\n+\n+            text_to_speech_tool(text=tts_text, output_path=mp3_path)\n+\n+            # Play the MP3 directly (the TTS tool returns OGG path but MP3 still exists)\n+            if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0:\n+                play_audio_file(mp3_path)\n+                # Clean up\n+                try:\n+                    os.unlink(mp3_path)\n+                    ogg_path = mp3_path.rsplit(\".\", 1)[0] + \".ogg\"\n+                    if os.path.isfile(ogg_path):\n+                        os.unlink(ogg_path)\n+                except OSError:\n+                    pass\n+        except Exception as e:\n+            logger.warning(\"Voice TTS playback failed: %s\", e)\n+            _cprint(f\"{_DIM}TTS playback failed: {e}{_RST}\")\n+        finally:\n+            self._voice_tts_done.set()\n+\n+    def _handle_voice_command(self, command: str):\n+        \"\"\"Handle /voice [on|off|tts|status] command.\"\"\"\n+        parts = command.strip().split(maxsplit=1)\n+        subcommand = parts[1].lower().strip() if len(parts) > 1 else \"\"\n+\n+        if subcommand == \"on\":\n+            self._enable_voice_mode()\n+        elif subcommand == \"off\":\n+            self._disable_voice_mode()\n+        elif subcommand == \"tts\":\n+            self._toggle_voice_tts()\n+        elif subcommand == \"status\":\n+            self._show_voice_status()\n+        elif subcommand == \"\":\n+            # Toggle\n+            if self._voice_mode:\n+                self._disable_voice_mode()\n+            else:\n+                self._enable_voice_mode()\n+        else:\n+            _cprint(f\"Unknown voice subcommand: {subcommand}\")\n+            _cprint(\"Usage: /voice [on|off|tts|status]\")\n+\n+    def _enable_voice_mode(self):\n+        \"\"\"Enable voice mode after checking requirements.\"\"\"\n+        if self._voice_mode:\n+            _cprint(f\"{_DIM}Voice mode is already enabled.{_RST}\")\n+            return\n+\n+        from tools.voice_mode import check_voice_requirements, detect_audio_environment\n+\n+        # Environment detection -- warn and block in incompatible environments\n+        env_check = detect_audio_environment()\n+        if not env_check[\"available\"]:\n+            _cprint(f\"\\n{_GOLD}Voice mode unavailable in this environment:{_RST}\")\n+            for warning in env_check[\"warnings\"]:\n+                _cprint(f\"  {_DIM}{warning}{_RST}\")\n+            return\n+\n+        reqs = check_voice_requirements()\n+        if not reqs[\"available\"]:\n+            _cprint(f\"\\n{_GOLD}Voice mode requirements not met:{_RST}\")\n+            for line in reqs[\"details\"].split(\"\\n\"):\n+                _cprint(f\"  {_DIM}{line}{_RST}\")\n+            if reqs[\"missing_packages\"]:\n+                _cprint(f\"\\n  {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}\")\n+                _cprint(f\"  {_DIM}Or: pip install hermes-agent[voice]{_RST}\")\n+            return\n+\n+        with self._voice_lock:\n+            self._voice_mode = True\n+\n+        # Check config for auto_tts\n+        try:\n+            from hermes_cli.config import load_config\n+            voice_config = load_config().get(\"voice\", {})\n+            if voice_config.get(\"auto_tts\", False):\n+                with self._voice_lock:\n+                    self._voice_tts = True\n+        except Exception:\n+            pass\n+\n+        # Voice mode instruction is injected as a user message prefix (not a\n+        # system prompt change) to avoid invalidating the prompt cache.  See\n+        # _voice_message_prefix property and its usage in _process_message().\n+\n+        tts_status = \" (TTS enabled)\" if self._voice_tts else \"\"\n+        try:\n+            from hermes_cli.config import load_config\n+            _raw_ptt = load_config().get(\"voice\", {}).get(\"record_key\", \"ctrl+b\")\n+            _ptt_key = _raw_ptt.lower().replace(\"ctrl+\", \"c-\").replace(\"alt+\", \"a-\")\n+        except Exception:\n+            _ptt_key = \"c-b\"\n+        _ptt_display = _ptt_key.replace(\"c-\", \"Ctrl+\").upper()\n+        _cprint(f\"\\n{_GOLD}Voice mode enabled{tts_status}{_RST}\")\n+        _cprint(f\"  {_DIM}{_ptt_display} to start/stop recording{_RST}\")\n+        _cprint(f\"  {_DIM}/voice tts  to toggle speech output{_RST}\")\n+        _cprint(f\"  {_DIM}/voice off  to disable voice mode{_RST}\")\n+\n+    def _disable_voice_mode(self):\n+        \"\"\"Disable voice mode, cancel any active recording, and stop TTS.\"\"\"\n+        recorder = None\n+        with self._voice_lock:\n+            if self._voice_recording and self._voice_recorder:\n+                self._voice_recorder.cancel()\n+                self._voice_recording = False\n+            recorder = self._voice_recorder\n+            self._voice_mode = False\n+            self._voice_tts = False\n+            self._voice_continuous = False\n+\n+        # Shut down the persistent audio stream in background\n+        if recorder is not None:\n+            def _bg_shutdown(rec=recorder):\n+                try:\n+                    rec.shutdown()\n+                except Exception:\n+                    pass\n+            threading.Thread(target=_bg_shutdown, daemon=True).start()\n+            self._voice_recorder = None\n+\n+        # Stop any active TTS playback\n+        try:\n+            from tools.voice_mode import stop_playback\n+            stop_playback()\n+        except Exception:\n+            pass\n+        self._voice_tts_done.set()\n+\n+        _cprint(f\"\\n{_DIM}Voice mode disabled.{_RST}\")\n+\n+    def _toggle_voice_tts(self):\n+        \"\"\"Toggle TTS output for voice mode.\"\"\"\n+        if not self._voice_mode:\n+            _cprint(f\"{_DIM}Enable voice mode first: /voice on{_RST}\")\n+            return\n+\n+        with self._voice_lock:\n+            self._voice_tts = not self._voice_tts\n+        status = \"enabled\" if self._voice_tts else \"disabled\"\n+\n+        if self._voice_tts:\n+            from tools.tts_tool import check_tts_requirements\n+            if not check_tts_requirements():\n+                _cprint(f\"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}\")\n+\n+        _cprint(f\"{_GOLD}Voice TTS {status}.{_RST}\")\n+\n+    def _show_voice_status(self):\n+        \"\"\"Show current voice mode status.\"\"\"\n+        from hermes_cli.config import load_config\n+        from tools.voice_mode import check_voice_requirements\n+\n+        reqs = check_voice_requirements()\n+\n+        _cprint(f\"\\n{_BOLD}Voice Mode Status{_RST}\")\n+        _cprint(f\"  Mode:      {'ON' if self._voice_mode else 'OFF'}\")\n+        _cprint(f\"  TTS:       {'ON' if self._voice_tts else 'OFF'}\")\n+        _cprint(f\"  Recording: {'YES' if self._voice_recording else 'no'}\")\n+        _raw_key = load_config().get(\"voice\", {}).get(\"record_key\", \"ctrl+b\")\n+        _display_key = _raw_key.replace(\"ctrl+\", \"Ctrl+\").upper() if \"ctrl+\" in _raw_key.lower() else _raw_key\n+        _cprint(f\"  Record key: {_display_key}\")\n+        _cprint(f\"\\n  {_BOLD}Requirements:{_RST}\")\n+        for line in reqs[\"details\"].split(\"\\n\"):\n+            _cprint(f\"    {line}\")\n+\n     def _clarify_callback(self, question, choices):\n         \"\"\"\n         Platform callback for the clarify tool. Called from the agent thread.","@@ -3752,19 +4158,90 @@ def chat(self, message, images: list = None) -> Optional[str]:\n         try:\n             # Run the conversation with interrupt monitoring\n             result = None\n-            \n+\n+            # --- Streaming TTS setup ---\n+            # When ElevenLabs is the TTS provider and sounddevice is available,\n+            # we stream audio sentence-by-sentence as the agent generates tokens\n+            # instead of waiting for the full response.\n+            use_streaming_tts = False\n+            _streaming_box_opened = False\n+            text_queue = None\n+            tts_thread = None\n+            stream_callback = None\n+            stop_event = None\n+\n+            if self._voice_tts:\n+                try:\n+                    from tools.tts_tool import (\n+                        _load_tts_config as _load_tts_cfg,\n+                        _get_provider as _get_prov,\n+                        _import_elevenlabs,\n+                        _import_sounddevice,\n+                        stream_tts_to_speaker,\n+                    )\n+                    _tts_cfg = _load_tts_cfg()\n+                    if _get_prov(_tts_cfg) == \"elevenlabs\":\n+                        # Verify both ElevenLabs SDK and audio output are available\n+                        _import_elevenlabs()\n+                        _import_sounddevice()\n+                        use_streaming_tts = True\n+                except (ImportError, OSError):\n+                    pass\n+                except Exception:\n+                    pass\n+\n+            if use_streaming_tts:\n+                text_queue = queue.Queue()\n+                stop_event = threading.Event()\n+\n+                def display_callback(sentence: str):\n+                    \"\"\"Called by TTS consumer when a sentence is ready to display + speak.\"\"\"\n+                    nonlocal _streaming_box_opened\n+                    if not _streaming_box_opened:\n+                        _streaming_box_opened = True\n+                        w = self.console.width\n+                        label = \" ⚕ Hermes \"\n+                        fill = w - 2 - len(label)\n+                        _cprint(f\"\\n{_GOLD}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}\")\n+                    _cprint(sentence.rstrip())\n+\n+                tts_thread = threading.Thread(\n+                    target=stream_tts_to_speaker,\n+                    args=(text_queue, stop_event, self._voice_tts_done),\n+                    kwargs={\"display_callback\": display_callback},\n+                    daemon=True,\n+                )\n+                tts_thread.start()\n+\n+                def stream_callback(delta: str):\n+                    if text_queue is not None:\n+                        text_queue.put(delta)\n+\n+            # When voice mode is active, prepend a brief instruction so the\n+            # model responds concisely.  The prefix is API-call-local only —\n+            # we strip it from the returned history so it never persists to\n+            # session DB or resumed sessions.\n+            _voice_prefix = \"\"\n+            if self._voice_mode and isinstance(message, str):\n+                _voice_prefix = (\n+                    \"[Voice input — respond concisely and conversationally, \"\n+                    \"2-3 sentences max. No code blocks or markdown.] \"\n+                )\n+\n             def run_agent():\n                 nonlocal result\n+                agent_message = _voice_prefix + message if _voice_prefix else message\n                 result = self.agent.run_conversation(\n-                    user_message=message,\n+                    user_message=agent_message,\n                     conversation_history=self.conversation_history[:-1],  # Exclude the message we just added\n+                    stream_callback=stream_callback,\n                     task_id=self.session_id,\n                 )\n-            \n+\n             # Start agent in background thread\n             agent_thread = threading.Thread(target=run_agent)\n             agent_thread.start()\n-            \n+\n             # Monitor the dedicated interrupt queue while the agent runs.\n             # _interrupt_queue is separate from _pending_input, so process_loop\n             # and chat() never compete for the same queue.","@@ -3783,6 +4260,9 @@ def run_agent():\n                             if self._clarify_state or self._clarify_freetext:\n                                 continue\n                             print(f\"\\n⚡ New message detected, interrupting...\")\n+                            # Signal TTS to stop on interrupt\n+                            if stop_event is not None:\n+                                stop_event.set()\n                             self.agent.interrupt(interrupt_msg)\n                             # Debug: log to file (stdout may be devnull from redirect_stdout)\n                             try:","@@ -3802,9 +4282,15 @@ def run_agent():\n                 else:\n                     # Fallback for non-interactive mode (e.g., single-query)\n                     agent_thread.join(0.1)\n-            \n+\n             agent_thread.join()  # Ensure agent thread completes\n \n+            # Signal end-of-text to TTS consumer and wait for it to finish\n+            if use_streaming_tts and text_queue is not None:\n+                text_queue.put(None)  # sentinel\n+                if tts_thread is not None:\n+                    tts_thread.join(timeout=120)\n+\n             # Drain any remaining agent output still in the StdoutProxy\n             # buffer so tool/status lines render ABOVE our response box.\n             # The flush pushes data into the renderer queue; the short","@@ -3815,15 +4301,29 @@ def run_agent():\n \n             # Update history with full conversation\n             self.conversation_history = result.get(\"messages\", self.conversation_history) if result else self.conversation_history\n-            \n+\n+            # Strip voice prefix from history so it never persists\n+            if _voice_prefix and self.conversation_history:\n+                for msg in self.conversation_history:\n+                    if msg.get(\"role\") == \"user\" and isinstance(msg.get(\"content\"), str):\n+                        if msg[\"content\"].startswith(_voice_prefix):\n+                            msg[\"content\"] = msg[\"content\"][len(_voice_prefix):]\n+\n             # Get the final response\n             response = result.get(\"final_response\", \"\") if result else \"\"\n-            \n-            # Handle failed results (e.g., non-retryable errors like invalid model)\n-            if result and result.get(\"failed\") and not response:\n+\n+            # Handle failed or partial results (e.g., non-retryable errors, rate limits,\n+            # truncated output, invalid tool calls). Both \"failed\" and \"partial\" with\n+            # an empty final_response mean the agent couldn't produce a usable answer.\n+            if result and (result.get(\"failed\") or result.get(\"partial\")) and not response:\n                 error_detail = result.get(\"error\", \"Unknown error\")\n                 response = f\"Error: {error_detail}\"\n-            \n+                # Stop continuous voice mode on persistent errors (e.g. 429 rate limit)\n+                # to avoid an infinite error → record → error loop\n+                if self._voice_continuous:\n+                    self._voice_continuous = False\n+                    _cprint(f\"\\n{_DIM}Continuous voice mode stopped due to error.{_RST}\")\n+\n             # Handle interrupt - check if we were interrupted\n             pending_message = None\n             if result and result.get(\"interrupted\"):","@@ -3831,8 +4331,9 @@ def run_agent():\n                 # Add indicator that we were interrupted\n                 if response and pending_message:\n                     response = response + \"\\n\\n---\\n_[Interrupted - processing new message]_\"\n-            \n+\n             response_previewed = result.get(\"response_previewed\", False) if result else False\n+\n             # Display reasoning (thinking) box if enabled and available\n             if self.show_reasoning and result:\n                 reasoning = result.get(\"last_reasoning\")","@@ -3852,8 +4353,7 @@ def run_agent():\n                     _cprint(f\"\\n{r_top}\\n{_DIM}{display_reasoning}{_RST}\\n{r_bot}\")\n \n             if response and not response_previewed:\n-                # Use a Rich Panel for the response box — adapts to terminal\n-                # width at render time instead of hard-coding border length.\n+                # Use skin engine for label/color with fallback\n                 try:\n                     from hermes_cli.skin_engine import get_active_skin\n                     _skin = get_active_skin()","@@ -3865,23 +4365,40 @@ def run_agent():\n                     _resp_color = \"#CD7F32\"\n                     _resp_text = \"#FFF8DC\"\n \n-                _chat_console = ChatConsole()\n-                _chat_console.print(Panel(\n-                    _rich_text_from_ansi(response),\n-                    title=f\"[{_resp_color} bold]{label}[/]\",\n-                    title_align=\"left\",\n-                    border_style=_resp_color,\n-                    style=_resp_text,\n-                    box=rich_box.HORIZONTALS,\n-                    padding=(1, 2),\n-                ))\n+                is_error_response = result and (result.get(\"failed\") or result.get(\"partial\"))\n+                if use_streaming_tts and _streaming_box_opened and not is_error_response:\n+                    # Text was already printed sentence-by-sentence; just close the box\n+                    w = shutil.get_terminal_size().columns\n+                    _cprint(f\"\\n{_GOLD}╰{'─' * (w - 2)}╯{_RST}\")\n+                else:\n+                    _chat_console = ChatConsole()\n+                    _chat_console.print(Panel(\n+                        _rich_text_from_ansi(response),\n+                        title=f\"[{_resp_color} bold]{label}[/]\",\n+                        title_align=\"left\",\n+                        border_style=_resp_color,\n+                        style=_resp_text,\n+                        box=rich_box.HORIZONTALS,\n+                        padding=(1, 2),\n+                    ))\n+\n \n             # Play terminal bell when agent finishes (if enabled).\n             # Works over SSH — the bell propagates to the user's terminal.\n             if self.bell_on_complete:\n                 sys.stdout.write(\"\\a\")\n                 sys.stdout.flush()\n-            \n+\n+            # Speak response aloud if voice TTS is enabled\n+            # Skip batch TTS when streaming TTS already handled it\n+            if self._voice_tts and response and not use_streaming_tts:\n+                threading.Thread(\n+                    target=self._voice_speak_response,\n+                    args=(response,),\n+                    daemon=True,\n+                ).start()\n+\n+\n             # Combine all interrupt messages (user may have typed multiple while waiting)\n             # and re-queue as one prompt for process_loop\n             if pending_message and hasattr(self, '_pending_input'):","@@ -3902,6 +4419,20 @@ def run_agent():\n         except Exception as e:\n             print(f\"Error: {e}\")\n             return None\n+        finally:\n+            # Ensure streaming TTS resources are cleaned up even on error.\n+            # Normal path sends the sentinel at line ~3568; this is a safety\n+            # net for exception paths that skip it.  Duplicate sentinels are\n+            # harmless — stream_tts_to_speaker exits on the first None.\n+            if text_queue is not None:\n+                try:\n+                    text_queue.put_nowait(None)\n+                except Exception:\n+                    pass\n+            if stop_event is not None:\n+                stop_event.set()\n+            if tts_thread is not None and tts_thread.is_alive():\n+                tts_thread.join(timeout=5)\n     \n     def _print_exit_summary(self):\n         \"\"\"Print session resume info on exit, similar to Claude Code.\"\"\"","@@ -3961,9 +4492,26 @@ def _get_tui_prompt_symbols(self) -> tuple[str, str]:\n         # Icon-only custom prompts should still remain visible in special states.\n         return symbol, symbol\n \n+    def _audio_level_bar(self) -> str:\n+        \"\"\"Return a visual audio level indicator based on current RMS.\"\"\"\n+        _LEVEL_BARS = \" ▁▂▃▄▅▆▇\"\n+        rec = getattr(self, \"_voice_recorder\", None)\n+        if rec is None:\n+            return \"\"\n+        rms = rec.current_rms\n+        # Normalize RMS (0-32767) to 0-7 index, with log-ish scaling\n+        # Typical speech RMS is 500-5000, we cap display at ~8000\n+        level = min(rms, 8000) * 7 // 8000\n+        return _LEVEL_BARS[level]\n+\n     def _get_tui_prompt_fragments(self):\n         \"\"\"Return the prompt_toolkit fragments for the current interactive state.\"\"\"\n         symbol, state_suffix = self._get_tui_prompt_symbols()\n+        if self._voice_recording:\n+            bar = self._audio_level_bar()\n+            return [(\"class:voice-recording\", f\"● {bar} {state_suffix}\")]\n+        if self._voice_processing:\n+            return [(\"class:voice-processing\", f\"◉ {state_suffix}\")]\n         if self._sudo_state:\n             return [(\"class:sudo-prompt\", f\"🔐 {state_suffix}\")]\n         if self._secret_state:","@@ -3978,6 +4526,8 @@ def _get_tui_prompt_fragments(self):\n             return [(\"class:prompt-working\", f\"{self._command_spinner_frame()} {state_suffix}\")]\n         if self._agent_running:\n             return [(\"class:prompt-working\", f\"⚕ {state_suffix}\")]\n+        if self._voice_mode:\n+            return [(\"class:voice-prompt\", f\"🎤 {state_suffix}\")]\n         return [(\"class:prompt\", symbol)]\n \n     def _get_tui_prompt_text(self) -> str:","@@ -4070,6 +4620,17 @@ def run(self):\n         self._attached_images: list[Path] = []\n         self._image_counter = 0\n \n+        # Voice mode state (protected by _voice_lock for cross-thread access)\n+        self._voice_lock = threading.Lock()\n+        self._voice_mode = False        # Whether voice mode is enabled\n+        self._voice_tts = False         # Whether TTS output is enabled\n+        self._voice_recorder = None     # AudioRecorder instance (lazy init)\n+        self._voice_recording = False   # Whether currently recording\n+        self._voice_processing = False  # Whether STT is in progress\n+        self._voice_continuous = False  # Whether to auto-restart after agent responds\n+        self._voice_tts_done = threading.Event()  # Signals TTS playback finished\n+        self._voice_tts_done.set()  # Initially \"done\" (no TTS pending)\n+\n         # Register callbacks so terminal_tool prompts route through our UI\n         set_sudo_password_callback(self._sudo_password_callback)\n         set_approval_callback(self._approval_callback)","@@ -4254,6 +4815,7 @@ def handle_ctrl_c(event):\n             \"\"\"Handle Ctrl+C - cancel interactive prompts, interrupt agent, or exit.\n             \n             Priority:\n+            0. Cancel active voice recording\n             1. Cancel active sudo/approval/clarify prompt\n             2. Interrupt the running agent (first press)\n             3. Force exit (second press within 2s, or when idle)","@@ -4261,6 +4823,25 @@ def handle_ctrl_c(event):\n             import time as _time\n             now = _time.time()\n \n+            # Cancel active voice recording.\n+            # Run cancel() in a background thread to prevent blocking the\n+            # event loop if AudioRecorder._lock or CoreAudio takes time.\n+            _should_cancel_voice = False\n+            _recorder_ref = None\n+            with cli_ref._voice_lock:\n+                if cli_ref._voice_recording and cli_ref._voice_recorder:\n+                    _recorder_ref = cli_ref._voice_recorder\n+                    cli_ref._voice_recording = False\n+                    cli_ref._voice_continuous = False\n+                    _should_cancel_voice = True\n+            if _should_cancel_voice:\n+                _cprint(f\"\\n{_DIM}Recording cancelled.{_RST}\")\n+                threading.Thread(\n+                    target=_recorder_ref.cancel, daemon=True\n+                ).start()\n+                event.app.invalidate()\n+                return\n+\n             # Cancel sudo prompt\n             if self._sudo_state:\n                 self._sudo_state[\"response_queue\"].put(\"\")","@@ -4321,6 +4902,75 @@ def handle_ctrl_d(event):\n             self._should_exit = True\n             event.app.exit()\n \n+        # Voice push-to-talk key: configurable via config.yaml (voice.record_key)\n+        # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search)\n+        # Config uses \"ctrl+b\" format; prompt_toolkit expects \"c-b\" format.\n+        try:\n+            from hermes_cli.config import load_config\n+            _raw_key = load_config().get(\"voice\", {}).get(\"record_key\", \"ctrl+b\")\n+            _voice_key = _raw_key.lower().replace(\"ctrl+\", \"c-\").replace(\"alt+\", \"a-\")\n+        except Exception:\n+            _voice_key = \"c-b\"\n+\n+        @kb.add(_voice_key)\n+        def handle_voice_record(event):\n+            \"\"\"Toggle voice recording when voice mode is active.\n+\n+            IMPORTANT: This handler runs in prompt_toolkit's event-loop thread.\n+            Any blocking call here (locks, sd.wait, disk I/O) freezes the\n+            entire UI.  All heavy work is dispatched to daemon threads.\n+            \"\"\"\n+            if not cli_ref._voice_mode:\n+                return\n+            # Always allow STOPPING a recording (even when agent is running)\n+            if cli_ref._voice_recording:\n+                # Manual stop via push-to-talk key: stop continuous mode\n+                with cli_ref._voice_lock:\n+                    cli_ref._voice_continuous = False\n+                # Flag clearing is handled atomically inside _voice_stop_and_transcribe\n+                event.app.invalidate()\n+                threading.Thread(\n+                    target=cli_ref._voice_stop_and_transcribe,\n+                    daemon=True,\n+                ).start()\n+            else:\n+                # Guard: don't START recording during agent run or interactive prompts\n+                if cli_ref._agent_running:\n+                    return\n+                if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state:\n+                    return\n+                # Guard: don't start while a previous stop/transcribe cycle is\n+                # still running — recorder.stop() holds AudioRecorder._lock and\n+                # start() would block the event-loop thread waiting for it.\n+                if cli_ref._voice_processing:\n+                    return\n+\n+                # Interrupt TTS if playing, so user can start talking.\n+                # stop_playback() is fast (just terminates a subprocess).\n+                if not cli_ref._voice_tts_done.is_set():\n+                    try:\n+                        from tools.voice_mode import stop_playback\n+                        stop_playback()\n+                        cli_ref._voice_tts_done.set()\n+                    except Exception:\n+                        pass\n+\n+                with cli_ref._voice_lock:\n+                    cli_ref._voice_continuous = True\n+\n+                # Dispatch to a daemon thread so play_beep(sd.wait),\n+                # AudioRecorder.start(lock acquire), and config I/O\n+                # never block the prompt_toolkit event loop.\n+                def _start_recording():\n+                    try:\n+                        cli_ref._voice_start_recording()\n+                        if hasattr(cli_ref, '_app') and cli_ref._app:\n+                            cli_ref._app.invalidate()\n+                    except Exception as e:\n+                        _cprint(f\"\\n{_DIM}Voice recording failed: {e}{_RST}\")\n+\n+                threading.Thread(target=_start_recording, daemon=True).start()\n+                event.app.invalidate()\n         from prompt_toolkit.keys import Keys\n \n         @kb.add(Keys.BracketedPaste, eager=True)","@@ -4460,6 +5110,10 @@ def apply_transformation(self, ti):\n                 return Transformation(fragments=ti.fragments)\n \n         def _get_placeholder():\n+            if cli_ref._voice_recording:\n+                return \"recording... Ctrl+B to stop, Ctrl+C to cancel\"\n+            if cli_ref._voice_processing:\n+                return \"transcribing...\"\n             if cli_ref._sudo_state:\n                 return \"type password (hidden), Enter to skip\"\n             if cli_ref._secret_state:","@@ -4476,6 +5130,8 @@ def _get_placeholder():\n                 return f\"{frame} {status}\"\n             if cli_ref._agent_running:\n                 return \"type a message + Enter to interrupt, Ctrl+C to cancel\"\n+            if cli_ref._voice_mode:\n+                return \"type or Ctrl+B to record\"\n             return \"\"\n \n         input_area.control.input_processors.append(_PlaceholderProcessor(_get_placeholder))","@@ -4813,6 +5469,24 @@ def _get_image_bar():\n             height=Condition(lambda: bool(cli_ref._attached_images)),\n         )\n \n+        # Persistent voice mode status bar (visible only when voice mode is on)\n+        def _get_voice_status():\n+            if cli_ref._voice_recording:\n+                return [('class:voice-status-recording', ' ● REC  Ctrl+B to stop ')]\n+            if cli_ref._voice_processing:\n+                return [('class:voice-status', ' ◉ Transcribing... ')]\n+            tts = \" | TTS on\" if cli_ref._voice_tts else \"\"\n+            cont = \" | Continuous\" if cli_ref._voice_continuous else \"\"\n+            return [('class:voice-status', f' 🎤 Voice mode{tts}{cont}  —  Ctrl+B to record ')]\n+\n+        voice_status_bar = ConditionalContainer(\n+            Window(\n+                FormattedTextControl(_get_voice_status),\n+                height=1,\n+            ),\n+            filter=Condition(lambda: cli_ref._voice_mode),\n+        )\n+\n         # Layout: interactive prompt widgets + ruled input at bottom.\n         # The sudo, approval, and clarify widgets appear above the input when\n         # the corresponding interactive prompt is active.","@@ -4829,6 +5503,7 @@ def _get_image_bar():\n                 image_bar,\n                 input_area,\n                 input_rule_bot,\n+                voice_status_bar,\n                 CompletionsMenu(max_height=12, scroll_offset=1),\n             ])\n         )","@@ -4869,6 +5544,12 @@ def _get_image_bar():\n             'approval-cmd': '#AAAAAA italic',\n             'approval-choice': '#AAAAAA',\n             'approval-selected': '#FFD700 bold',\n+            # Voice mode\n+            'voice-prompt': '#87CEEB',\n+            'voice-recording': '#FF4444 bold',\n+            'voice-processing': '#FFA500 italic',\n+            'voice-status': 'bg:#1a1a2e #87CEEB',\n+            'voice-status-recording': 'bg:#1a1a2e #FF4444 bold',\n         }\n         style = PTStyle.from_dict(self._build_tui_style_dict())\n         ","@@ -4961,13 +5642,29 @@ def process_loop():\n                     # Regular chat - run agent\n                     self._agent_running = True\n                     app.invalidate()  # Refresh status line\n-                    \n+\n                     try:\n                         self.chat(user_input, images=submit_images or None)\n                     finally:\n                         self._agent_running = False\n                         self._spinner_text = \"\"\n                         app.invalidate()  # Refresh status line\n+\n+                        # Continuous voice: auto-restart recording after agent responds.\n+                        # Dispatch to a daemon thread so play_beep (sd.wait) and\n+                        # AudioRecorder.start (lock acquire) never block process_loop —\n+                        # otherwise queued user input would stall silently.\n+                        if self._voice_mode and self._voice_continuous and not self._voice_recording:\n+                            def _restart_recording():\n+                                try:\n+                                    if self._voice_tts:\n+                                        self._voice_tts_done.wait(timeout=60)\n+                                        time.sleep(0.3)\n+                                    self._voice_start_recording()\n+                                    app.invalidate()\n+                                except Exception as e:\n+                                    _cprint(f\"{_DIM}Voice auto-restart failed: {e}{_RST}\")\n+                            threading.Thread(target=_restart_recording, daemon=True).start()\n                     \n                 except Exception as e:\n                     print(f\"Error: {e}\")","@@ -4993,6 +5690,19 @@ def process_loop():\n                     self.agent.flush_memories(self.conversation_history)\n                 except Exception:\n                     pass\n+            # Shut down voice recorder (release persistent audio stream)\n+            if hasattr(self, '_voice_recorder') and self._voice_recorder:\n+                try:\n+                    self._voice_recorder.shutdown()\n+                except Exception:\n+                    pass\n+                self._voice_recorder = None\n+            # Clean up old temp voice recordings\n+            try:\n+                from tools.voice_mode import cleanup_temp_recordings\n+                cleanup_temp_recordings()\n+            except Exception:\n+                pass\n             # Unregister callbacks to avoid dangling references\n             set_sudo_password_callback(None)\n             set_approval_callback(None)"]},{"file":"gateway/platforms/base.py","hunks":["@@ -351,6 +351,8 @@ def __init__(self, config: PlatformConfig, platform: Platform):\n         # Key: session_key (e.g., chat_id), Value: (event, asyncio.Event for interrupt)\n         self._active_sessions: Dict[str, asyncio.Event] = {}\n         self._pending_messages: Dict[str, MessageEvent] = {}\n+        # Chats where auto-TTS on voice input is disabled (set by /voice off)\n+        self._auto_tts_disabled_chats: set = set()\n     \n     @property\n     def name(self) -> str:","@@ -537,6 +539,20 @@ async def send_voice(\n             text = f\"{caption}\\n{text}\"\n         return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)\n \n+    async def play_tts(\n+        self,\n+        chat_id: str,\n+        audio_path: str,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"\n+        Play auto-TTS audio for voice replies.\n+\n+        Override in subclasses for invisible playback (e.g. Web UI).\n+        Default falls back to send_voice (shows audio player).\n+        \"\"\"\n+        return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)\n+\n     async def send_video(\n         self,\n         chat_id: str,","@@ -718,7 +734,43 @@ async def _process_message_background(self, event: MessageEvent, session_key: st\n                 if images:\n                     logger.info(\"[%s] extract_images found %d image(s) in response (%d chars)\", self.name, len(images), len(response))\n                 \n-                # Send the text portion first (if any remains after extractions)\n+                # Auto-TTS: if voice message, generate audio FIRST (before sending text)\n+                # Skipped when the chat has voice mode disabled (/voice off)\n+                _tts_path = None\n+                if (event.message_type == MessageType.VOICE\n+                        and text_content\n+                        and not media_files\n+                        and event.source.chat_id not in self._auto_tts_disabled_chats):\n+                    try:\n+                        from tools.tts_tool import text_to_speech_tool, check_tts_requirements\n+                        if check_tts_requirements():\n+                            import json as _json\n+                            speech_text = re.sub(r'[*_`#\\[\\]()]', '', text_content)[:4000].strip()\n+                            if not speech_text:\n+                                raise ValueError(\"Empty text after markdown cleanup\")\n+                            tts_result_str = await asyncio.to_thread(\n+                                text_to_speech_tool, text=speech_text\n+                            )\n+                            tts_data = _json.loads(tts_result_str)\n+                            _tts_path = tts_data.get(\"file_path\")\n+                    except Exception as tts_err:\n+                        logger.warning(\"[%s] Auto-TTS failed: %s\", self.name, tts_err)\n+\n+                # Play TTS audio before text (voice-first experience)\n+                if _tts_path and Path(_tts_path).exists():\n+                    try:\n+                        await self.play_tts(\n+                            chat_id=event.source.chat_id,\n+                            audio_path=_tts_path,\n+                            metadata=_thread_metadata,\n+                        )\n+                    finally:\n+                        try:\n+                            os.remove(_tts_path)\n+                        except OSError:\n+                            pass\n+\n+                # Send the text portion\n                 if text_content:\n                     logger.info(\"[%s] Sending response (%d chars) to %s\", self.name, len(text_content), event.source.chat_id)\n                     result = await self.send(","@@ -727,7 +779,7 @@ async def _process_message_background(self, event: MessageEvent, session_key: st\n                         reply_to=event.message_id,\n                         metadata=_thread_metadata,\n                     )\n-                    \n+\n                     # Log send failures (don't raise - user already saw tool progress)\n                     if not result.success:\n                         print(f\"[{self.name}] Failed to send response: {result.error}\")","@@ -740,10 +792,10 @@ async def _process_message_background(self, event: MessageEvent, session_key: st\n                         )\n                         if not fallback_result.success:\n                             print(f\"[{self.name}] Fallback send also failed: {fallback_result.error}\")\n-                \n+\n                 # Human-like pacing delay between text and media\n                 human_delay = self._get_human_delay()\n-                \n+\n                 # Send extracted images as native attachments\n                 if images:\n                     logger.info(\"[%s] Extracted %d image(s) to send as attachments\", self.name, len(images))","@@ -771,7 +823,7 @@ async def _process_message_background(self, event: MessageEvent, session_key: st\n                             logger.error(\"[%s] Failed to send image: %s\", self.name, img_result.error)\n                     except Exception as img_err:\n                         logger.error(\"[%s] Error sending image: %s\", self.name, img_err, exc_info=True)\n-                \n+\n                 # Send extracted media files — route by file type\n                 _AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}\n                 _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.3gp'}"]},{"file":"gateway/platforms/discord.py","hunks":["@@ -10,7 +10,13 @@\n import asyncio\n import logging\n import os\n-from typing import Dict, List, Optional, Any\n+import struct\n+import subprocess\n+import tempfile\n+import threading\n+import time\n+from collections import defaultdict\n+from typing import Callable, Dict, List, Optional, Any\n \n logger = logging.getLogger(__name__)\n ","@@ -65,6 +71,299 @@ def check_discord_requirements() -> bool:\n     return DISCORD_AVAILABLE\n \n \n+class VoiceReceiver:\n+    \"\"\"Captures and decodes voice audio from a Discord voice channel.\n+\n+    Attaches to a VoiceClient's socket listener, decrypts RTP packets\n+    (NaCl transport + DAVE E2EE), decodes Opus to PCM, and buffers\n+    per-user audio.  A polling loop detects silence and delivers\n+    completed utterances via a callback.\n+    \"\"\"\n+\n+    SILENCE_THRESHOLD = 1.5    # seconds of silence → end of utterance\n+    MIN_SPEECH_DURATION = 0.5  # minimum seconds to process (skip noise)\n+    SAMPLE_RATE = 48000        # Discord native rate\n+    CHANNELS = 2               # Discord sends stereo\n+\n+    def __init__(self, voice_client):\n+        self._vc = voice_client\n+        self._running = False\n+\n+        # Decryption\n+        self._secret_key: [redacted:secret-assignment] = None\n+        self._dave_session = None\n+        self._bot_ssrc: int = 0\n+\n+        # SSRC -> user_id mapping (populated from SPEAKING events)\n+        self._ssrc_to_user: Dict[int, int] = {}\n+        self._lock = threading.Lock()\n+\n+        # Per-user audio buffers\n+        self._buffers: Dict[int, bytearray] = defaultdict(bytearray)\n+        self._last_packet_time: Dict[int, float] = {}\n+\n+        # Opus decoder per SSRC (each user needs own decoder state)\n+        self._decoders: Dict[int, object] = {}\n+\n+        # Pause flag: don't capture while bot is playing TTS\n+        self._paused = False\n+\n+        # Debug logging counter (instance-level to avoid cross-instance races)\n+        self._packet_debug_count = 0\n+\n+    # ------------------------------------------------------------------\n+    # Lifecycle\n+    # ------------------------------------------------------------------\n+\n+    def start(self):\n+        \"\"\"Start listening for voice packets.\"\"\"\n+        conn = self._vc._connection\n+        self._secret_key = [redacted:secret-assignment]\n+        self._dave_session = conn.dave_session\n+        self._bot_ssrc = conn.ssrc\n+\n+        self._install_speaking_hook(conn)\n+        conn.add_socket_listener(self._on_packet)\n+        self._running = True\n+        logger.info(\"VoiceReceiver started (bot_ssrc=%d)\", self._bot_ssrc)\n+\n+    def stop(self):\n+        \"\"\"Stop listening and clean up.\"\"\"\n+        self._running = False\n+        try:\n+            self._vc._connection.remove_socket_listener(self._on_packet)\n+        except Exception:\n+            pass\n+        with self._lock:\n+            self._buffers.clear()\n+            self._last_packet_time.clear()\n+            self._decoders.clear()\n+            self._ssrc_to_user.clear()\n+        logger.info(\"VoiceReceiver stopped\")\n+\n+    def pause(self):\n+        self._paused = True\n+\n+    def resume(self):\n+        self._paused = False\n+\n+    # ------------------------------------------------------------------\n+    # SSRC -> user_id mapping via SPEAKING opcode hook\n+    # ------------------------------------------------------------------\n+\n+    def map_ssrc(self, ssrc: int, user_id: int):\n+        with self._lock:\n+            self._ssrc_to_user[ssrc] = user_id\n+\n+    def _install_speaking_hook(self, conn):\n+        \"\"\"Wrap the voice websocket hook to capture SPEAKING events (op 5).\n+\n+        VoiceConnectionState stores the hook as ``conn.hook`` (public attr).\n+        It is passed to DiscordVoiceWebSocket on each (re)connect, so we\n+        must wrap it on the VoiceConnectionState level AND on the current\n+        live websocket instance.\n+        \"\"\"\n+        original_hook = conn.hook\n+        receiver_self = self\n+\n+        async def wrapped_hook(ws, msg):\n+            if isinstance(msg, dict) and msg.get(\"op\") == 5:\n+                data = msg.get(\"d\", {})\n+                ssrc = data.get(\"ssrc\")\n+                user_id = data.get(\"user_id\")\n+                if ssrc and user_id:\n+                    logger.info(\"SPEAKING event: ssrc=%d -> user=%s\", ssrc, user_id)\n+                    receiver_self.map_ssrc(int(ssrc), int(user_id))\n+            if original_hook:\n+                await original_hook(ws, msg)\n+\n+        # Set on connection state (for future reconnects)\n+        conn.hook = wrapped_hook\n+        # Set on the current live websocket (for immediate effect)\n+        try:\n+            from discord.utils import MISSING\n+            if hasattr(conn, 'ws') and conn.ws is not MISSING:\n+                conn.ws._hook = wrapped_hook\n+                logger.info(\"Speaking hook installed on live websocket\")\n+        except Exception as e:\n+            logger.warning(\"Could not install hook on live ws: %s\", e)\n+\n+    # ------------------------------------------------------------------\n+    # Packet handler (called from SocketReader thread)\n+    # ------------------------------------------------------------------\n+\n+    def _on_packet(self, data: bytes):\n+        if not self._running or self._paused:\n+            return\n+\n+        # Log first few raw packets for debugging\n+        self._packet_debug_count += 1\n+        if self._packet_debug_count <= 5:\n+            logger.debug(\n+                \"Raw UDP packet: len=%d, first_bytes=%s\",\n+                len(data), data[:4].hex() if len(data) >= 4 else \"short\",\n+            )\n+\n+        if len(data) < 16:\n+            return\n+\n+        # RTP version check: top 2 bits must be 10 (version 2).\n+        # Lower bits may vary (padding, extension, CSRC count).\n+        # Payload type (byte 1 lower 7 bits) = 0x78 (120) for voice.\n+        if (data[0] >> 6) != 2 or (data[1] & 0x7F) != 0x78:\n+            if self._packet_debug_count <= 5:\n+                logger.debug(\"Skipped non-RTP: byte0=0x%02x byte1=0x%02x\", data[0], data[1])\n+            return\n+\n+        first_byte = data[0]\n+        _, _, seq, timestamp, ssrc = struct.unpack_from(\">BBHII\", data, 0)\n+\n+        # Skip bot's own audio\n+        if ssrc == self._bot_ssrc:\n+            return\n+\n+        # Calculate dynamic RTP header size (RFC 9335 / rtpsize mode)\n+        cc = first_byte & 0x0F  # CSRC count\n+        has_extension = bool(first_byte & 0x10)  # extension bit\n+        header_size = 12 + (4 * cc) + (4 if has_extension else 0)\n+\n+        if len(data) < header_size + 4:  # need at least header + nonce\n+            return\n+\n+        # Read extension length from preamble (for skipping after decrypt)\n+        ext_data_len = 0\n+        if has_extension:\n+            ext_preamble_offset = 12 + (4 * cc)\n+            ext_words = struct.unpack_from(\">H\", data, ext_preamble_offset + 2)[0]\n+            ext_data_len = ext_words * 4\n+\n+        if self._packet_debug_count <= 10:\n+            with self._lock:\n+                known_user = self._ssrc_to_user.get(ssrc, \"unknown\")\n+            logger.debug(\n+                \"RTP packet: ssrc=%d, seq=%d, user=%s, hdr=%d, ext_data=%d\",\n+                ssrc, seq, known_user, header_size, ext_data_len,\n+            )\n+\n+        header = bytes(data[:header_size])\n+        payload_with_nonce = data[header_size:]\n+\n+        # --- NaCl transport decrypt (aead_xchacha20_poly1305_rtpsize) ---\n+        if len(payload_with_nonce) < 4:\n+            return\n+        nonce = bytearray(24)\n+        nonce[:4] = payload_with_nonce[-4:]\n+        encrypted = bytes(payload_with_nonce[:-4])\n+\n+        try:\n+            import nacl.secret  # noqa: delayed import – only in voice path\n+            box = nacl.secret.Aead(self._secret_key)\n+            decrypted = box.decrypt(encrypted, header, bytes(nonce))\n+        except Exception as e:\n+            if self._packet_debug_count <= 10:\n+                logger.warning(\"NaCl decrypt failed: %s (hdr=%d, enc=%d)\", e, header_size, len(encrypted))\n+            return\n+\n+        # Skip encrypted extension data to get the actual opus payload\n+        if ext_data_len and len(decrypted) > ext_data_len:\n+            decrypted = decrypted[ext_data_len:]\n+\n+        # --- DAVE E2EE decrypt ---\n+        if self._dave_session:\n+            with self._lock:\n+                user_id = self._ssrc_to_user.get(ssrc, 0)\n+            if user_id == 0:\n+                if self._packet_debug_count <= 10:\n+                    logger.warning(\"DAVE skip: unknown user for ssrc=%d\", ssrc)\n+                return  # unknown user, can't DAVE-decrypt\n+            try:\n+                import davey\n+                decrypted = self._dave_session.decrypt(\n+                    user_id, davey.MediaType.audio, decrypted\n+                )\n+            except Exception as e:\n+                if self._packet_debug_count <= 10:\n+                    logger.warning(\"DAVE decrypt failed for ssrc=%d: %s\", ssrc, e)\n+                return\n+\n+        # --- Opus decode -> PCM ---\n+        try:\n+            if ssrc not in self._decoders:\n+                self._decoders[ssrc] = discord.opus.Decoder()\n+            pcm = self._decoders[ssrc].decode(decrypted)\n+            with self._lock:\n+                self._buffers[ssrc].extend(pcm)\n+                self._last_packet_time[ssrc] = time.monotonic()\n+        except Exception as e:\n+            logger.debug(\"Opus decode error for SSRC %s: %s\", ssrc, e)\n+            return\n+\n+    # ------------------------------------------------------------------\n+    # Silence detection\n+    # ------------------------------------------------------------------\n+\n+    def check_silence(self) -> list:\n+        \"\"\"Return list of (user_id, pcm_bytes) for completed utterances.\"\"\"\n+        now = time.monotonic()\n+        completed = []\n+\n+        with self._lock:\n+            ssrc_user_map = dict(self._ssrc_to_user)\n+            ssrc_list = list(self._buffers.keys())\n+\n+            for ssrc in ssrc_list:\n+                last_time = self._last_packet_time.get(ssrc, now)\n+                silence_duration = now - last_time\n+                buf = self._buffers[ssrc]\n+                # 48kHz, 16-bit, stereo = 192000 bytes/sec\n+                buf_duration = len(buf) / (self.SAMPLE_RATE * self.CHANNELS * 2)\n+\n+                if silence_duration >= self.SILENCE_THRESHOLD and buf_duration >= self.MIN_SPEECH_DURATION:\n+                    user_id = ssrc_user_map.get(ssrc, 0)\n+                    if user_id:\n+                        completed.append((user_id, bytes(buf)))\n+                    self._buffers[ssrc] = bytearray()\n+                    self._last_packet_time.pop(ssrc, None)\n+                elif silence_duration >= self.SILENCE_THRESHOLD * 2:\n+                    # Stale buffer with no valid user — discard\n+                    self._buffers.pop(ssrc, None)\n+                    self._last_packet_time.pop(ssrc, None)\n+\n+        return completed\n+\n+    # ------------------------------------------------------------------\n+    # PCM -> WAV conversion (for Whisper STT)\n+    # ------------------------------------------------------------------\n+\n+    @staticmethod\n+    def pcm_to_wav(pcm_data: bytes, output_path: str,\n+                   src_rate: int = 48000, src_channels: int = 2):\n+        \"\"\"Convert raw PCM to 16kHz mono WAV via ffmpeg.\"\"\"\n+        with tempfile.NamedTemporaryFile(suffix=\".pcm\", delete=False) as f:\n+            f.write(pcm_data)\n+            pcm_path = f.name\n+        try:\n+            subprocess.run(\n+                [\n+                    \"ffmpeg\", \"-y\", \"-loglevel\", \"error\",\n+                    \"-f\", \"s16le\",\n+                    \"-ar\", str(src_rate),\n+                    \"-ac\", str(src_channels),\n+                    \"-i\", pcm_path,\n+                    \"-ar\", \"16000\",\n+                    \"-ac\", \"1\",\n+                    output_path,\n+                ],\n+                check=True,\n+                timeout=10,\n+            )\n+        finally:\n+            try:\n+                os.unlink(pcm_path)\n+            except OSError:\n+                pass\n+\n+\n class DiscordAdapter(BasePlatformAdapter):\n     \"\"\"\n     Discord bot adapter.","@@ -82,17 +381,54 @@ class DiscordAdapter(BasePlatformAdapter):\n     # Discord message limits\n     MAX_MESSAGE_LENGTH = 2000\n     \n+    # Auto-disconnect from voice channel after this many seconds of inactivity\n+    VOICE_TIMEOUT = 300\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.DISCORD)\n         self._client: Optional[commands.Bot] = None\n         self._ready_event = asyncio.Event()\n         self._allowed_user_ids: set = set()  # For button approval authorization\n+        # Voice channel state (per-guild)\n+        self._voice_clients: Dict[int, Any] = {}  # guild_id -> VoiceClient\n+        self._voice_text_channels: Dict[int, int] = {}  # guild_id -> text_channel_id\n+        self._voice_timeout_tasks: Dict[int, asyncio.Task] = {}  # guild_id -> timeout task\n+        # Phase 2: voice listening\n+        self._voice_receivers: Dict[int, VoiceReceiver] = {}  # guild_id -> VoiceReceiver\n+        self._voice_listen_tasks: Dict[int, asyncio.Task] = {}  # guild_id -> listen loop\n+        self._voice_input_callback: Optional[Callable] = None  # set by run.py\n+        self._on_voice_disconnect: Optional[Callable] = None  # set by run.py\n     \n     async def connect(self) -> bool:\n         \"\"\"Connect to Discord and start receiving events.\"\"\"\n         if not DISCORD_AVAILABLE:\n             logger.error(\"[%s] discord.py not installed. Run: pip install discord.py\", self.name)\n             return False\n+\n+        # Load opus codec for voice channel support\n+        if not discord.opus.is_loaded():\n+            import ctypes.util\n+            opus_path = ctypes.util.find_library(\"opus\")\n+            # ctypes.util.find_library fails on macOS with Homebrew-installed libs,\n+            # so fall back to known Homebrew paths if needed.\n+            if not opus_path:\n+                import sys\n+                _homebrew_paths = (\n+                    \"/opt/homebrew/lib/libopus.dylib\",  # Apple Silicon\n+                    \"/usr/local/lib/libopus.dylib\",     # Intel Mac\n+                )\n+                if sys.platform == \"darwin\":\n+                    for _hp in _homebrew_paths:\n+                        if os.path.isfile(_hp):\n+                            opus_path = _hp\n+                            break\n+            if opus_path:\n+                try:\n+                    discord.opus.load_opus(opus_path)\n+                except Exception:\n+                    logger.warning(\"Opus codec found at %s but failed to load\", opus_path)\n+            if not discord.opus.is_loaded():\n+                logger.warning(\"Opus codec not found — voice channel playback disabled\")\n         \n         if not self.config.token:\n             [redacted:secret-assignment]\"[%s] No bot token configured\", self.name)","@@ -105,6 +441,7 @@ async def connect(self) -> bool:\n             intents.dm_messages = True\n             intents.guild_messages = True\n             intents.members = True\n+            intents.voice_states = True\n             \n             # Create bot\n             self._client = commands.Bot(","@@ -158,7 +495,40 @@ async def on_message(message: DiscordMessage):\n                     # \"all\" falls through to handle_message\n                 \n                 await self._handle_message(message)\n-            \n+\n+            @self._client.event\n+            async def on_voice_state_update(member, before, after):\n+                \"\"\"Track voice channel join/leave events.\"\"\"\n+                # Only track channels where the bot is connected\n+                bot_guild_ids = set(adapter_self._voice_clients.keys())\n+                if not bot_guild_ids:\n+                    return\n+                guild_id = member.guild.id\n+                if guild_id not in bot_guild_ids:\n+                    return\n+                # Ignore the bot itself\n+                if member == adapter_self._client.user:\n+                    return\n+\n+                joined = before.channel is None and after.channel is not None\n+                left = before.channel is not None and after.channel is None\n+                switched = (\n+                    before.channel is not None\n+                    and after.channel is not None\n+                    and before.channel != after.channel\n+                )\n+\n+                if joined or left or switched:\n+                    logger.info(\n+                        \"Voice state: %s (%d) %s (guild %d)\",\n+                        member.display_name,\n+                        member.id,\n+                        \"joined \" + after.channel.name if joined\n+                        else \"left \" + before.channel.name if left\n+                        else f\"moved {before.channel.name} -> {after.channel.name}\",\n+                        guild_id,\n+                    )\n+\n             # Register slash commands\n             self._register_slash_commands()\n             ","@@ -180,12 +550,19 @@ async def on_message(message: DiscordMessage):\n     \n     async def disconnect(self) -> None:\n         \"\"\"Disconnect from Discord.\"\"\"\n+        # Clean up all active voice connections before closing the client\n+        for guild_id in list(self._voice_clients.keys()):\n+            try:\n+                await self.leave_voice_channel(guild_id)\n+            except Exception as e:  # pragma: no cover - defensive logging\n+                logger.debug(\"[%s] Error leaving voice channel %s: %s\", self.name, guild_id, e)\n+\n         if self._client:\n             try:\n                 await self._client.close()\n             except Exception as e:  # pragma: no cover - defensive logging\n                 logger.warning(\"[%s] Error during disconnect: %s\", self.name, e, exc_info=True)\n-        \n+\n         self._running = False\n         self._client = None\n         self._ready_event.clear()","@@ -287,6 +664,23 @@ async def _send_file_attachment(\n             msg = await channel.send(content=caption if caption else None, file=file)\n         return SendResult(success=True, message_id=str(msg.id))\n \n+    async def play_tts(\n+        self,\n+        chat_id: str,\n+        audio_path: str,\n+        **kwargs,\n+    ) -> SendResult:\n+        \"\"\"Play auto-TTS audio.\n+\n+        When the bot is in a voice channel for this chat's guild, skip the\n+        file attachment — the gateway runner plays audio in the VC instead.\n+        \"\"\"\n+        for gid, text_ch_id in self._voice_text_channels.items():\n+            if str(text_ch_id) == str(chat_id) and self.is_in_voice_channel(gid):\n+                logger.debug(\"[%s] Skipping play_tts for %s — VC playback handled by runner\", self.name, chat_id)\n+                return SendResult(success=True)\n+        return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)\n+\n     async def send_voice(\n         self,\n         chat_id: str,","@@ -294,16 +688,356 @@ async def send_voice(\n         caption: Optional[str] = None,\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n+        **kwargs,\n     ) -> SendResult:\n         \"\"\"Send audio as a Discord file attachment.\"\"\"\n         try:\n-            return await self._send_file_attachment(chat_id, audio_path, caption)\n-        except FileNotFoundError:\n-            return SendResult(success=False, error=f\"Audio file not found: {audio_path}\")\n+            import io\n+\n+            channel = self._client.get_channel(int(chat_id))\n+            if not channel:\n+                channel = await self._client.fetch_channel(int(chat_id))\n+            if not channel:\n+                return SendResult(success=False, error=f\"Channel {chat_id} not found\")\n+\n+            if not os.path.exists(audio_path):\n+                return SendResult(success=False, error=f\"Audio file not found: {audio_path}\")\n+\n+            filename = os.path.basename(audio_path)\n+\n+            with open(audio_path, \"rb\") as f:\n+                file_data = f.read()\n+\n+            # Try sending as a native voice message via raw API (flags=8192).\n+            try:\n+                import base64\n+\n+                duration_secs = 5.0\n+                try:\n+                    from mutagen.oggopus import OggOpus\n+                    info = OggOpus(audio_path)\n+                    duration_secs = info.info.length\n+                except Exception:\n+                    duration_secs = max(1.0, len(file_data) / 2000.0)\n+\n+                waveform_bytes = bytes([128] * 256)\n+                waveform_b64 = base64.b64encode(waveform_bytes).decode()\n+\n+                import json as _json\n+                payload = _json.dumps({\n+                    \"flags\": 8192,\n+                    \"attachments\": [{\n+                        \"id\": \"0\",\n+                        \"filename\": \"voice-message.ogg\",\n+                        \"duration_secs\": round(duration_secs, 2),\n+                        \"waveform\": waveform_b64,\n+                    }],\n+                })\n+                form = [\n+                    {\"name\": \"payload_json\", \"value\": payload},\n+                    {\n+                        \"name\": \"files[0]\",\n+                        \"value\": file_data,\n+                        \"filename\": \"voice-message.ogg\",\n+                        \"content_type\": \"audio/ogg\",\n+                    },\n+                ]\n+                msg_data = await self._client.http.request(\n+                    discord.http.Route(\"POST\", \"/channels/{channel_id}/messages\", channel_id=channel.id),\n+                    form=form,\n+                )\n+                return SendResult(success=True, message_id=str(msg_data[\"id\"]))\n+            except Exception as voice_err:\n+                logger.debug(\"Voice message flag failed, falling back to file: %s\", voice_err)\n+                file = discord.File(io.BytesIO(file_data), filename=filename)\n+                msg = await channel.send(file=file)\n+                return SendResult(success=True, message_id=str(msg.id))\n         except Exception as e:  # pragma: no cover - defensive logging\n             logger.error(\"[%s] Failed to send audio, falling back to base adapter: %s\", self.name, e, exc_info=True)\n             return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)\n \n+    # ------------------------------------------------------------------\n+    # Voice channel methods (join / leave / play)\n+    # ------------------------------------------------------------------\n+\n+    async def join_voice_channel(self, channel) -> bool:\n+        \"\"\"Join a Discord voice channel. Returns True on success.\"\"\"\n+        if not self._client or not DISCORD_AVAILABLE:\n+            return False\n+        guild_id = channel.guild.id\n+\n+        # Already connected in this guild?\n+        existing = self._voice_clients.get(guild_id)\n+        if existing and existing.is_connected():\n+            if existing.channel.id == channel.id:\n+                self._reset_voice_timeout(guild_id)\n+                return True\n+            await existing.move_to(channel)\n+            self._reset_voice_timeout(guild_id)\n+            return True\n+\n+        vc = await channel.connect()\n+        self._voice_clients[guild_id] = vc\n+        self._reset_voice_timeout(guild_id)\n+\n+        # Start voice receiver (Phase 2: listen to users)\n+        try:\n+            receiver = VoiceReceiver(vc)\n+            receiver.start()\n+            self._voice_receivers[guild_id] = receiver\n+            self._voice_listen_tasks[guild_id] = asyncio.ensure_future(\n+                self._voice_listen_loop(guild_id)\n+            )\n+        except Exception as e:\n+            logger.warning(\"Voice receiver failed to start: %s\", e)\n+\n+        return True\n+\n+    async def leave_voice_channel(self, guild_id: int) -> None:\n+        \"\"\"Disconnect from the voice channel in a guild.\"\"\"\n+        # Stop voice receiver first\n+        receiver = self._voice_receivers.pop(guild_id, None)\n+        if receiver:\n+            receiver.stop()\n+        listen_task = self._voice_listen_tasks.pop(guild_id, None)\n+        if listen_task:\n+            listen_task.cancel()\n+\n+        vc = self._voice_clients.pop(guild_id, None)\n+        if vc and vc.is_connected():\n+            await vc.disconnect()\n+        task = self._voice_timeout_tasks.pop(guild_id, None)\n+        if task:\n+            task.cancel()\n+        self._voice_text_channels.pop(guild_id, None)\n+\n+    # Maximum seconds to wait for voice playback before giving up\n+    PLAYBACK_TIMEOUT = 120\n+\n+    async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:\n+        \"\"\"Play an audio file in the connected voice channel.\"\"\"\n+        vc = self._voice_clients.get(guild_id)\n+        if not vc or not vc.is_connected():\n+            return False\n+\n+        # Pause voice receiver while playing (echo prevention)\n+        receiver = self._voice_receivers.get(guild_id)\n+        if receiver:\n+            receiver.pause()\n+\n+        try:\n+            # Wait for current playback to finish (with timeout)\n+            wait_start = time.monotonic()\n+            while vc.is_playing():\n+                if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT:\n+                    logger.warning(\"Timed out waiting for previous playback to finish\")\n+                    vc.stop()\n+                    break\n+                await asyncio.sleep(0.1)\n+\n+            done = asyncio.Event()\n+            loop = asyncio.get_running_loop()\n+\n+            def _after(error):\n+                if error:\n+                    logger.error(\"Voice playback error: %s\", error)\n+                loop.call_soon_threadsafe(done.set)\n+\n+            source = discord.FFmpegPCMAudio(audio_path)\n+            source = discord.PCMVolumeTransformer(source, volume=1.0)\n+            vc.play(source, after=_after)\n+            try:\n+                await asyncio.wait_for(done.wait(), timeout=self.PLAYBACK_TIMEOUT)\n+            except asyncio.TimeoutError:\n+                logger.warning(\"Voice playback timed out after %ds\", self.PLAYBACK_TIMEOUT)\n+                vc.stop()\n+            self._reset_voice_timeout(guild_id)\n+            return True\n+        finally:\n+            if receiver:\n+                receiver.resume()\n+\n+    async def get_user_voice_channel(self, guild_id: int, user_id: str):\n+        \"\"\"Return the voice channel the user is currently in, or None.\"\"\"\n+        if not self._client:\n+            return None\n+        guild = self._client.get_guild(guild_id)\n+        if not guild:\n+            return None\n+        member = guild.get_member(int(user_id))\n+        if not member or not member.voice:\n+            return None\n+        return member.voice.channel\n+\n+    def _reset_voice_timeout(self, guild_id: int) -> None:\n+        \"\"\"Reset the auto-disconnect inactivity timer.\"\"\"\n+        task = self._voice_timeout_tasks.pop(guild_id, None)\n+        if task:\n+            task.cancel()\n+        self._voice_timeout_tasks[guild_id] = asyncio.ensure_future(\n+            self._voice_timeout_handler(guild_id)\n+        )\n+\n+    async def _voice_timeout_handler(self, guild_id: int) -> None:\n+        \"\"\"Auto-disconnect after VOICE_TIMEOUT seconds of inactivity.\"\"\"\n+        try:\n+            await asyncio.sleep(self.VOICE_TIMEOUT)\n+        except asyncio.CancelledError:\n+            return\n+        text_ch_id = self._voice_text_channels.get(guild_id)\n+        await self.leave_voice_channel(guild_id)\n+        # Notify the runner so it can clean up voice_mode state\n+        if self._on_voice_disconnect and text_ch_id:\n+            try:\n+                self._on_voice_disconnect(str(text_ch_id))\n+            except Exception:\n+                pass\n+        if text_ch_id and self._client:\n+            ch = self._client.get_channel(text_ch_id)\n+            if ch:\n+                try:\n+                    await ch.send(\"Left voice channel (inactivity timeout).\")\n+                except Exception:\n+                    pass\n+\n+    def is_in_voice_channel(self, guild_id: int) -> bool:\n+        \"\"\"Check if the bot is connected to a voice channel in this guild.\"\"\"\n+        vc = self._voice_clients.get(guild_id)\n+        return vc is not None and vc.is_connected()\n+\n+    def get_voice_channel_info(self, guild_id: int) -> Optional[Dict[str, Any]]:\n+        \"\"\"Return voice channel awareness info for the given guild.\n+\n+        Returns None if the bot is not in a voice channel.  Otherwise\n+        returns a dict with channel name, member list, count, and\n+        currently-speaking user IDs (from SSRC mapping).\n+        \"\"\"\n+        vc = self._voice_clients.get(guild_id)\n+        if not vc or not vc.is_connected():\n+            return None\n+\n+        channel = vc.channel\n+        if not channel:\n+            return None\n+\n+        # Members currently in the voice channel (includes bot)\n+        members_info = []\n+        bot_user = self._client.user if self._client else None\n+        for m in channel.members:\n+            if bot_user and m.id == bot_user.id:\n+                continue  # skip the bot itself\n+            members_info.append({\n+                \"user_id\": m.id,\n+                \"display_name\": m.display_name,\n+                \"is_bot\": m.bot,\n+            })\n+\n+        # Currently speaking users (from SSRC mapping + active buffers)\n+        speaking_user_ids: set = set()\n+        receiver = self._voice_receivers.get(guild_id)\n+        if receiver:\n+            import time as _time\n+            now = _time.monotonic()\n+            with receiver._lock:\n+                for ssrc, last_t in receiver._last_packet_time.items():\n+                    # Consider \"speaking\" if audio received within last 2 seconds\n+                    if now - last_t < 2.0:\n+                        uid = receiver._ssrc_to_user.get(ssrc)\n+                        if uid:\n+                            speaking_user_ids.add(uid)\n+\n+        # Tag speaking status on members\n+        for info in members_info:\n+            info[\"is_speaking\"] = info[\"user_id\"] in speaking_user_ids\n+\n+        return {\n+            \"channel_name\": channel.name,\n+            \"member_count\": len(members_info),\n+            \"members\": members_info,\n+            \"speaking_count\": len(speaking_user_ids),\n+        }\n+\n+    def get_voice_channel_context(self, guild_id: int) -> str:\n+        \"\"\"Return a human-readable voice channel context string.\n+\n+        Suitable for injection into the system/ephemeral prompt so the\n+        agent is always aware of voice channel state.\n+        \"\"\"\n+        info = self.get_voice_channel_info(guild_id)\n+        if not info:\n+            return \"\"\n+\n+        parts = [f\"[Voice channel: #{info['channel_name']} — {info['member_count']} participant(s)]\"]\n+        for m in info[\"members\"]:\n+            status = \" (speaking)\" if m[\"is_speaking\"] else \"\"\n+            parts.append(f\"  - {m['display_name']}{status}\")\n+\n+        return \"\\n\".join(parts)\n+\n+    # ------------------------------------------------------------------\n+    # Voice listening (Phase 2)\n+    # ------------------------------------------------------------------\n+\n+    async def _voice_listen_loop(self, guild_id: int):\n+        \"\"\"Periodically check for completed utterances and process them.\"\"\"\n+        receiver = self._voice_receivers.get(guild_id)\n+        if not receiver:\n+            return\n+        try:\n+            while receiver._running:\n+                await asyncio.sleep(0.2)\n+                completed = receiver.check_silence()\n+                for user_id, pcm_data in completed:\n+                    if not self._is_allowed_user(str(user_id)):\n+                        continue\n+                    await self._process_voice_input(guild_id, user_id, pcm_data)\n+        except asyncio.CancelledError:\n+            pass\n+        except Exception as e:\n+            logger.error(\"Voice listen loop error: %s\", e, exc_info=True)\n+\n+    async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes):\n+        \"\"\"Convert PCM -> WAV -> STT -> callback.\"\"\"\n+        from tools.voice_mode import is_whisper_hallucination\n+\n+        tmp_f = tempfile.NamedTemporaryFile(suffix=\".wav\", prefix=\"vc_listen_\", delete=False)\n+        wav_path = tmp_f.name\n+        tmp_f.close()\n+        try:\n+            await asyncio.to_thread(VoiceReceiver.pcm_to_wav, pcm_data, wav_path)\n+\n+            from tools.transcription_tools import transcribe_audio, get_stt_model_from_config\n+            stt_model = get_stt_model_from_config()\n+            result = await asyncio.to_thread(transcribe_audio, wav_path, model=stt_model)\n+\n+            if not result.get(\"success\"):\n+                return\n+            transcript = result.get(\"transcript\", \"\").strip()\n+            if not transcript or is_whisper_hallucination(transcript):\n+                return\n+\n+            logger.info(\"Voice input from user %d: %s\", user_id, transcript[:100])\n+\n+            if self._voice_input_callback:\n+                await self._voice_input_callback(\n+                    guild_id=guild_id,\n+                    user_id=user_id,\n+                    transcript=transcript,\n+                )\n+        except Exception as e:\n+            logger.warning(\"Voice input processing failed: %s\", e, exc_info=True)\n+        finally:\n+            try:\n+                os.unlink(wav_path)\n+            except OSError:\n+                pass\n+\n+    def _is_allowed_user(self, user_id: str) -> bool:\n+        \"\"\"Check if user is in DISCORD_ALLOWED_USERS.\"\"\"\n+        if not self._allowed_user_ids:\n+            return True\n+        return user_id in self._allowed_user_ids\n+\n     async def send_image_file(\n         self,\n         chat_id: str,","@@ -627,6 +1361,25 @@ async def slash_insights(interaction: discord.Interaction, days: int = 7):\n         async def slash_reload_mcp(interaction: discord.Interaction):\n             await self._run_simple_slash(interaction, \"/reload-mcp\")\n \n+        @tree.command(name=\"voice\", description=\"Toggle voice reply mode\")\n+        @discord.app_commands.describe(mode=\"Voice mode: on, off, tts, channel, leave, or status\")\n+        @discord.app_commands.choices(mode=[\n+            discord.app_commands.Choice(name=\"channel — join your voice channel\", value=\"channel\"),\n+            discord.app_commands.Choice(name=\"leave — leave voice channel\", value=\"leave\"),\n+            discord.app_commands.Choice(name=\"on — voice reply to voice messages\", value=\"on\"),\n+            discord.app_commands.Choice(name=\"tts — voice reply to all messages\", value=\"tts\"),\n+            discord.app_commands.Choice(name=\"off — text only\", value=\"off\"),\n+            discord.app_commands.Choice(name=\"status — show current mode\", value=\"status\"),\n+        ])\n+        async def slash_voice(interaction: discord.Interaction, mode: str = \"\"):\n+            await interaction.response.defer(ephemeral=True)\n+            event = self._build_slash_event(interaction, f\"/voice {mode}\".strip())\n+            await self.handle_message(event)\n+            try:\n+                await interaction.followup.send(\"Done~\", ephemeral=True)\n+            except Exception as e:\n+                logger.debug(\"Discord followup failed: %s\", e)\n+\n         @tree.command(name=\"update\", description=\"Update Hermes Agent to the latest version\")\n         async def slash_update(interaction: discord.Interaction):\n             await self._run_simple_slash(interaction, \"/update\", \"Update initiated~\")"]},{"file":"gateway/platforms/slack.py","hunks":["@@ -506,6 +506,7 @@ async def send_voice(\n         caption: Optional[str] = None,\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n+        **kwargs,\n     ) -> SendResult:\n         \"\"\"Send an audio file to Slack.\"\"\"\n         try:"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -150,7 +150,10 @@ async def connect(self) -> bool:\n             # Start polling in background\n             await self._app.initialize()\n             await self._app.start()\n-            await self._app.updater.start_polling(allowed_updates=Update.ALL_TYPES)\n+            await self._app.updater.start_polling(\n+                allowed_updates=Update.ALL_TYPES,\n+                drop_pending_updates=True,\n+            )\n             \n             # Register bot commands so Telegram shows a hint menu when users type /\n             try:","@@ -174,6 +177,7 @@ async def connect(self) -> bool:\n                     BotCommand(\"insights\", \"Show usage insights and analytics\"),\n                     BotCommand(\"update\", \"Update Hermes to the latest version\"),\n                     BotCommand(\"reload_mcp\", \"Reload MCP servers from config\"),\n+                    BotCommand(\"voice\", \"Toggle voice reply mode\"),\n                     BotCommand(\"help\", \"Show available commands\"),\n                 ])\n             except Exception as e:","@@ -307,6 +311,7 @@ async def send_voice(\n         caption: Optional[str] = None,\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n+        **kwargs,\n     ) -> SendResult:\n         \"\"\"Send audio as a native Telegram voice message or audio file.\"\"\"\n         if not self._bot:"]},{"file":"gateway/run.py","hunks":["@@ -14,12 +14,15 @@\n \"\"\"\n \n import asyncio\n+import json\n import logging\n import os\n import re\n import sys\n import signal\n+import tempfile\n import threading\n+import time\n from logging.handlers import RotatingFileHandler\n from pathlib import Path\n from datetime import datetime","@@ -280,6 +283,9 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n         from gateway.hooks import HookRegistry\n         self.hooks = HookRegistry()\n \n+        # Per-chat voice reply mode: \"off\" | \"voice_only\" | \"all\"\n+        self._voice_mode: Dict[str, str] = self._load_voice_modes()\n+\n     def _get_or_create_gateway_honcho(self, session_key: str):\n         \"\"\"Return a persistent Honcho manager/config pair for this gateway session.\"\"\"\n         if not hasattr(self, \"_honcho_managers\"):","@@ -335,6 +341,27 @@ def _shutdown_all_gateway_honcho(self) -> None:\n         for session_key in list(managers.keys()):\n             self._shutdown_gateway_honcho(session_key)\n     \n+    # -- Voice mode persistence ------------------------------------------\n+\n+    _VOICE_MODE_PATH = _hermes_home / \"gateway_voice_mode.json\"\n+\n+    def _load_voice_modes(self) -> Dict[str, str]:\n+        try:\n+            return json.loads(self._VOICE_MODE_PATH.read_text())\n+        except (FileNotFoundError, json.JSONDecodeError, OSError):\n+            return {}\n+\n+    def _save_voice_modes(self) -> None:\n+        try:\n+            self._VOICE_MODE_PATH.parent.mkdir(parents=True, exist_ok=True)\n+            self._VOICE_MODE_PATH.write_text(\n+                json.dumps(self._voice_mode, indent=2)\n+            )\n+        except OSError as e:\n+            logger.warning(\"Failed to save voice modes: %s\", e)\n+\n+    # -----------------------------------------------------------------\n+\n     def _flush_memories_for_session(self, old_session_id: str):\n         \"\"\"Prompt the agent to save memories/skills before context is lost.\n ","@@ -727,7 +754,7 @@ async def stop(self) -> None:\n         logger.info(\"Stopping gateway...\")\n         self._running = False\n         \n-        for platform, adapter in self.adapters.items():\n+        for platform, adapter in list(self.adapters.items()):\n             try:\n                 await adapter.disconnect()\n                 logger.info(\"✓ %s disconnected\", platform.value)","@@ -887,7 +914,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n         7. Return response\n         \"\"\"\n         source = event.source\n-        \n+\n         # Check if user is authorized\n         if not self._is_user_authorized(source):\n             logger.warning(\"Unauthorized user: %s (%s) on %s\", source.user_id, source.user_name, source.platform.value)","@@ -939,7 +966,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                           \"personality\", \"retry\", \"undo\", \"sethome\", \"set-home\",\n                           \"compress\", \"usage\", \"insights\", \"reload-mcp\", \"reload_mcp\",\n                           \"update\", \"title\", \"resume\", \"provider\", \"rollback\",\n-                          \"background\", \"reasoning\"}\n+                          \"background\", \"reasoning\", \"voice\"}\n         if command and command in _known_commands:\n             await self.hooks.emit(f\"command:{command}\", {\n                 \"platform\": source.platform.value if source.platform else \"\",","@@ -1010,7 +1037,10 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n \n         if command == \"reasoning\":\n             return await self._handle_reasoning_command(event)\n-        \n+\n+        if command == \"voice\":\n+            return await self._handle_voice_command(event)\n+\n         # User-defined quick commands (bypass agent loop, no LLM call)\n         if command:\n             quick_commands = self.config.get(\"quick_commands\", {})","@@ -1362,6 +1392,19 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                         f\"or ignore to skip.\"\n                     )\n         \n+        # -----------------------------------------------------------------\n+        # Voice channel awareness — inject current voice channel state\n+        # into context so the agent knows who is in the channel and who\n+        # is speaking, without needing a separate tool call.\n+        # -----------------------------------------------------------------\n+        if source.platform == Platform.DISCORD:\n+            adapter = self.adapters.get(Platform.DISCORD)\n+            guild_id = self._get_guild_id(event)\n+            if guild_id and adapter and hasattr(adapter, \"get_voice_channel_context\"):\n+                vc_context = adapter.get_voice_channel_context(guild_id)\n+                if vc_context:\n+                    context_prompt += f\"\\n\\n{vc_context}\"\n+\n         # -----------------------------------------------------------------\n         # Auto-analyze images sent by the user\n         #","@@ -1568,7 +1611,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 session_entry.session_key,\n                 last_prompt_tokens=[redacted:secret-assignment]\"last_prompt_tokens\", 0),\n             )\n-            \n+\n+            # Auto voice reply: send TTS audio before the text response\n+            if self._should_send_voice_reply(event, response, agent_messages):\n+                await self._send_voice_reply(event, response)\n+\n             return response\n             \n         except Exception as e:","@@ -1677,6 +1724,7 @@ async def _handle_help_command(self, event: MessageEvent) -> str:\n             \"`/reasoning [level|show|hide]` — Set reasoning effort or toggle display\",\n             \"`/rollback [number]` — List or restore filesystem checkpoints\",\n             \"`/background <prompt>` — Run a prompt in a separate background session\",\n+            \"`/voice [on|off|tts|status]` — Toggle voice reply mode\",\n             \"`/reload-mcp` — Reload MCP servers from config\",\n             \"`/update` — Update Hermes Agent to the latest version\",\n             \"`/help` — Show this message\",","@@ -2052,6 +2100,334 @@ async def _handle_set_home_command(self, event: MessageEvent) -> str:\n             f\"Cron jobs and cross-platform messages will be delivered here.\"\n         )\n     \n+    @staticmethod\n+    def _get_guild_id(event: MessageEvent) -> Optional[int]:\n+        \"\"\"Extract Discord guild_id from the raw message object.\"\"\"\n+        raw = getattr(event, \"raw_message\", None)\n+        if raw is None:\n+            return None\n+        # Slash command interaction\n+        if hasattr(raw, \"guild_id\") and raw.guild_id:\n+            return int(raw.guild_id)\n+        # Regular message\n+        if hasattr(raw, \"guild\") and raw.guild:\n+            return raw.guild.id\n+        return None\n+\n+    async def _handle_voice_command(self, event: MessageEvent) -> str:\n+        \"\"\"Handle /voice [on|off|tts|channel|leave|status] command.\"\"\"\n+        args = event.get_command_args().strip().lower()\n+        chat_id = event.source.chat_id\n+\n+        adapter = self.adapters.get(event.source.platform)\n+\n+        if args in (\"on\", \"enable\"):\n+            self._voice_mode[chat_id] = \"voice_only\"\n+            self._save_voice_modes()\n+            if adapter:\n+                adapter._auto_tts_disabled_chats.discard(chat_id)\n+            return (\n+                \"Voice mode enabled.\\n\"\n+                \"I'll reply with voice when you send voice messages.\\n\"\n+                \"Use /voice tts to get voice replies for all messages.\"\n+            )\n+        elif args in (\"off\", \"disable\"):\n+            self._voice_mode.pop(chat_id, None)\n+            self._save_voice_modes()\n+            if adapter:\n+                adapter._auto_tts_disabled_chats.add(chat_id)\n+            return \"Voice mode disabled. Text-only replies.\"\n+        elif args == \"tts\":\n+            self._voice_mode[chat_id] = \"all\"\n+            self._save_voice_modes()\n+            if adapter:\n+                adapter._auto_tts_disabled_chats.discard(chat_id)\n+            return (\n+                \"Auto-TTS enabled.\\n\"\n+                \"All replies will include a voice message.\"\n+            )\n+        elif args in (\"channel\", \"join\"):\n+            return await self._handle_voice_channel_join(event)\n+        elif args == \"leave\":\n+            return await self._handle_voice_channel_leave(event)\n+        elif args == \"status\":\n+            mode = self._voice_mode.get(chat_id, \"off\")\n+            labels = {\n+                \"off\": \"Off (text only)\",\n+                \"voice_only\": \"On (voice reply to voice messages)\",\n+                \"all\": \"TTS (voice reply to all messages)\",\n+            }\n+            # Append voice channel info if connected\n+            adapter = self.adapters.get(event.source.platform)\n+            guild_id = self._get_guild_id(event)\n+            if guild_id and hasattr(adapter, \"get_voice_channel_info\"):\n+                info = adapter.get_voice_channel_info(guild_id)\n+                if info:\n+                    lines = [\n+                        f\"Voice mode: {labels.get(mode, mode)}\",\n+                        f\"Voice channel: #{info['channel_name']}\",\n+                        f\"Participants: {info['member_count']}\",\n+                    ]\n+                    for m in info[\"members\"]:\n+                        status = \" (speaking)\" if m.get(\"is_speaking\") else \"\"\n+                        lines.append(f\"  - {m['display_name']}{status}\")\n+                    return \"\\n\".join(lines)\n+            return f\"Voice mode: {labels.get(mode, mode)}\"\n+        else:\n+            # Toggle: off → on, on/all → off\n+            current = self._voice_mode.get(chat_id, \"off\")\n+            if current == \"off\":\n+                self._voice_mode[chat_id] = \"voice_only\"\n+                self._save_voice_modes()\n+                if adapter:\n+                    adapter._auto_tts_disabled_chats.discard(chat_id)\n+                return \"Voice mode enabled.\"\n+            else:\n+                self._voice_mode.pop(chat_id, None)\n+                self._save_voice_modes()\n+                if adapter:\n+                    adapter._auto_tts_disabled_chats.add(chat_id)\n+                return \"Voice mode disabled.\"\n+\n+    async def _handle_voice_channel_join(self, event: MessageEvent) -> str:\n+        \"\"\"Join the user's current Discord voice channel.\"\"\"\n+        adapter = self.adapters.get(event.source.platform)\n+        if not hasattr(adapter, \"join_voice_channel\"):\n+            return \"Voice channels are not supported on this platform.\"\n+\n+        guild_id = self._get_guild_id(event)\n+        if not guild_id:\n+            return \"This command only works in a Discord server.\"\n+\n+        voice_channel = await adapter.get_user_voice_channel(\n+            guild_id, event.source.user_id\n+        )\n+        if not voice_channel:\n+            return \"You need to be in a voice channel first.\"\n+\n+        # Wire callbacks BEFORE join so voice input arriving immediately\n+        # after connection is not lost.\n+        if hasattr(adapter, \"_voice_input_callback\"):\n+            adapter._voice_input_callback = self._handle_voice_channel_input\n+        if hasattr(adapter, \"_on_voice_disconnect\"):\n+            adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup\n+\n+        try:\n+            success = await adapter.join_voice_channel(voice_channel)\n+        except Exception as e:\n+            logger.warning(\"Failed to join voice channel: %s\", e)\n+            adapter._voice_input_callback = None\n+            return f\"Failed to join voice channel: {e}\"\n+\n+        if success:\n+            adapter._voice_text_channels[guild_id] = int(event.source.chat_id)\n+            self._voice_mode[event.source.chat_id] = \"all\"\n+            self._save_voice_modes()\n+            adapter._auto_tts_disabled_chats.discard(event.source.chat_id)\n+            return (\n+                f\"Joined voice channel **{voice_channel.name}**.\\n\"\n+                f\"I'll speak my replies and listen to you. Use /voice leave to disconnect.\"\n+            )\n+        # Join failed — clear callback\n+        adapter._voice_input_callback = None\n+        return \"Failed to join voice channel. Check bot permissions (Connect + Speak).\"\n+\n+    async def _handle_voice_channel_leave(self, event: MessageEvent) -> str:\n+        \"\"\"Leave the Discord voice channel.\"\"\"\n+        adapter = self.adapters.get(event.source.platform)\n+        guild_id = self._get_guild_id(event)\n+\n+        if not guild_id or not hasattr(adapter, \"leave_voice_channel\"):\n+            return \"Not in a voice channel.\"\n+\n+        if not hasattr(adapter, \"is_in_voice_channel\") or not adapter.is_in_voice_channel(guild_id):\n+            return \"Not in a voice channel.\"\n+\n+        try:\n+            await adapter.leave_voice_channel(guild_id)\n+        except Exception as e:\n+            logger.warning(\"Error leaving voice channel: %s\", e)\n+        # Always clean up state even if leave raised an exception\n+        self._voice_mode.pop(event.source.chat_id, None)\n+        self._save_voice_modes()\n+        if hasattr(adapter, \"_voice_input_callback\"):\n+            adapter._voice_input_callback = None\n+        return \"Left voice channel.\"\n+\n+    def _handle_voice_timeout_cleanup(self, chat_id: str) -> None:\n+        \"\"\"Called by the adapter when a voice channel times out.\n+\n+        Cleans up runner-side voice_mode state that the adapter cannot reach.\n+        \"\"\"\n+        self._voice_mode.pop(chat_id, None)\n+        self._save_voice_modes()\n+\n+    async def _handle_voice_channel_input(\n+        self, guild_id: int, user_id: int, transcript: str\n+    ):\n+        \"\"\"Handle transcribed voice from a user in a voice channel.\n+\n+        Creates a synthetic MessageEvent and processes it through the\n+        adapter's full message pipeline (session, typing, agent, TTS reply).\n+        \"\"\"\n+        adapter = self.adapters.get(Platform.DISCORD)\n+        if not adapter:\n+            return\n+\n+        text_ch_id = adapter._voice_text_channels.get(guild_id)\n+        if not text_ch_id:\n+            return\n+\n+        # Check authorization before processing voice input\n+        source = SessionSource(\n+            platform=Platform.DISCORD,\n+            chat_id=str(text_ch_id),\n+            user_id=str(user_id),\n+            user_name=str(user_id),\n+            chat_type=\"channel\",\n+        )\n+        if not self._is_user_authorized(source):\n+            logger.debug(\"Unauthorized voice input from user %d, ignoring\", user_id)\n+            return\n+\n+        # Show transcript in text channel (after auth, with mention sanitization)\n+        try:\n+            channel = adapter._client.get_channel(text_ch_id)\n+            if channel:\n+                safe_text = transcript[:2000].replace(\"@everyone\", \"@\\u200beveryone\").replace(\"@here\", \"@\\u200bhere\")\n+                await channel.send(f\"**[Voice]** <@{user_id}>: {safe_text}\")\n+        except Exception:\n+            pass\n+\n+        # Build a synthetic MessageEvent and feed through the normal pipeline\n+        # Use SimpleNamespace as raw_message so _get_guild_id() can extract\n+        # guild_id and _send_voice_reply() plays audio in the voice channel.\n+        from types import SimpleNamespace\n+        event = MessageEvent(\n+            source=source,\n+            text=transcript,\n+            message_type=MessageType.VOICE,\n+            raw_message=SimpleNamespace(guild_id=guild_id, guild=None),\n+        )\n+\n+        await adapter.handle_message(event)\n+\n+    def _should_send_voice_reply(\n+        self,\n+        event: MessageEvent,\n+        response: str,\n+        agent_messages: list,\n+    ) -> bool:\n+        \"\"\"Decide whether the runner should send a TTS voice reply.\n+\n+        Returns False when:\n+        - voice_mode is off for this chat\n+        - response is empty or an error\n+        - agent already called text_to_speech tool (dedup)\n+        - voice input and base adapter auto-TTS already handled it (skip_double)\n+          Exception: Discord voice channel — base play_tts is a no-op there,\n+          so the runner must handle VC playback.\n+        \"\"\"\n+        if not response or response.startswith(\"Error:\"):\n+            return False\n+\n+        chat_id = event.source.chat_id\n+        voice_mode = self._voice_mode.get(chat_id, \"off\")\n+        is_voice_input = (event.message_type == MessageType.VOICE)\n+\n+        should = (\n+            (voice_mode == \"all\")\n+            or (voice_mode == \"voice_only\" and is_voice_input)\n+        )\n+        if not should:\n+            return False\n+\n+        # Dedup: agent already called TTS tool\n+        has_agent_tts = any(\n+            msg.get(\"role\") == \"assistant\"\n+            and any(\n+                tc.get(\"function\", {}).get(\"name\") == \"text_to_speech\"\n+                for tc in (msg.get(\"tool_calls\") or [])\n+            )\n+            for msg in agent_messages\n+        )\n+        if has_agent_tts:\n+            return False\n+\n+        # Dedup: base adapter auto-TTS already handles voice input.\n+        # Exception: Discord voice channel — play_tts override is a no-op,\n+        # so the runner must handle VC playback.\n+        skip_double = is_voice_input\n+        if skip_double:\n+            adapter = self.adapters.get(event.source.platform)\n+            guild_id = self._get_guild_id(event)\n+            if (guild_id and adapter\n+                    and hasattr(adapter, \"is_in_voice_channel\")\n+                    and adapter.is_in_voice_channel(guild_id)):\n+                skip_double = False\n+        if skip_double:\n+            return False\n+\n+        return True\n+\n+    async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:\n+        \"\"\"Generate TTS audio and send as a voice message before the text reply.\"\"\"\n+        import uuid as _uuid\n+        audio_path = None\n+        actual_path = None\n+        try:\n+            from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts\n+\n+            tts_text = _strip_markdown_for_tts(text[:4000])\n+            if not tts_text:\n+                return\n+\n+            # Use .mp3 extension so edge-tts conversion to opus works correctly.\n+            # The TTS tool may convert to .ogg — use file_path from result.\n+            audio_path = os.path.join(\n+                tempfile.gettempdir(), \"hermes_voice\",\n+                f\"tts_reply_{_uuid.uuid4().hex[:12]}.mp3\",\n+            )\n+            os.makedirs(os.path.dirname(audio_path), exist_ok=True)\n+\n+            result_json = await asyncio.to_thread(\n+                text_to_speech_tool, text=tts_text, output_path=audio_path\n+            )\n+            result = json.loads(result_json)\n+\n+            # Use the actual file path from result (may differ after opus conversion)\n+            actual_path = result.get(\"file_path\", audio_path)\n+            if not result.get(\"success\") or not os.path.isfile(actual_path):\n+                logger.warning(\"Auto voice reply TTS failed: %s\", result.get(\"error\"))\n+                return\n+\n+            adapter = self.adapters.get(event.source.platform)\n+\n+            # If connected to a voice channel, play there instead of sending a file\n+            guild_id = self._get_guild_id(event)\n+            if (guild_id\n+                    and hasattr(adapter, \"play_in_voice_channel\")\n+                    and hasattr(adapter, \"is_in_voice_channel\")\n+                    and adapter.is_in_voice_channel(guild_id)):\n+                await adapter.play_in_voice_channel(guild_id, actual_path)\n+            elif adapter and hasattr(adapter, \"send_voice\"):\n+                send_kwargs: Dict[str, Any] = {\n+                    \"chat_id\": event.source.chat_id,\n+                    \"audio_path\": actual_path,\n+                    \"reply_to\": event.message_id,\n+                }\n+                if event.source.thread_id:\n+                    send_kwargs[\"metadata\"] = {\"thread_id\": event.source.thread_id}\n+                await adapter.send_voice(**send_kwargs)\n+        except Exception as e:\n+            logger.warning(\"Auto voice reply failed: %s\", e, exc_info=True)\n+        finally:\n+            for p in {audio_path, actual_path} - {None}:\n+                try:\n+                    os.unlink(p)\n+                except OSError:\n+                    pass\n+\n     async def _handle_rollback_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /rollback command — list or restore filesystem checkpoints.\"\"\"\n         from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list","@@ -2912,14 +3288,16 @@ async def _enrich_message_with_transcription(\n         Returns:\n             The enriched message string with transcriptions prepended.\n         \"\"\"\n-        from tools.transcription_tools import transcribe_audio\n+        from tools.transcription_tools import transcribe_audio, get_stt_model_from_config\n         import asyncio\n \n+        stt_model = get_stt_model_from_config()\n+\n         enriched_parts = []\n         for path in audio_paths:\n             try:\n                 logger.debug(\"Transcribing user voice: %s\", path)\n-                result = await asyncio.to_thread(transcribe_audio, path)\n+                result = await asyncio.to_thread(transcribe_audio, path, model=stt_model)\n                 if result[\"success\"]:\n                     transcript = result[\"transcript\"]\n                     enriched_parts.append(","@@ -2928,10 +3306,10 @@ async def _enrich_message_with_transcription(\n                     )\n                 else:\n                     error = result.get(\"error\", \"unknown error\")\n-                    if \"OPENAI_API_KEY\" in error or \"VOICE_TOOLS_OPENAI_KEY\" in error:\n+                    if \"No STT provider\" in error or \"not set\" in error:\n                         enriched_parts.append(\n                             \"[The user sent a voice message but I can't listen \"\n-                            \"to it right now~ VOICE_TOOLS_OPENAI_KEY isn't set up yet \"\n+                            \"to it right now~ No STT provider is configured \"\n                             \"(';w;') Let them know!]\"\n                         )\n                     else:","@@ -3081,7 +3459,7 @@ async def _run_agent(\n             Platform.HOMEASSISTANT: \"hermes-homeassistant\",\n             Platform.EMAIL: \"hermes-email\",\n         }\n-        \n+\n         # Try to load platform_toolsets from config\n         platform_toolsets_config = {}\n         try:","@@ -3093,7 +3471,7 @@ async def _run_agent(\n                 platform_toolsets_config = user_config.get(\"platform_toolsets\", {})\n         except Exception as e:\n             logger.debug(\"Could not load platform_toolsets config: %s\", e)\n-        \n+\n         # Map platform enum to config key\n         platform_config_key = {\n             Platform.LOCAL: \"cli\","]},{"file":"gateway/session.py","hunks":["@@ -383,7 +383,11 @@ def _ensure_loaded(self) -> None:\n                 with open(sessions_file, \"r\", encoding=\"utf-8\") as f:\n                     data = json.load(f)\n                     for key, entry_data in data.items():\n-                        self._entries[key] = SessionEntry.from_dict(entry_data)\n+                        try:\n+                            self._entries[key] = SessionEntry.from_dict(entry_data)\n+                        except (ValueError, KeyError):\n+                            # Skip entries with unknown/removed platform values\n+                            continue\n             except Exception as e:\n                 print(f\"[gateway] Warning: Failed to load sessions: {e}\")\n         "]},{"file":"hermes_cli/commands.py","hunks":["@@ -37,6 +37,7 @@\n         \"/verbose\": \"Cycle tool progress display: off → new → all → verbose\",\n         \"/reasoning\": \"Manage reasoning effort and display (usage: /reasoning [level|show|hide])\",\n         \"/skin\": \"Show or change the display skin/theme\",\n+        \"/voice\": \"Toggle voice mode (Ctrl+B to record). Usage: /voice [on|off|tts|status]\",\n     },\n     \"Tools & Skills\": {\n         \"/tools\": \"List available tools\","]},{"file":"hermes_cli/config.py","hunks":["@@ -202,6 +202,14 @@ def ensure_hermes_home():\n             \"model\": \"whisper-1\",  # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n         },\n     },\n+\n+    \"voice\": {\n+        \"record_key\": \"ctrl+b\",\n+        \"max_recording_seconds\": 120,\n+        \"auto_tts\": False,\n+        \"silence_threshold\": 200,     # RMS below this = silence (0-32767)\n+        \"silence_duration\": 3.0,      # Seconds of silence before auto-stop\n+    },\n     \n     \"human_delay\": {\n         \"mode\": \"off\","]},{"file":"pyproject.toml","hunks":["@@ -43,11 +43,12 @@ dependencies = [\n modal = [\"swe-rex[modal]>=1.4.0\"]\n daytona = [\"daytona>=0.148.0\"]\n dev = [\"pytest\", \"pytest-asyncio\", \"pytest-xdist\", \"mcp>=1.2.0\"]\n-messaging = [\"python-telegram-bot>=20.0\", \"discord.py>=2.0\", \"aiohttp>=3.9.0\", \"slack-bolt>=1.18.0\", \"slack-sdk>=3.27.0\"]\n+messaging = [\"python-telegram-bot>=20.0\", \"discord.py[voice]>=2.0\", \"aiohttp>=3.9.0\", \"slack-bolt>=1.18.0\", \"slack-sdk>=3.27.0\"]\n cron = [\"croniter\"]\n slack = [\"slack-bolt>=1.18.0\", \"slack-sdk>=3.27.0\"]\n cli = [\"simple-term-menu\"]\n tts-premium = [\"elevenlabs\"]\n+voice = [\"sounddevice>=0.4.6\", \"numpy>=1.24.0\"]\n pty = [\n   \"ptyprocess>=0.7.0; sys_platform != 'win32'\",\n   \"pywinpty>=2.0.0; sys_platform == 'win32'\",","@@ -78,6 +79,7 @@ all = [\n   \"hermes-agent[mcp]\",\n   \"hermes-agent[homeassistant]\",\n   \"hermes-agent[acp]\",\n+  \"hermes-agent[voice]\",\n ]\n \n [project.scripts]"]},{"file":"run_agent.py","hunks":["@@ -493,6 +493,10 @@ def __init__(\n                 ]:\n                     logging.getLogger(quiet_logger).setLevel(logging.ERROR)\n         \n+        # Internal stream callback (set during streaming TTS).\n+        # Initialized here so _vprint can reference it before run_conversation.\n+        self._stream_callback = None\n+\n         # Initialize LLM client via centralized provider router.\n         # The router handles auth resolution, base URL, headers, and\n         # Codex/Anthropic wrapping for all known providers.","@@ -504,6 +508,7 @@ def __init__(\n             from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token\n             effective_key = api_key or resolve_anthropic_token() or \"\"\n             self._anthropic_api_key = [redacted:secret-assignment]\n+            self._anthropic_base_url = base_url\n             self._anthropic_client = build_anthropic_client(effective_key, base_url)\n             # No OpenAI client needed for Anthropic mode\n             self.client = None","@@ -812,6 +817,16 @@ def __init__(\n             else:\n                 print(f\"📊 Context limit: {self.context_compressor.context_length:,} tokens (auto-compression disabled)\")\n     \n+    def _vprint(self, *args, force: bool = False, **kwargs):\n+        \"\"\"Verbose print — suppressed when streaming TTS is active.\n+\n+        Pass ``force=True`` for error/warning messages that should always be\n+        shown even during streaming TTS playback.\n+        \"\"\"\n+        if not force and getattr(self, \"_stream_callback\", None) is not None:\n+            return\n+        print(*args, **kwargs)\n+\n     def _max_tokens_param(self, value: int) -> dict:\n         \"\"\"Return the correct max tokens kwarg for the current provider.\n         ","@@ -1340,7 +1355,7 @@ def _dump_api_request_debug(\n                 encoding=\"utf-8\",\n             )\n \n-            print(f\"{self.log_prefix}🧾 Request debug dump written to: {dump_file}\")\n+            self._vprint(f\"{self.log_prefix}🧾 Request debug dump written to: {dump_file}\")\n \n             if os.getenv(\"HERMES_DUMP_REQUEST_STDOUT\", \"\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}:\n                 print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))","@@ -1482,7 +1497,7 @@ def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:\n             # Replay the items into the store (replace mode)\n             self._todo_store.write(last_todo_response, merge=False)\n             if not self.quiet_mode:\n-                print(f\"{self.log_prefix}📋 Restored {len(last_todo_response)} todo item(s) from history\")\n+                self._vprint(f\"{self.log_prefix}📋 Restored {len(last_todo_response)} todo item(s) from history\")\n         _set_interrupt(False)\n     \n     @property","@@ -2576,7 +2591,7 @@ def _interruptible_api_call(self, api_kwargs: dict):\n         \"\"\"\n         Run the API call in a background thread so the main conversation loop\n         can detect interrupts without waiting for the full HTTP round-trip.\n-        \n+\n         On interrupt, closes the HTTP client to cancel the in-flight request\n         (stops token generation and avoids wasting money), then rebuilds the\n         client for future calls.","@@ -2611,7 +2626,139 @@ def _call():\n                 try:\n                     if self.api_mode == \"anthropic_messages\":\n                         from agent.anthropic_adapter import build_anthropic_client\n-                        self._anthropic_client = build_anthropic_client(self._anthropic_api_key)\n+                        self._anthropic_client = build_anthropic_client(self._anthropic_api_key, getattr(self, \"_anthropic_base_url\", None))\n+                    else:\n+                        self.client = OpenAI(**self._client_kwargs)\n+                except Exception:\n+                    pass\n+                raise InterruptedError(\"Agent interrupted during API call\")\n+        if result[\"error\"] is not None:\n+            raise result[\"error\"]\n+        return result[\"response\"]\n+\n+    def _streaming_api_call(self, api_kwargs: dict, stream_callback):\n+        \"\"\"Streaming variant of _interruptible_api_call for voice TTS pipeline.\n+\n+        Uses ``stream=True`` and forwards content deltas to *stream_callback*\n+        in real-time.  Returns a ``SimpleNamespace`` that mimics a normal\n+        ``ChatCompletion`` so the rest of the agent loop works unchanged.\n+\n+        This method is separate from ``_interruptible_api_call`` to keep the\n+        core agent loop untouched for non-voice users.\n+        \"\"\"\n+        result = {\"response\": None, \"error\": None}\n+\n+        def _call():\n+            try:\n+                stream_kwargs = {**api_kwargs, \"stream\": True}\n+                stream = self.client.chat.completions.create(**stream_kwargs)\n+\n+                content_parts: list[str] = []\n+                tool_calls_acc: dict[int, dict] = {}\n+                finish_reason = None\n+                model_name = None\n+                role = \"assistant\"\n+\n+                for chunk in stream:\n+                    if not chunk.choices:\n+                        if hasattr(chunk, \"model\") and chunk.model:\n+                            model_name = chunk.model\n+                        continue\n+\n+                    delta = chunk.choices[0].delta\n+                    if hasattr(chunk, \"model\") and chunk.model:\n+                        model_name = chunk.model\n+\n+                    if delta and delta.content:\n+                        content_parts.append(delta.content)\n+                        try:\n+                            stream_callback(delta.content)\n+                        except Exception:\n+                            pass\n+\n+                    if delta and delta.tool_calls:\n+                        for tc_delta in delta.tool_calls:\n+                            idx = tc_delta.index if tc_delta.index is not None else 0\n+                            if idx in tool_calls_acc and tc_delta.id and tc_delta.id != tool_calls_acc[idx][\"id\"]:\n+                                matched = False\n+                                for eidx, eentry in tool_calls_acc.items():\n+                                    if eentry[\"id\"] == tc_delta.id:\n+                                        idx = eidx\n+                                        matched = True\n+                                        break\n+                                if not matched:\n+                                    idx = (max(k for k in tool_calls_acc if isinstance(k, int)) + 1) if tool_calls_acc else 0\n+                            if idx not in tool_calls_acc:\n+                                tool_calls_acc[idx] = {\n+                                    \"id\": tc_delta.id or \"\",\n+                                    \"type\": \"function\",\n+                                    \"function\": {\"name\": \"\", \"arguments\": \"\"},\n+                                }\n+                            entry = tool_calls_acc[idx]\n+                            if tc_delta.id:\n+                                entry[\"id\"] = tc_delta.id\n+                            if tc_delta.function:\n+                                if tc_delta.function.name:\n+                                    entry[\"function\"][\"name\"] += tc_delta.function.name\n+                                if tc_delta.function.arguments:\n+                                    entry[\"function\"][\"arguments\"] += tc_delta.function.arguments\n+\n+                    if chunk.choices[0].finish_reason:\n+                        finish_reason = chunk.choices[0].finish_reason\n+\n+                full_content = \"\".join(content_parts) or None\n+                mock_tool_calls = None\n+                if tool_calls_acc:\n+                    mock_tool_calls = []\n+                    for idx in sorted(tool_calls_acc):\n+                        tc = tool_calls_acc[idx]\n+                        mock_tool_calls.append(SimpleNamespace(\n+                            id=tc[\"id\"],\n+                            type=tc[\"type\"],\n+                            function=SimpleNamespace(\n+                                name=tc[\"function\"][\"name\"],\n+                                arguments=tc[\"function\"][\"arguments\"],\n+                            ),\n+                        ))\n+\n+                mock_message = SimpleNamespace(\n+                    role=role,\n+                    content=full_content,\n+                    tool_calls=mock_tool_calls,\n+                    reasoning_content=None,\n+                )\n+                mock_choice = SimpleNamespace(\n+                    index=0,\n+                    message=mock_message,\n+                    finish_reason=finish_reason or \"stop\",\n+                )\n+                mock_response = SimpleNamespace(\n+                    id=\"stream-\" + str(uuid.uuid4()),\n+                    model=model_name,\n+                    choices=[mock_choice],\n+                    usage=None,\n+                )\n+                result[\"response\"] = mock_response\n+\n+            except Exception as e:\n+                result[\"error\"] = e\n+\n+        t = threading.Thread(target=_call, daemon=True)\n+        t.start()\n+        while t.is_alive():\n+            t.join(timeout=0.3)\n+            if self._interrupt_requested:\n+                try:\n+                    if self.api_mode == \"anthropic_messages\":\n+                        self._anthropic_client.close()\n+                    else:\n+                        self.client.close()\n+                except Exception:\n+                    pass\n+                try:\n+                    if self.api_mode == \"anthropic_messages\":\n+                        from agent.anthropic_adapter import build_anthropic_client\n+                        self._anthropic_client = build_anthropic_client(self._anthropic_api_key, getattr(self, \"_anthropic_base_url\", None))\n                     else:\n                         self.client = OpenAI(**self._client_kwargs)\n                 except Exception:","@@ -2677,7 +2824,8 @@ def _try_activate_fallback(self) -> bool:\n                 from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token\n                 effective_key = fb_client.api_key or resolve_anthropic_token() or \"\"\n                 self._anthropic_api_key = [redacted:secret-assignment]\n-                self._anthropic_client = build_anthropic_client(effective_key)\n+                self._anthropic_base_url = getattr(fb_client, \"base_url\", None)\n+                self._anthropic_client = build_anthropic_client(effective_key, self._anthropic_base_url)\n                 self.client = None\n                 self._client_kwargs = {}\n             else:","@@ -3479,7 +3627,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n             if self._interrupt_requested:\n                 remaining_calls = assistant_message.tool_calls[i-1:]\n                 if remaining_calls:\n-                    print(f\"{self.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)\")\n+                    self._vprint(f\"{self.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)\", force=True)\n                 for skipped_tc in remaining_calls:\n                     skipped_name = skipped_tc.function.name\n                     skip_msg = {","@@ -3541,7 +3689,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n                 )\n                 tool_duration = time.time() - tool_start_time\n                 if self.quiet_mode:\n-                    print(f\"  {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}\")\n+                    self._vprint(f\"  {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}\")\n             elif function_name == \"session_search\":\n                 if not self._session_db:\n                     function_result = json.dumps({\"success\": False, \"error\": \"Session database not available.\"})","@@ -3556,7 +3704,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n                     )\n                 tool_duration = time.time() - tool_start_time\n                 if self.quiet_mode:\n-                    print(f\"  {_get_cute_tool_message_impl('session_search', function_args, tool_duration, result=function_result)}\")\n+                    self._vprint(f\"  {_get_cute_tool_message_impl('session_search', function_args, tool_duration, result=function_result)}\")\n             elif function_name == \"memory\":\n                 target = function_args.get(\"target\", \"memory\")\n                 from tools.memory_tool import memory_tool as _memory_tool","@@ -3572,7 +3720,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n                     self._honcho_save_user_observation(function_args.get(\"content\", \"\"))\n                 tool_duration = time.time() - tool_start_time\n                 if self.quiet_mode:\n-                    print(f\"  {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}\")\n+                    self._vprint(f\"  {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}\")\n             elif function_name == \"clarify\":\n                 from tools.clarify_tool import clarify_tool as _clarify_tool\n                 function_result = _clarify_tool(","@@ -3582,7 +3730,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n                 )\n                 tool_duration = time.time() - tool_start_time\n                 if self.quiet_mode:\n-                    print(f\"  {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}\")\n+                    self._vprint(f\"  {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}\")\n             elif function_name == \"delegate_task\":\n                 from tools.delegate_tool import delegate_task as _delegate_task\n                 tasks_arg = function_args.get(\"tasks\")","@@ -3615,8 +3763,8 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n                     if spinner:\n                         spinner.stop(cute_msg)\n                     elif self.quiet_mode:\n-                        print(f\"  {cute_msg}\")\n-            elif self.quiet_mode:\n+                        self._vprint(f\"  {cute_msg}\")\n+            elif self.quiet_mode and self._stream_callback is None:\n                 face = random.choice(KawaiiSpinner.KAWAII_WAITING)\n                 tool_emoji_map = {\n                     'web_search': '🔍', 'web_extract': '📄', 'web_crawl': '🕸️',","@@ -3703,7 +3851,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe\n \n             if self._interrupt_requested and i < len(assistant_message.tool_calls):\n                 remaining = len(assistant_message.tool_calls) - i\n-                print(f\"{self.log_prefix}⚡ Interrupt: skipping {remaining} remaining tool call(s)\")\n+                self._vprint(f\"{self.log_prefix}⚡ Interrupt: skipping {remaining} remaining tool call(s)\", force=True)\n                 for skipped_tc in assistant_message.tool_calls[i:]:\n                     skipped_name = skipped_tc.function.name\n                     skip_msg = {","@@ -3915,7 +4063,8 @@ def run_conversation(\n         user_message: str,\n         system_message: str = None,\n         conversation_history: List[Dict[str, Any]] = None,\n-        task_id: str = None\n+        task_id: str = None,\n+        stream_callback: Optional[callable] = None,\n     ) -> Dict[str, Any]:\n         \"\"\"\n         Run a complete conversation with tool calling until completion.","@@ -3925,6 +4074,9 @@ def run_conversation(\n             system_message (str): Custom system message (optional, overrides ephemeral_system_prompt if provided)\n             conversation_history (List[Dict]): Previous conversation messages (optional)\n             task_id (str): Unique identifier for this task to isolate VMs between concurrent tasks (optional, auto-generated if not provided)\n+            stream_callback: Optional callback invoked with each text delta during streaming.\n+                Used by the TTS pipeline to start audio generation before the full response.\n+                When None (default), API calls use the standard non-streaming path.\n \n         Returns:\n             Dict: Complete conversation result with final response and message history","@@ -3933,6 +4085,8 @@ def run_conversation(\n         # Installed once, transparent when streams are healthy, prevents crash on write.\n         _install_safe_stdio()\n \n+        # Store stream callback for _interruptible_api_call to pick up\n+        self._stream_callback = stream_callback\n         # Generate unique task_id if not provided to isolate VMs between concurrent tasks\n         effective_task_id = task_id or str(uuid.uuid4())\n         ","@@ -4239,11 +4393,11 @@ def run_conversation(\n             thinking_spinner = None\n             \n             if not self.quiet_mode:\n-                print(f\"\\n{self.log_prefix}🔄 Making API call #{api_call_count}/{self.max_iterations}...\")\n-                print(f\"{self.log_prefix}   📊 Request size: {len(api_messages)} messages, ~{approx_tokens:,} tokens (~{total_chars:,} chars)\")\n-                print(f\"{self.log_prefix}   🔧 Available tools: {len(self.tools) if self.tools else 0}\")\n-            else:\n-                # Animated thinking spinner in quiet mode\n+                self._vprint(f\"\\n{self.log_prefix}🔄 Making API call #{api_call_count}/{self.max_iterations}...\")\n+                self._vprint(f\"{self.log_prefix}   📊 Request size: {len(api_messages)} messages, ~{approx_tokens:,} tokens (~{total_chars:,} chars)\")\n+                self._vprint(f\"{self.log_prefix}   🔧 Available tools: {len(self.tools) if self.tools else 0}\")\n+            elif self._stream_callback is None:\n+                # Animated thinking spinner in quiet mode (skip during streaming TTS)\n                 face = random.choice(KawaiiSpinner.KAWAII_THINKING)\n                 verb = random.choice(KawaiiSpinner.THINKING_VERBS)\n                 if self.thinking_callback:","@@ -4283,7 +4437,33 @@ def run_conversation(\n                     if os.getenv(\"HERMES_DUMP_REQUESTS\", \"\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}:\n                         self._dump_api_request_debug(api_kwargs, reason=\"preflight\")\n \n-                    response = self._interruptible_api_call(api_kwargs)\n+                    cb = getattr(self, \"_stream_callback\", None)\n+                    if cb is not None and self.api_mode == \"chat_completions\":\n+                        response = self._streaming_api_call(api_kwargs, cb)\n+                    else:\n+                        response = self._interruptible_api_call(api_kwargs)\n+                        # Forward full response to TTS callback for non-streaming providers\n+                        # (e.g. Anthropic) so voice TTS still works via batch delivery.\n+                        if cb is not None and response:\n+                            try:\n+                                content = None\n+                                # Try choices first — _interruptible_api_call converts all\n+                                # providers (including Anthropic) to this format.\n+                                try:\n+                                    content = response.choices[0].message.content\n+                                except (AttributeError, IndexError):\n+                                    pass\n+                                # Fallback: Anthropic native content blocks\n+                                if not content and self.api_mode == \"anthropic_messages\":\n+                                    text_parts = [\n+                                        block.text for block in getattr(response, \"content\", [])\n+                                        if getattr(block, \"type\", None) == \"text\" and getattr(block, \"text\", None)\n+                                    ]\n+                                    content = \" \".join(text_parts) if text_parts else None\n+                                if content:\n+                                    cb(content)\n+                            except Exception:\n+                                pass\n                     \n                     api_duration = time.time() - api_start_time\n                     ","@@ -4296,7 +4476,7 @@ def run_conversation(\n                         self.thinking_callback(\"\")\n                     \n                     if not self.quiet_mode:\n-                        print(f\"{self.log_prefix}⏱️  API call completed in {api_duration:.2f}s\")\n+                        self._vprint(f\"{self.log_prefix}⏱️  API call completed in {api_duration:.2f}s\")\n                     \n                     if self.verbose_logging:\n                         # Log response with provider info if available","@@ -4373,17 +4553,17 @@ def run_conversation(\n                             if self.verbose_logging:\n                                 logging.debug(f\"Response attributes for invalid response: {resp_attrs}\")\n                         \n-                        print(f\"{self.log_prefix}⚠️  Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}\")\n-                        print(f\"{self.log_prefix}   🏢 Provider: {provider_name}\")\n-                        print(f\"{self.log_prefix}   📝 Provider message: {error_msg[:200]}\")\n-                        print(f\"{self.log_prefix}   ⏱️  Response time: {api_duration:.2f}s (fast response often indicates rate limiting)\")\n+                        self._vprint(f\"{self.log_prefix}⚠️  Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}\", force=True)\n+                        self._vprint(f\"{self.log_prefix}   🏢 Provider: {provider_name}\", force=True)\n+                        self._vprint(f\"{self.log_prefix}   📝 Provider message: {error_msg[:200]}\", force=True)\n+                        self._vprint(f\"{self.log_prefix}   ⏱️  Response time: {api_duration:.2f}s (fast response often indicates rate limiting)\", force=True)\n                         \n                         if retry_count >= max_retries:\n                             # Try fallback before giving up\n                             if self._try_activate_fallback():\n                                 retry_count = 0\n                                 continue\n-                            print(f\"{self.log_prefix}❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.\")\n+                            self._vprint(f\"{self.log_prefix}❌ Max retries ({max_retries}) exceeded for invalid responses. Giving up.\", force=True)\n                             logging.error(f\"{self.log_prefix}Invalid API response after {max_retries} retries.\")\n                             self._persist_session(messages, conversation_history)\n                             return {","@@ -4396,14 +4576,14 @@ def run_conversation(\n                         \n                         # Longer backoff for rate limiting (likely cause of None choices)\n                         wait_time = min(5 * (2 ** (retry_count - 1)), 120)  # 5s, 10s, 20s, 40s, 80s, 120s\n-                        print(f\"{self.log_prefix}⏳ Retrying in {wait_time}s (extended backoff for possible rate limit)...\")\n+                        self._vprint(f\"{self.log_prefix}⏳ Retrying in {wait_time}s (extended backoff for possible rate limit)...\", force=True)\n                         logging.warning(f\"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}\")\n                         \n                         # Sleep in small increments to stay responsive to interrupts\n                         sleep_end = time.time() + wait_time\n                         while time.time() < sleep_end:\n                             if self._interrupt_requested:\n-                                print(f\"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.\")\n+                                self._vprint(f\"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.\", force=True)\n                                 self._persist_session(messages, conversation_history)\n                                 self.clear_interrupt()\n                                 return {","@@ -4436,7 +4616,7 @@ def run_conversation(\n                         finish_reason = response.choices[0].finish_reason\n \n                     if finish_reason == \"length\":\n-                        print(f\"{self.log_prefix}⚠️  Response truncated (finish_reason='length') - model hit max output tokens\")\n+                        self._vprint(f\"{self.log_prefix}⚠️  Response truncated (finish_reason='length') - model hit max output tokens\", force=True)\n \n                         if self.api_mode == \"chat_completions\":\n                             assistant_message = response.choices[0].message","@@ -4448,7 +4628,7 @@ def run_conversation(\n                                     truncated_response_prefix += assistant_message.content\n \n                                 if length_continue_retries < 3:\n-                                    print(\n+                                    self._vprint(\n                                         f\"{self.log_prefix}↻ Requesting continuation \"\n                                         f\"({length_continue_retries}/3)...\"\n                                     )","@@ -4480,7 +4660,7 @@ def run_conversation(\n \n                         # If we have prior messages, roll back to last complete state\n                         if len(messages) > 1:\n-                            print(f\"{self.log_prefix}   ⏪ Rolling back to last complete assistant turn\")\n+                            self._vprint(f\"{self.log_prefix}   ⏪ Rolling back to last complete assistant turn\")\n                             rolled_back_messages = self._get_messages_up_to_last_assistant(messages)\n \n                             self._cleanup_task_resources(effective_task_id)","@@ -4496,7 +4676,7 @@ def run_conversation(\n                             }\n                         else:\n                             # First message was truncated - mark as failed\n-                            print(f\"{self.log_prefix}❌ First response truncated - cannot recover\")\n+                            self._vprint(f\"{self.log_prefix}❌ First response truncated - cannot recover\", force=True)\n                             self._persist_session(messages, conversation_history)\n                             return {\n                                 \"final_response\": None,","@@ -4556,7 +4736,7 @@ def run_conversation(\n                             prompt = usage_dict[\"prompt_tokens\"]\n                             hit_pct = (cached / prompt * 100) if prompt > 0 else 0\n                             if not self.quiet_mode:\n-                                print(f\"{self.log_prefix}   💾 Cache: {cached:,}/{prompt:,} tokens ({hit_pct:.0f}% hit, {written:,} written)\")\n+                                self._vprint(f\"{self.log_prefix}   💾 Cache: {cached:,}/{prompt:,} tokens ({hit_pct:.0f}% hit, {written:,} written)\")\n                     \n                     break  # Success, exit retry loop\n ","@@ -4567,7 +4747,7 @@ def run_conversation(\n                     if self.thinking_callback:\n                         self.thinking_callback(\"\")\n                     api_elapsed = time.time() - api_start_time\n-                    print(f\"{self.log_prefix}⚡ Interrupted during API call.\")\n+                    self._vprint(f\"{self.log_prefix}⚡ Interrupted during API call.\", force=True)\n                     self._persist_session(messages, conversation_history)\n                     interrupted = True\n                     final_response = f\"Operation interrupted: waiting for model response ({api_elapsed:.1f}s elapsed).\"","@@ -4590,7 +4770,7 @@ def run_conversation(\n                     ):\n                         codex_auth_retry_attempted = True\n                         if self._try_refresh_codex_client_credentials(force=True):\n-                            print(f\"{self.log_prefix}🔐 Codex auth refreshed after 401. Retrying request...\")\n+                            self._vprint(f\"{self.log_prefix}🔐 Codex auth refreshed after 401. Retrying request...\")\n                             continue\n                     if (\n                         self.api_mode == \"chat_completions\"","@@ -4614,7 +4794,7 @@ def run_conversation(\n                         new_token = [redacted:secret-assignment]\n                         if new_token and new_token != self._anthropic_api_key:\n                             [redacted:secret-assignment] = new_token\n-                            self._anthropic_client = build_anthropic_client(new_token)\n+                            self._anthropic_client = build_anthropic_client(new_token, getattr(self, \"_anthropic_base_url\", None))\n                             print(f\"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...\")\n                             continue\n                         # Credential refresh didn't help — show diagnostic info","@@ -4638,14 +4818,14 @@ def run_conversation(\n                     error_type = type(api_error).__name__\n                     error_msg = str(api_error).lower()\n                     \n-                    print(f\"{self.log_prefix}⚠️  API call failed (attempt {retry_count}/{max_retries}): {error_type}\")\n-                    print(f\"{self.log_prefix}   ⏱️  Time elapsed before failure: {elapsed_time:.2f}s\")\n-                    print(f\"{self.log_prefix}   📝 Error: {str(api_error)[:200]}\")\n-                    print(f\"{self.log_prefix}   📊 Request context: {len(api_messages)} messages, ~{approx_tokens:,} tokens, {len(self.tools) if self.tools else 0} tools\")\n+                    self._vprint(f\"{self.log_prefix}⚠️  API call failed (attempt {retry_count}/{max_retries}): {error_type}\", force=True)\n+                    self._vprint(f\"{self.log_prefix}   ⏱️  Time elapsed before failure: {elapsed_time:.2f}s\")\n+                    self._vprint(f\"{self.log_prefix}   📝 Error: {str(api_error)[:200]}\", force=True)\n+                    self._vprint(f\"{self.log_prefix}   📊 Request context: {len(api_messages)} messages, ~{approx_tokens:,} tokens, {len(self.tools) if self.tools else 0} tools\")\n                     \n                     # Check for interrupt before deciding to retry\n                     if self._interrupt_requested:\n-                        print(f\"{self.log_prefix}⚡ Interrupt detected during error handling, aborting retries.\")\n+                        self._vprint(f\"{self.log_prefix}⚡ Interrupt detected during error handling, aborting retries.\", force=True)\n                         self._persist_session(messages, conversation_history)\n                         self.clear_interrupt()\n                         return {","@@ -4670,7 +4850,7 @@ def run_conversation(\n                     if is_payload_too_large:\n                         compression_attempts += 1\n                         if compression_attempts > max_compression_attempts:\n-                            print(f\"{self.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached for payload-too-large error.\")\n+                            self._vprint(f\"{self.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached for payload-too-large error.\", force=True)\n                             logging.error(f\"{self.log_prefix}413 compression failed after {max_compression_attempts} attempts.\")\n                             self._persist_session(messages, conversation_history)\n                             return {","@@ -4680,7 +4860,7 @@ def run_conversation(\n                                 \"error\": f\"Request payload too large: max compression attempts ({max_compression_attempts}) reached.\",\n                                 \"partial\": True\n                             }\n-                        print(f\"{self.log_prefix}⚠️  Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...\")\n+                        self._vprint(f\"{self.log_prefix}⚠️  Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...\")\n \n                         original_len = len(messages)\n                         messages, active_system_prompt = self._compress_context(","@@ -4689,12 +4869,12 @@ def run_conversation(\n                         )\n \n                         if len(messages) < original_len:\n-                            print(f\"{self.log_prefix}   🗜️  Compressed {original_len} → {len(messages)} messages, retrying...\")\n+                            self._vprint(f\"{self.log_prefix}   🗜️  Compressed {original_len} → {len(messages)} messages, retrying...\")\n                             time.sleep(2)  # Brief pause between compression retries\n                             restart_with_compressed_messages = True\n                             break\n                         else:\n-                            print(f\"{self.log_prefix}❌ Payload too large and cannot compress further.\")\n+                            self._vprint(f\"{self.log_prefix}❌ Payload too large and cannot compress further.\", force=True)\n                             logging.error(f\"{self.log_prefix}413 payload too large. Cannot compress further.\")\n                             self._persist_session(messages, conversation_history)\n                             return {","@@ -4725,7 +4905,7 @@ def run_conversation(\n                         parsed_limit = parse_context_limit_from_error(error_msg)\n                         if parsed_limit and parsed_limit < old_ctx:\n                             new_ctx = parsed_limit\n-                            print(f\"{self.log_prefix}⚠️  Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})\")\n+                            self._vprint(f\"{self.log_prefix}⚠️  Context limit detected from API: {new_ctx:,} tokens (was {old_ctx:,})\", force=True)\n                         else:\n                             # Step down to the next probe tier\n                             new_ctx = get_next_probe_tier(old_ctx)","@@ -4734,13 +4914,13 @@ def run_conversation(\n                             compressor.context_length = new_ctx\n                             compressor.threshold_tokens = [redacted:secret-assignment] * compressor.threshold_percent)\n                             compressor._context_probed = True\n-                            print(f\"{self.log_prefix}⚠️  Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens\")\n+                            self._vprint(f\"{self.log_prefix}⚠️  Context length exceeded — stepping down: {old_ctx:,} → {new_ctx:,} tokens\", force=True)\n                         else:\n-                            print(f\"{self.log_prefix}⚠️  Context length exceeded at minimum tier — attempting compression...\")\n+                            self._vprint(f\"{self.log_prefix}⚠️  Context length exceeded at minimum tier — attempting compression...\", force=True)\n \n                         compression_attempts += 1\n                         if compression_attempts > max_compression_attempts:\n-                            print(f\"{self.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.\")\n+                            self._vprint(f\"{self.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.\", force=True)\n                             logging.error(f\"{self.log_prefix}Context compression failed after {max_compression_attempts} attempts.\")\n                             self._persist_session(messages, conversation_history)\n                             return {","@@ -4750,7 +4930,7 @@ def run_conversation(\n                                 \"error\": f\"Context length exceeded: max compression attempts ({max_compression_attempts}) reached.\",\n                                 \"partial\": True\n                             }\n-                        print(f\"{self.log_prefix}   🗜️  Context compression attempt {compression_attempts}/{max_compression_attempts}...\")\n+                        self._vprint(f\"{self.log_prefix}   🗜️  Context compression attempt {compression_attempts}/{max_compression_attempts}...\")\n \n                         original_len = len(messages)\n                         messages, active_system_prompt = self._compress_context(","@@ -4760,14 +4940,14 @@ def run_conversation(\n \n                         if len(messages) < original_len or new_ctx and new_ctx < old_ctx:\n                             if len(messages) < original_len:\n-                                print(f\"{self.log_prefix}   🗜️  Compressed {original_len} → {len(messages)} messages, retrying...\")\n+                                self._vprint(f\"{self.log_prefix}   🗜️  Compressed {original_len} → {len(messages)} messages, retrying...\")\n                             time.sleep(2)  # Brief pause between compression retries\n                             restart_with_compressed_messages = True\n                             break\n                         else:\n                             # Can't compress further and already at minimum tier\n-                            print(f\"{self.log_prefix}❌ Context length exceeded and cannot compress further.\")\n-                            print(f\"{self.log_prefix}   💡 The conversation has accumulated too much content.\")\n+                            self._vprint(f\"{self.log_prefix}❌ Context length exceeded and cannot compress further.\", force=True)\n+                            self._vprint(f\"{self.log_prefix}   💡 The conversation has accumulated too much content.\", force=True)\n                             logging.error(f\"{self.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.\")\n                             self._persist_session(messages, conversation_history)\n                             return {","@@ -4803,8 +4983,8 @@ def run_conversation(\n                         self._dump_api_request_debug(\n                             api_kwargs, reason=\"non_retryable_client_error\", error=api_error,\n                         )\n-                        print(f\"{self.log_prefix}❌ Non-retryable client error detected. Aborting immediately.\")\n-                        print(f\"{self.log_prefix}   💡 This type of error won't be fixed by retrying.\")\n+                        self._vprint(f\"{self.log_prefix}❌ Non-retryable client error detected. Aborting immediately.\", force=True)\n+                        self._vprint(f\"{self.log_prefix}   💡 This type of error won't be fixed by retrying.\", force=True)\n                         logging.error(f\"{self.log_prefix}Non-retryable client error: {api_error}\")\n                         self._persist_session(messages, conversation_history)\n                         return {","@@ -4821,7 +5001,7 @@ def run_conversation(\n                         if self._try_activate_fallback():\n                             retry_count = 0\n                             continue\n-                        print(f\"{self.log_prefix}❌ Max retries ({max_retries}) exceeded. Giving up.\")\n+                        self._vprint(f\"{self.log_prefix}❌ Max retries ({max_retries}) exceeded. Giving up.\", force=True)\n                         logging.error(f\"{self.log_prefix}API call failed after {max_retries} retries. Last error: {api_error}\")\n                         logging.error(f\"{self.log_prefix}Request details - Messages: {len(api_messages)}, Approx tokens: [redacted:secret-assignment]\")\n                         raise api_error","@@ -4829,15 +5009,15 @@ def run_conversation(\n                     wait_time = min(2 ** retry_count, 60)  # Exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s, 60s\n                     logging.warning(f\"API retry {retry_count}/{max_retries} after error: {api_error}\")\n                     if retry_count >= max_retries:\n-                        print(f\"{self.log_prefix}⚠️  API call failed after {retry_count} attempts: {str(api_error)[:100]}\")\n-                        print(f\"{self.log_prefix}⏳ Final retry in {wait_time}s...\")\n+                        self._vprint(f\"{self.log_prefix}⚠️  API call failed after {retry_count} attempts: {str(api_error)[:100]}\")\n+                        self._vprint(f\"{self.log_prefix}⏳ Final retry in {wait_time}s...\")\n                     \n                     # Sleep in small increments so we can respond to interrupts quickly\n                     # instead of blocking the entire wait_time in one sleep() call\n                     sleep_end = time.time() + wait_time\n                     while time.time() < sleep_end:\n                         if self._interrupt_requested:\n-                            print(f\"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.\")\n+                            self._vprint(f\"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.\", force=True)\n                             self._persist_session(messages, conversation_history)\n                             self.clear_interrupt()\n                             return {","@@ -4901,7 +5081,7 @@ def run_conversation(\n \n                 # Handle assistant response\n                 if assistant_message.content and not self.quiet_mode:\n-                    print(f\"{self.log_prefix}🤖 Assistant: {assistant_message.content[:100]}{'...' if len(assistant_message.content) > 100 else ''}\")\n+                    self._vprint(f\"{self.log_prefix}🤖 Assistant: {assistant_message.content[:100]}{'...' if len(assistant_message.content) > 100 else ''}\")\n \n                 # Notify progress callback of model's thinking (used by subagent\n                 # delegation to relay the child's reasoning to the parent display).","@@ -4928,15 +5108,15 @@ def run_conversation(\n                         self._incomplete_scratchpad_retries = 0\n                     self._incomplete_scratchpad_retries += 1\n                     \n-                    print(f\"{self.log_prefix}⚠️  Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)\")\n+                    self._vprint(f\"{self.log_prefix}⚠️  Incomplete <REASONING_SCRATCHPAD> detected (opened but never closed)\")\n                     \n                     if self._incomplete_scratchpad_retries <= 2:\n-                        print(f\"{self.log_prefix}🔄 Retrying API call ({self._incomplete_scratchpad_retries}/2)...\")\n+                        self._vprint(f\"{self.log_prefix}🔄 Retrying API call ({self._incomplete_scratchpad_retries}/2)...\")\n                         # Don't add the broken message, just retry\n                         continue\n                     else:\n                         # Max retries - discard this turn and save as partial\n-                        print(f\"{self.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.\")\n+                        self._vprint(f\"{self.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.\", force=True)\n                         self._incomplete_scratchpad_retries = 0\n                         \n                         rolled_back_messages = self._get_messages_up_to_last_assistant(messages)","@@ -4979,7 +5159,7 @@ def run_conversation(\n \n                     if self._codex_incomplete_retries < 3:\n                         if not self.quiet_mode:\n-                            print(f\"{self.log_prefix}↻ Codex response incomplete; continuing turn ({self._codex_incomplete_retries}/3)\")\n+                            self._vprint(f\"{self.log_prefix}↻ Codex response incomplete; continuing turn ({self._codex_incomplete_retries}/3)\")\n                         self._session_messages = messages\n                         self._save_session_log(messages)\n                         continue","@@ -5000,7 +5180,7 @@ def run_conversation(\n                 # Check for tool calls\n                 if assistant_message.tool_calls:\n                     if not self.quiet_mode:\n-                        print(f\"{self.log_prefix}🔧 Processing {len(assistant_message.tool_calls)} tool call(s)...\")\n+                        self._vprint(f\"{self.log_prefix}🔧 Processing {len(assistant_message.tool_calls)} tool call(s)...\")\n                     \n                     if self.verbose_logging:\n                         for tc in assistant_message.tool_calls:","@@ -5019,11 +5199,30 @@ def run_conversation(\n                         if tc.function.name not in self.valid_tool_names\n                     ]\n                     if invalid_tool_calls:\n+                        # Track retries for invalid tool calls\n+                        if not hasattr(self, '_invalid_tool_retries'):\n+                            self._invalid_tool_retries = 0\n+                        self._invalid_tool_retries += 1\n+\n                         # Return helpful error to model — model can self-correct next turn\n                         available = \", \".join(sorted(self.valid_tool_names))\n                         invalid_name = invalid_tool_calls[0]\n                         invalid_preview = invalid_name[:80] + \"...\" if len(invalid_name) > 80 else invalid_name\n-                        print(f\"{self.log_prefix}⚠️  Unknown tool '{invalid_preview}' — sending error to model for self-correction\")\n+                        self._vprint(f\"{self.log_prefix}⚠️  Unknown tool '{invalid_preview}' — sending error to model for self-correction ({self._invalid_tool_retries}/3)\")\n+\n+                        if self._invalid_tool_retries >= 3:\n+                            self._vprint(f\"{self.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.\", force=True)\n+                            self._invalid_tool_retries = 0\n+                            self._persist_session(messages, conversation_history)\n+                            return {\n+                                \"final_response\": None,\n+                                \"messages\": messages,\n+                                \"api_calls\": api_call_count,\n+                                \"completed\": False,\n+                                \"partial\": True,\n+                                \"error\": f\"Model generated invalid tool call: {invalid_preview}\"\n+                            }\n+\n                         assistant_msg = self._build_assistant_message(assistant_message, finish_reason)\n                         messages.append(assistant_msg)\n                         for tc in assistant_message.tool_calls:","@@ -5060,15 +5259,15 @@ def run_conversation(\n                         self._invalid_json_retries += 1\n                         \n                         tool_name, error_msg = invalid_json_args[0]\n-                        print(f\"{self.log_prefix}⚠️  Invalid JSON in tool call arguments for '{tool_name}': {error_msg}\")\n+                        self._vprint(f\"{self.log_prefix}⚠️  Invalid JSON in tool call arguments for '{tool_name}': {error_msg}\")\n                         \n                         if self._invalid_json_retries < 3:\n-                            print(f\"{self.log_prefix}🔄 Retrying API call ({self._invalid_json_retries}/3)...\")\n+                            self._vprint(f\"{self.log_prefix}🔄 Retrying API call ({self._invalid_json_retries}/3)...\")\n                             # Don't add anything to messages, just retry the API call\n                             continue\n                         else:\n                             # Instead of returning partial, inject a helpful message and let model recover\n-                            print(f\"{self.log_prefix}⚠️  Injecting recovery message for invalid JSON...\")\n+                            self._vprint(f\"{self.log_prefix}⚠️  Injecting recovery message for invalid JSON...\")\n                             self._invalid_json_retries = 0  # Reset for next attempt\n                             \n                             # Add a user message explaining the issue","@@ -5098,7 +5297,7 @@ def run_conversation(\n                         if self.quiet_mode:\n                             clean = self._strip_think_blocks(turn_content).strip()\n                             if clean:\n-                                print(f\"  ┊ 💬 {clean}\")\n+                                self._vprint(f\"  ┊ 💬 {clean}\")\n                     \n                     messages.append(assistant_msg)\n                     ","@@ -5174,19 +5373,19 @@ def run_conversation(\n                         self._empty_content_retries += 1\n                         \n                         reasoning_text = self._extract_reasoning(assistant_message)\n-                        print(f\"{self.log_prefix}⚠️  Response only contains think block with no content after it\")\n+                        self._vprint(f\"{self.log_prefix}⚠️  Response only contains think block with no content after it\")\n                         if reasoning_text:\n                             reasoning_preview = reasoning_text[:500] + \"...\" if len(reasoning_text) > 500 else reasoning_text\n-                            print(f\"{self.log_prefix}   Reasoning: {reasoning_preview}\")\n+                            self._vprint(f\"{self.log_prefix}   Reasoning: {reasoning_preview}\")\n                         else:\n                             content_preview = final_response[:80] + \"...\" if len(final_response) > 80 else final_response\n-                            print(f\"{self.log_prefix}   Content: '{content_preview}'\")\n+                            self._vprint(f\"{self.log_prefix}   Content: '{content_preview}'\")\n                         \n                         if self._empty_content_retries < 3:\n-                            print(f\"{self.log_prefix}🔄 Retrying API call ({self._empty_content_retries}/3)...\")\n+                            self._vprint(f\"{self.log_prefix}🔄 Retrying API call ({self._empty_content_retries}/3)...\")\n                             continue\n                         else:\n-                            print(f\"{self.log_prefix}❌ Max retries (3) for empty content exceeded.\")\n+                            self._vprint(f\"{self.log_prefix}❌ Max retries (3) for empty content exceeded.\", force=True)\n                             self._empty_content_retries = 0\n                             \n                             # If a prior tool_calls turn had real content, salvage it:","@@ -5377,20 +5576,24 @@ def run_conversation(\n         \n         # Clear interrupt state after handling\n         self.clear_interrupt()\n-        \n+\n+        # Clear stream callback so it doesn't leak into future calls\n+        self._stream_callback = None\n+\n         return result\n-    \n-    def chat(self, message: str) -> str:\n+\n+    def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:\n         \"\"\"\n         Simple chat interface that returns just the final response.\n-        \n+\n         Args:\n             message (str): User message\n-            \n+            stream_callback: Optional callback invoked with each text delta during streaming.\n+\n         Returns:\n             str: Final assistant response\n         \"\"\"\n-        result = self.run_conversation(message)\n+        result = self.run_conversation(message, stream_callback=stream_callback)\n         return result[\"final_response\"]\n \n "]},{"file":"tests/fakes/fake_ha_server.py","hunks":["@@ -275,12 +275,25 @@ async def _handle_call_service(self, request: web.Request) -> web.Response:\n         affected = []\n         entity_id = body.get(\"entity_id\")\n         if entity_id:\n-            new_state = \"on\" if service == \"turn_on\" else \"off\"\n             for s in ENTITY_STATES:\n                 if s[\"entity_id\"] == entity_id:\n+                    if service == \"turn_on\":\n+                        s[\"state\"] = \"on\"\n+                    elif service == \"turn_off\":\n+                        s[\"state\"] = \"off\"\n+                    elif service == \"set_temperature\" and \"temperature\" in body:\n+                        s[\"attributes\"][\"temperature\"] = body[\"temperature\"]\n+                        # Keep current state or set to heat if off\n+                        if s[\"state\"] == \"off\":\n+                            s[\"state\"] = \"heat\"\n+                        # Simulate temperature sensor approaching the target\n+                        for ts in ENTITY_STATES:\n+                            if ts[\"entity_id\"] == \"sensor.temperature\":\n+                                ts[\"state\"] = str(body[\"temperature\"] - 0.5)\n+                                break\n                     affected.append({\n                         \"entity_id\": entity_id,\n-                        \"state\": new_state,\n+                        \"state\": s[\"state\"],\n                         \"attributes\": s.get(\"attributes\", {}),\n                     })\n                     break"]},{"file":"tests/gateway/test_background_command.py","hunks":["@@ -32,6 +32,7 @@ def _make_runner():\n     from gateway.run import GatewayRunner\n     runner = object.__new__(GatewayRunner)\n     runner.adapters = {}\n+    runner._voice_mode = {}\n     runner._session_db = None\n     runner._reasoning_config = None\n     runner._provider_routing = {}"]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -29,6 +29,8 @@ def _ensure_discord_mock():\n     discord_mod.Embed = MagicMock\n     discord_mod.app_commands = SimpleNamespace(\n         describe=lambda **kwargs: (lambda fn: fn),\n+        choices=lambda **kwargs: (lambda fn: fn),\n+        Choice=lambda **kwargs: SimpleNamespace(**kwargs),\n     )\n \n     ext_mod = MagicMock()"]},{"file":"tests/gateway/test_discord_opus.py","hunks":["@@ -0,0 +1,44 @@\n+\"\"\"Tests for Discord Opus codec loading — must use ctypes.util.find_library.\"\"\"\n+\n+import inspect\n+\n+\n+class TestOpusFindLibrary:\n+    \"\"\"Opus loading must try ctypes.util.find_library first, with platform fallback.\"\"\"\n+\n+    def test_uses_find_library_first(self):\n+        \"\"\"find_library must be the primary lookup strategy.\"\"\"\n+        from gateway.platforms.discord import DiscordAdapter\n+        source = inspect.getsource(DiscordAdapter.connect)\n+        assert \"find_library\" in source, \\\n+            \"Opus loading must use ctypes.util.find_library\"\n+\n+    def test_homebrew_fallback_is_conditional(self):\n+        \"\"\"Homebrew paths must only be tried when find_library returns None.\"\"\"\n+        from gateway.platforms.discord import DiscordAdapter\n+        source = inspect.getsource(DiscordAdapter.connect)\n+        # Homebrew fallback must exist\n+        assert \"/opt/homebrew\" in source or \"homebrew\" in source, \\\n+            \"Opus loading should have macOS Homebrew fallback\"\n+        # find_library must appear BEFORE any Homebrew path\n+        fl_idx = source.index(\"find_library\")\n+        hb_idx = source.index(\"/opt/homebrew\")\n+        assert fl_idx < hb_idx, \\\n+            \"find_library must be tried before Homebrew fallback paths\"\n+        # Fallback must be guarded by platform check\n+        assert \"sys.platform\" in source or \"darwin\" in source, \\\n+            \"Homebrew fallback must be guarded by macOS platform check\"\n+\n+    def test_opus_decode_error_logged(self):\n+        \"\"\"Opus decode failure must log the error, not silently return.\"\"\"\n+        from gateway.platforms.discord import VoiceReceiver\n+        source = inspect.getsource(VoiceReceiver._on_packet)\n+        assert \"logger\" in source, \\\n+            \"_on_packet must log Opus decode errors\"\n+        # Must not have bare `except Exception:\\n            return`\n+        lines = source.split(\"\\n\")\n+        for i, line in enumerate(lines):\n+            if \"except Exception\" in line and i + 1 < len(lines):\n+                next_line = lines[i + 1].strip()\n+                assert next_line != \"return\", \\\n+                    f\"_on_packet has bare 'except Exception: return' at line {i+1}\""]},{"file":"tests/gateway/test_discord_slash_commands.py","hunks":["@@ -21,6 +21,8 @@ def _ensure_discord_mock():\n     discord_mod.Interaction = object\n     discord_mod.app_commands = SimpleNamespace(\n         describe=lambda **kwargs: (lambda fn: fn),\n+        choices=lambda **kwargs: (lambda fn: fn),\n+        Choice=lambda **kwargs: SimpleNamespace(**kwargs),\n     )\n \n     ext_mod = MagicMock()"]},{"file":"tests/gateway/test_resume_command.py","hunks":["@@ -36,6 +36,7 @@ def _make_runner(session_db=None, current_session_id=\"current_session_001\",\n     from gateway.run import GatewayRunner\n     runner = object.__new__(GatewayRunner)\n     runner.adapters = {}\n+    runner._voice_mode = {}\n     runner._session_db = session_db\n     runner._running_agents = {}\n "]},{"file":"tests/gateway/test_run_progress_topics.py","hunks":["@@ -77,6 +77,7 @@ def _make_runner(adapter):\n \n     runner = object.__new__(GatewayRunner)\n     runner.adapters = {Platform.TELEGRAM: adapter}\n+    runner._voice_mode = {}\n     runner._prefill_messages = []\n     runner._ephemeral_system_prompt = \"\"\n     runner._reasoning_config = None"]},{"file":"tests/gateway/test_session_hygiene.py","hunks":["@@ -266,6 +266,7 @@ def _compress_context(self, messages, *_args, **_kwargs):\n         platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")}\n     )\n     runner.adapters = {Platform.TELEGRAM: adapter}\n+    runner._voice_mode = {}\n     runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)\n     runner.session_store = MagicMock()\n     runner.session_store.get_or_create_session.return_value = SessionEntry("]},{"file":"tests/gateway/test_title_command.py","hunks":["@@ -31,6 +31,7 @@ def _make_runner(session_db=None):\n     from gateway.run import GatewayRunner\n     runner = object.__new__(GatewayRunner)\n     runner.adapters = {}\n+    runner._voice_mode = {}\n     runner._session_db = session_db\n \n     # Mock session_store that returns a session entry with a known session_id"]},{"file":"tests/gateway/test_update_command.py","hunks":["@@ -33,6 +33,7 @@ def _make_runner():\n     from gateway.run import GatewayRunner\n     runner = object.__new__(GatewayRunner)\n     runner.adapters = {}\n+    runner._voice_mode = {}\n     return runner\n \n "]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -0,0 +1,1965 @@\n+\"\"\"Tests for the /voice command and auto voice reply in the gateway.\"\"\"\n+\n+import json\n+import os\n+import queue\n+import threading\n+import time\n+import pytest\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+from gateway.platforms.base import MessageEvent, MessageType, SessionSource\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _make_event(text: str = \"\", message_type=MessageType.TEXT, chat_id=\"123\") -> MessageEvent:\n+    source = SessionSource(\n+        chat_id=chat_id,\n+        user_id=\"user1\",\n+        platform=MagicMock(),\n+    )\n+    source.platform.value = \"telegram\"\n+    source.thread_id = None\n+    event = MessageEvent(text=text, message_type=message_type, source=source)\n+    event.message_id = \"msg42\"\n+    return event\n+\n+\n+def _make_runner(tmp_path):\n+    \"\"\"Create a bare GatewayRunner without calling __init__.\"\"\"\n+    from gateway.run import GatewayRunner\n+    runner = object.__new__(GatewayRunner)\n+    runner.adapters = {}\n+    runner._voice_mode = {}\n+    runner._VOICE_MODE_PATH = tmp_path / \"gateway_voice_mode.json\"\n+    runner._session_db = None\n+    runner.session_store = MagicMock()\n+    runner._is_user_authorized = lambda source: True\n+    return runner\n+\n+\n+# =====================================================================\n+# /voice command handler\n+# =====================================================================\n+\n+class TestHandleVoiceCommand:\n+\n+    @pytest.fixture\n+    def runner(self, tmp_path):\n+        return _make_runner(tmp_path)\n+\n+    @pytest.mark.asyncio\n+    async def test_voice_on(self, runner):\n+        event = _make_event(\"/voice on\")\n+        result = await runner._handle_voice_command(event)\n+        assert \"enabled\" in result.lower()\n+        assert runner._voice_mode[\"123\"] == \"voice_only\"\n+\n+    @pytest.mark.asyncio\n+    async def test_voice_off(self, runner):\n+        runner._voice_mode[\"123\"] = \"voice_only\"\n+        event = _make_event(\"/voice off\")\n+        result = await runner._handle_voice_command(event)\n+        assert \"disabled\" in result.lower()\n+        assert \"123\" not in runner._voice_mode\n+\n+    @pytest.mark.asyncio\n+    async def test_voice_tts(self, runner):\n+        event = _make_event(\"/voice tts\")\n+        result = await runner._handle_voice_command(event)\n+        assert \"tts\" in result.lower()\n+        assert runner._voice_mode[\"123\"] == \"all\"\n+\n+    @pytest.mark.asyncio\n+    async def test_voice_status_off(self, runner):\n+        event = _make_event(\"/voice status\")\n+        result = await runner._handle_voice_command(event)\n+        assert \"off\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_voice_status_on(self, runner):\n+        runner._voice_mode[\"123\"] = \"voice_only\"\n+        event = _make_event(\"/voice status\")\n+        result = await runner._handle_voice_command(event)\n+        assert \"voice reply\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_toggle_off_to_on(self, runner):\n+        event = _make_event(\"/voice\")\n+        result = await runner._handle_voice_command(event)\n+        assert \"enabled\" in result.lower()\n+        assert runner._voice_mode[\"123\"] == \"voice_only\"\n+\n+    @pytest.mark.asyncio\n+    async def test_toggle_on_to_off(self, runner):\n+        runner._voice_mode[\"123\"] = \"voice_only\"\n+        event = _make_event(\"/voice\")\n+        result = await runner._handle_voice_command(event)\n+        assert \"disabled\" in result.lower()\n+        assert \"123\" not in runner._voice_mode\n+\n+    @pytest.mark.asyncio\n+    async def test_persistence_saved(self, runner):\n+        event = _make_event(\"/voice on\")\n+        await runner._handle_voice_command(event)\n+        assert runner._VOICE_MODE_PATH.exists()\n+        data = json.loads(runner._VOICE_MODE_PATH.read_text())\n+        assert data[\"123\"] == \"voice_only\"\n+\n+    @pytest.mark.asyncio\n+    async def test_persistence_loaded(self, runner):\n+        runner._VOICE_MODE_PATH.write_text(json.dumps({\"456\": \"all\"}))\n+        loaded = runner._load_voice_modes()\n+        assert loaded == {\"456\": \"all\"}\n+\n+    @pytest.mark.asyncio\n+    async def test_per_chat_isolation(self, runner):\n+        e1 = _make_event(\"/voice on\", chat_id=\"aaa\")\n+        e2 = _make_event(\"/voice tts\", chat_id=\"bbb\")\n+        await runner._handle_voice_command(e1)\n+        await runner._handle_voice_command(e2)\n+        assert runner._voice_mode[\"aaa\"] == \"voice_only\"\n+        assert runner._voice_mode[\"bbb\"] == \"all\"\n+\n+\n+# =====================================================================\n+# Auto voice reply decision logic\n+# =====================================================================\n+\n+class TestAutoVoiceReply:\n+    \"\"\"Test the real _should_send_voice_reply method on GatewayRunner.\n+\n+    The gateway has two TTS paths:\n+      1. base adapter auto-TTS: fires for voice input in _process_message_background\n+      2. gateway _send_voice_reply: fires based on voice_mode setting\n+\n+    To prevent double audio, _send_voice_reply is skipped when voice input\n+    already triggered base adapter auto-TTS (skip_double = is_voice_input).\n+    Exception: Discord voice channel — both auto-TTS and Discord play_tts\n+    override skip, so the runner must handle it via play_in_voice_channel.\n+    \"\"\"\n+\n+    @pytest.fixture\n+    def runner(self, tmp_path):\n+        return _make_runner(tmp_path)\n+\n+    def _call(self, runner, voice_mode, message_type, agent_messages=None,\n+              response=\"Hello!\", in_voice_channel=False):\n+        \"\"\"Call real _should_send_voice_reply on a GatewayRunner instance.\"\"\"\n+        chat_id = \"123\"\n+        if voice_mode != \"off\":\n+            runner._voice_mode[chat_id] = voice_mode\n+        else:\n+            runner._voice_mode.pop(chat_id, None)\n+\n+        event = _make_event(message_type=message_type)\n+\n+        if in_voice_channel:\n+            mock_adapter = MagicMock()\n+            mock_adapter.is_in_voice_channel = MagicMock(return_value=True)\n+            event.raw_message = SimpleNamespace(guild_id=111, guild=None)\n+            runner.adapters[event.source.platform] = mock_adapter\n+\n+        return runner._should_send_voice_reply(\n+            event, response, agent_messages or []\n+        )\n+\n+    # -- Full platform x input x mode matrix --------------------------------\n+    #\n+    # Legend:\n+    #   base = base adapter auto-TTS (play_tts)\n+    #   runner = gateway _send_voice_reply\n+    #\n+    # | Platform      | Input | Mode       | base | runner | Expected     |\n+    # |---------------|-------|------------|------|--------|--------------|\n+    # | Telegram      | voice | off        | yes  | skip   | 1 audio      |\n+    # | Telegram      | voice | voice_only | yes  | skip*  | 1 audio      |\n+    # | Telegram      | voice | all        | yes  | skip*  | 1 audio      |\n+    # | Telegram      | text  | off        | skip | skip   | 0 audio      |\n+    # | Telegram      | text  | voice_only | skip | skip   | 0 audio      |\n+    # | Telegram      | text  | all        | skip | yes    | 1 audio      |\n+    # | Discord text  | voice | all        | yes  | skip*  | 1 audio      |\n+    # | Discord text  | text  | all        | skip | yes    | 1 audio      |\n+    # | Discord VC    | voice | all        | skip†| yes    | 1 audio (VC) |\n+    # | Web UI        | voice | off        | yes  | skip   | 1 audio      |\n+    # | Web UI        | voice | all        | yes  | skip*  | 1 audio      |\n+    # | Web UI        | text  | all        | skip | yes    | 1 audio      |\n+    # | Slack         | voice | all        | yes  | skip*  | 1 audio      |\n+    # | Slack         | text  | all        | skip | yes    | 1 audio      |\n+    #\n+    # * skip_double: voice input → base already handles\n+    # † Discord play_tts override skips when in VC\n+\n+    # -- Telegram/Slack/Web: voice input, base handles ---------------------\n+\n+    def test_voice_input_voice_only_skipped(self, runner):\n+        \"\"\"voice_only + voice input: base auto-TTS handles it, runner skips.\"\"\"\n+        assert self._call(runner, \"voice_only\", MessageType.VOICE) is False\n+\n+    def test_voice_input_all_mode_skipped(self, runner):\n+        \"\"\"all + voice input: base auto-TTS handles it, runner skips.\"\"\"\n+        assert self._call(runner, \"all\", MessageType.VOICE) is False\n+\n+    # -- Text input: only runner handles -----------------------------------\n+\n+    def test_text_input_all_mode_runner_fires(self, runner):\n+        \"\"\"all + text input: only runner fires (base auto-TTS only for voice).\"\"\"\n+        assert self._call(runner, \"all\", MessageType.TEXT) is True\n+\n+    def test_text_input_voice_only_no_reply(self, runner):\n+        \"\"\"voice_only + text input: neither fires.\"\"\"\n+        assert self._call(runner, \"voice_only\", MessageType.TEXT) is False\n+\n+    # -- Mode off: nothing fires -------------------------------------------\n+\n+    def test_off_mode_voice(self, runner):\n+        assert self._call(runner, \"off\", MessageType.VOICE) is False\n+\n+    def test_off_mode_text(self, runner):\n+        assert self._call(runner, \"off\", MessageType.TEXT) is False\n+\n+    # -- Discord VC exception: runner must handle --------------------------\n+\n+    def test_discord_vc_voice_input_runner_fires(self, runner):\n+        \"\"\"Discord VC + voice input: base play_tts skips (VC override),\n+        so runner must handle via play_in_voice_channel.\"\"\"\n+        assert self._call(runner, \"all\", MessageType.VOICE, in_voice_channel=True) is True\n+\n+    def test_discord_vc_voice_only_runner_fires(self, runner):\n+        \"\"\"Discord VC + voice_only + voice: runner must handle.\"\"\"\n+        assert self._call(runner, \"voice_only\", MessageType.VOICE, in_voice_channel=True) is True\n+\n+    # -- Edge cases --------------------------------------------------------\n+\n+    def test_error_response_skipped(self, runner):\n+        assert self._call(runner, \"all\", MessageType.TEXT, response=\"Error: boom\") is False\n+\n+    def test_empty_response_skipped(self, runner):\n+        assert self._call(runner, \"all\", MessageType.TEXT, response=\"\") is False\n+\n+    def test_dedup_skips_when_agent_called_tts(self, runner):\n+        messages = [{\n+            \"role\": \"assistant\",\n+            \"tool_calls\": [{\n+                \"id\": \"call_1\",\n+                \"type\": \"function\",\n+                \"function\": {\"name\": \"text_to_speech\", \"arguments\": \"{}\"},\n+            }],\n+        }]\n+        assert self._call(runner, \"all\", MessageType.TEXT, agent_messages=messages) is False\n+\n+    def test_no_dedup_for_other_tools(self, runner):\n+        messages = [{\n+            \"role\": \"assistant\",\n+            \"tool_calls\": [{\n+                \"id\": \"call_1\",\n+                \"type\": \"function\",\n+                \"function\": {\"name\": \"web_search\", \"arguments\": \"{}\"},\n+            }],\n+        }]\n+        assert self._call(runner, \"all\", MessageType.TEXT, agent_messages=messages) is True\n+\n+\n+# =====================================================================\n+# _send_voice_reply\n+# =====================================================================\n+\n+class TestSendVoiceReply:\n+\n+    @pytest.fixture\n+    def runner(self, tmp_path):\n+        return _make_runner(tmp_path)\n+\n+    @pytest.mark.asyncio\n+    async def test_calls_tts_and_send_voice(self, runner):\n+        mock_adapter = AsyncMock()\n+        mock_adapter.send_voice = AsyncMock()\n+        event = _make_event()\n+        runner.adapters[event.source.platform] = mock_adapter\n+\n+        tts_result = json.dumps({\"success\": True, \"file_path\": \"/tmp/test.ogg\"})\n+\n+        with patch(\"tools.tts_tool.text_to_speech_tool\", return_value=tts_result), \\\n+             patch(\"tools.tts_tool._strip_markdown_for_tts\", side_effect=lambda t: t), \\\n+             patch(\"os.path.isfile\", return_value=True), \\\n+             patch(\"os.unlink\"), \\\n+             patch(\"os.makedirs\"):\n+            await runner._send_voice_reply(event, \"Hello world\")\n+\n+        mock_adapter.send_voice.assert_called_once()\n+        call_args = mock_adapter.send_voice.call_args\n+        assert call_args.kwargs.get(\"chat_id\") == \"123\"\n+\n+    @pytest.mark.asyncio\n+    async def test_empty_text_after_strip_skips(self, runner):\n+        event = _make_event()\n+\n+        with patch(\"tools.tts_tool.text_to_speech_tool\") as mock_tts, \\\n+             patch(\"tools.tts_tool._strip_markdown_for_tts\", return_value=\"\"):\n+            await runner._send_voice_reply(event, \"```code only```\")\n+\n+        mock_tts.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_tts_failure_no_crash(self, runner):\n+        event = _make_event()\n+        mock_adapter = AsyncMock()\n+        runner.adapters[event.source.platform] = mock_adapter\n+        tts_result = json.dumps({\"success\": False, \"error\": \"API error\"})\n+\n+        with patch(\"tools.tts_tool.text_to_speech_tool\", return_value=tts_result), \\\n+             patch(\"tools.tts_tool._strip_markdown_for_tts\", side_effect=lambda t: t), \\\n+             patch(\"os.path.isfile\", return_value=False), \\\n+             patch(\"os.makedirs\"):\n+            await runner._send_voice_reply(event, \"Hello\")\n+\n+        mock_adapter.send_voice.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_exception_caught(self, runner):\n+        event = _make_event()\n+        with patch(\"tools.tts_tool.text_to_speech_tool\", side_effect=RuntimeError(\"boom\")), \\\n+             patch(\"tools.tts_tool._strip_markdown_for_tts\", side_effect=lambda t: t), \\\n+             patch(\"os.makedirs\"):\n+            # Should not raise\n+            await runner._send_voice_reply(event, \"Hello\")\n+\n+\n+# =====================================================================\n+# Discord play_tts skip when in voice channel\n+# =====================================================================\n+\n+class TestDiscordPlayTtsSkip:\n+    \"\"\"Discord adapter skips play_tts when bot is in a voice channel.\"\"\"\n+\n+    def _make_discord_adapter(self):\n+        from gateway.platforms.discord import DiscordAdapter\n+        from gateway.config import Platform, PlatformConfig\n+        config = PlatformConfig(enabled=True, extra={})\n+        config.token = \"[redacted:secret-assignment]\"\n+        adapter = object.__new__(DiscordAdapter)\n+        adapter.platform = Platform.DISCORD\n+        adapter.config = config\n+        adapter._voice_clients = {}\n+        adapter._voice_text_channels = {}\n+        adapter._voice_timeout_tasks = {}\n+        adapter._voice_receivers = {}\n+        adapter._voice_listen_tasks = {}\n+        adapter._client = None\n+        adapter._broadcast = AsyncMock()\n+        return adapter\n+\n+    @pytest.mark.asyncio\n+    async def test_play_tts_skipped_when_in_vc(self):\n+        adapter = self._make_discord_adapter()\n+        # Simulate bot in voice channel for guild 111, text channel 123\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_text_channels[111] = 123\n+\n+        result = await adapter.play_tts(chat_id=\"123\", audio_path=\"/tmp/test.ogg\")\n+        assert result.success is True\n+        # send_voice should NOT have been called (no client, would fail)\n+\n+    @pytest.mark.asyncio\n+    async def test_play_tts_not_skipped_when_not_in_vc(self):\n+        adapter = self._make_discord_adapter()\n+        # No voice connection — play_tts falls through to send_voice\n+        result = await adapter.play_tts(chat_id=\"123\", audio_path=\"/tmp/test.ogg\")\n+        # send_voice will fail (no client), but play_tts should NOT return early\n+        assert result.success is False\n+\n+    @pytest.mark.asyncio\n+    async def test_play_tts_not_skipped_for_different_channel(self):\n+        adapter = self._make_discord_adapter()\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_text_channels[111] = 999  # different channel\n+\n+        result = await adapter.play_tts(chat_id=\"123\", audio_path=\"/tmp/test.ogg\")\n+        # Different channel — should NOT skip, falls through to send_voice (fails)\n+        assert result.success is False\n+\n+\n+# =====================================================================\n+# Web play_tts sends play_audio (not voice bubble)\n+# =====================================================================\n+\n+# =====================================================================\n+# Help text + known commands\n+# =====================================================================\n+\n+class TestVoiceInHelp:\n+\n+    def test_voice_in_help_output(self):\n+        from gateway.run import GatewayRunner\n+        import inspect\n+        source = inspect.getsource(GatewayRunner._handle_help_command)\n+        assert \"/voice\" in source\n+\n+    def test_voice_is_known_command(self):\n+        from gateway.run import GatewayRunner\n+        import inspect\n+        source = inspect.getsource(GatewayRunner._handle_message)\n+        assert '\"voice\"' in source\n+\n+\n+# =====================================================================\n+# VoiceReceiver unit tests\n+# =====================================================================\n+\n+class TestVoiceReceiver:\n+    \"\"\"Test VoiceReceiver silence detection, SSRC mapping, and lifecycle.\"\"\"\n+\n+    def _make_receiver(self):\n+        from gateway.platforms.discord import VoiceReceiver\n+        mock_vc = MagicMock()\n+        mock_vc._connection.secret_key = [0] * 32\n+        mock_vc._connection.dave_session = None\n+        mock_vc._connection.ssrc = 9999\n+        mock_vc._connection.add_socket_listener = MagicMock()\n+        mock_vc._connection.remove_socket_listener = MagicMock()\n+        mock_vc._connection.hook = None\n+        receiver = VoiceReceiver(mock_vc)\n+        return receiver\n+\n+    def test_initial_state(self):\n+        receiver = self._make_receiver()\n+        assert receiver._running is False\n+        assert receiver._paused is False\n+        assert len(receiver._buffers) == 0\n+        assert len(receiver._ssrc_to_user) == 0\n+\n+    def test_start_sets_running(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        assert receiver._running is True\n+\n+    def test_stop_clears_state(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver.map_ssrc(100, 42)\n+        receiver._buffers[100] = bytearray(b\"\\x00\" * 1000)\n+        receiver._last_packet_time[100] = time.monotonic()\n+        receiver.stop()\n+        assert receiver._running is False\n+        assert len(receiver._buffers) == 0\n+        assert len(receiver._ssrc_to_user) == 0\n+        assert len(receiver._last_packet_time) == 0\n+\n+    def test_map_ssrc(self):\n+        receiver = self._make_receiver()\n+        receiver.map_ssrc(100, 42)\n+        assert receiver._ssrc_to_user[100] == 42\n+\n+    def test_map_ssrc_overwrites(self):\n+        receiver = self._make_receiver()\n+        receiver.map_ssrc(100, 42)\n+        receiver.map_ssrc(100, 99)\n+        assert receiver._ssrc_to_user[100] == 99\n+\n+    def test_pause_resume(self):\n+        receiver = self._make_receiver()\n+        assert receiver._paused is False\n+        receiver.pause()\n+        assert receiver._paused is True\n+        receiver.resume()\n+        assert receiver._paused is False\n+\n+    def test_check_silence_empty(self):\n+        receiver = self._make_receiver()\n+        assert receiver.check_silence() == []\n+\n+    def test_check_silence_returns_completed_utterance(self):\n+        receiver = self._make_receiver()\n+        receiver.map_ssrc(100, 42)\n+        # 48kHz, stereo, 16-bit = 192000 bytes/sec\n+        # MIN_SPEECH_DURATION = 0.5s → need 96000 bytes\n+        pcm_data = bytearray(b\"\\x00\" * 96000)\n+        receiver._buffers[100] = pcm_data\n+        # Set last_packet_time far enough in the past to exceed SILENCE_THRESHOLD\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        user_id, data = completed[0]\n+        assert user_id == 42\n+        assert len(data) == 96000\n+        # Buffer should be cleared after extraction\n+        assert len(receiver._buffers[100]) == 0\n+\n+    def test_check_silence_ignores_short_buffer(self):\n+        receiver = self._make_receiver()\n+        receiver.map_ssrc(100, 42)\n+        # Too short to meet MIN_SPEECH_DURATION\n+        receiver._buffers[100] = bytearray(b\"\\x00\" * 100)\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_check_silence_ignores_recent_audio(self):\n+        receiver = self._make_receiver()\n+        receiver.map_ssrc(100, 42)\n+        receiver._buffers[100] = bytearray(b\"\\x00\" * 96000)\n+        receiver._last_packet_time[100] = time.monotonic()  # just now\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_check_silence_unknown_user_discarded(self):\n+        receiver = self._make_receiver()\n+        # No SSRC mapping — user_id will be 0\n+        receiver._buffers[100] = bytearray(b\"\\x00\" * 96000)\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_stale_buffer_discarded(self):\n+        receiver = self._make_receiver()\n+        # Buffer with no user mapping and very old timestamp\n+        receiver._buffers[200] = bytearray(b\"\\x00\" * 100)\n+        receiver._last_packet_time[200] = time.monotonic() - 10.0\n+        receiver.check_silence()\n+        # Stale buffer (> 2x threshold) should be discarded\n+        assert 200 not in receiver._buffers\n+\n+    def test_on_packet_skips_when_not_running(self):\n+        receiver = self._make_receiver()\n+        # Not started — _running is False\n+        receiver._on_packet(b\"\\x00\" * 100)\n+        assert len(receiver._buffers) == 0\n+\n+    def test_on_packet_skips_when_paused(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver.pause()\n+        receiver._on_packet(b\"\\x00\" * 100)\n+        # Paused — should not process\n+        assert len(receiver._buffers) == 0\n+\n+    def test_on_packet_skips_short_data(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver._on_packet(b\"\\x00\" * 10)\n+        assert len(receiver._buffers) == 0\n+\n+    def test_on_packet_skips_non_rtp(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        # Valid length but wrong RTP version\n+        data = bytearray(b\"\\x00\" * 20)\n+        data[0] = 0x00  # version 0, not 2\n+        receiver._on_packet(bytes(data))\n+        assert len(receiver._buffers) == 0\n+\n+\n+# =====================================================================\n+# Gateway voice channel commands (join / leave / input)\n+# =====================================================================\n+\n+class TestVoiceChannelCommands:\n+    \"\"\"Test _handle_voice_channel_join, _handle_voice_channel_leave,\n+    _handle_voice_channel_input on the GatewayRunner.\"\"\"\n+\n+    @pytest.fixture\n+    def runner(self, tmp_path):\n+        return _make_runner(tmp_path)\n+\n+    def _make_discord_event(self, text=\"/voice channel\", chat_id=\"123\",\n+                            guild_id=111, user_id=\"user1\"):\n+        \"\"\"Create event with raw_message carrying guild info.\"\"\"\n+        source = SessionSource(\n+            chat_id=chat_id,\n+            user_id=user_id,\n+            platform=MagicMock(),\n+        )\n+        source.platform.value = \"discord\"\n+        source.thread_id = None\n+        event = MessageEvent(text=text, message_type=MessageType.TEXT, source=source)\n+        event.message_id = \"msg42\"\n+        event.raw_message = SimpleNamespace(guild_id=guild_id, guild=None)\n+        return event\n+\n+    # -- _handle_voice_channel_join --\n+\n+    @pytest.mark.asyncio\n+    async def test_join_unsupported_platform(self, runner):\n+        \"\"\"Platform without join_voice_channel returns unsupported message.\"\"\"\n+        mock_adapter = AsyncMock(spec=[])  # no join_voice_channel\n+        event = self._make_discord_event()\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"not supported\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_join_no_guild_id(self, runner):\n+        \"\"\"DM context (no guild_id) returns error.\"\"\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock()\n+        event = self._make_discord_event()\n+        event.raw_message = None  # no guild info\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"discord server\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_join_user_not_in_vc(self, runner):\n+        \"\"\"User not in any voice channel.\"\"\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock()\n+        mock_adapter.get_user_voice_channel = AsyncMock(return_value=None)\n+        event = self._make_discord_event()\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"need to be in a voice channel\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_join_success(self, runner):\n+        \"\"\"Successful join sets voice_mode and returns confirmation.\"\"\"\n+        mock_channel = MagicMock()\n+        mock_channel.name = \"General\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock(return_value=True)\n+        mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)\n+        mock_adapter._voice_text_channels = {}\n+        mock_adapter._voice_input_callback = None\n+        event = self._make_discord_event()\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"joined\" in result.lower()\n+        assert \"General\" in result\n+        assert runner._voice_mode[\"123\"] == \"all\"\n+\n+    @pytest.mark.asyncio\n+    async def test_join_failure(self, runner):\n+        \"\"\"Failed join returns permissions error.\"\"\"\n+        mock_channel = MagicMock()\n+        mock_channel.name = \"General\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock(return_value=False)\n+        mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)\n+        event = self._make_discord_event()\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"failed\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_join_exception(self, runner):\n+        \"\"\"Exception during join is caught and reported.\"\"\"\n+        mock_channel = MagicMock()\n+        mock_channel.name = \"General\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock(side_effect=RuntimeError(\"No permission\"))\n+        mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)\n+        event = self._make_discord_event()\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"failed\" in result.lower()\n+\n+    # -- _handle_voice_channel_leave --\n+\n+    @pytest.mark.asyncio\n+    async def test_leave_not_in_vc(self, runner):\n+        \"\"\"Leave when not in VC returns appropriate message.\"\"\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.is_in_voice_channel = MagicMock(return_value=False)\n+        event = self._make_discord_event(\"/voice leave\")\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_leave(event)\n+        assert \"not in\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_leave_no_guild(self, runner):\n+        \"\"\"Leave from DM returns not in voice channel.\"\"\"\n+        mock_adapter = AsyncMock()\n+        event = self._make_discord_event(\"/voice leave\")\n+        event.raw_message = None\n+        runner.adapters[event.source.platform] = mock_adapter\n+        result = await runner._handle_voice_channel_leave(event)\n+        assert \"not in\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_leave_success(self, runner):\n+        \"\"\"Successful leave disconnects and clears voice mode.\"\"\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.is_in_voice_channel = MagicMock(return_value=True)\n+        mock_adapter.leave_voice_channel = AsyncMock()\n+        event = self._make_discord_event(\"/voice leave\")\n+        runner.adapters[event.source.platform] = mock_adapter\n+        runner._voice_mode[\"123\"] = \"all\"\n+        result = await runner._handle_voice_channel_leave(event)\n+        assert \"left\" in result.lower()\n+        assert \"123\" not in runner._voice_mode\n+        mock_adapter.leave_voice_channel.assert_called_once_with(111)\n+\n+    # -- _handle_voice_channel_input --\n+\n+    @pytest.mark.asyncio\n+    async def test_input_no_adapter(self, runner):\n+        \"\"\"No Discord adapter — early return, no crash.\"\"\"\n+        from gateway.config import Platform\n+        # No adapters set\n+        await runner._handle_voice_channel_input(111, 42, \"Hello\")\n+\n+    @pytest.mark.asyncio\n+    async def test_input_no_text_channel(self, runner):\n+        \"\"\"No text channel mapped for guild — early return.\"\"\"\n+        from gateway.config import Platform\n+        mock_adapter = AsyncMock()\n+        mock_adapter._voice_text_channels = {}\n+        mock_adapter._client = MagicMock()\n+        runner.adapters[Platform.DISCORD] = mock_adapter\n+        await runner._handle_voice_channel_input(111, 42, \"Hello\")\n+\n+    @pytest.mark.asyncio\n+    async def test_input_creates_event_and_dispatches(self, runner):\n+        \"\"\"Voice input creates synthetic event and calls handle_message.\"\"\"\n+        from gateway.config import Platform\n+        mock_adapter = AsyncMock()\n+        mock_adapter._voice_text_channels = {111: 123}\n+        mock_channel = AsyncMock()\n+        mock_adapter._client = MagicMock()\n+        mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)\n+        mock_adapter.handle_message = AsyncMock()\n+        runner.adapters[Platform.DISCORD] = mock_adapter\n+        await runner._handle_voice_channel_input(111, 42, \"Hello from VC\")\n+        mock_adapter.handle_message.assert_called_once()\n+        event = mock_adapter.handle_message.call_args[0][0]\n+        assert event.text == \"Hello from VC\"\n+        assert event.message_type == MessageType.VOICE\n+        assert event.source.chat_id == \"123\"\n+        assert event.source.chat_type == \"channel\"\n+\n+    @pytest.mark.asyncio\n+    async def test_input_posts_transcript_in_text_channel(self, runner):\n+        \"\"\"Voice input sends transcript message to text channel.\"\"\"\n+        from gateway.config import Platform\n+        mock_adapter = AsyncMock()\n+        mock_adapter._voice_text_channels = {111: 123}\n+        mock_channel = AsyncMock()\n+        mock_adapter._client = MagicMock()\n+        mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)\n+        mock_adapter.handle_message = AsyncMock()\n+        runner.adapters[Platform.DISCORD] = mock_adapter\n+        await runner._handle_voice_channel_input(111, 42, \"Test transcript\")\n+        mock_channel.send.assert_called_once()\n+        msg = mock_channel.send.call_args[0][0]\n+        assert \"Test transcript\" in msg\n+        assert \"42\" in msg  # user_id in mention\n+\n+    # -- _get_guild_id --\n+\n+    def test_get_guild_id_from_guild(self, runner):\n+        event = _make_event()\n+        mock_guild = MagicMock()\n+        mock_guild.id = 555\n+        event.raw_message = SimpleNamespace(guild_id=None, guild=mock_guild)\n+        result = runner._get_guild_id(event)\n+        assert result == 555\n+\n+    def test_get_guild_id_from_interaction(self, runner):\n+        event = _make_event()\n+        event.raw_message = SimpleNamespace(guild_id=777, guild=None)\n+        result = runner._get_guild_id(event)\n+        assert result == 777\n+\n+    def test_get_guild_id_none(self, runner):\n+        event = _make_event()\n+        event.raw_message = None\n+        result = runner._get_guild_id(event)\n+        assert result is None\n+\n+    def test_get_guild_id_dm(self, runner):\n+        event = _make_event()\n+        event.raw_message = SimpleNamespace(guild_id=None, guild=None)\n+        result = runner._get_guild_id(event)\n+        assert result is None\n+\n+\n+# =====================================================================\n+# Discord adapter voice channel methods\n+# =====================================================================\n+\n+class TestDiscordVoiceChannelMethods:\n+    \"\"\"Test DiscordAdapter voice channel methods (join, leave, play, etc.).\"\"\"\n+\n+    def _make_adapter(self):\n+        from gateway.platforms.discord import DiscordAdapter\n+        from gateway.config import Platform, PlatformConfig\n+        config = PlatformConfig(enabled=True, extra={})\n+        config.token = \"[redacted:secret-assignment]\"\n+        adapter = object.__new__(DiscordAdapter)\n+        adapter.platform = Platform.DISCORD\n+        adapter.config = config\n+        adapter._client = MagicMock()\n+        adapter._voice_clients = {}\n+        adapter._voice_text_channels = {}\n+        adapter._voice_timeout_tasks = {}\n+        adapter._voice_receivers = {}\n+        adapter._voice_listen_tasks = {}\n+        adapter._voice_input_callback = None\n+        adapter._allowed_user_ids = set()\n+        adapter._running = True\n+        return adapter\n+\n+    def test_is_in_voice_channel_true(self):\n+        adapter = self._make_adapter()\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        adapter._voice_clients[111] = mock_vc\n+        assert adapter.is_in_voice_channel(111) is True\n+\n+    def test_is_in_voice_channel_false_no_client(self):\n+        adapter = self._make_adapter()\n+        assert adapter.is_in_voice_channel(111) is False\n+\n+    def test_is_in_voice_channel_false_disconnected(self):\n+        adapter = self._make_adapter()\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = False\n+        adapter._voice_clients[111] = mock_vc\n+        assert adapter.is_in_voice_channel(111) is False\n+\n+    @pytest.mark.asyncio\n+    async def test_leave_voice_channel_cleans_up(self):\n+        adapter = self._make_adapter()\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        mock_vc.disconnect = AsyncMock()\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_text_channels[111] = 123\n+\n+        mock_receiver = MagicMock()\n+        adapter._voice_receivers[111] = mock_receiver\n+\n+        mock_task = MagicMock()\n+        adapter._voice_listen_tasks[111] = mock_task\n+\n+        mock_timeout = MagicMock()\n+        adapter._voice_timeout_tasks[111] = mock_timeout\n+\n+        await adapter.leave_voice_channel(111)\n+\n+        mock_receiver.stop.assert_called_once()\n+        mock_task.cancel.assert_called_once()\n+        mock_vc.disconnect.assert_called_once()\n+        mock_timeout.cancel.assert_called_once()\n+        assert 111 not in adapter._voice_clients\n+        assert 111 not in adapter._voice_text_channels\n+        assert 111 not in adapter._voice_receivers\n+\n+    @pytest.mark.asyncio\n+    async def test_leave_voice_channel_no_connection(self):\n+        \"\"\"Leave when not connected — no crash.\"\"\"\n+        adapter = self._make_adapter()\n+        await adapter.leave_voice_channel(111)  # should not raise\n+\n+    @pytest.mark.asyncio\n+    async def test_get_user_voice_channel_no_client(self):\n+        adapter = self._make_adapter()\n+        adapter._client = None\n+        result = await adapter.get_user_voice_channel(111, \"42\")\n+        assert result is None\n+\n+    @pytest.mark.asyncio\n+    async def test_get_user_voice_channel_no_guild(self):\n+        adapter = self._make_adapter()\n+        adapter._client.get_guild = MagicMock(return_value=None)\n+        result = await adapter.get_user_voice_channel(111, \"42\")\n+        assert result is None\n+\n+    @pytest.mark.asyncio\n+    async def test_get_user_voice_channel_user_not_in_vc(self):\n+        adapter = self._make_adapter()\n+        mock_guild = MagicMock()\n+        mock_member = MagicMock()\n+        mock_member.voice = None\n+        mock_guild.get_member = MagicMock(return_value=mock_member)\n+        adapter._client.get_guild = MagicMock(return_value=mock_guild)\n+        result = await adapter.get_user_voice_channel(111, \"42\")\n+        assert result is None\n+\n+    @pytest.mark.asyncio\n+    async def test_get_user_voice_channel_success(self):\n+        adapter = self._make_adapter()\n+        mock_vc = MagicMock()\n+        mock_guild = MagicMock()\n+        mock_member = MagicMock()\n+        mock_member.voice = MagicMock()\n+        mock_member.voice.channel = mock_vc\n+        mock_guild.get_member = MagicMock(return_value=mock_member)\n+        adapter._client.get_guild = MagicMock(return_value=mock_guild)\n+        result = await adapter.get_user_voice_channel(111, \"42\")\n+        assert result is mock_vc\n+\n+    @pytest.mark.asyncio\n+    async def test_play_in_voice_channel_not_connected(self):\n+        adapter = self._make_adapter()\n+        result = await adapter.play_in_voice_channel(111, \"/tmp/test.ogg\")\n+        assert result is False\n+\n+    def test_is_allowed_user_empty_list(self):\n+        adapter = self._make_adapter()\n+        assert adapter._is_allowed_user(\"42\") is True\n+\n+    def test_is_allowed_user_in_list(self):\n+        adapter = self._make_adapter()\n+        adapter._allowed_user_ids = {\"42\", \"99\"}\n+        assert adapter._is_allowed_user(\"42\") is True\n+\n+    def test_is_allowed_user_not_in_list(self):\n+        adapter = self._make_adapter()\n+        adapter._allowed_user_ids = {\"99\"}\n+        assert adapter._is_allowed_user(\"42\") is False\n+\n+    @pytest.mark.asyncio\n+    async def test_process_voice_input_success(self):\n+        \"\"\"Successful voice input: PCM->WAV->STT->callback.\"\"\"\n+        adapter = self._make_adapter()\n+        callback = AsyncMock()\n+        adapter._voice_input_callback = callback\n+        adapter._allowed_user_ids = set()\n+\n+        pcm_data = b\"\\x00\" * 96000\n+\n+        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\"), \\\n+             patch(\"tools.transcription_tools.transcribe_audio\",\n+                   return_value={\"success\": True, \"transcript\": \"Hello\"}), \\\n+             patch(\"tools.voice_mode.is_whisper_hallucination\", return_value=False):\n+            await adapter._process_voice_input(111, 42, pcm_data)\n+\n+        callback.assert_called_once_with(guild_id=111, user_id=42, transcript=\"Hello\")\n+\n+    @pytest.mark.asyncio\n+    async def test_process_voice_input_hallucination_filtered(self):\n+        \"\"\"Whisper hallucination is filtered out.\"\"\"\n+        adapter = self._make_adapter()\n+        callback = AsyncMock()\n+        adapter._voice_input_callback = callback\n+\n+        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\"), \\\n+             patch(\"tools.transcription_tools.transcribe_audio\",\n+                   return_value={\"success\": True, \"transcript\": \"Thank you.\"}), \\\n+             patch(\"tools.voice_mode.is_whisper_hallucination\", return_value=True):\n+            await adapter._process_voice_input(111, 42, b\"\\x00\" * 96000)\n+\n+        callback.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_process_voice_input_stt_failure(self):\n+        \"\"\"STT failure — callback not called.\"\"\"\n+        adapter = self._make_adapter()\n+        callback = AsyncMock()\n+        adapter._voice_input_callback = callback\n+\n+        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\"), \\\n+             patch(\"tools.transcription_tools.transcribe_audio\",\n+                   return_value={\"success\": False, \"error\": \"API error\"}):\n+            await adapter._process_voice_input(111, 42, b\"\\x00\" * 96000)\n+\n+        callback.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_process_voice_input_exception_caught(self):\n+        \"\"\"Exception during processing is caught, no crash.\"\"\"\n+        adapter = self._make_adapter()\n+        adapter._voice_input_callback = AsyncMock()\n+\n+        with patch(\"gateway.platforms.discord.VoiceReceiver.pcm_to_wav\",\n+                   side_effect=RuntimeError(\"ffmpeg not found\")):\n+            await adapter._process_voice_input(111, 42, b\"\\x00\" * 96000)\n+        # Should not raise\n+\n+\n+# =====================================================================\n+# stream_tts_to_speaker functional tests\n+# =====================================================================\n+\n+# =====================================================================\n+# VoiceReceiver thread-safety (lock coverage)\n+# =====================================================================\n+\n+class TestVoiceReceiverThreadSafety:\n+    \"\"\"Verify that VoiceReceiver buffer access is protected by lock.\"\"\"\n+\n+    def _make_receiver(self):\n+        from gateway.platforms.discord import VoiceReceiver\n+        mock_vc = MagicMock()\n+        mock_vc._connection.secret_key = [0] * 32\n+        mock_vc._connection.dave_session = None\n+        mock_vc._connection.ssrc = 9999\n+        mock_vc._connection.add_socket_listener = MagicMock()\n+        mock_vc._connection.remove_socket_listener = MagicMock()\n+        mock_vc._connection.hook = None\n+        return VoiceReceiver(mock_vc)\n+\n+    def test_check_silence_holds_lock(self):\n+        \"\"\"check_silence must hold lock while iterating buffers.\"\"\"\n+        import ast, inspect, textwrap\n+        from gateway.platforms.discord import VoiceReceiver\n+        source = textwrap.dedent(inspect.getsource(VoiceReceiver.check_silence))\n+        tree = ast.parse(source)\n+        # Find 'with self._lock:' that contains buffer iteration\n+        found_lock_with_for = False\n+        for node in ast.walk(tree):\n+            if isinstance(node, ast.With):\n+                # Check if lock context and contains for loop\n+                has_lock = any(\n+                    \"lock\" in ast.dump(item) for item in node.items\n+                )\n+                has_for = any(isinstance(n, ast.For) for n in ast.walk(node))\n+                if has_lock and has_for:\n+                    found_lock_with_for = True\n+        assert found_lock_with_for, (\n+            \"check_silence must hold self._lock while iterating buffers\"\n+        )\n+\n+    def test_on_packet_buffer_write_holds_lock(self):\n+        \"\"\"_on_packet must hold lock when writing to buffers.\"\"\"\n+        import ast, inspect, textwrap\n+        from gateway.platforms.discord import VoiceReceiver\n+        source = textwrap.dedent(inspect.getsource(VoiceReceiver._on_packet))\n+        tree = ast.parse(source)\n+        # Find 'with self._lock:' that contains buffer extend\n+        found_lock_with_extend = False\n+        for node in ast.walk(tree):\n+            if isinstance(node, ast.With):\n+                src_fragment = ast.dump(node)\n+                if \"lock\" in src_fragment and \"extend\" in src_fragment:\n+                    found_lock_with_extend = True\n+        assert found_lock_with_extend, (\n+            \"_on_packet must hold self._lock when extending buffers\"\n+        )\n+\n+    def test_concurrent_buffer_access_safe(self):\n+        \"\"\"Simulate concurrent buffer writes and reads under lock.\"\"\"\n+        import threading\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        errors = []\n+\n+        def writer():\n+            for _ in range(1000):\n+                with receiver._lock:\n+                    receiver._buffers[100].extend(b\"\\x00\" * 192)\n+                    receiver._last_packet_time[100] = time.monotonic()\n+\n+        def reader():\n+            for _ in range(1000):\n+                try:\n+                    receiver.check_silence()\n+                except Exception as e:\n+                    errors.append(str(e))\n+\n+        t1 = threading.Thread(target=writer)\n+        t2 = threading.Thread(target=reader)\n+        t1.start()\n+        t2.start()\n+        t1.join()\n+        t2.join()\n+        assert len(errors) == 0, f\"Race detected: {errors[:3]}\"\n+\n+\n+# =====================================================================\n+# Callback wiring order (join)\n+# =====================================================================\n+\n+class TestCallbackWiringOrder:\n+    \"\"\"Verify callback is wired BEFORE join, not after.\"\"\"\n+\n+    def test_callback_set_before_join(self):\n+        \"\"\"_handle_voice_channel_join wires callback before calling join.\"\"\"\n+        import ast, inspect\n+        from gateway.run import GatewayRunner\n+        source = inspect.getsource(GatewayRunner._handle_voice_channel_join)\n+        lines = source.split(\"\\n\")\n+        callback_line = None\n+        join_line = None\n+        for i, line in enumerate(lines):\n+            if \"_voice_input_callback\" in line and \"=\" in line and \"None\" not in line:\n+                if callback_line is None:\n+                    callback_line = i\n+            if \"join_voice_channel\" in line and \"await\" in line:\n+                join_line = i\n+        assert callback_line is not None, \"callback wiring not found\"\n+        assert join_line is not None, \"join_voice_channel call not found\"\n+        assert callback_line < join_line, (\n+            f\"callback must be wired (line {callback_line}) BEFORE \"\n+            f\"join_voice_channel (line {join_line})\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_join_failure_clears_callback(self, tmp_path):\n+        \"\"\"If join fails with exception, callback is cleaned up.\"\"\"\n+        runner = _make_runner(tmp_path)\n+\n+        mock_channel = MagicMock()\n+        mock_channel.name = \"General\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock(\n+            side_effect=RuntimeError(\"No permission\")\n+        )\n+        mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)\n+        mock_adapter._voice_input_callback = None\n+\n+        event = _make_event(\"/voice channel\")\n+        event.raw_message = SimpleNamespace(guild_id=111, guild=None)\n+        runner.adapters[event.source.platform] = mock_adapter\n+\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"failed\" in result.lower()\n+        assert mock_adapter._voice_input_callback is None\n+\n+    @pytest.mark.asyncio\n+    async def test_join_returns_false_clears_callback(self, tmp_path):\n+        \"\"\"If join returns False, callback is cleaned up.\"\"\"\n+        runner = _make_runner(tmp_path)\n+\n+        mock_channel = MagicMock()\n+        mock_channel.name = \"General\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock(return_value=False)\n+        mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)\n+        mock_adapter._voice_input_callback = None\n+\n+        event = _make_event(\"/voice channel\")\n+        event.raw_message = SimpleNamespace(guild_id=111, guild=None)\n+        runner.adapters[event.source.platform] = mock_adapter\n+\n+        result = await runner._handle_voice_channel_join(event)\n+        assert \"failed\" in result.lower()\n+        assert mock_adapter._voice_input_callback is None\n+\n+\n+# =====================================================================\n+# Leave exception handling\n+# =====================================================================\n+\n+class TestLeaveExceptionHandling:\n+    \"\"\"Verify state is cleaned up even when leave_voice_channel raises.\"\"\"\n+\n+    @pytest.fixture\n+    def runner(self, tmp_path):\n+        return _make_runner(tmp_path)\n+\n+    @pytest.mark.asyncio\n+    async def test_leave_exception_still_cleans_state(self, runner):\n+        \"\"\"If leave_voice_channel raises, voice_mode is still cleaned up.\"\"\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.is_in_voice_channel = MagicMock(return_value=True)\n+        mock_adapter.leave_voice_channel = AsyncMock(\n+            side_effect=RuntimeError(\"Connection reset\")\n+        )\n+        mock_adapter._voice_input_callback = MagicMock()\n+\n+        event = _make_event(\"/voice leave\")\n+        event.raw_message = SimpleNamespace(guild_id=111, guild=None)\n+        runner.adapters[event.source.platform] = mock_adapter\n+        runner._voice_mode[\"123\"] = \"all\"\n+\n+        result = await runner._handle_voice_channel_leave(event)\n+        assert \"left\" in result.lower()\n+        assert \"123\" not in runner._voice_mode\n+        assert mock_adapter._voice_input_callback is None\n+\n+    @pytest.mark.asyncio\n+    async def test_leave_clears_callback(self, runner):\n+        \"\"\"Normal leave also clears the voice input callback.\"\"\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.is_in_voice_channel = MagicMock(return_value=True)\n+        mock_adapter.leave_voice_channel = AsyncMock()\n+        mock_adapter._voice_input_callback = MagicMock()\n+\n+        event = _make_event(\"/voice leave\")\n+        event.raw_message = SimpleNamespace(guild_id=111, guild=None)\n+        runner.adapters[event.source.platform] = mock_adapter\n+        runner._voice_mode[\"123\"] = \"all\"\n+\n+        await runner._handle_voice_channel_leave(event)\n+        assert mock_adapter._voice_input_callback is None\n+\n+\n+# =====================================================================\n+# Base adapter empty text guard\n+# =====================================================================\n+\n+class TestAutoTtsEmptyTextGuard:\n+    \"\"\"Verify base adapter skips TTS when text is empty after markdown strip.\"\"\"\n+\n+    def test_empty_after_strip_skips_tts(self):\n+        \"\"\"Markdown-only content should not trigger TTS call.\"\"\"\n+        import re\n+        text_content = \"****\"\n+        speech_text = re.sub(r'[*_`#\\[\\]()]', '', text_content)[:4000].strip()\n+        assert not speech_text, \"Expected empty after stripping markdown chars\"\n+\n+    def test_code_block_response_skips_tts(self):\n+        \"\"\"Code-only response results in empty speech text.\"\"\"\n+        import re\n+        text_content = \"```python\\nprint(1)\\n```\"\n+        speech_text = re.sub(r'[*_`#\\[\\]()]', '', text_content)[:4000].strip()\n+        # Note: base.py regex only strips individual chars, not full code blocks\n+        # So code blocks are partially stripped but may leave content\n+        # The real fix is in base.py — empty check after strip\n+\n+    def test_base_empty_check_in_source(self):\n+        \"\"\"base.py must check speech_text is non-empty before calling TTS.\"\"\"\n+        import ast, inspect\n+        from gateway.platforms.base import BasePlatformAdapter\n+        source = inspect.getsource(BasePlatformAdapter._process_message_background)\n+        assert \"if not speech_text\" in source or \"not speech_text\" in source, (\n+            \"base.py must guard against empty speech_text before TTS call\"\n+        )\n+\n+\n+class TestStreamTtsToSpeaker:\n+    \"\"\"Functional tests for the streaming TTS pipeline.\"\"\"\n+\n+    def test_none_sentinel_flushes_buffer(self):\n+        \"\"\"None sentinel causes remaining buffer to be spoken.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        def display(text):\n+            spoken.append(text)\n+\n+        text_q.put(\"Hello world.\")\n+        text_q.put(None)\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt, display_callback=display)\n+        assert done_evt.is_set()\n+        assert any(\"Hello\" in s for s in spoken)\n+\n+    def test_stop_event_aborts_early(self):\n+        \"\"\"Setting stop_event causes early exit.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        stop_evt.set()\n+        text_q.put(\"Should not be spoken.\")\n+        text_q.put(None)\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt, display_callback=lambda t: spoken.append(t))\n+        assert done_evt.is_set()\n+        assert len(spoken) == 0\n+\n+    def test_done_event_set_on_exception(self):\n+        \"\"\"tts_done_event is set even when an exception occurs.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+\n+        # Put a non-string that will cause concatenation to fail\n+        text_q.put(12345)\n+        text_q.put(None)\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt)\n+        assert done_evt.is_set()\n+\n+    def test_think_blocks_stripped(self):\n+        \"\"\"<think>...</think> content is not spoken.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        text_q.put(\"<think>internal reasoning</think>\")\n+        text_q.put(\"Visible response. \")\n+        text_q.put(None)\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt, display_callback=lambda t: spoken.append(t))\n+        assert done_evt.is_set()\n+        joined = \" \".join(spoken)\n+        assert \"internal reasoning\" not in joined\n+        assert \"Visible\" in joined\n+\n+    def test_sentence_splitting(self):\n+        \"\"\"Sentences are split at boundaries and spoken individually.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        # Two sentences long enough to exceed min_sentence_len (20)\n+        text_q.put(\"This is the first sentence. \")\n+        text_q.put(\"This is the second sentence. \")\n+        text_q.put(None)\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt, display_callback=lambda t: spoken.append(t))\n+        assert done_evt.is_set()\n+        assert len(spoken) >= 2\n+\n+    def test_markdown_stripped_in_speech(self):\n+        \"\"\"Markdown formatting is removed before display/speech.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        text_q.put(\"**Bold text** and `code`. \")\n+        text_q.put(None)\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt, display_callback=lambda t: spoken.append(t))\n+        assert done_evt.is_set()\n+        # Display callback gets raw text (before markdown stripping)\n+        # But the actual TTS audio would be stripped — we verify pipeline doesn't crash\n+\n+    def test_duplicate_sentences_deduped(self):\n+        \"\"\"Repeated sentences are spoken only once.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        # Same sentence twice, each long enough\n+        text_q.put(\"This is a repeated sentence. \")\n+        text_q.put(\"This is a repeated sentence. \")\n+        text_q.put(None)\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt, display_callback=lambda t: spoken.append(t))\n+        assert done_evt.is_set()\n+        # First occurrence is spoken, second is deduped\n+        assert len(spoken) == 1\n+\n+    def test_no_api_key_display_only(self):\n+        \"\"\"Without ELEVENLABS_API_KEY, display callback still works.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        text_q.put(\"Display only text. \")\n+        text_q.put(None)\n+\n+        with patch.dict(os.environ, {\"ELEVENLABS_API_KEY\": \"\"}):\n+            stream_tts_to_speaker(text_q, stop_evt, done_evt,\n+                                  display_callback=lambda t: spoken.append(t))\n+        assert done_evt.is_set()\n+        assert len(spoken) >= 1\n+\n+    def test_long_buffer_flushed_on_timeout(self):\n+        \"\"\"Buffer longer than long_flush_len is flushed on queue timeout.\"\"\"\n+        from tools.tts_tool import stream_tts_to_speaker\n+        text_q = queue.Queue()\n+        stop_evt = threading.Event()\n+        done_evt = threading.Event()\n+        spoken = []\n+\n+        # Put a long text without sentence boundary, then None after a delay\n+        long_text = \"a\" * 150  # > long_flush_len (100)\n+        text_q.put(long_text)\n+\n+        def delayed_sentinel():\n+            time.sleep(1.0)\n+            text_q.put(None)\n+\n+        t = threading.Thread(target=delayed_sentinel, daemon=True)\n+        t.start()\n+\n+        stream_tts_to_speaker(text_q, stop_evt, done_evt,\n+                              display_callback=lambda t: spoken.append(t))\n+        t.join(timeout=5)\n+        assert done_evt.is_set()\n+        assert len(spoken) >= 1\n+\n+\n+# =====================================================================\n+# Bug 1: VoiceReceiver.stop() must hold lock while clearing shared state\n+# =====================================================================\n+\n+class TestStopAcquiresLock:\n+    \"\"\"stop() must acquire _lock before clearing buffers/state.\"\"\"\n+\n+    @staticmethod\n+    def _make_receiver():\n+        from gateway.platforms.discord import VoiceReceiver\n+        vc = MagicMock()\n+        vc._connection.secret_key = [0] * 32\n+        vc._connection.dave_session = None\n+        vc._connection.ssrc = 1\n+        return VoiceReceiver(vc)\n+\n+    def test_stop_clears_under_lock(self):\n+        \"\"\"stop() acquires _lock before clearing buffers.\n+\n+        Verify by holding the lock from another thread and checking that\n+        stop() blocks until the lock is released.\n+        \"\"\"\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver._buffers[100] = bytearray(b\"\\x00\" * 500)\n+        receiver._last_packet_time[100] = time.monotonic()\n+        receiver.map_ssrc(100, 42)\n+\n+        # Hold the lock from another thread\n+        lock_acquired = threading.Event()\n+        release_lock = threading.Event()\n+\n+        def hold_lock():\n+            with receiver._lock:\n+                lock_acquired.set()\n+                release_lock.wait(timeout=5)\n+\n+        holder = threading.Thread(target=hold_lock, daemon=True)\n+        holder.start()\n+        lock_acquired.wait(timeout=2)\n+\n+        # stop() in another thread — should block on the lock\n+        stop_done = threading.Event()\n+\n+        def do_stop():\n+            receiver.stop()\n+            stop_done.set()\n+\n+        stopper = threading.Thread(target=do_stop, daemon=True)\n+        stopper.start()\n+\n+        # stop should NOT complete while lock is held\n+        assert not stop_done.wait(timeout=0.3), \\\n+            \"stop() should block while _lock is held by another thread\"\n+\n+        # Release the lock — stop should complete\n+        release_lock.set()\n+        assert stop_done.wait(timeout=2), \\\n+            \"stop() should complete after lock is released\"\n+\n+        # State should be cleared\n+        assert len(receiver._buffers) == 0\n+        assert len(receiver._ssrc_to_user) == 0\n+        holder.join(timeout=2)\n+        stopper.join(timeout=2)\n+\n+    def test_stop_does_not_deadlock_with_on_packet(self):\n+        \"\"\"stop() during _on_packet should not deadlock.\"\"\"\n+        receiver = self._make_receiver()\n+        receiver.start()\n+\n+        blocked = threading.Event()\n+        released = threading.Event()\n+\n+        def hold_lock():\n+            with receiver._lock:\n+                blocked.set()\n+                released.wait(timeout=2)\n+\n+        t = threading.Thread(target=hold_lock, daemon=True)\n+        t.start()\n+        blocked.wait(timeout=2)\n+\n+        stop_done = threading.Event()\n+\n+        def do_stop():\n+            receiver.stop()\n+            stop_done.set()\n+\n+        t2 = threading.Thread(target=do_stop, daemon=True)\n+        t2.start()\n+\n+        # stop should be blocked waiting for lock\n+        assert not stop_done.wait(timeout=0.2), \\\n+            \"stop() should wait for lock, not clear without it\"\n+\n+        released.set()\n+        assert stop_done.wait(timeout=2), \"stop() should complete after lock released\"\n+        t.join(timeout=2)\n+        t2.join(timeout=2)\n+\n+\n+# =====================================================================\n+# Bug 2: _packet_debug_count must be instance-level, not class-level\n+# =====================================================================\n+\n+class TestPacketDebugCounterIsInstanceLevel:\n+    \"\"\"Each VoiceReceiver instance has its own debug counter.\"\"\"\n+\n+    @staticmethod\n+    def _make_receiver():\n+        from gateway.platforms.discord import VoiceReceiver\n+        vc = MagicMock()\n+        vc._connection.secret_key = [0] * 32\n+        vc._connection.dave_session = None\n+        vc._connection.ssrc = 1\n+        return VoiceReceiver(vc)\n+\n+    def test_counter_is_per_instance(self):\n+        \"\"\"Two receivers have independent counters.\"\"\"\n+        r1 = self._make_receiver()\n+        r2 = self._make_receiver()\n+\n+        r1._packet_debug_count = 10\n+        assert r2._packet_debug_count == 0, \\\n+            \"_packet_debug_count must be instance-level, not shared across instances\"\n+\n+    def test_counter_initialized_in_init(self):\n+        \"\"\"Counter is set in __init__, not as a class variable.\"\"\"\n+        r = self._make_receiver()\n+        assert \"_packet_debug_count\" in r.__dict__, \\\n+            \"_packet_debug_count should be in instance __dict__, not class\"\n+\n+\n+# =====================================================================\n+# Bug 3: play_in_voice_channel uses get_running_loop not get_event_loop\n+# =====================================================================\n+\n+class TestPlayInVoiceChannelUsesRunningLoop:\n+    \"\"\"play_in_voice_channel must use asyncio.get_running_loop().\"\"\"\n+\n+    def test_source_uses_get_running_loop(self):\n+        \"\"\"The method source code calls get_running_loop, not get_event_loop.\"\"\"\n+        import inspect\n+        from gateway.platforms.discord import DiscordAdapter\n+        source = inspect.getsource(DiscordAdapter.play_in_voice_channel)\n+        assert \"get_running_loop\" in source, \\\n+            \"play_in_voice_channel should use asyncio.get_running_loop()\"\n+        assert \"get_event_loop\" not in source, \\\n+            \"play_in_voice_channel should NOT use deprecated asyncio.get_event_loop()\"\n+\n+\n+# =====================================================================\n+# Bug 4: _send_voice_reply filename uses uuid (no collision)\n+# =====================================================================\n+\n+class TestSendVoiceReplyFilename:\n+    \"\"\"_send_voice_reply uses uuid for unique filenames.\"\"\"\n+\n+    def test_filename_uses_uuid(self):\n+        \"\"\"The method uses uuid in the filename, not time-based.\"\"\"\n+        import inspect\n+        from gateway.run import GatewayRunner\n+        source = inspect.getsource(GatewayRunner._send_voice_reply)\n+        assert \"uuid\" in source, \\\n+            \"_send_voice_reply should use uuid for unique filenames\"\n+        assert \"int(time.time())\" not in source, \\\n+            \"_send_voice_reply should not use int(time.time()) — collision risk\"\n+\n+    def test_filenames_are_unique(self):\n+        \"\"\"Two calls produce different filenames.\"\"\"\n+        import uuid\n+        names = set()\n+        for _ in range(100):\n+            name = f\"tts_reply_{uuid.uuid4().hex[:12]}.mp3\"\n+            assert name not in names, f\"Collision detected: {name}\"\n+            names.add(name)\n+\n+\n+# =====================================================================\n+# Bug 5: Voice timeout cleans up runner voice_mode via callback\n+# =====================================================================\n+\n+class TestVoiceTimeoutCleansRunnerState:\n+    \"\"\"Timeout disconnect notifies runner to clean voice_mode.\"\"\"\n+\n+    @staticmethod\n+    def _make_discord_adapter():\n+        from gateway.platforms.discord import DiscordAdapter\n+        from gateway.config import PlatformConfig, Platform\n+        config = PlatformConfig(enabled=True, extra={})\n+        config.token = \"[redacted:secret-assignment]\"\n+        adapter = object.__new__(DiscordAdapter)\n+        adapter.platform = Platform.DISCORD\n+        adapter.config = config\n+        adapter._voice_clients = {}\n+        adapter._voice_text_channels = {}\n+        adapter._voice_timeout_tasks = {}\n+        adapter._voice_receivers = {}\n+        adapter._voice_listen_tasks = {}\n+        adapter._voice_input_callback = None\n+        adapter._on_voice_disconnect = None\n+        adapter._client = None\n+        adapter._broadcast = AsyncMock()\n+        adapter._allowed_user_ids = set()\n+        return adapter\n+\n+    @pytest.fixture\n+    def adapter(self):\n+        return self._make_discord_adapter()\n+\n+    def test_adapter_has_on_voice_disconnect_attr(self, adapter):\n+        \"\"\"DiscordAdapter has _on_voice_disconnect callback attribute.\"\"\"\n+        assert hasattr(adapter, \"_on_voice_disconnect\")\n+        assert adapter._on_voice_disconnect is None\n+\n+    @pytest.mark.asyncio\n+    async def test_timeout_calls_disconnect_callback(self, adapter):\n+        \"\"\"_voice_timeout_handler calls _on_voice_disconnect with chat_id.\"\"\"\n+        callback_calls = []\n+        adapter._on_voice_disconnect = lambda chat_id: callback_calls.append(chat_id)\n+\n+        # Set up state as if we're in a voice channel\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        mock_vc.disconnect = AsyncMock()\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_text_channels[111] = 999\n+        adapter._voice_timeout_tasks[111] = MagicMock()\n+        adapter._voice_receivers[111] = MagicMock()\n+        adapter._voice_listen_tasks[111] = MagicMock()\n+\n+        # Patch sleep to return immediately\n+        with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+            await adapter._voice_timeout_handler(111)\n+\n+        assert \"999\" in callback_calls, \\\n+            \"_on_voice_disconnect must be called with chat_id on timeout\"\n+\n+    @pytest.mark.asyncio\n+    async def test_runner_cleanup_method_removes_voice_mode(self, tmp_path):\n+        \"\"\"_handle_voice_timeout_cleanup removes voice_mode for chat.\"\"\"\n+        runner = _make_runner(tmp_path)\n+        runner._voice_mode[\"999\"] = \"all\"\n+\n+        runner._handle_voice_timeout_cleanup(\"999\")\n+\n+        assert \"999\" not in runner._voice_mode, \\\n+            \"voice_mode must be removed after timeout cleanup\"\n+\n+    @pytest.mark.asyncio\n+    async def test_timeout_without_callback_does_not_crash(self, adapter):\n+        \"\"\"Timeout works even without _on_voice_disconnect set.\"\"\"\n+        adapter._on_voice_disconnect = None\n+\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        mock_vc.disconnect = AsyncMock()\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_text_channels[111] = 999\n+        adapter._voice_timeout_tasks[111] = MagicMock()\n+\n+        with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+            await adapter._voice_timeout_handler(111)\n+\n+        assert 111 not in adapter._voice_clients\n+\n+\n+# =====================================================================\n+# Bug 6: play_in_voice_channel has playback timeout\n+# =====================================================================\n+\n+class TestPlaybackTimeout:\n+    \"\"\"play_in_voice_channel must time out instead of blocking forever.\"\"\"\n+\n+    @staticmethod\n+    def _make_discord_adapter():\n+        from gateway.platforms.discord import DiscordAdapter\n+        from gateway.config import PlatformConfig, Platform\n+        config = PlatformConfig(enabled=True, extra={})\n+        config.token = \"[redacted:secret-assignment]\"\n+        adapter = object.__new__(DiscordAdapter)\n+        adapter.platform = Platform.DISCORD\n+        adapter.config = config\n+        adapter._voice_clients = {}\n+        adapter._voice_text_channels = {}\n+        adapter._voice_timeout_tasks = {}\n+        adapter._voice_receivers = {}\n+        adapter._voice_listen_tasks = {}\n+        adapter._voice_input_callback = None\n+        adapter._on_voice_disconnect = None\n+        adapter._client = None\n+        adapter._broadcast = AsyncMock()\n+        adapter._allowed_user_ids = set()\n+        return adapter\n+\n+    def test_source_has_wait_for_timeout(self):\n+        \"\"\"The method uses asyncio.wait_for with timeout.\"\"\"\n+        import inspect\n+        from gateway.platforms.discord import DiscordAdapter\n+        source = inspect.getsource(DiscordAdapter.play_in_voice_channel)\n+        assert \"wait_for\" in source, \\\n+            \"play_in_voice_channel must use asyncio.wait_for for timeout\"\n+        assert \"PLAYBACK_TIMEOUT\" in source, \\\n+            \"play_in_voice_channel must reference PLAYBACK_TIMEOUT constant\"\n+\n+    def test_playback_timeout_constant_exists(self):\n+        \"\"\"PLAYBACK_TIMEOUT constant is defined on DiscordAdapter.\"\"\"\n+        from gateway.platforms.discord import DiscordAdapter\n+        assert hasattr(DiscordAdapter, \"PLAYBACK_TIMEOUT\")\n+        assert DiscordAdapter.PLAYBACK_TIMEOUT > 0\n+\n+    @pytest.mark.asyncio\n+    async def test_playback_timeout_fires(self):\n+        \"\"\"When done event is never set, playback times out gracefully.\"\"\"\n+        from gateway.platforms.discord import DiscordAdapter\n+        adapter = self._make_discord_adapter()\n+\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        mock_vc.is_playing.return_value = False\n+        # play() never calls the after callback -> done never set\n+        mock_vc.play = MagicMock()\n+        mock_vc.stop = MagicMock()\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_timeout_tasks[111] = MagicMock()\n+\n+        # Use a tiny timeout for test speed\n+        original_timeout = DiscordAdapter.PLAYBACK_TIMEOUT\n+        DiscordAdapter.PLAYBACK_TIMEOUT = 0.1\n+        try:\n+            with patch(\"discord.FFmpegPCMAudio\"), \\\n+                 patch(\"discord.PCMVolumeTransformer\", side_effect=lambda s, **kw: s):\n+                result = await adapter.play_in_voice_channel(111, \"/tmp/test.mp3\")\n+            assert result is True\n+            # vc.stop() should have been called due to timeout\n+            mock_vc.stop.assert_called()\n+        finally:\n+            DiscordAdapter.PLAYBACK_TIMEOUT = original_timeout\n+\n+    @pytest.mark.asyncio\n+    async def test_is_playing_wait_has_timeout(self):\n+        \"\"\"While loop waiting for previous playback has a timeout.\"\"\"\n+        from gateway.platforms.discord import DiscordAdapter\n+        adapter = self._make_discord_adapter()\n+\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        # is_playing always returns True — would loop forever without timeout\n+        mock_vc.is_playing.return_value = True\n+        mock_vc.stop = MagicMock()\n+        mock_vc.play = MagicMock()\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_timeout_tasks[111] = MagicMock()\n+\n+        original_timeout = DiscordAdapter.PLAYBACK_TIMEOUT\n+        DiscordAdapter.PLAYBACK_TIMEOUT = 0.2\n+        try:\n+            with patch(\"discord.FFmpegPCMAudio\"), \\\n+                 patch(\"discord.PCMVolumeTransformer\", side_effect=lambda s, **kw: s):\n+                result = await adapter.play_in_voice_channel(111, \"/tmp/test.mp3\")\n+            assert result is True\n+            # stop() called to break out of the is_playing loop\n+            mock_vc.stop.assert_called()\n+        finally:\n+            DiscordAdapter.PLAYBACK_TIMEOUT = original_timeout\n+\n+\n+# =====================================================================\n+# Bug 7: _send_voice_reply cleanup in finally block\n+# =====================================================================\n+\n+class TestSendVoiceReplyCleanup:\n+    \"\"\"_send_voice_reply must clean up temp files even on exception.\"\"\"\n+\n+    def test_cleanup_in_finally(self):\n+        \"\"\"The method has cleanup in a finally block, not inside try.\"\"\"\n+        import inspect, textwrap, ast\n+        from gateway.run import GatewayRunner\n+        source = textwrap.dedent(inspect.getsource(GatewayRunner._send_voice_reply))\n+        tree = ast.parse(source)\n+        func = tree.body[0]\n+\n+        has_finally_unlink = False\n+        for node in ast.walk(func):\n+            if isinstance(node, ast.Try) and node.finalbody:\n+                finally_source = ast.dump(node.finalbody[0])\n+                if \"unlink\" in finally_source or \"remove\" in finally_source:\n+                    has_finally_unlink = True\n+                    break\n+\n+        assert has_finally_unlink, \\\n+            \"_send_voice_reply must have os.unlink in a finally block\"\n+\n+    @pytest.mark.asyncio\n+    async def test_files_cleaned_on_send_exception(self, tmp_path):\n+        \"\"\"Temp files are removed even when send_voice raises.\"\"\"\n+        runner = _make_runner(tmp_path)\n+        adapter = MagicMock()\n+        adapter.send_voice = AsyncMock(side_effect=RuntimeError(\"send failed\"))\n+        adapter.is_in_voice_channel = MagicMock(return_value=False)\n+        event = _make_event(message_type=MessageType.VOICE)\n+        runner.adapters[event.source.platform] = adapter\n+        runner._get_guild_id = MagicMock(return_value=None)\n+\n+        # Create a fake audio file that TTS would produce\n+        fake_audio = tmp_path / \"hermes_voice\"\n+        fake_audio.mkdir()\n+        audio_file = fake_audio / \"test.mp3\"\n+        audio_file.write_bytes(b\"fake audio\")\n+\n+        tts_result = json.dumps({\n+            \"success\": True,\n+            \"file_path\": str(audio_file),\n+        })\n+\n+        with patch(\"gateway.run.asyncio.to_thread\", new_callable=AsyncMock, return_value=tts_result), \\\n+             patch(\"tools.tts_tool._strip_markdown_for_tts\", return_value=\"hello\"), \\\n+             patch(\"os.path.isfile\", return_value=True), \\\n+             patch(\"os.makedirs\"):\n+            await runner._send_voice_reply(event, \"Hello world\")\n+\n+        # File should be cleaned up despite exception\n+        assert not audio_file.exists(), \\\n+            \"Temp audio file must be cleaned up even when send_voice raises\"\n+\n+\n+# =====================================================================\n+# Bug 8: Base adapter auto-TTS cleans up temp file after play_tts\n+# =====================================================================\n+\n+class TestAutoTtsTempFileCleanup:\n+    \"\"\"Base adapter auto-TTS must clean up generated audio file.\"\"\"\n+\n+    def test_source_has_finally_remove(self):\n+        \"\"\"play_tts call is wrapped in try/finally with os.remove.\"\"\"\n+        import inspect\n+        from gateway.platforms.base import BasePlatformAdapter\n+        source = inspect.getsource(BasePlatformAdapter._process_message_background)\n+        # Find the play_tts section and verify cleanup\n+        play_tts_idx = source.find(\"play_tts\")\n+        assert play_tts_idx > 0\n+        after_play = source[play_tts_idx:]\n+        finally_idx = after_play.find(\"finally\")\n+        remove_idx = after_play.find(\"os.remove\")\n+        assert finally_idx > 0, \"play_tts must be in a try/finally block\"\n+        assert remove_idx > 0, \"finally block must call os.remove on _tts_path\"\n+        assert remove_idx > finally_idx, \"os.remove must be inside the finally block\"\n+\n+\n+# =====================================================================\n+# Voice channel awareness (get_voice_channel_info / context)\n+# =====================================================================\n+\n+\n+class TestVoiceChannelAwareness:\n+    \"\"\"Tests for get_voice_channel_info() and get_voice_channel_context().\"\"\"\n+\n+    def _make_adapter(self):\n+        from gateway.platforms.discord import DiscordAdapter\n+        from gateway.config import PlatformConfig\n+        config = PlatformConfig(enabled=True, extra={})\n+        config.token = \"[redacted:secret-assignment]\"\n+        adapter = object.__new__(DiscordAdapter)\n+        adapter._voice_clients = {}\n+        adapter._voice_text_channels = {}\n+        adapter._voice_receivers = {}\n+        adapter._client = MagicMock()\n+        adapter._client.user = SimpleNamespace(id=99999, name=\"HermesBot\")\n+        return adapter\n+\n+    def _make_member(self, user_id, display_name, is_bot=False):\n+        return SimpleNamespace(\n+            id=user_id, display_name=display_name, bot=is_bot,\n+        )\n+\n+    def test_returns_none_when_not_connected(self):\n+        adapter = self._make_adapter()\n+        assert adapter.get_voice_channel_info(111) is None\n+\n+    def test_returns_none_when_vc_disconnected(self):\n+        adapter = self._make_adapter()\n+        vc = MagicMock()\n+        vc.is_connected.return_value = False\n+        adapter._voice_clients[111] = vc\n+        assert adapter.get_voice_channel_info(111) is None\n+\n+    def test_returns_info_with_members(self):\n+        adapter = self._make_adapter()\n+        vc = MagicMock()\n+        vc.is_connected.return_value = True\n+        bot_member = self._make_member(99999, \"HermesBot\", is_bot=True)\n+        user_a = self._make_member(1001, \"Alice\")\n+        user_b = self._make_member(1002, \"Bob\")\n+        vc.channel.name = \"general-voice\"\n+        vc.channel.members = [bot_member, user_a, user_b]\n+        adapter._voice_clients[111] = vc\n+\n+        info = adapter.get_voice_channel_info(111)\n+        assert info is not None\n+        assert info[\"channel_name\"] == \"general-voice\"\n+        assert info[\"member_count\"] == 2  # bot excluded\n+        names = [m[\"display_name\"] for m in info[\"members\"]]\n+        assert \"Alice\" in names\n+        assert \"Bob\" in names\n+        assert \"HermesBot\" not in names\n+\n+    def test_speaking_detection(self):\n+        adapter = self._make_adapter()\n+        vc = MagicMock()\n+        vc.is_connected.return_value = True\n+        user_a = self._make_member(1001, \"Alice\")\n+        user_b = self._make_member(1002, \"Bob\")\n+        vc.channel.name = \"voice\"\n+        vc.channel.members = [user_a, user_b]\n+        adapter._voice_clients[111] = vc\n+\n+        # Set up a mock receiver with Alice speaking\n+        import time as _time\n+        receiver = MagicMock()\n+        receiver._lock = threading.Lock()\n+        receiver._last_packet_time = {100: _time.monotonic()}  # ssrc 100 is active\n+        receiver._ssrc_to_user = {100: 1001}  # ssrc 100 -> Alice\n+        adapter._voice_receivers[111] = receiver\n+\n+        info = adapter.get_voice_channel_info(111)\n+        alice = [m for m in info[\"members\"] if m[\"display_name\"] == \"Alice\"][0]\n+        bob = [m for m in info[\"members\"] if m[\"display_name\"] == \"Bob\"][0]\n+        assert alice[\"is_speaking\"] is True\n+        assert bob[\"is_speaking\"] is False\n+        assert info[\"speaking_count\"] == 1\n+\n+    def test_context_string_format(self):\n+        adapter = self._make_adapter()\n+        vc = MagicMock()\n+        vc.is_connected.return_value = True\n+        user_a = self._make_member(1001, \"Alice\")\n+        vc.channel.name = \"chat-room\"\n+        vc.channel.members = [user_a]\n+        adapter._voice_clients[111] = vc\n+\n+        ctx = adapter.get_voice_channel_context(111)\n+        assert \"#chat-room\" in ctx\n+        assert \"1 participant\" in ctx\n+        assert \"Alice\" in ctx\n+\n+    def test_context_empty_when_not_connected(self):\n+        adapter = self._make_adapter()\n+        assert adapter.get_voice_channel_context(111) == \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bugfix: disconnect() must clean up voice state\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestDisconnectVoiceCleanup:\n+    \"\"\"Bug: disconnect() left voice dicts populated after closing client.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_disconnect_clears_voice_state(self):\n+        from unittest.mock import AsyncMock\n+\n+        adapter = MagicMock()\n+        adapter._voice_clients = {111: MagicMock(), 222: MagicMock()}\n+        adapter._voice_receivers = {111: MagicMock(), 222: MagicMock()}\n+        adapter._voice_listen_tasks = {111: MagicMock(), 222: MagicMock()}\n+        adapter._voice_timeout_tasks = {111: MagicMock(), 222: MagicMock()}\n+        adapter._voice_text_channels = {111: 999, 222: 888}\n+\n+        async def mock_leave(guild_id):\n+            adapter._voice_receivers.pop(guild_id, None)\n+            adapter._voice_listen_tasks.pop(guild_id, None)\n+            adapter._voice_clients.pop(guild_id, None)\n+            adapter._voice_timeout_tasks.pop(guild_id, None)\n+            adapter._voice_text_channels.pop(guild_id, None)\n+\n+        for gid in list(adapter._voice_clients.keys()):\n+            await mock_leave(gid)\n+\n+        assert len(adapter._voice_clients) == 0\n+        assert len(adapter._voice_receivers) == 0\n+        assert len(adapter._voice_listen_tasks) == 0\n+        assert len(adapter._voice_timeout_tasks) == 0"]},{"file":"tests/hermes_cli/test_commands.py","hunks":["@@ -12,7 +12,7 @@\n     \"/personality\", \"/clear\", \"/history\", \"/new\", \"/reset\", \"/retry\",\n     \"/undo\", \"/save\", \"/config\", \"/cron\", \"/skills\", \"/platforms\",\n     \"/verbose\", \"/reasoning\", \"/compress\", \"/title\", \"/usage\", \"/insights\", \"/paste\",\n-    \"/reload-mcp\", \"/rollback\", \"/background\", \"/skin\", \"/quit\",\n+    \"/reload-mcp\", \"/rollback\", \"/background\", \"/skin\", \"/voice\", \"/quit\",\n }\n \n "]},{"file":"tests/test_cli_skin_integration.py","hunks":["@@ -14,6 +14,9 @@ def _make_cli_stub():\n     cli._clarify_freetext = False\n     cli._command_running = False\n     cli._agent_running = False\n+    cli._voice_recording = False\n+    cli._voice_processing = False\n+    cli._voice_mode = False\n     cli._command_spinner_frame = lambda: \"⟳\"\n     cli._tui_style_base = {\n         \"prompt\": \"#fff\","]},{"file":"tests/test_run_agent.py","hunks":["@@ -2083,3 +2083,332 @@ def test_none_base_url_passed_as_none(self):\n             # No base_url provided, should be default empty string or None\n             passed_url = call_args[0][1]\n             assert not passed_url or passed_url is None\n+\n+\n+# ===================================================================\n+# _streaming_api_call tests\n+# ===================================================================\n+\n+def _make_chunk(content=None, tool_calls=None, finish_reason=None, model=\"test/model\"):\n+    \"\"\"Build a SimpleNamespace mimicking an OpenAI streaming chunk.\"\"\"\n+    delta = SimpleNamespace(content=content, tool_calls=tool_calls)\n+    choice = SimpleNamespace(delta=delta, finish_reason=finish_reason)\n+    return SimpleNamespace(model=model, choices=[choice])\n+\n+\n+def _make_tc_delta(index=0, tc_id=None, name=None, arguments=None):\n+    \"\"\"Build a SimpleNamespace mimicking a streaming tool_call delta.\"\"\"\n+    func = SimpleNamespace(name=name, arguments=arguments)\n+    return SimpleNamespace(index=index, id=tc_id, function=func)\n+\n+\n+class TestStreamingApiCall:\n+    \"\"\"Tests for _streaming_api_call — voice TTS streaming pipeline.\"\"\"\n+\n+    def test_content_assembly(self, agent):\n+        chunks = [\n+            _make_chunk(content=\"Hel\"),\n+            _make_chunk(content=\"lo \"),\n+            _make_chunk(content=\"World\"),\n+            _make_chunk(finish_reason=\"stop\"),\n+        ]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+        callback = MagicMock()\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, callback)\n+\n+        assert resp.choices[0].message.content == \"Hello World\"\n+        assert resp.choices[0].finish_reason == \"stop\"\n+        assert callback.call_count == 3\n+        callback.assert_any_call(\"Hel\")\n+        callback.assert_any_call(\"lo \")\n+        callback.assert_any_call(\"World\")\n+\n+    def test_tool_call_accumulation(self, agent):\n+        chunks = [\n+            _make_chunk(tool_calls=[_make_tc_delta(0, \"call_1\", \"web_\", '{\"q\":')]),\n+            _make_chunk(tool_calls=[_make_tc_delta(0, None, \"search\", '\"test\"}')]),\n+            _make_chunk(finish_reason=\"tool_calls\"),\n+        ]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+        tc = resp.choices[0].message.tool_calls\n+        assert len(tc) == 1\n+        assert tc[0].function.name == \"web_search\"\n+        assert tc[0].function.arguments == '{\"q\":\"test\"}'\n+        assert tc[0].id == \"call_1\"\n+\n+    def test_multiple_tool_calls(self, agent):\n+        chunks = [\n+            _make_chunk(tool_calls=[_make_tc_delta(0, \"call_a\", \"search\", '{}')]),\n+            _make_chunk(tool_calls=[_make_tc_delta(1, \"call_b\", \"read\", '{}')]),\n+            _make_chunk(finish_reason=\"tool_calls\"),\n+        ]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+        tc = resp.choices[0].message.tool_calls\n+        assert len(tc) == 2\n+        assert tc[0].function.name == \"search\"\n+        assert tc[1].function.name == \"read\"\n+\n+    def test_content_and_tool_calls_together(self, agent):\n+        chunks = [\n+            _make_chunk(content=\"I'll search\"),\n+            _make_chunk(tool_calls=[_make_tc_delta(0, \"call_1\", \"search\", '{}')]),\n+            _make_chunk(finish_reason=\"tool_calls\"),\n+        ]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+        assert resp.choices[0].message.content == \"I'll search\"\n+        assert len(resp.choices[0].message.tool_calls) == 1\n+\n+    def test_empty_content_returns_none(self, agent):\n+        chunks = [_make_chunk(finish_reason=\"stop\")]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+        assert resp.choices[0].message.content is None\n+        assert resp.choices[0].message.tool_calls is None\n+\n+    def test_callback_exception_swallowed(self, agent):\n+        chunks = [\n+            _make_chunk(content=\"Hello\"),\n+            _make_chunk(content=\" World\"),\n+            _make_chunk(finish_reason=\"stop\"),\n+        ]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+        callback = MagicMock(side_effect=ValueError(\"boom\"))\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, callback)\n+\n+        assert resp.choices[0].message.content == \"Hello World\"\n+\n+    def test_model_name_captured(self, agent):\n+        chunks = [\n+            _make_chunk(content=\"Hi\", model=\"gpt-4o\"),\n+            _make_chunk(finish_reason=\"stop\", model=\"gpt-4o\"),\n+        ]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+        assert resp.model == \"gpt-4o\"\n+\n+    def test_stream_kwarg_injected(self, agent):\n+        chunks = [_make_chunk(content=\"x\"), _make_chunk(finish_reason=\"stop\")]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        agent._streaming_api_call({\"messages\": [], \"model\": \"test\"}, MagicMock())\n+\n+        call_kwargs = agent.client.chat.completions.create.call_args\n+        assert call_kwargs[1].get(\"stream\") is True or call_kwargs.kwargs.get(\"stream\") is True\n+\n+    def test_api_exception_propagated(self, agent):\n+        agent.client.chat.completions.create.side_effect = ConnectionError(\"fail\")\n+\n+        with pytest.raises(ConnectionError, match=\"fail\"):\n+            agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+    def test_response_has_uuid_id(self, agent):\n+        chunks = [_make_chunk(content=\"x\"), _make_chunk(finish_reason=\"stop\")]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+        assert resp.id.startswith(\"stream-\")\n+        assert len(resp.id) > len(\"stream-\")\n+\n+    def test_empty_choices_chunk_skipped(self, agent):\n+        empty_chunk = SimpleNamespace(model=\"gpt-4\", choices=[])\n+        chunks = [\n+            empty_chunk,\n+            _make_chunk(content=\"Hello\", model=\"gpt-4\"),\n+            _make_chunk(finish_reason=\"stop\", model=\"gpt-4\"),\n+        ]\n+        agent.client.chat.completions.create.return_value = iter(chunks)\n+\n+        resp = agent._streaming_api_call({\"messages\": []}, MagicMock())\n+\n+        assert resp.choices[0].message.content == \"Hello\"\n+        assert resp.model == \"gpt-4\"\n+\n+\n+# ===================================================================\n+# Interrupt _vprint force=True verification\n+# ===================================================================\n+\n+\n+class TestInterruptVprintForceTrue:\n+    \"\"\"All interrupt _vprint calls must use force=True so they are always visible.\"\"\"\n+\n+    def test_all_interrupt_vprint_have_force_true(self):\n+        \"\"\"Scan source for _vprint calls containing 'Interrupt' — each must have force=True.\"\"\"\n+        import inspect\n+        source = inspect.getsource(AIAgent)\n+        lines = source.split(\"\\n\")\n+        violations = []\n+        for i, line in enumerate(lines, 1):\n+            stripped = line.strip()\n+            if \"_vprint(\" in stripped and \"Interrupt\" in stripped:\n+                if \"force=True\" not in stripped:\n+                    violations.append(f\"line {i}: {stripped}\")\n+        assert not violations, (\n+            f\"Interrupt _vprint calls missing force=True:\\n\"\n+            + \"\\n\".join(violations)\n+        )\n+\n+\n+# ===================================================================\n+# Anthropic interrupt handler in _interruptible_api_call\n+# ===================================================================\n+\n+\n+class TestAnthropicInterruptHandler:\n+    \"\"\"_interruptible_api_call must handle Anthropic mode when interrupted.\"\"\"\n+\n+    def test_interruptible_has_anthropic_branch(self):\n+        \"\"\"The interrupt handler must check api_mode == 'anthropic_messages'.\"\"\"\n+        import inspect\n+        source = inspect.getsource(AIAgent._interruptible_api_call)\n+        assert \"anthropic_messages\" in source, \\\n+            \"_interruptible_api_call must handle Anthropic interrupt (api_mode check)\"\n+\n+    def test_interruptible_rebuilds_anthropic_client(self):\n+        \"\"\"After interrupting, the Anthropic client should be rebuilt.\"\"\"\n+        import inspect\n+        source = inspect.getsource(AIAgent._interruptible_api_call)\n+        assert \"build_anthropic_client\" in source, \\\n+            \"_interruptible_api_call must rebuild Anthropic client after interrupt\"\n+\n+    def test_streaming_has_anthropic_branch(self):\n+        \"\"\"_streaming_api_call must also handle Anthropic interrupt.\"\"\"\n+        import inspect\n+        source = inspect.getsource(AIAgent._streaming_api_call)\n+        assert \"anthropic_messages\" in source, \\\n+            \"_streaming_api_call must handle Anthropic interrupt\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bugfix: stream_callback forwarding for non-streaming providers\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestStreamCallbackNonStreamingProvider:\n+    \"\"\"When api_mode != chat_completions, stream_callback must still receive\n+    the response content so TTS works (batch delivery).\"\"\"\n+\n+    def test_callback_receives_chat_completions_response(self, agent):\n+        \"\"\"For chat_completions-shaped responses, callback gets content.\"\"\"\n+        agent.api_mode = \"anthropic_messages\"\n+        mock_response = SimpleNamespace(\n+            choices=[SimpleNamespace(\n+                message=SimpleNamespace(content=\"Hello\", tool_calls=None, reasoning_content=None),\n+                finish_reason=\"stop\", index=0,\n+            )],\n+            usage=None, model=\"test\", id=\"test-id\",\n+        )\n+        agent._interruptible_api_call = MagicMock(return_value=mock_response)\n+\n+        received = []\n+        cb = lambda delta: received.append(delta)\n+        agent._stream_callback = cb\n+\n+        _cb = getattr(agent, \"_stream_callback\", None)\n+        response = agent._interruptible_api_call({})\n+        if _cb is not None and response:\n+            try:\n+                if agent.api_mode == \"anthropic_messages\":\n+                    text_parts = [\n+                        block.text for block in getattr(response, \"content\", [])\n+                        if getattr(block, \"type\", None) == \"text\" and getattr(block, \"text\", None)\n+                    ]\n+                    content = \" \".join(text_parts) if text_parts else None\n+                else:\n+                    content = response.choices[0].message.content\n+                if content:\n+                    _cb(content)\n+            except Exception:\n+                pass\n+\n+        # Anthropic format not matched above; fallback via except\n+        # Test the actual code path by checking chat_completions branch\n+        received2 = []\n+        agent.api_mode = \"some_other_mode\"\n+        agent._stream_callback = lambda d: received2.append(d)\n+        _cb2 = agent._stream_callback\n+        if _cb2 is not None and mock_response:\n+            try:\n+                content = mock_response.choices[0].message.content\n+                if content:\n+                    _cb2(content)\n+            except Exception:\n+                pass\n+        assert received2 == [\"Hello\"]\n+\n+    def test_callback_receives_anthropic_content(self, agent):\n+        \"\"\"For Anthropic responses, text blocks are extracted and forwarded.\"\"\"\n+        agent.api_mode = \"anthropic_messages\"\n+        mock_response = SimpleNamespace(\n+            content=[SimpleNamespace(type=\"text\", text=\"Hello from Claude\")],\n+            stop_reason=\"end_turn\",\n+        )\n+\n+        received = []\n+        cb = lambda d: received.append(d)\n+        agent._stream_callback = cb\n+        _cb = agent._stream_callback\n+\n+        if _cb is not None and mock_response:\n+            try:\n+                if agent.api_mode == \"anthropic_messages\":\n+                    text_parts = [\n+                        block.text for block in getattr(mock_response, \"content\", [])\n+                        if getattr(block, \"type\", None) == \"text\" and getattr(block, \"text\", None)\n+                    ]\n+                    content = \" \".join(text_parts) if text_parts else None\n+                else:\n+                    content = mock_response.choices[0].message.content\n+                if content:\n+                    _cb(content)\n+            except Exception:\n+                pass\n+\n+        assert received == [\"Hello from Claude\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bugfix: _vprint force=True on error messages during TTS\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestVprintForceOnErrors:\n+    \"\"\"Error/warning messages must be visible during streaming TTS.\"\"\"\n+\n+    def test_forced_message_shown_during_tts(self, agent):\n+        agent._stream_callback = lambda x: None\n+        printed = []\n+        with patch(\"builtins.print\", side_effect=lambda *a, **kw: printed.append(a)):\n+            agent._vprint(\"error msg\", force=True)\n+        assert len(printed) == 1\n+\n+    def test_non_forced_suppressed_during_tts(self, agent):\n+        agent._stream_callback = lambda x: None\n+        printed = []\n+        with patch(\"builtins.print\", side_effect=lambda *a, **kw: printed.append(a)):\n+            agent._vprint(\"debug info\")\n+        assert len(printed) == 0\n+\n+    def test_all_shown_without_tts(self, agent):\n+        agent._stream_callback = None\n+        printed = []\n+        with patch(\"builtins.print\", side_effect=lambda *a, **kw: printed.append(a)):\n+            agent._vprint(\"debug\")\n+            agent._vprint(\"error\", force=True)\n+        assert len(printed) == 2"]},{"file":"tests/tools/test_transcription.py","hunks":["@@ -28,6 +28,7 @@ def test_local_when_available(self):\n \n     def test_local_fallback_to_openai(self, monkeypatch):\n         monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+        monkeypatch.delenv(\"GROQ_API_KEY\", raising=False)\n         with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False), \\\n              patch(\"tools.transcription_tools._HAS_OPENAI\", True):\n             from tools.transcription_tools import _get_provider","@@ -124,7 +125,7 @@ def test_successful_transcription(self, tmp_path):\n         mock_model.transcribe.return_value = ([mock_segment], mock_info)\n \n         with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", True), \\\n-             patch(\"tools.transcription_tools.WhisperModel\", return_value=mock_model), \\\n+             patch(\"faster_whisper.WhisperModel\", return_value=mock_model), \\\n              patch(\"tools.transcription_tools._local_model\", None):\n             from tools.transcription_tools import _transcribe_local\n             result = _transcribe_local(str(audio_file), \"base\")","@@ -163,7 +164,7 @@ def test_successful_transcription(self, monkeypatch, tmp_path):\n         mock_client.audio.transcriptions.create.return_value = \"Hello from OpenAI\"\n \n         with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n-             patch(\"tools.transcription_tools.OpenAI\", return_value=mock_client):\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n             from tools.transcription_tools import _transcribe_openai\n             result = _transcribe_openai(str(audio_file), \"whisper-1\")\n "]},{"file":"tests/tools/test_transcription_tools.py","hunks":["@@ -0,0 +1,716 @@\n+\"\"\"Tests for tools.transcription_tools — three-provider STT pipeline.\n+\n+Covers the full provider matrix (local, groq, openai), fallback chains,\n+model auto-correction, config loading, validation edge cases, and\n+end-to-end dispatch.  All external dependencies are mocked.\n+\"\"\"\n+\n+import os\n+import struct\n+import wave\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+# ============================================================================\n+# Fixtures\n+# ============================================================================\n+\n[redacted:email]\n+def sample_wav(tmp_path):\n+    \"\"\"Create a minimal valid WAV file (1 second of silence at 16kHz).\"\"\"\n+    wav_path = tmp_path / \"test.wav\"\n+    n_frames = 16000\n+    silence = struct.pack(f\"<{n_frames}h\", *([0] * n_frames))\n+\n+    with wave.open(str(wav_path), \"wb\") as wf:\n+        wf.setnchannels(1)\n+        wf.setsampwidth(2)\n+        wf.setframerate(16000)\n+        wf.writeframes(silence)\n+\n+    return str(wav_path)\n+\n+\n[redacted:email]\n+def sample_ogg(tmp_path):\n+    \"\"\"Create a fake OGG file for validation tests.\"\"\"\n+    ogg_path = tmp_path / \"test.ogg\"\n+    ogg_path.write_bytes(b\"fake audio data\")\n+    return str(ogg_path)\n+\n+\n[redacted:email](autouse=True)\n+def clean_env(monkeypatch):\n+    \"\"\"Ensure no real API keys leak into tests.\"\"\"\n+    monkeypatch.delenv(\"VOICE_TOOLS_OPENAI_KEY\", raising=False)\n+    monkeypatch.delenv(\"GROQ_API_KEY\", raising=False)\n+\n+\n+# ============================================================================\n+# _get_provider — full permutation matrix\n+# ============================================================================\n+\n+class TestGetProviderGroq:\n+    \"\"\"Groq-specific provider selection tests.\"\"\"\n+\n+    def test_groq_when_key_set(self, monkeypatch):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"groq\"}) == \"groq\"\n+\n+    def test_groq_fallback_to_local(self, monkeypatch):\n+        monkeypatch.delenv(\"GROQ_API_KEY\", raising=False)\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", True):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"groq\"}) == \"local\"\n+\n+    def test_groq_fallback_to_openai(self, monkeypatch):\n+        monkeypatch.delenv(\"GROQ_API_KEY\", raising=False)\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False), \\\n+             patch(\"tools.transcription_tools._HAS_OPENAI\", True):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"groq\"}) == \"openai\"\n+\n+    def test_groq_nothing_available(self, monkeypatch):\n+        monkeypatch.delenv(\"GROQ_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"VOICE_TOOLS_OPENAI_KEY\", raising=False)\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False), \\\n+             patch(\"tools.transcription_tools._HAS_OPENAI\", False):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"groq\"}) == \"none\"\n+\n+\n+class TestGetProviderFallbackPriority:\n+    \"\"\"Cross-provider fallback priority tests.\"\"\"\n+\n+    def test_local_fallback_prefers_groq_over_openai(self, monkeypatch):\n+        \"\"\"When local unavailable, groq (free) is preferred over openai (paid).\"\"\"\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False), \\\n+             patch(\"tools.transcription_tools._HAS_OPENAI\", True):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"local\"}) == \"groq\"\n+\n+    def test_local_fallback_to_groq_only(self, monkeypatch):\n+        \"\"\"When only groq key available, falls back to groq.\"\"\"\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False), \\\n+             patch(\"tools.transcription_tools._HAS_OPENAI\", True):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"local\"}) == \"groq\"\n+\n+    def test_openai_fallback_to_groq(self, monkeypatch):\n+        \"\"\"When openai key missing but groq available, falls back to groq.\"\"\"\n+        monkeypatch.delenv(\"VOICE_TOOLS_OPENAI_KEY\", raising=False)\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False), \\\n+             patch(\"tools.transcription_tools._HAS_OPENAI\", True):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"openai\"}) == \"groq\"\n+\n+    def test_openai_nothing_available(self, monkeypatch):\n+        \"\"\"When no openai key and no local, returns none.\"\"\"\n+        monkeypatch.delenv(\"VOICE_TOOLS_OPENAI_KEY\", raising=False)\n+        monkeypatch.delenv(\"GROQ_API_KEY\", raising=False)\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", False), \\\n+             patch(\"tools.transcription_tools._HAS_OPENAI\", True):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({\"provider\": \"openai\"}) == \"none\"\n+\n+    def test_unknown_provider_passed_through(self):\n+        from tools.transcription_tools import _get_provider\n+        assert _get_provider({\"provider\": \"custom-endpoint\"}) == \"custom-endpoint\"\n+\n+    def test_empty_config_defaults_to_local(self):\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", True):\n+            from tools.transcription_tools import _get_provider\n+            assert _get_provider({}) == \"local\"\n+\n+\n+# ============================================================================\n+# _transcribe_groq\n+# ============================================================================\n+\n+class TestTranscribeGroq:\n+    def test_no_key(self, monkeypatch):\n+        monkeypatch.delenv(\"GROQ_API_KEY\", raising=False)\n+        from tools.transcription_tools import _transcribe_groq\n+        result = _transcribe_groq(\"/tmp/test.ogg\", \"whisper-large-v3-turbo\")\n+        assert result[\"success\"] is False\n+        assert \"GROQ_API_KEY\" in result[\"error\"]\n+\n+    def test_openai_package_not_installed(self, monkeypatch):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", False):\n+            from tools.transcription_tools import _transcribe_groq\n+            result = _transcribe_groq(\"/tmp/test.ogg\", \"whisper-large-v3-turbo\")\n+        assert result[\"success\"] is False\n+        assert \"openai package\" in result[\"error\"]\n+\n+    def test_successful_transcription(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"hello world\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq\n+            result = _transcribe_groq(sample_wav, \"whisper-large-v3-turbo\")\n+\n+        assert result[\"success\"] is True\n+        assert result[\"transcript\"] == \"hello world\"\n+        assert result[\"provider\"] == \"groq\"\n+\n+    def test_whitespace_stripped(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"  hello world  \\n\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq\n+            result = _transcribe_groq(sample_wav, \"whisper-large-v3-turbo\")\n+\n+        assert result[\"transcript\"] == \"hello world\"\n+\n+    def test_uses_groq_base_url(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client) as mock_openai_cls:\n+            from tools.transcription_tools import _transcribe_groq, GROQ_BASE_URL\n+            _transcribe_groq(sample_wav, \"whisper-large-v3-turbo\")\n+\n+        call_kwargs = mock_openai_cls.call_args\n+        assert call_kwargs.kwargs[\"base_url\"] == GROQ_BASE_URL\n+\n+    def test_api_error_returns_failure(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.side_effect = Exception(\"API error\")\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq\n+            result = _transcribe_groq(sample_wav, \"whisper-large-v3-turbo\")\n+\n+        assert result[\"success\"] is False\n+        assert \"API error\" in result[\"error\"]\n+\n+    def test_permission_error(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.side_effect = PermissionError(\"denied\")\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq\n+            result = _transcribe_groq(sample_wav, \"whisper-large-v3-turbo\")\n+\n+        assert result[\"success\"] is False\n+        assert \"Permission denied\" in result[\"error\"]\n+\n+\n+# ============================================================================\n+# _transcribe_openai — additional tests\n+# ============================================================================\n+\n+class TestTranscribeOpenAIExtended:\n+    def test_openai_package_not_installed(self, monkeypatch):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", False):\n+            from tools.transcription_tools import _transcribe_openai\n+            result = _transcribe_openai(\"/tmp/test.ogg\", \"whisper-1\")\n+        assert result[\"success\"] is False\n+        assert \"openai package\" in result[\"error\"]\n+\n+    def test_uses_openai_base_url(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client) as mock_openai_cls:\n+            from tools.transcription_tools import _transcribe_openai, OPENAI_BASE_URL\n+            _transcribe_openai(sample_wav, \"whisper-1\")\n+\n+        call_kwargs = mock_openai_cls.call_args\n+        assert call_kwargs.kwargs[\"base_url\"] == OPENAI_BASE_URL\n+\n+    def test_whitespace_stripped(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"  hello  \\n\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_openai\n+            result = _transcribe_openai(sample_wav, \"whisper-1\")\n+\n+        assert result[\"transcript\"] == \"hello\"\n+\n+    def test_permission_error(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.side_effect = PermissionError(\"denied\")\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_openai\n+            result = _transcribe_openai(sample_wav, \"whisper-1\")\n+\n+        assert result[\"success\"] is False\n+        assert \"Permission denied\" in result[\"error\"]\n+\n+\n+# ============================================================================\n+# _transcribe_local — additional tests\n+# ============================================================================\n+\n+class TestTranscribeLocalExtended:\n+    def test_model_reuse_on_second_call(self, tmp_path):\n+        \"\"\"Second call with same model should NOT reload the model.\"\"\"\n+        audio = tmp_path / \"test.ogg\"\n+        audio.write_bytes(b\"fake\")\n+\n+        mock_segment = MagicMock()\n+        mock_segment.text = \"hi\"\n+        mock_info = MagicMock()\n+        mock_info.language = \"en\"\n+        mock_info.duration = 1.0\n+\n+        mock_model = MagicMock()\n+        mock_model.transcribe.return_value = ([mock_segment], mock_info)\n+        mock_whisper_cls = MagicMock(return_value=mock_model)\n+\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", True), \\\n+             patch(\"faster_whisper.WhisperModel\", mock_whisper_cls), \\\n+             patch(\"tools.transcription_tools._local_model\", None), \\\n+             patch(\"tools.transcription_tools._local_model_name\", None):\n+            from tools.transcription_tools import _transcribe_local\n+            _transcribe_local(str(audio), \"base\")\n+            _transcribe_local(str(audio), \"base\")\n+\n+        # WhisperModel should be created only once\n+        assert mock_whisper_cls.call_count == 1\n+\n+    def test_model_reloaded_on_change(self, tmp_path):\n+        \"\"\"Switching model name should reload the model.\"\"\"\n+        audio = tmp_path / \"test.ogg\"\n+        audio.write_bytes(b\"fake\")\n+\n+        mock_segment = MagicMock()\n+        mock_segment.text = \"hi\"\n+        mock_info = MagicMock()\n+        mock_info.language = \"en\"\n+        mock_info.duration = 1.0\n+\n+        mock_model = MagicMock()\n+        mock_model.transcribe.return_value = ([mock_segment], mock_info)\n+        mock_whisper_cls = MagicMock(return_value=mock_model)\n+\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", True), \\\n+             patch(\"faster_whisper.WhisperModel\", mock_whisper_cls), \\\n+             patch(\"tools.transcription_tools._local_model\", None), \\\n+             patch(\"tools.transcription_tools._local_model_name\", None):\n+            from tools.transcription_tools import _transcribe_local\n+            _transcribe_local(str(audio), \"base\")\n+            _transcribe_local(str(audio), \"small\")\n+\n+        assert mock_whisper_cls.call_count == 2\n+\n+    def test_exception_returns_failure(self, tmp_path):\n+        audio = tmp_path / \"test.ogg\"\n+        audio.write_bytes(b\"fake\")\n+\n+        mock_whisper_cls = MagicMock(side_effect=RuntimeError(\"CUDA out of memory\"))\n+\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", True), \\\n+             patch(\"faster_whisper.WhisperModel\", mock_whisper_cls), \\\n+             patch(\"tools.transcription_tools._local_model\", None):\n+            from tools.transcription_tools import _transcribe_local\n+            result = _transcribe_local(str(audio), \"large-v3\")\n+\n+        assert result[\"success\"] is False\n+        assert \"CUDA out of memory\" in result[\"error\"]\n+\n+    def test_multiple_segments_joined(self, tmp_path):\n+        audio = tmp_path / \"test.ogg\"\n+        audio.write_bytes(b\"fake\")\n+\n+        seg1 = MagicMock()\n+        seg1.text = \"Hello\"\n+        seg2 = MagicMock()\n+        seg2.text = \" world\"\n+        mock_info = MagicMock()\n+        mock_info.language = \"en\"\n+        mock_info.duration = 3.0\n+\n+        mock_model = MagicMock()\n+        mock_model.transcribe.return_value = ([seg1, seg2], mock_info)\n+\n+        with patch(\"tools.transcription_tools._HAS_FASTER_WHISPER\", True), \\\n+             patch(\"faster_whisper.WhisperModel\", return_value=mock_model), \\\n+             patch(\"tools.transcription_tools._local_model\", None):\n+            from tools.transcription_tools import _transcribe_local\n+            result = _transcribe_local(str(audio), \"base\")\n+\n+        assert result[\"success\"] is True\n+        assert result[\"transcript\"] == \"Hello world\"\n+\n+\n+# ============================================================================\n+# Model auto-correction\n+# ============================================================================\n+\n+class TestModelAutoCorrection:\n+    def test_groq_corrects_openai_model(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"hello world\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq, DEFAULT_GROQ_STT_MODEL\n+            _transcribe_groq(sample_wav, \"whisper-1\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == DEFAULT_GROQ_STT_MODEL\n+\n+    def test_groq_corrects_gpt4o_transcribe(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq, DEFAULT_GROQ_STT_MODEL\n+            _transcribe_groq(sample_wav, \"gpt-4o-transcribe\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == DEFAULT_GROQ_STT_MODEL\n+\n+    def test_openai_corrects_groq_model(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"hello world\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_openai, DEFAULT_STT_MODEL\n+            _transcribe_openai(sample_wav, \"whisper-large-v3-turbo\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == DEFAULT_STT_MODEL\n+\n+    def test_openai_corrects_distil_whisper(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_openai, DEFAULT_STT_MODEL\n+            _transcribe_openai(sample_wav, \"distil-whisper-large-v3-en\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == DEFAULT_STT_MODEL\n+\n+    def test_compatible_groq_model_not_overridden(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq\n+            _transcribe_groq(sample_wav, \"whisper-large-v3\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == \"whisper-large-v3\"\n+\n+    def test_compatible_openai_model_not_overridden(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_openai\n+            _transcribe_openai(sample_wav, \"gpt-4o-mini-transcribe\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == \"gpt-4o-mini-transcribe\"\n+\n+    def test_unknown_model_passes_through_groq(self, monkeypatch, sample_wav):\n+        \"\"\"A model not in either known set should not be overridden.\"\"\"\n+        monkeypatch.setenv(\"GROQ_API_KEY\", \"gsk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_groq\n+            _transcribe_groq(sample_wav, \"my-custom-model\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == \"my-custom-model\"\n+\n+    def test_unknown_model_passes_through_openai(self, monkeypatch, sample_wav):\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test\")\n+\n+        mock_client = MagicMock()\n+        mock_client.audio.transcriptions.create.return_value = \"test\"\n+\n+        with patch(\"tools.transcription_tools._HAS_OPENAI\", True), \\\n+             patch(\"openai.OpenAI\", return_value=mock_client):\n+            from tools.transcription_tools import _transcribe_openai\n+            _transcribe_openai(sample_wav, \"my-custom-model\")\n+\n+        call_kwargs = mock_client.audio.transcriptions.create.call_args\n+        assert call_kwargs.kwargs[\"model\"] == \"my-custom-model\"\n+\n+\n+# ============================================================================\n+# _load_stt_config\n+# ============================================================================\n+\n+class TestLoadSttConfig:\n+    def test_returns_dict_when_import_fails(self):\n+        with patch(\"tools.transcription_tools._load_stt_config\") as mock_load:\n+            mock_load.return_value = {}\n+            from tools.transcription_tools import _load_stt_config\n+            assert _load_stt_config() == {}\n+\n+    def test_real_load_returns_dict(self):\n+        \"\"\"_load_stt_config should always return a dict, even on import error.\"\"\"\n+        with patch.dict(\"sys.modules\", {\"hermes_cli\": None, \"hermes_cli.config\": None}):\n+            from tools.transcription_tools import _load_stt_config\n+            result = _load_stt_config()\n+        assert isinstance(result, dict)\n+\n+\n+# ============================================================================\n+# _validate_audio_file — edge cases\n+# ============================================================================\n+\n+class TestValidateAudioFileEdgeCases:\n+    def test_directory_is_not_a_file(self, tmp_path):\n+        from tools.transcription_tools import _validate_audio_file\n+        # tmp_path itself is a directory with an .ogg-ish name? No.\n+        # Create a directory with a valid audio extension\n+        d = tmp_path / \"audio.ogg\"\n+        d.mkdir()\n+        result = _validate_audio_file(str(d))\n+        assert result is not None\n+        assert \"not a file\" in result[\"error\"]\n+\n+    def test_stat_oserror(self, tmp_path):\n+        f = tmp_path / \"test.ogg\"\n+        f.write_bytes(b\"data\")\n+        from tools.transcription_tools import _validate_audio_file\n+        real_stat = f.stat()\n+        call_count = 0\n+\n+        def stat_side_effect(*args, **kwargs):\n+            nonlocal call_count\n+            call_count += 1\n+            # First calls are from exists() and is_file(), let them pass\n+            if call_count <= 2:\n+                return real_stat\n+            raise OSError(\"disk error\")\n+\n+        with patch(\"pathlib.Path.stat\", side_effect=stat_side_effect):\n+            result = _validate_audio_file(str(f))\n+        assert result is not None\n+        assert \"Failed to access\" in result[\"error\"]\n+\n+    def test_all_supported_formats_accepted(self, tmp_path):\n+        from tools.transcription_tools import _validate_audio_file, SUPPORTED_FORMATS\n+        for fmt in SUPPORTED_FORMATS:\n+            f = tmp_path / f\"test{fmt}\"\n+            f.write_bytes(b\"data\")\n+            assert _validate_audio_file(str(f)) is None, f\"Format {fmt} should be accepted\"\n+\n+    def test_case_insensitive_extension(self, tmp_path):\n+        from tools.transcription_tools import _validate_audio_file\n+        f = tmp_path / \"test.MP3\"\n+        f.write_bytes(b\"data\")\n+        assert _validate_audio_file(str(f)) is None\n+\n+\n+# ============================================================================\n+# transcribe_audio — end-to-end dispatch\n+# ============================================================================\n+\n+class TestTranscribeAudioDispatch:\n+    def test_dispatches_to_groq(self, sample_ogg):\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value={\"provider\": \"groq\"}), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"groq\"), \\\n+             patch(\"tools.transcription_tools._transcribe_groq\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\", \"provider\": \"groq\"}) as mock_groq:\n+            from tools.transcription_tools import transcribe_audio\n+            result = transcribe_audio(sample_ogg)\n+\n+        assert result[\"success\"] is True\n+        assert result[\"provider\"] == \"groq\"\n+        mock_groq.assert_called_once()\n+\n+    def test_dispatches_to_local(self, sample_ogg):\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value={}), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"local\"), \\\n+             patch(\"tools.transcription_tools._transcribe_local\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\"}) as mock_local:\n+            from tools.transcription_tools import transcribe_audio\n+            result = transcribe_audio(sample_ogg)\n+\n+        assert result[\"success\"] is True\n+        mock_local.assert_called_once()\n+\n+    def test_dispatches_to_openai(self, sample_ogg):\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value={\"provider\": \"openai\"}), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"openai\"), \\\n+             patch(\"tools.transcription_tools._transcribe_openai\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\", \"provider\": \"openai\"}) as mock_openai:\n+            from tools.transcription_tools import transcribe_audio\n+            result = transcribe_audio(sample_ogg)\n+\n+        assert result[\"success\"] is True\n+        mock_openai.assert_called_once()\n+\n+    def test_no_provider_returns_error(self, sample_ogg):\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value={}), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"none\"):\n+            from tools.transcription_tools import transcribe_audio\n+            result = transcribe_audio(sample_ogg)\n+\n+        assert result[\"success\"] is False\n+        assert \"No STT provider\" in result[\"error\"]\n+        assert \"faster-whisper\" in result[\"error\"]\n+        assert \"GROQ_API_KEY\" in result[\"error\"]\n+\n+    def test_invalid_file_short_circuits(self):\n+        from tools.transcription_tools import transcribe_audio\n+        result = transcribe_audio(\"/nonexistent/audio.wav\")\n+        assert result[\"success\"] is False\n+        assert \"not found\" in result[\"error\"]\n+\n+    def test_model_override_passed_to_groq(self, sample_ogg):\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value={}), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"groq\"), \\\n+             patch(\"tools.transcription_tools._transcribe_groq\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\"}) as mock_groq:\n+            from tools.transcription_tools import transcribe_audio\n+            transcribe_audio(sample_ogg, model=\"whisper-large-v3\")\n+\n+        _, kwargs = mock_groq.call_args\n+        assert kwargs.get(\"model_name\") or mock_groq.call_args[0][1] == \"whisper-large-v3\"\n+\n+    def test_model_override_passed_to_local(self, sample_ogg):\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value={}), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"local\"), \\\n+             patch(\"tools.transcription_tools._transcribe_local\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\"}) as mock_local:\n+            from tools.transcription_tools import transcribe_audio\n+            transcribe_audio(sample_ogg, model=\"large-v3\")\n+\n+        assert mock_local.call_args[0][1] == \"large-v3\"\n+\n+    def test_default_model_used_when_none(self, sample_ogg):\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value={}), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"groq\"), \\\n+             patch(\"tools.transcription_tools._transcribe_groq\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\"}) as mock_groq:\n+            from tools.transcription_tools import transcribe_audio, DEFAULT_GROQ_STT_MODEL\n+            transcribe_audio(sample_ogg, model=None)\n+\n+        assert mock_groq.call_args[0][1] == DEFAULT_GROQ_STT_MODEL\n+\n+    def test_config_local_model_used(self, sample_ogg):\n+        config = {\"local\": {\"model\": \"small\"}}\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value=config), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"local\"), \\\n+             patch(\"tools.transcription_tools._transcribe_local\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\"}) as mock_local:\n+            from tools.transcription_tools import transcribe_audio\n+            transcribe_audio(sample_ogg, model=None)\n+\n+        assert mock_local.call_args[0][1] == \"small\"\n+\n+    def test_config_openai_model_used(self, sample_ogg):\n+        config = {\"openai\": {\"model\": \"gpt-4o-transcribe\"}}\n+        with patch(\"tools.transcription_tools._load_stt_config\", return_value=config), \\\n+             patch(\"tools.transcription_tools._get_provider\", return_value=\"openai\"), \\\n+             patch(\"tools.transcription_tools._transcribe_openai\",\n+                   return_value={\"success\": True, \"transcript\": \"hi\"}) as mock_openai:\n+            from tools.transcription_tools import transcribe_audio\n+            transcribe_audio(sample_ogg, model=None)\n+\n+        assert mock_openai.call_args[0][1] == \"gpt-4o-transcribe\"\n+\n+\n+# ============================================================================\n+# get_stt_model_from_config\n+# ============================================================================\n+\n+class TestGetSttModelFromConfig:\n+    def test_returns_model_from_config(self, tmp_path, monkeypatch):\n+        cfg = tmp_path / \"config.yaml\"\n+        cfg.write_text(\"stt:\\n  model: whisper-large-v3\\n\")\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        from tools.transcription_tools import get_stt_model_from_config\n+        assert get_stt_model_from_config() == \"whisper-large-v3\"\n+\n+    def test_returns_none_when_no_stt_section(self, tmp_path, monkeypatch):\n+        cfg = tmp_path / \"config.yaml\"\n+        cfg.write_text(\"tts:\\n  provider: edge\\n\")\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        from tools.transcription_tools import get_stt_model_from_config\n+        assert get_stt_model_from_config() is None\n+\n+    def test_returns_none_when_no_config_file(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        from tools.transcription_tools import get_stt_model_from_config\n+        assert get_stt_model_from_config() is None\n+\n+    def test_returns_none_on_invalid_yaml(self, tmp_path, monkeypatch):\n+        cfg = tmp_path / \"config.yaml\"\n+        cfg.write_text(\": : :\\n  bad yaml [[[\")\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        from tools.transcription_tools import get_stt_model_from_config\n+        assert get_stt_model_from_config() is None\n+\n+    def test_returns_none_when_model_key_missing(self, tmp_path, monkeypatch):\n+        cfg = tmp_path / \"config.yaml\"\n+        cfg.write_text(\"stt:\\n  enabled: true\\n\")\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        from tools.transcription_tools import get_stt_model_from_config\n+        assert get_stt_model_from_config() is None"]},{"file":"tests/tools/test_voice_cli_integration.py","hunks":["@@ -0,0 +1,1233 @@\n+\"\"\"Tests for CLI voice mode integration -- command parsing, markdown stripping,\n+state management, streaming TTS activation, voice message prefix, _vprint.\"\"\"\n+\n+import ast\n+import os\n+import queue\n+import threading\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+def _make_voice_cli(**overrides):\n+    \"\"\"Create a minimal HermesCLI with only voice-related attrs initialized.\n+\n+    Uses ``__new__()`` to bypass ``__init__`` so no config/env/API setup is\n+    needed.  Only the voice state attributes (from __init__ lines 3749-3758)\n+    are populated.\n+    \"\"\"\n+    from cli import HermesCLI\n+\n+    cli = HermesCLI.__new__(HermesCLI)\n+    cli._voice_lock = threading.Lock()\n+    cli._voice_mode = False\n+    cli._voice_tts = False\n+    cli._voice_recorder = None\n+    cli._voice_recording = False\n+    cli._voice_processing = False\n+    cli._voice_continuous = False\n+    cli._voice_tts_done = threading.Event()\n+    cli._voice_tts_done.set()\n+    cli._pending_input = queue.Queue()\n+    cli._app = None\n+    cli.console = SimpleNamespace(width=80)\n+    for k, v in overrides.items():\n+        setattr(cli, k, v)\n+    return cli\n+\n+\n+# ============================================================================\n+# Markdown stripping — import real function from tts_tool\n+# ============================================================================\n+\n+from tools.tts_tool import _strip_markdown_for_tts\n+\n+\n+class TestMarkdownStripping:\n+    def test_strips_bold(self):\n+        assert _strip_markdown_for_tts(\"This is **bold** text\") == \"This is bold text\"\n+\n+    def test_strips_italic(self):\n+        assert _strip_markdown_for_tts(\"This is *italic* text\") == \"This is italic text\"\n+\n+    def test_strips_inline_code(self):\n+        assert _strip_markdown_for_tts(\"Run `pip install foo`\") == \"Run pip install foo\"\n+\n+    def test_strips_fenced_code_blocks(self):\n+        text = \"Here is code:\\n```python\\nprint('hello')\\n```\\nDone.\"\n+        result = _strip_markdown_for_tts(text)\n+        assert \"print\" not in result\n+        assert \"Done.\" in result\n+\n+    def test_strips_headers(self):\n+        assert _strip_markdown_for_tts(\"## Summary\\nSome text\") == \"Summary\\nSome text\"\n+\n+    def test_strips_list_markers(self):\n+        text = \"- item one\\n- item two\\n* item three\"\n+        result = _strip_markdown_for_tts(text)\n+        assert \"item one\" in result\n+        assert \"- \" not in result\n+        assert \"* \" not in result\n+\n+    def test_strips_urls(self):\n+        text = \"Visit https://example.com for details\"\n+        result = _strip_markdown_for_tts(text)\n+        assert \"https://\" not in result\n+        assert \"Visit\" in result\n+\n+    def test_strips_markdown_links(self):\n+        text = \"See [the docs](https://example.com/docs) for info\"\n+        result = _strip_markdown_for_tts(text)\n+        assert \"the docs\" in result\n+        assert \"https://\" not in result\n+        assert \"[\" not in result\n+\n+    def test_strips_horizontal_rules(self):\n+        text = \"Part one\\n---\\nPart two\"\n+        result = _strip_markdown_for_tts(text)\n+        assert \"---\" not in result\n+        assert \"Part one\" in result\n+        assert \"Part two\" in result\n+\n+    def test_empty_after_stripping_returns_empty(self):\n+        text = \"```python\\nprint('hello')\\n```\"\n+        result = _strip_markdown_for_tts(text)\n+        assert result == \"\"\n+\n+    def test_long_text_not_truncated(self):\n+        \"\"\"_strip_markdown_for_tts does NOT truncate — that's the caller's job.\"\"\"\n+        text = \"a\" * 5000\n+        result = _strip_markdown_for_tts(text)\n+        assert len(result) == 5000\n+\n+    def test_complex_response(self):\n+        text = (\n+            \"## Answer\\n\\n\"\n+            \"Here's how to do it:\\n\\n\"\n+            \"```python\\ndef hello():\\n    print('hi')\\n```\\n\\n\"\n+            \"Run it with `python main.py`. \"\n+            \"See [docs](https://example.com) for more.\\n\\n\"\n+            \"- Step one\\n- Step two\\n\\n\"\n+            \"---\\n\\n\"\n+            \"**Good luck!**\"\n+        )\n+        result = _strip_markdown_for_tts(text)\n+        assert \"```\" not in result\n+        assert \"https://\" not in result\n+        assert \"**\" not in result\n+        assert \"---\" not in result\n+        assert \"Answer\" in result\n+        assert \"Good luck!\" in result\n+        assert \"docs\" in result\n+\n+\n+# ============================================================================\n+# Voice command parsing\n+# ============================================================================\n+\n+class TestVoiceCommandParsing:\n+    \"\"\"Test _handle_voice_command logic without full CLI setup.\"\"\"\n+\n+    def test_parse_subcommands(self):\n+        \"\"\"Verify subcommand extraction from /voice commands.\"\"\"\n+        test_cases = [\n+            (\"/voice on\", \"on\"),\n+            (\"/voice off\", \"off\"),\n+            (\"/voice tts\", \"tts\"),\n+            (\"/voice status\", \"status\"),\n+            (\"/voice\", \"\"),\n+            (\"/voice  ON  \", \"on\"),\n+        ]\n+        for command, expected in test_cases:\n+            parts = command.strip().split(maxsplit=1)\n+            subcommand = parts[1].lower().strip() if len(parts) > 1 else \"\"\n+            assert subcommand == expected, f\"Failed for {command!r}: got {subcommand!r}\"\n+\n+\n+# ============================================================================\n+# Voice state thread safety\n+# ============================================================================\n+\n+class TestVoiceStateLock:\n+    def test_lock_protects_state(self):\n+        \"\"\"Verify that concurrent state changes don't corrupt state.\"\"\"\n+        lock = threading.Lock()\n+        state = {\"recording\": False, \"count\": 0}\n+\n+        def toggle_many(n):\n+            for _ in range(n):\n+                with lock:\n+                    state[\"recording\"] = not state[\"recording\"]\n+                    state[\"count\"] += 1\n+\n+        threads = [threading.Thread(target=toggle_many, args=(1000,)) for _ in range(4)]\n+        for t in threads:\n+            t.start()\n+        for t in threads:\n+            t.join()\n+\n+        assert state[\"count\"] == 4000\n+\n+\n+# ============================================================================\n+# Streaming TTS lazy import activation (Bug A fix)\n+# ============================================================================\n+\n+class TestStreamingTTSActivation:\n+    \"\"\"Verify streaming TTS uses lazy imports to check availability.\"\"\"\n+\n+    def test_activates_when_elevenlabs_and_sounddevice_available(self):\n+        \"\"\"use_streaming_tts should be True when provider is elevenlabs\n+        and both lazy imports succeed.\"\"\"\n+        use_streaming_tts = False\n+        try:\n+            from tools.tts_tool import (\n+                _load_tts_config as _load_tts_cfg,\n+                _get_provider as _get_prov,\n+                _import_elevenlabs,\n+                _import_sounddevice,\n+            )\n+            assert callable(_import_elevenlabs)\n+            assert callable(_import_sounddevice)\n+        except ImportError:\n+            pytest.skip(\"tools.tts_tool not available\")\n+\n+        with patch(\"tools.tts_tool._load_tts_config\") as mock_cfg, \\\n+             patch(\"tools.tts_tool._get_provider\", return_value=\"elevenlabs\"), \\\n+             patch(\"tools.tts_tool._import_elevenlabs\") as mock_el, \\\n+             patch(\"tools.tts_tool._import_sounddevice\") as mock_sd:\n+            mock_cfg.return_value = {\"provider\": \"elevenlabs\"}\n+            mock_el.return_value = MagicMock()\n+            mock_sd.return_value = MagicMock()\n+\n+            from tools.tts_tool import (\n+                _load_tts_config as load_cfg,\n+                _get_provider as get_prov,\n+                _import_elevenlabs as import_el,\n+                _import_sounddevice as import_sd,\n+            )\n+            cfg = load_cfg()\n+            if get_prov(cfg) == \"elevenlabs\":\n+                import_el()\n+                import_sd()\n+                use_streaming_tts = True\n+\n+        assert use_streaming_tts is True\n+\n+    def test_does_not_activate_when_elevenlabs_missing(self):\n+        \"\"\"use_streaming_tts stays False when elevenlabs import fails.\"\"\"\n+        use_streaming_tts = False\n+        with patch(\"tools.tts_tool._load_tts_config\", return_value={\"provider\": \"elevenlabs\"}), \\\n+             patch(\"tools.tts_tool._get_provider\", return_value=\"elevenlabs\"), \\\n+             patch(\"tools.tts_tool._import_elevenlabs\", side_effect=ImportError(\"no elevenlabs\")):\n+            try:\n+                from tools.tts_tool import (\n+                    _load_tts_config as load_cfg,\n+                    _get_provider as get_prov,\n+                    _import_elevenlabs as import_el,\n+                    _import_sounddevice as import_sd,\n+                )\n+                cfg = load_cfg()\n+                if get_prov(cfg) == \"elevenlabs\":\n+                    import_el()\n+                    import_sd()\n+                    use_streaming_tts = True\n+            except (ImportError, OSError):\n+                pass\n+\n+        assert use_streaming_tts is False\n+\n+    def test_does_not_activate_when_sounddevice_missing(self):\n+        \"\"\"use_streaming_tts stays False when sounddevice import fails.\"\"\"\n+        use_streaming_tts = False\n+        with patch(\"tools.tts_tool._load_tts_config\", return_value={\"provider\": \"elevenlabs\"}), \\\n+             patch(\"tools.tts_tool._get_provider\", return_value=\"elevenlabs\"), \\\n+             patch(\"tools.tts_tool._import_elevenlabs\", return_value=MagicMock()), \\\n+             patch(\"tools.tts_tool._import_sounddevice\", side_effect=OSError(\"no PortAudio\")):\n+            try:\n+                from tools.tts_tool import (\n+                    _load_tts_config as load_cfg,\n+                    _get_provider as get_prov,\n+                    _import_elevenlabs as import_el,\n+                    _import_sounddevice as import_sd,\n+                )\n+                cfg = load_cfg()\n+                if get_prov(cfg) == \"elevenlabs\":\n+                    import_el()\n+                    import_sd()\n+                    use_streaming_tts = True\n+            except (ImportError, OSError):\n+                pass\n+\n+        assert use_streaming_tts is False\n+\n+    def test_does_not_activate_for_non_elevenlabs_provider(self):\n+        \"\"\"use_streaming_tts stays False when provider is not elevenlabs.\"\"\"\n+        use_streaming_tts = False\n+        with patch(\"tools.tts_tool._load_tts_config\", return_value={\"provider\": \"edge\"}), \\\n+             patch(\"tools.tts_tool._get_provider\", return_value=\"edge\"):\n+            try:\n+                from tools.tts_tool import (\n+                    _load_tts_config as load_cfg,\n+                    _get_provider as get_prov,\n+                    _import_elevenlabs as import_el,\n+                    _import_sounddevice as import_sd,\n+                )\n+                cfg = load_cfg()\n+                if get_prov(cfg) == \"elevenlabs\":\n+                    import_el()\n+                    import_sd()\n+                    use_streaming_tts = True\n+            except (ImportError, OSError):\n+                pass\n+\n+        assert use_streaming_tts is False\n+\n+    def test_stale_boolean_imports_no_longer_exist(self):\n+        \"\"\"Confirm _HAS_ELEVENLABS and _HAS_AUDIO are not in tts_tool module.\"\"\"\n+        import tools.tts_tool as tts_mod\n+        assert not hasattr(tts_mod, \"_HAS_ELEVENLABS\"), \\\n+            \"_HAS_ELEVENLABS should not exist -- lazy imports replaced it\"\n+        assert not hasattr(tts_mod, \"_HAS_AUDIO\"), \\\n+            \"_HAS_AUDIO should not exist -- lazy imports replaced it\"\n+\n+\n+# ============================================================================\n+# Voice mode user message prefix (Bug B fix)\n+# ============================================================================\n+\n+class TestVoiceMessagePrefix:\n+    \"\"\"Voice mode should inject instruction via user message prefix,\n+    not by modifying the system prompt (which breaks prompt cache).\"\"\"\n+\n+    def test_prefix_added_when_voice_mode_active(self):\n+        \"\"\"When voice mode is active and message is str, agent_message\n+        should have the voice instruction prefix.\"\"\"\n+        voice_mode = True\n+        message = \"What's the weather like?\"\n+\n+        agent_message = message\n+        if voice_mode and isinstance(message, str):\n+            agent_message = (\n+                \"[Voice input — respond concisely and conversationally, \"\n+                \"2-3 sentences max. No code blocks or markdown.] \"\n+                + message\n+            )\n+\n+        assert agent_message.startswith(\"[Voice input\")\n+        assert \"What's the weather like?\" in agent_message\n+\n+    def test_no_prefix_when_voice_mode_inactive(self):\n+        \"\"\"When voice mode is off, message passes through unchanged.\"\"\"\n+        voice_mode = False\n+        message = \"What's the weather like?\"\n+\n+        agent_message = message\n+        if voice_mode and isinstance(message, str):\n+            agent_message = (\n+                \"[Voice input — respond concisely and conversationally, \"\n+                \"2-3 sentences max. No code blocks or markdown.] \"\n+                + message\n+            )\n+\n+        assert agent_message == message\n+\n+    def test_no_prefix_for_multimodal_content(self):\n+        \"\"\"When message is a list (multimodal), no prefix is added.\"\"\"\n+        voice_mode = True\n+        message = [{\"type\": \"text\", \"text\": \"describe this\"}, {\"type\": \"image_url\"}]\n+\n+        agent_message = message\n+        if voice_mode and isinstance(message, str):\n+            agent_message = (\n+                \"[Voice input — respond concisely and conversationally, \"\n+                \"2-3 sentences max. No code blocks or markdown.] \"\n+                + message\n+            )\n+\n+        assert agent_message is message\n+\n+    def test_history_stays_clean(self):\n+        \"\"\"conversation_history should contain the original message,\n+        not the prefixed version.\"\"\"\n+        voice_mode = True\n+        message = \"Hello there\"\n+        conversation_history = []\n+\n+        conversation_history.append({\"role\": \"user\", \"content\": message})\n+\n+        agent_message = message\n+        if voice_mode and isinstance(message, str):\n+            agent_message = (\n+                \"[Voice input — respond concisely and conversationally, \"\n+                \"2-3 sentences max. No code blocks or markdown.] \"\n+                + message\n+            )\n+\n+        assert conversation_history[-1][\"content\"] == \"Hello there\"\n+        assert agent_message.startswith(\"[Voice input\")\n+        assert agent_message != conversation_history[-1][\"content\"]\n+\n+    def test_enable_voice_mode_does_not_modify_system_prompt(self):\n+        \"\"\"_enable_voice_mode should NOT modify self.system_prompt or\n+        agent.ephemeral_system_prompt -- the system prompt must stay\n+        stable to preserve prompt cache.\"\"\"\n+        cli = SimpleNamespace(\n+            _voice_mode=False,\n+            _voice_tts=False,\n+            _voice_lock=threading.Lock(),\n+            system_prompt=\"You are helpful\",\n+            agent=SimpleNamespace(ephemeral_system_prompt=\"You are helpful\"),\n+        )\n+\n+        original_system = cli.system_prompt\n+        original_ephemeral = cli.agent.ephemeral_system_prompt\n+\n+        cli._voice_mode = True\n+\n+        assert cli.system_prompt == original_system\n+        assert cli.agent.ephemeral_system_prompt == original_ephemeral\n+\n+\n+# ============================================================================\n+# _vprint force parameter (Minor fix)\n+# ============================================================================\n+\n+class TestVprintForceParameter:\n+    \"\"\"_vprint should suppress output during streaming TTS unless force=True.\"\"\"\n+\n+    def _make_agent_with_stream(self, stream_active: bool):\n+        \"\"\"Create a minimal agent-like object with _vprint.\"\"\"\n+        agent = SimpleNamespace(\n+            _stream_callback=MagicMock() if stream_active else None,\n+        )\n+\n+        def _vprint(*args, force=False, **kwargs):\n+            if not force and getattr(agent, \"_stream_callback\", None) is not None:\n+                return\n+            print(*args, **kwargs)\n+\n+        agent._vprint = _vprint\n+        return agent\n+\n+    def test_suppressed_during_streaming(self, capsys):\n+        \"\"\"Normal _vprint output is suppressed when streaming TTS is active.\"\"\"\n+        agent = self._make_agent_with_stream(stream_active=True)\n+        agent._vprint(\"should be hidden\")\n+        captured = capsys.readouterr()\n+        assert captured.out == \"\"\n+\n+    def test_shown_when_not_streaming(self, capsys):\n+        \"\"\"Normal _vprint output is shown when streaming is not active.\"\"\"\n+        agent = self._make_agent_with_stream(stream_active=False)\n+        agent._vprint(\"should be shown\")\n+        captured = capsys.readouterr()\n+        assert \"should be shown\" in captured.out\n+\n+    def test_force_shown_during_streaming(self, capsys):\n+        \"\"\"force=True bypasses the streaming suppression.\"\"\"\n+        agent = self._make_agent_with_stream(stream_active=True)\n+        agent._vprint(\"critical error!\", force=True)\n+        captured = capsys.readouterr()\n+        assert \"critical error!\" in captured.out\n+\n+    def test_force_shown_when_not_streaming(self, capsys):\n+        \"\"\"force=True works normally when not streaming (no regression).\"\"\"\n+        agent = self._make_agent_with_stream(stream_active=False)\n+        agent._vprint(\"normal message\", force=True)\n+        captured = capsys.readouterr()\n+        assert \"normal message\" in captured.out\n+\n+    def test_error_messages_use_force_in_run_agent(self):\n+        \"\"\"Verify that critical error _vprint calls in run_agent.py\n+        include force=True.\"\"\"\n+        with open(\"run_agent.py\", \"r\") as f:\n+            source = f.read()\n+\n+        tree = ast.parse(source)\n+\n+        forced_error_count = 0\n+        unforced_error_count = 0\n+\n+        for node in ast.walk(tree):\n+            if not isinstance(node, ast.Call):\n+                continue\n+            func = node.func\n+            if not (isinstance(func, ast.Attribute) and func.attr == \"_vprint\"):\n+                continue\n+            has_fatal = False\n+            for arg in node.args:\n+                if isinstance(arg, ast.JoinedStr):\n+                    for val in arg.values:\n+                        if isinstance(val, ast.Constant) and isinstance(val.value, str):\n+                            if \"\\u274c\" in val.value:\n+                                has_fatal = True\n+                                break\n+\n+            if not has_fatal:\n+                continue\n+\n+            has_force = any(\n+                kw.arg == \"force\"\n+                and isinstance(kw.value, ast.Constant)\n+                and kw.value.value is True\n+                for kw in node.keywords\n+            )\n+\n+            if has_force:\n+                forced_error_count += 1\n+            else:\n+                unforced_error_count += 1\n+\n+        assert forced_error_count > 0, \\\n+            \"Expected at least one _vprint with force=True for error messages\"\n+        assert unforced_error_count == 0, \\\n+            f\"Found {unforced_error_count} critical error _vprint calls without force=True\"\n+\n+\n+# ============================================================================\n+# Bug fix regression tests\n+# ============================================================================\n+\n+class TestEdgeTTSLazyImport:\n+    \"\"\"Bug #3: _generate_edge_tts must use lazy import, not bare module name.\"\"\"\n+\n+    def test_generate_edge_tts_calls_lazy_import(self):\n+        \"\"\"AST check: _generate_edge_tts must call _import_edge_tts(), not\n+        reference bare 'edge_tts' module name.\"\"\"\n+        import ast as _ast\n+\n+        with open(\"tools/tts_tool.py\") as f:\n+            tree = _ast.parse(f.read())\n+\n+        for node in _ast.walk(tree):\n+            if isinstance(node, _ast.AsyncFunctionDef) and node.name == \"_generate_edge_tts\":\n+                # Collect all Name references (bare identifiers)\n+                bare_refs = [\n+                    n.id for n in _ast.walk(node)\n+                    if isinstance(n, _ast.Name) and n.id == \"edge_tts\"\n+                ]\n+                assert bare_refs == [], (\n+                    f\"_generate_edge_tts uses bare 'edge_tts' name — \"\n+                    f\"should use _import_edge_tts() lazy helper\"\n+                )\n+\n+                # Must have a call to _import_edge_tts\n+                lazy_calls = [\n+                    n for n in _ast.walk(node)\n+                    if isinstance(n, _ast.Call)\n+                    and isinstance(n.func, _ast.Name)\n+                    and n.func.id == \"_import_edge_tts\"\n+                ]\n+                assert len(lazy_calls) >= 1, (\n+                    \"_generate_edge_tts must call _import_edge_tts()\"\n+                )\n+                break\n+        else:\n+            pytest.fail(\"_generate_edge_tts not found in tts_tool.py\")\n+\n+\n+class TestStreamingTTSOutputStreamCleanup:\n+    \"\"\"Bug #7: output_stream must be closed in finally block.\"\"\"\n+\n+    def test_output_stream_closed_in_finally(self):\n+        \"\"\"AST check: stream_tts_to_speaker's finally block must close\n+        output_stream even on exception.\"\"\"\n+        import ast as _ast\n+\n+        with open(\"tools/tts_tool.py\") as f:\n+            tree = _ast.parse(f.read())\n+\n+        for node in _ast.walk(tree):\n+            if isinstance(node, _ast.FunctionDef) and node.name == \"stream_tts_to_speaker\":\n+                # Find the outermost try that has a finally with tts_done_event.set()\n+                for child in _ast.walk(node):\n+                    if isinstance(child, _ast.Try) and child.finalbody:\n+                        finally_text = \"\\n\".join(\n+                            _ast.dump(n) for n in child.finalbody\n+                        )\n+                        if \"tts_done_event\" in finally_text:\n+                            assert \"output_stream\" in finally_text, (\n+                                \"finally block must close output_stream\"\n+                            )\n+                            return\n+                pytest.fail(\"No finally block with tts_done_event found\")\n+\n+\n+class TestCtrlCResetsContinuousMode:\n+    \"\"\"Bug #4: Ctrl+C cancel must reset _voice_continuous.\"\"\"\n+\n+    def test_ctrl_c_handler_resets_voice_continuous(self):\n+        \"\"\"Source check: Ctrl+C voice cancel block must set\n+        _voice_continuous = False.\"\"\"\n+        with open(\"cli.py\") as f:\n+            source = f.read()\n+\n+        # Find the Ctrl+C handler's voice cancel block\n+        lines = source.split(\"\\n\")\n+        in_cancel_block = False\n+        found_continuous_reset = False\n+        for i, line in enumerate(lines):\n+            if \"Cancel active voice recording\" in line:\n+                in_cancel_block = True\n+            if in_cancel_block:\n+                if \"_voice_continuous = False\" in line:\n+                    found_continuous_reset = True\n+                    break\n+                # Block ends at next comment section or return\n+                if \"return\" in line and in_cancel_block:\n+                    break\n+\n+        assert found_continuous_reset, (\n+            \"Ctrl+C voice cancel block must set _voice_continuous = False\"\n+        )\n+\n+\n+class TestDisableVoiceModeStopsTTS:\n+    \"\"\"Bug #5: _disable_voice_mode must stop active TTS playback.\"\"\"\n+\n+    def test_disable_voice_mode_calls_stop_playback(self):\n+        \"\"\"Source check: _disable_voice_mode must call stop_playback().\"\"\"\n+        import inspect\n+        from cli import HermesCLI\n+\n+        source = inspect.getsource(HermesCLI._disable_voice_mode)\n+        assert \"stop_playback\" in source, (\n+            \"_disable_voice_mode must call stop_playback()\"\n+        )\n+        assert \"_voice_tts_done.set()\" in source, (\n+            \"_disable_voice_mode must set _voice_tts_done\"\n+        )\n+\n+\n+class TestVoiceStatusUsesConfigKey:\n+    \"\"\"Bug #8: _show_voice_status must read record key from config.\"\"\"\n+\n+    def test_show_voice_status_not_hardcoded(self):\n+        \"\"\"Source check: _show_voice_status must not hardcode Ctrl+B.\"\"\"\n+        with open(\"cli.py\") as f:\n+            source = f.read()\n+\n+        lines = source.split(\"\\n\")\n+        in_method = False\n+        for line in lines:\n+            if \"def _show_voice_status\" in line:\n+                in_method = True\n+            elif in_method and line.strip().startswith(\"def \"):\n+                break\n+            elif in_method:\n+                assert 'Record key: Ctrl+B\"' not in line, (\n+                    \"_show_voice_status hardcodes 'Ctrl+B' — \"\n+                    \"should read from config\"\n+                )\n+\n+    def test_show_voice_status_reads_config(self):\n+        \"\"\"Source check: _show_voice_status must use load_config().\"\"\"\n+        with open(\"cli.py\") as f:\n+            source = f.read()\n+\n+        lines = source.split(\"\\n\")\n+        in_method = False\n+        method_lines = []\n+        for line in lines:\n+            if \"def _show_voice_status\" in line:\n+                in_method = True\n+            elif in_method and line.strip().startswith(\"def \"):\n+                break\n+            elif in_method:\n+                method_lines.append(line)\n+\n+        method_body = \"\\n\".join(method_lines)\n+        assert \"load_config\" in method_body or \"record_key\" in method_body, (\n+            \"_show_voice_status should read record_key from config\"\n+        )\n+\n+\n+class TestChatTTSCleanupOnException:\n+    \"\"\"Bug #2: chat() must clean up streaming TTS resources on exception.\"\"\"\n+\n+    def test_chat_has_finally_for_tts_cleanup(self):\n+        \"\"\"AST check: chat() method must have a finally block that cleans up\n+        text_queue, stop_event, and tts_thread.\"\"\"\n+        import ast as _ast\n+\n+        with open(\"cli.py\") as f:\n+            tree = _ast.parse(f.read())\n+\n+        for node in _ast.walk(tree):\n+            if isinstance(node, _ast.FunctionDef) and node.name == \"chat\":\n+                # Find Try nodes with finally blocks\n+                for child in _ast.walk(node):\n+                    if isinstance(child, _ast.Try) and child.finalbody:\n+                        finally_text = \"\\n\".join(\n+                            _ast.dump(n) for n in child.finalbody\n+                        )\n+                        if \"text_queue\" in finally_text:\n+                            assert \"stop_event\" in finally_text, (\n+                                \"finally must also handle stop_event\"\n+                            )\n+                            assert \"tts_thread\" in finally_text, (\n+                                \"finally must also handle tts_thread\"\n+                            )\n+                            return\n+                pytest.fail(\n+                    \"chat() must have a finally block cleaning up \"\n+                    \"text_queue/stop_event/tts_thread\"\n+                )\n+\n+\n+class TestBrowserToolSignalHandlerRemoved:\n+    \"\"\"browser_tool.py must NOT register SIGINT/SIGTERM handlers that call\n+    sys.exit() — this conflicts with prompt_toolkit's event loop and causes\n+    the process to become unkillable during voice mode.\"\"\"\n+\n+    def test_no_signal_handler_registration(self):\n+        \"\"\"Source check: browser_tool.py must not call signal.signal()\n+        for SIGINT or SIGTERM.\"\"\"\n+        with open(\"tools/browser_tool.py\") as f:\n+            source = f.read()\n+\n+        lines = source.split(\"\\n\")\n+        for i, line in enumerate(lines, 1):\n+            stripped = line.strip()\n+            # Skip comments\n+            if stripped.startswith(\"#\"):\n+                continue\n+            assert \"signal.signal(signal.SIGINT\" not in stripped, (\n+                f\"browser_tool.py:{i} registers SIGINT handler — \"\n+                f\"use atexit instead to avoid prompt_toolkit conflicts\"\n+            )\n+            assert \"signal.signal(signal.SIGTERM\" not in stripped, (\n+                f\"browser_tool.py:{i} registers SIGTERM handler — \"\n+                f\"use atexit instead to avoid prompt_toolkit conflicts\"\n+            )\n+\n+\n+class TestKeyHandlerNeverBlocks:\n+    \"\"\"The Ctrl+B key handler runs in prompt_toolkit's event-loop thread.\n+    Any blocking call freezes the entire UI.  Verify that:\n+    1. _voice_start_recording is NOT called directly (must be in daemon thread)\n+    2. _voice_processing guard prevents starting while stop/transcribe runs\n+    3. _voice_processing is set atomically with _voice_recording in stop_and_transcribe\n+    \"\"\"\n+\n+    def test_start_recording_not_called_directly_in_handler(self):\n+        \"\"\"AST check: handle_voice_record must NOT call _voice_start_recording()\n+        directly — it must wrap it in a Thread to avoid blocking the UI.\"\"\"\n+        import ast as _ast\n+\n+        with open(\"cli.py\") as f:\n+            tree = _ast.parse(f.read())\n+\n+        for node in _ast.walk(tree):\n+            if isinstance(node, _ast.FunctionDef) and node.name == \"handle_voice_record\":\n+                # Collect all direct calls to _voice_start_recording in this function.\n+                # They should ONLY appear inside a nested def (the _start_recording wrapper).\n+                for child in _ast.iter_child_nodes(node):\n+                    # Direct statements in the handler body (not nested defs)\n+                    if isinstance(child, _ast.Expr) and isinstance(child.value, _ast.Call):\n+                        call_src = _ast.dump(child.value)\n+                        assert \"_voice_start_recording\" not in call_src, (\n+                            \"handle_voice_record calls _voice_start_recording directly \"\n+                            \"— must dispatch to a daemon thread\"\n+                        )\n+                break\n+\n+    def test_processing_guard_in_start_path(self):\n+        \"\"\"Source check: key handler must check _voice_processing before\n+        starting a new recording.\"\"\"\n+        with open(\"cli.py\") as f:\n+            source = f.read()\n+\n+        lines = source.split(\"\\n\")\n+        in_handler = False\n+        in_else = False\n+        found_guard = False\n+        for line in lines:\n+            if \"def handle_voice_record\" in line:\n+                in_handler = True\n+            elif in_handler and line.strip().startswith(\"def \") and \"_start_recording\" not in line:\n+                break\n+            elif in_handler and \"else:\" in line:\n+                in_else = True\n+            elif in_else and \"_voice_processing\" in line:\n+                found_guard = True\n+                break\n+\n+        assert found_guard, (\n+            \"Key handler START path must guard against _voice_processing \"\n+            \"to prevent blocking on AudioRecorder._lock\"\n+        )\n+\n+    def test_processing_set_atomically_with_recording_false(self):\n+        \"\"\"Source check: _voice_stop_and_transcribe must set _voice_processing = True\n+        in the same lock block where it sets _voice_recording = False.\"\"\"\n+        with open(\"cli.py\") as f:\n+            source = f.read()\n+\n+        lines = source.split(\"\\n\")\n+        in_method = False\n+        in_first_lock = False\n+        found_recording_false = False\n+        found_processing_true = False\n+        for line in lines:\n+            if \"def _voice_stop_and_transcribe\" in line:\n+                in_method = True\n+            elif in_method and \"with self._voice_lock:\" in line and not in_first_lock:\n+                in_first_lock = True\n+            elif in_first_lock:\n+                stripped = line.strip()\n+                if not stripped or stripped.startswith(\"#\"):\n+                    continue\n+                if \"_voice_recording = False\" in stripped:\n+                    found_recording_false = True\n+                if \"_voice_processing = True\" in stripped:\n+                    found_processing_true = True\n+                # End of with block (dedent)\n+                if stripped and not line.startswith(\"            \") and not line.startswith(\"\\t\\t\\t\"):\n+                    break\n+\n+        assert found_recording_false and found_processing_true, (\n+            \"_voice_stop_and_transcribe must set _voice_processing = True \"\n+            \"atomically (same lock block) with _voice_recording = False\"\n+        )\n+\n+\n+# ============================================================================\n+# Real behavior tests — CLI voice methods via _make_voice_cli()\n+# ============================================================================\n+\n+class TestHandleVoiceCommandReal:\n+    \"\"\"Tests _handle_voice_command routing with real CLI instance.\"\"\"\n+\n+    def _cli(self):\n+        cli = _make_voice_cli()\n+        cli._enable_voice_mode = MagicMock()\n+        cli._disable_voice_mode = MagicMock()\n+        cli._toggle_voice_tts = MagicMock()\n+        cli._show_voice_status = MagicMock()\n+        return cli\n+\n+    @patch(\"cli._cprint\")\n+    def test_on_calls_enable(self, _cp):\n+        cli = self._cli()\n+        cli._handle_voice_command(\"/voice on\")\n+        cli._enable_voice_mode.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    def test_off_calls_disable(self, _cp):\n+        cli = self._cli()\n+        cli._handle_voice_command(\"/voice off\")\n+        cli._disable_voice_mode.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    def test_tts_calls_toggle(self, _cp):\n+        cli = self._cli()\n+        cli._handle_voice_command(\"/voice tts\")\n+        cli._toggle_voice_tts.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    def test_status_calls_show(self, _cp):\n+        cli = self._cli()\n+        cli._handle_voice_command(\"/voice status\")\n+        cli._show_voice_status.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    def test_toggle_off_when_enabled(self, _cp):\n+        cli = self._cli()\n+        cli._voice_mode = True\n+        cli._handle_voice_command(\"/voice\")\n+        cli._disable_voice_mode.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    def test_toggle_on_when_disabled(self, _cp):\n+        cli = self._cli()\n+        cli._voice_mode = False\n+        cli._handle_voice_command(\"/voice\")\n+        cli._enable_voice_mode.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    def test_unknown_subcommand(self, mock_cp):\n+        cli = self._cli()\n+        cli._handle_voice_command(\"/voice foobar\")\n+        cli._enable_voice_mode.assert_not_called()\n+        cli._disable_voice_mode.assert_not_called()\n+        # Should print usage via _cprint\n+        assert any(\"Unknown\" in str(c) or \"unknown\" in str(c)\n+                    for c in mock_cp.call_args_list)\n+\n+\n+class TestEnableVoiceModeReal:\n+    \"\"\"Tests _enable_voice_mode with real CLI instance.\"\"\"\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"voice\": {}})\n+    @patch(\"tools.voice_mode.check_voice_requirements\",\n+           return_value={\"available\": True, \"details\": \"OK\"})\n+    @patch(\"tools.voice_mode.detect_audio_environment\",\n+           return_value={\"available\": True, \"warnings\": []})\n+    def test_success_sets_voice_mode(self, _env, _req, _cfg, _cp):\n+        cli = _make_voice_cli()\n+        cli._enable_voice_mode()\n+        assert cli._voice_mode is True\n+\n+    @patch(\"cli._cprint\")\n+    def test_already_enabled_noop(self, _cp):\n+        cli = _make_voice_cli(_voice_mode=True)\n+        cli._enable_voice_mode()\n+        assert cli._voice_mode is True\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.detect_audio_environment\",\n+           return_value={\"available\": False, \"warnings\": [\"SSH session\"]})\n+    def test_env_check_fails(self, _env, _cp):\n+        cli = _make_voice_cli()\n+        cli._enable_voice_mode()\n+        assert cli._voice_mode is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.check_voice_requirements\",\n+           return_value={\"available\": False, \"details\": \"Missing\",\n+                         \"missing_packages\": [\"sounddevice\"]})\n+    @patch(\"tools.voice_mode.detect_audio_environment\",\n+           return_value={\"available\": True, \"warnings\": []})\n+    def test_requirements_fail(self, _env, _req, _cp):\n+        cli = _make_voice_cli()\n+        cli._enable_voice_mode()\n+        assert cli._voice_mode is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"voice\": {\"auto_tts\": True}})\n+    @patch(\"tools.voice_mode.check_voice_requirements\",\n+           return_value={\"available\": True, \"details\": \"OK\"})\n+    @patch(\"tools.voice_mode.detect_audio_environment\",\n+           return_value={\"available\": True, \"warnings\": []})\n+    def test_auto_tts_from_config(self, _env, _req, _cfg, _cp):\n+        cli = _make_voice_cli()\n+        cli._enable_voice_mode()\n+        assert cli._voice_tts is True\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"voice\": {}})\n+    @patch(\"tools.voice_mode.check_voice_requirements\",\n+           return_value={\"available\": True, \"details\": \"OK\"})\n+    @patch(\"tools.voice_mode.detect_audio_environment\",\n+           return_value={\"available\": True, \"warnings\": []})\n+    def test_no_auto_tts_default(self, _env, _req, _cfg, _cp):\n+        cli = _make_voice_cli()\n+        cli._enable_voice_mode()\n+        assert cli._voice_tts is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"hermes_cli.config.load_config\", side_effect=Exception(\"broken config\"))\n+    @patch(\"tools.voice_mode.check_voice_requirements\",\n+           return_value={\"available\": True, \"details\": \"OK\"})\n+    @patch(\"tools.voice_mode.detect_audio_environment\",\n+           return_value={\"available\": True, \"warnings\": []})\n+    def test_config_exception_still_enables(self, _env, _req, _cfg, _cp):\n+        cli = _make_voice_cli()\n+        cli._enable_voice_mode()\n+        assert cli._voice_mode is True\n+\n+\n+class TestDisableVoiceModeReal:\n+    \"\"\"Tests _disable_voice_mode with real CLI instance.\"\"\"\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.stop_playback\")\n+    def test_all_flags_reset(self, _sp, _cp):\n+        cli = _make_voice_cli(_voice_mode=True, _voice_tts=True,\n+                              _voice_continuous=True)\n+        cli._disable_voice_mode()\n+        assert cli._voice_mode is False\n+        assert cli._voice_tts is False\n+        assert cli._voice_continuous is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.stop_playback\")\n+    def test_active_recording_cancelled(self, _sp, _cp):\n+        recorder = MagicMock()\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._disable_voice_mode()\n+        recorder.cancel.assert_called_once()\n+        assert cli._voice_recording is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.stop_playback\")\n+    def test_stop_playback_called(self, mock_sp, _cp):\n+        cli = _make_voice_cli()\n+        cli._disable_voice_mode()\n+        mock_sp.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.stop_playback\")\n+    def test_tts_done_event_set(self, _sp, _cp):\n+        cli = _make_voice_cli()\n+        cli._voice_tts_done.clear()\n+        cli._disable_voice_mode()\n+        assert cli._voice_tts_done.is_set()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.stop_playback\")\n+    def test_no_recorder_no_crash(self, _sp, _cp):\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=None)\n+        cli._disable_voice_mode()\n+        assert cli._voice_mode is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.stop_playback\", side_effect=RuntimeError(\"boom\"))\n+    def test_stop_playback_exception_swallowed(self, _sp, _cp):\n+        cli = _make_voice_cli(_voice_mode=True)\n+        cli._disable_voice_mode()\n+        assert cli._voice_mode is False\n+\n+\n+class TestVoiceSpeakResponseReal:\n+    \"\"\"Tests _voice_speak_response with real CLI instance.\"\"\"\n+\n+    @patch(\"cli._cprint\")\n+    def test_early_return_when_tts_off(self, _cp):\n+        cli = _make_voice_cli(_voice_tts=False)\n+        with patch(\"tools.tts_tool.text_to_speech_tool\") as mock_tts:\n+            cli._voice_speak_response(\"Hello\")\n+            mock_tts.assert_not_called()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.getsize\", return_value=1000)\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"cli.os.makedirs\")\n+    @patch(\"tools.voice_mode.play_audio_file\")\n+    @patch(\"tools.tts_tool.text_to_speech_tool\", return_value='{\"success\": true}')\n+    def test_markdown_stripped(self, mock_tts, _play, _mkd, _isf, _gsz, _unl, _cp):\n+        cli = _make_voice_cli(_voice_tts=True)\n+        cli._voice_speak_response(\"## Title\\n**bold** and `code`\")\n+        call_text = mock_tts.call_args.kwargs[\"text\"]\n+        assert \"##\" not in call_text\n+        assert \"**\" not in call_text\n+        assert \"`\" not in call_text\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.makedirs\")\n+    @patch(\"tools.tts_tool.text_to_speech_tool\", return_value='{\"success\": true}')\n+    def test_code_blocks_removed(self, mock_tts, _mkd, _cp):\n+        cli = _make_voice_cli(_voice_tts=True)\n+        cli._voice_speak_response(\"```python\\nprint('hi')\\n```\\nSome text\")\n+        call_text = mock_tts.call_args.kwargs[\"text\"]\n+        assert \"print\" not in call_text\n+        assert \"```\" not in call_text\n+        assert \"Some text\" in call_text\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.makedirs\")\n+    def test_empty_after_strip_returns_early(self, _mkd, _cp):\n+        cli = _make_voice_cli(_voice_tts=True)\n+        with patch(\"tools.tts_tool.text_to_speech_tool\") as mock_tts:\n+            cli._voice_speak_response(\"```python\\nprint('hi')\\n```\")\n+            mock_tts.assert_not_called()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.makedirs\")\n+    @patch(\"tools.tts_tool.text_to_speech_tool\", return_value='{\"success\": true}')\n+    def test_long_text_truncated(self, mock_tts, _mkd, _cp):\n+        cli = _make_voice_cli(_voice_tts=True)\n+        cli._voice_speak_response(\"A\" * 5000)\n+        call_text = mock_tts.call_args.kwargs[\"text\"]\n+        assert len(call_text) <= 4000\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.makedirs\")\n+    @patch(\"tools.tts_tool.text_to_speech_tool\", side_effect=RuntimeError(\"tts fail\"))\n+    def test_exception_sets_done_event(self, _tts, _mkd, _cp):\n+        cli = _make_voice_cli(_voice_tts=True)\n+        cli._voice_tts_done.clear()\n+        cli._voice_speak_response(\"Hello\")\n+        assert cli._voice_tts_done.is_set()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.getsize\", return_value=1000)\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"cli.os.makedirs\")\n+    @patch(\"tools.voice_mode.play_audio_file\")\n+    @patch(\"tools.tts_tool.text_to_speech_tool\", return_value='{\"success\": true}')\n+    def test_play_audio_called(self, _tts, mock_play, _mkd, _isf, _gsz, _unl, _cp):\n+        cli = _make_voice_cli(_voice_tts=True)\n+        cli._voice_speak_response(\"Hello world\")\n+        mock_play.assert_called_once()\n+\n+\n+class TestVoiceStopAndTranscribeReal:\n+    \"\"\"Tests _voice_stop_and_transcribe with real CLI instance.\"\"\"\n+\n+    @patch(\"cli._cprint\")\n+    def test_guard_not_recording(self, _cp):\n+        cli = _make_voice_cli(_voice_recording=False)\n+        with patch(\"tools.voice_mode.transcribe_recording\") as mock_tr:\n+            cli._voice_stop_and_transcribe()\n+            mock_tr.assert_not_called()\n+\n+    @patch(\"cli._cprint\")\n+    def test_no_recorder_returns_early(self, _cp):\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=None)\n+        with patch(\"tools.voice_mode.transcribe_recording\") as mock_tr:\n+            cli._voice_stop_and_transcribe()\n+            mock_tr.assert_not_called()\n+        assert cli._voice_recording is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_no_speech_detected(self, _beep, _cp):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = None\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._voice_stop_and_transcribe()\n+        assert cli._pending_input.empty()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"stt\": {}})\n+    @patch(\"tools.voice_mode.transcribe_recording\",\n+           return_value={\"success\": True, \"transcript\": \"hello world\"})\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_successful_transcription_queues_input(\n+        self, _beep, _tr, _cfg, _isf, _unl, _cp\n+    ):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = \"/tmp/test.wav\"\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._voice_stop_and_transcribe()\n+        assert cli._pending_input.get_nowait() == \"hello world\"\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"stt\": {}})\n+    @patch(\"tools.voice_mode.transcribe_recording\",\n+           return_value={\"success\": True, \"transcript\": \"\"})\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_empty_transcript_not_queued(self, _beep, _tr, _cfg, _isf, _unl, _cp):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = \"/tmp/test.wav\"\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._voice_stop_and_transcribe()\n+        assert cli._pending_input.empty()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"stt\": {}})\n+    @patch(\"tools.voice_mode.transcribe_recording\",\n+           return_value={\"success\": False, \"error\": \"API timeout\"})\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_transcription_failure(self, _beep, _tr, _cfg, _isf, _unl, _cp):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = \"/tmp/test.wav\"\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._voice_stop_and_transcribe()\n+        assert cli._pending_input.empty()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"stt\": {}})\n+    @patch(\"tools.voice_mode.transcribe_recording\",\n+           side_effect=ConnectionError(\"network\"))\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_exception_caught(self, _beep, _tr, _cfg, _isf, _unl, _cp):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = \"/tmp/test.wav\"\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._voice_stop_and_transcribe()  # Should not raise\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_processing_flag_cleared(self, _beep, _cp):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = None\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._voice_stop_and_transcribe()\n+        assert cli._voice_processing is False\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_continuous_restarts_on_no_speech(self, _beep, _cp):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = None\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,\n+                              _voice_continuous=True)\n+        cli._voice_start_recording = MagicMock()\n+        cli._voice_stop_and_transcribe()\n+        cli._voice_start_recording.assert_called_once()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"stt\": {}})\n+    @patch(\"tools.voice_mode.transcribe_recording\",\n+           return_value={\"success\": True, \"transcript\": \"hello\"})\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_continuous_no_restart_on_success(\n+        self, _beep, _tr, _cfg, _isf, _unl, _cp\n+    ):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = \"/tmp/test.wav\"\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,\n+                              _voice_continuous=True)\n+        cli._voice_start_recording = MagicMock()\n+        cli._voice_stop_and_transcribe()\n+        cli._voice_start_recording.assert_not_called()\n+\n+    @patch(\"cli._cprint\")\n+    @patch(\"cli.os.unlink\")\n+    @patch(\"cli.os.path.isfile\", return_value=True)\n+    @patch(\"hermes_cli.config.load_config\", return_value={\"stt\": {\"model\": \"whisper-large-v3\"}})\n+    @patch(\"tools.voice_mode.transcribe_recording\",\n+           return_value={\"success\": True, \"transcript\": \"hi\"})\n+    @patch(\"tools.voice_mode.play_beep\")\n+    def test_stt_model_from_config(self, _beep, mock_tr, _cfg, _isf, _unl, _cp):\n+        recorder = MagicMock()\n+        recorder.stop.return_value = \"/tmp/test.wav\"\n+        cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)\n+        cli._voice_stop_and_transcribe()\n+        mock_tr.assert_called_once_with(\"/tmp/test.wav\", model=\"whisper-large-v3\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bugfix: _refresh_level must read _voice_recording under lock\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestRefreshLevelLock:\n+    \"\"\"Bug: _refresh_level thread read _voice_recording without lock.\"\"\"\n+\n+    def test_refresh_stops_when_recording_false(self):\n+        import threading, time\n+\n+        lock = threading.Lock()\n+        recording = True\n+        iterations = 0\n+\n+        def refresh_level():\n+            nonlocal iterations\n+            while True:\n+                with lock:\n+                    still = recording\n+                if not still:\n+                    break\n+                iterations += 1\n+                time.sleep(0.01)\n+\n+        t = threading.Thread(target=refresh_level, daemon=True)\n+        t.start()\n+\n+        time.sleep(0.05)\n+        with lock:\n+            recording = False\n+\n+        t.join(timeout=1)\n+        assert not t.is_alive(), \"Refresh thread did not stop\"\n+        assert iterations > 0, \"Refresh thread never ran\""]},{"file":"tests/tools/test_voice_mode.py","hunks":["@@ -0,0 +1,938 @@\n+\"\"\"Tests for tools.voice_mode -- all mocked, no real microphone or API calls.\"\"\"\n+\n+import os\n+import struct\n+import time\n+import wave\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+# ============================================================================\n+# Fixtures\n+# ============================================================================\n+\n[redacted:email]\n+def sample_wav(tmp_path):\n+    \"\"\"Create a minimal valid WAV file (1 second of silence at 16kHz).\"\"\"\n+    wav_path = tmp_path / \"test.wav\"\n+    n_frames = 16000  # 1 second at 16kHz\n+    silence = struct.pack(f\"<{n_frames}h\", *([0] * n_frames))\n+\n+    with wave.open(str(wav_path), \"wb\") as wf:\n+        wf.setnchannels(1)\n+        wf.setsampwidth(2)\n+        wf.setframerate(16000)\n+        wf.writeframes(silence)\n+\n+    return str(wav_path)\n+\n+\n[redacted:email]\n+def temp_voice_dir(tmp_path, monkeypatch):\n+    \"\"\"Redirect _TEMP_DIR to a temporary path.\"\"\"\n+    voice_dir = tmp_path / \"hermes_voice\"\n+    voice_dir.mkdir()\n+    monkeypatch.setattr(\"tools.voice_mode._TEMP_DIR\", str(voice_dir))\n+    return voice_dir\n+\n+\n[redacted:email]\n+def mock_sd(monkeypatch):\n+    \"\"\"Mock _import_audio to return (mock_sd, real_np) so lazy imports work.\"\"\"\n+    mock = MagicMock()\n+    try:\n+        import numpy as real_np\n+    except ImportError:\n+        real_np = MagicMock()\n+\n+    def _fake_import_audio():\n+        return mock, real_np\n+\n+    monkeypatch.setattr(\"tools.voice_mode._import_audio\", _fake_import_audio)\n+    monkeypatch.setattr(\"tools.voice_mode._audio_available\", lambda: True)\n+    return mock\n+\n+\n+# ============================================================================\n+# check_voice_requirements\n+# ============================================================================\n+\n+class TestCheckVoiceRequirements:\n+    def test_all_requirements_met(self, monkeypatch):\n+        monkeypatch.setattr(\"tools.voice_mode._audio_available\", lambda: True)\n+        monkeypatch.setattr(\"tools.voice_mode.detect_audio_environment\",\n+                            lambda: {\"available\": True, \"warnings\": []})\n+        monkeypatch.setattr(\"tools.transcription_tools._get_provider\", lambda cfg: \"openai\")\n+\n+        from tools.voice_mode import check_voice_requirements\n+\n+        result = check_voice_requirements()\n+        assert result[\"available\"] is True\n+        assert result[\"audio_available\"] is True\n+        assert result[\"stt_available\"] is True\n+        assert result[\"missing_packages\"] == []\n+\n+    def test_missing_audio_packages(self, monkeypatch):\n+        monkeypatch.setattr(\"tools.voice_mode._audio_available\", lambda: False)\n+        monkeypatch.setattr(\"tools.voice_mode.detect_audio_environment\",\n+                            lambda: {\"available\": False, \"warnings\": [\"Audio libraries not installed\"]})\n+        monkeypatch.setenv(\"VOICE_TOOLS_OPENAI_KEY\", \"sk-test-key\")\n+\n+        from tools.voice_mode import check_voice_requirements\n+\n+        result = check_voice_requirements()\n+        assert result[\"available\"] is False\n+        assert result[\"audio_available\"] is False\n+        assert \"sounddevice\" in result[\"missing_packages\"]\n+        assert \"numpy\" in result[\"missing_packages\"]\n+\n+    def test_missing_stt_provider(self, monkeypatch):\n+        monkeypatch.setattr(\"tools.voice_mode._audio_available\", lambda: True)\n+        monkeypatch.setattr(\"tools.voice_mode.detect_audio_environment\",\n+                            lambda: {\"available\": True, \"warnings\": []})\n+        monkeypatch.setattr(\"tools.transcription_tools._get_provider\", lambda cfg: \"none\")\n+\n+        from tools.voice_mode import check_voice_requirements\n+\n+        result = check_voice_requirements()\n+        assert result[\"available\"] is False\n+        assert result[\"stt_available\"] is False\n+        assert \"STT provider: MISSING\" in result[\"details\"]\n+\n+\n+# ============================================================================\n+# AudioRecorder\n+# ============================================================================\n+\n+class TestAudioRecorderStart:\n+    def test_start_raises_without_audio(self, monkeypatch):\n+        def _fail_import():\n+            raise ImportError(\"no sounddevice\")\n+        monkeypatch.setattr(\"tools.voice_mode._import_audio\", _fail_import)\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        with pytest.raises(RuntimeError, match=\"sounddevice and numpy\"):\n+            recorder.start()\n+\n+    def test_start_creates_and_starts_stream(self, mock_sd):\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+\n+        assert recorder.is_recording is True\n+        mock_sd.InputStream.assert_called_once()\n+        mock_stream.start.assert_called_once()\n+\n+    def test_double_start_is_noop(self, mock_sd):\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+        recorder.start()  # second call should be noop\n+\n+        assert mock_sd.InputStream.call_count == 1\n+\n+\n+class TestAudioRecorderStop:\n+    def test_stop_returns_none_when_not_recording(self):\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        assert recorder.stop() is None\n+\n+    def test_stop_writes_wav_file(self, mock_sd, temp_voice_dir):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder, SAMPLE_RATE\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+\n+        # Simulate captured audio frames (1 second of loud audio above RMS threshold)\n+        frame = np.full((SAMPLE_RATE, 1), 1000, dtype=\"int16\")\n+        recorder._frames = [frame]\n+        recorder._peak_rms = 1000  # Peak RMS above threshold\n+\n+        wav_path = recorder.stop()\n+\n+        assert wav_path is not None\n+        assert os.path.isfile(wav_path)\n+        assert wav_path.endswith(\".wav\")\n+        assert recorder.is_recording is False\n+\n+        # Verify it is a valid WAV\n+        with wave.open(wav_path, \"rb\") as wf:\n+            assert wf.getnchannels() == 1\n+            assert wf.getsampwidth() == 2\n+            assert wf.getframerate() == SAMPLE_RATE\n+\n+    def test_stop_returns_none_for_very_short_recording(self, mock_sd, temp_voice_dir):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+\n+        # Very short recording (100 samples = ~6ms at 16kHz)\n+        frame = np.zeros((100, 1), dtype=\"int16\")\n+        recorder._frames = [frame]\n+\n+        wav_path = recorder.stop()\n+        assert wav_path is None\n+\n+    def test_stop_returns_none_for_silent_recording(self, mock_sd, temp_voice_dir):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder, SAMPLE_RATE\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+\n+        # 1 second of near-silence (RMS well below threshold)\n+        frame = np.full((SAMPLE_RATE, 1), 10, dtype=\"int16\")\n+        recorder._frames = [frame]\n+        recorder._peak_rms = 10  # Peak RMS also below threshold\n+\n+        wav_path = recorder.stop()\n+        assert wav_path is None\n+\n+\n+class TestAudioRecorderCancel:\n+    def test_cancel_discards_frames(self, mock_sd):\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+        recorder._frames = [MagicMock()]  # simulate captured data\n+\n+        recorder.cancel()\n+\n+        assert recorder.is_recording is False\n+        assert recorder._frames == []\n+        # Stream is kept alive (persistent) — cancel() does NOT close it.\n+        mock_stream.stop.assert_not_called()\n+        mock_stream.close.assert_not_called()\n+\n+    def test_cancel_when_not_recording_is_safe(self):\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.cancel()  # should not raise\n+        assert recorder.is_recording is False\n+\n+\n+class TestAudioRecorderProperties:\n+    def test_elapsed_seconds_when_not_recording(self):\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        assert recorder.elapsed_seconds == 0.0\n+\n+    def test_elapsed_seconds_when_recording(self, mock_sd):\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+\n+        # Force start time to 1 second ago\n+        recorder._start_time = time.monotonic() - 1.0\n+        elapsed = recorder.elapsed_seconds\n+        assert 0.9 < elapsed < 2.0\n+\n+        recorder.cancel()\n+\n+\n+# ============================================================================\n+# transcribe_recording\n+# ============================================================================\n+\n+class TestTranscribeRecording:\n+    def test_delegates_to_transcribe_audio(self):\n+        mock_transcribe = MagicMock(return_value={\n+            \"success\": True,\n+            \"transcript\": \"hello world\",\n+        })\n+\n+        with patch(\"tools.transcription_tools.transcribe_audio\", mock_transcribe):\n+            from tools.voice_mode import transcribe_recording\n+            result = transcribe_recording(\"/tmp/test.wav\", model=\"whisper-1\")\n+\n+        assert result[\"success\"] is True\n+        assert result[\"transcript\"] == \"hello world\"\n+        mock_transcribe.assert_called_once_with(\"/tmp/test.wav\", model=\"whisper-1\")\n+\n+    def test_filters_whisper_hallucination(self):\n+        mock_transcribe = MagicMock(return_value={\n+            \"success\": True,\n+            \"transcript\": \"Thank you.\",\n+        })\n+\n+        with patch(\"tools.transcription_tools.transcribe_audio\", mock_transcribe):\n+            from tools.voice_mode import transcribe_recording\n+            result = transcribe_recording(\"/tmp/test.wav\")\n+\n+        assert result[\"success\"] is True\n+        assert result[\"transcript\"] == \"\"\n+        assert result[\"filtered\"] is True\n+\n+    def test_does_not_filter_real_speech(self):\n+        mock_transcribe = MagicMock(return_value={\n+            \"success\": True,\n+            \"transcript\": \"Thank you for helping me with this code.\",\n+        })\n+\n+        with patch(\"tools.transcription_tools.transcribe_audio\", mock_transcribe):\n+            from tools.voice_mode import transcribe_recording\n+            result = transcribe_recording(\"/tmp/test.wav\")\n+\n+        assert result[\"transcript\"] == \"Thank you for helping me with this code.\"\n+        assert \"filtered\" not in result\n+\n+\n+class TestWhisperHallucinationFilter:\n+    def test_known_hallucinations(self):\n+        from tools.voice_mode import is_whisper_hallucination\n+\n+        assert is_whisper_hallucination(\"Thank you.\") is True\n+        assert is_whisper_hallucination(\"thank you\") is True\n+        assert is_whisper_hallucination(\"Thanks for watching.\") is True\n+        assert is_whisper_hallucination(\"Bye.\") is True\n+        assert is_whisper_hallucination(\"  Thank you.  \") is True  # with whitespace\n+        assert is_whisper_hallucination(\"you\") is True\n+\n+    def test_real_speech_not_filtered(self):\n+        from tools.voice_mode import is_whisper_hallucination\n+\n+        assert is_whisper_hallucination(\"Hello, how are you?\") is False\n+        assert is_whisper_hallucination(\"Thank you for your help with the project.\") is False\n+        assert is_whisper_hallucination(\"Can you explain this code?\") is False\n+\n+\n+# ============================================================================\n+# play_audio_file\n+# ============================================================================\n+\n+class TestPlayAudioFile:\n+    def test_play_wav_via_sounddevice(self, monkeypatch, sample_wav):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_sd_obj = MagicMock()\n+        # Simulate stream completing immediately (get_stream().active = False)\n+        mock_stream = MagicMock()\n+        mock_stream.active = False\n+        mock_sd_obj.get_stream.return_value = mock_stream\n+\n+        def _fake_import():\n+            return mock_sd_obj, np\n+\n+        monkeypatch.setattr(\"tools.voice_mode._import_audio\", _fake_import)\n+\n+        from tools.voice_mode import play_audio_file\n+\n+        result = play_audio_file(sample_wav)\n+\n+        assert result is True\n+        mock_sd_obj.play.assert_called_once()\n+        mock_sd_obj.stop.assert_called_once()\n+\n+    def test_returns_false_when_no_player(self, monkeypatch, sample_wav):\n+        def _fail_import():\n+            raise ImportError(\"no sounddevice\")\n+        monkeypatch.setattr(\"tools.voice_mode._import_audio\", _fail_import)\n+        monkeypatch.setattr(\"shutil.which\", lambda _: None)\n+\n+        from tools.voice_mode import play_audio_file\n+\n+        result = play_audio_file(sample_wav)\n+        assert result is False\n+\n+    def test_returns_false_for_missing_file(self):\n+        from tools.voice_mode import play_audio_file\n+\n+        result = play_audio_file(\"/nonexistent/file.wav\")\n+        assert result is False\n+\n+\n+# ============================================================================\n+# cleanup_temp_recordings\n+# ============================================================================\n+\n+class TestCleanupTempRecordings:\n+    def test_old_files_deleted(self, temp_voice_dir):\n+        # Create an \"old\" file\n+        old_file = temp_voice_dir / \"recording_20240101_000000.wav\"\n+        old_file.write_bytes(b\"\\x00\" * 100)\n+        # Set mtime to 2 hours ago\n+        old_mtime = time.time() - 7200\n+        os.utime(str(old_file), (old_mtime, old_mtime))\n+\n+        from tools.voice_mode import cleanup_temp_recordings\n+\n+        deleted = cleanup_temp_recordings(max_age_seconds=3600)\n+        assert deleted == 1\n+        assert not old_file.exists()\n+\n+    def test_recent_files_preserved(self, temp_voice_dir):\n+        # Create a \"recent\" file\n+        recent_file = temp_voice_dir / \"recording_20260303_120000.wav\"\n+        recent_file.write_bytes(b\"\\x00\" * 100)\n+\n+        from tools.voice_mode import cleanup_temp_recordings\n+\n+        deleted = cleanup_temp_recordings(max_age_seconds=3600)\n+        assert deleted == 0\n+        assert recent_file.exists()\n+\n+    def test_nonexistent_dir_returns_zero(self, monkeypatch):\n+        monkeypatch.setattr(\"tools.voice_mode._TEMP_DIR\", \"/nonexistent/dir\")\n+\n+        from tools.voice_mode import cleanup_temp_recordings\n+\n+        assert cleanup_temp_recordings() == 0\n+\n+    def test_non_recording_files_ignored(self, temp_voice_dir):\n+        # Create a file that doesn't match the pattern\n+        other_file = temp_voice_dir / \"other_file.txt\"\n+        other_file.write_bytes(b\"\\x00\" * 100)\n+        old_mtime = time.time() - 7200\n+        os.utime(str(other_file), (old_mtime, old_mtime))\n+\n+        from tools.voice_mode import cleanup_temp_recordings\n+\n+        deleted = cleanup_temp_recordings(max_age_seconds=3600)\n+        assert deleted == 0\n+        assert other_file.exists()\n+\n+\n+# ============================================================================\n+# play_beep\n+# ============================================================================\n+\n+class TestPlayBeep:\n+    def test_beep_calls_sounddevice_play(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        from tools.voice_mode import play_beep\n+\n+        # play_beep uses polling (get_stream) + sd.stop() instead of sd.wait()\n+        mock_stream = MagicMock()\n+        mock_stream.active = False\n+        mock_sd.get_stream.return_value = mock_stream\n+\n+        play_beep(frequency=880, duration=0.1, count=1)\n+\n+        mock_sd.play.assert_called_once()\n+        mock_sd.stop.assert_called()\n+        # Verify audio data is int16 numpy array\n+        audio_arg = mock_sd.play.call_args[0][0]\n+        assert audio_arg.dtype == np.int16\n+        assert len(audio_arg) > 0\n+\n+    def test_beep_double_produces_longer_audio(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        from tools.voice_mode import play_beep\n+\n+        play_beep(frequency=660, duration=0.1, count=2)\n+\n+        audio_arg = mock_sd.play.call_args[0][0]\n+        single_beep_samples = int(16000 * 0.1)\n+        # Double beep should be longer than a single beep\n+        assert len(audio_arg) > single_beep_samples\n+\n+    def test_beep_noop_without_audio(self, monkeypatch):\n+        def _fail_import():\n+            raise ImportError(\"no sounddevice\")\n+        monkeypatch.setattr(\"tools.voice_mode._import_audio\", _fail_import)\n+\n+        from tools.voice_mode import play_beep\n+\n+        # Should not raise\n+        play_beep()\n+\n+    def test_beep_handles_playback_error(self, mock_sd):\n+        mock_sd.play.side_effect = Exception(\"device error\")\n+\n+        from tools.voice_mode import play_beep\n+\n+        # Should not raise\n+        play_beep()\n+\n+\n+# ============================================================================\n+# Silence detection\n+# ============================================================================\n+\n+class TestSilenceDetection:\n+    def test_silence_callback_fires_after_speech_then_silence(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+        import threading\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder, SAMPLE_RATE\n+\n+        recorder = AudioRecorder()\n+        # Use very short durations for testing\n+        recorder._silence_duration = 0.05\n+        recorder._min_speech_duration = 0.05\n+\n+        fired = threading.Event()\n+\n+        def on_silence():\n+            fired.set()\n+\n+        recorder.start(on_silence_stop=on_silence)\n+\n+        # Get the callback function from InputStream constructor\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        # Simulate sustained speech (multiple loud chunks to exceed min_speech_duration)\n+        loud_frame = np.full((1600, 1), 5000, dtype=\"int16\")\n+        callback(loud_frame, 1600, None, None)\n+        time.sleep(0.06)\n+        callback(loud_frame, 1600, None, None)\n+        assert recorder._has_spoken is True\n+\n+        # Simulate silence\n+        silent_frame = np.zeros((1600, 1), dtype=\"int16\")\n+        callback(silent_frame, 1600, None, None)\n+\n+        # Wait a bit past the silence duration, then send another silent frame\n+        time.sleep(0.06)\n+        callback(silent_frame, 1600, None, None)\n+\n+        # The callback should have been fired\n+        assert fired.wait(timeout=1.0) is True\n+\n+        recorder.cancel()\n+\n+    def test_silence_without_speech_does_not_fire(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+        import threading\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder._silence_duration = 0.02\n+\n+        fired = threading.Event()\n+        recorder.start(on_silence_stop=lambda: fired.set())\n+\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        # Only silence -- no speech detected, so callback should NOT fire\n+        silent_frame = np.zeros((1600, 1), dtype=\"int16\")\n+        for _ in range(5):\n+            callback(silent_frame, 1600, None, None)\n+            time.sleep(0.01)\n+\n+        assert fired.wait(timeout=0.2) is False\n+\n+        recorder.cancel()\n+\n+    def test_micro_pause_tolerance_during_speech(self, mock_sd):\n+        \"\"\"Brief dips below threshold during speech should NOT reset speech tracking.\"\"\"\n+        np = pytest.importorskip(\"numpy\")\n+        import threading\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder._silence_duration = 0.05\n+        recorder._min_speech_duration = 0.15\n+        recorder._max_dip_tolerance = 0.1\n+\n+        fired = threading.Event()\n+        recorder.start(on_silence_stop=lambda: fired.set())\n+\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        loud_frame = np.full((1600, 1), 5000, dtype=\"int16\")\n+        quiet_frame = np.full((1600, 1), 50, dtype=\"int16\")\n+\n+        # Speech chunk 1\n+        callback(loud_frame, 1600, None, None)\n+        time.sleep(0.05)\n+        # Brief micro-pause (dip < max_dip_tolerance)\n+        callback(quiet_frame, 1600, None, None)\n+        time.sleep(0.05)\n+        # Speech resumes -- speech_start should NOT have been reset\n+        callback(loud_frame, 1600, None, None)\n+        assert recorder._speech_start > 0, \"Speech start should be preserved across brief dips\"\n+        time.sleep(0.06)\n+        # Another speech chunk to exceed min_speech_duration\n+        callback(loud_frame, 1600, None, None)\n+        assert recorder._has_spoken is True, \"Speech should be confirmed after tolerating micro-pause\"\n+\n+        recorder.cancel()\n+\n+    def test_no_callback_means_no_silence_detection(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()  # no on_silence_stop\n+\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        # Even with speech then silence, nothing should happen\n+        loud_frame = np.full((1600, 1), 5000, dtype=\"int16\")\n+        silent_frame = np.zeros((1600, 1), dtype=\"int16\")\n+        callback(loud_frame, 1600, None, None)\n+        callback(silent_frame, 1600, None, None)\n+\n+        # No crash, no callback\n+        assert recorder._on_silence_stop is None\n+        recorder.cancel()\n+\n+\n+# ============================================================================\n+# Playback interrupt\n+# ============================================================================\n+\n+class TestPlaybackInterrupt:\n+    \"\"\"Verify that TTS playback can be interrupted.\"\"\"\n+\n+    def test_stop_playback_terminates_process(self):\n+        from tools.voice_mode import stop_playback, _playback_lock\n+        import tools.voice_mode as vm\n+\n+        mock_proc = MagicMock()\n+        mock_proc.poll.return_value = None  # process is running\n+\n+        with _playback_lock:\n+            vm._active_playback = mock_proc\n+\n+        stop_playback()\n+\n+        mock_proc.terminate.assert_called_once()\n+\n+        with _playback_lock:\n+            assert vm._active_playback is None\n+\n+    def test_stop_playback_noop_when_nothing_playing(self):\n+        import tools.voice_mode as vm\n+\n+        with vm._playback_lock:\n+            vm._active_playback = None\n+\n+        vm.stop_playback()\n+\n+    def test_play_audio_file_sets_active_playback(self, monkeypatch, sample_wav):\n+        import tools.voice_mode as vm\n+\n+        def _fail_import():\n+            raise ImportError(\"no sounddevice\")\n+        monkeypatch.setattr(\"tools.voice_mode._import_audio\", _fail_import)\n+\n+        mock_proc = MagicMock()\n+        mock_proc.wait.return_value = 0\n+\n+        mock_popen = MagicMock(return_value=mock_proc)\n+        monkeypatch.setattr(\"subprocess.Popen\", mock_popen)\n+        monkeypatch.setattr(\"shutil.which\", lambda cmd: \"/usr/bin/\" + cmd)\n+\n+        vm.play_audio_file(sample_wav)\n+\n+        assert mock_popen.called\n+        with vm._playback_lock:\n+            assert vm._active_playback is None\n+\n+\n+# ============================================================================\n+# Continuous mode flow\n+# ============================================================================\n+\n+class TestContinuousModeFlow:\n+    \"\"\"Verify continuous mode: auto-restart after transcription or silence.\"\"\"\n+\n+    def test_continuous_restart_on_no_speech(self, mock_sd, temp_voice_dir):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+\n+        # First recording: only silence -> stop returns None\n+        recorder.start()\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        for _ in range(10):\n+            silence = np.full((1600, 1), 10, dtype=\"int16\")\n+            callback(silence, 1600, None, None)\n+\n+        wav_path = recorder.stop()\n+        assert wav_path is None\n+\n+        # Simulate continuous mode restart\n+        recorder.start()\n+        assert recorder.is_recording is True\n+\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        for _ in range(10):\n+            speech = np.full((1600, 1), 5000, dtype=\"int16\")\n+            callback(speech, 1600, None, None)\n+\n+        wav_path = recorder.stop()\n+        assert wav_path is not None\n+\n+        recorder.cancel()\n+\n+    def test_recorder_reusable_after_stop(self, mock_sd, temp_voice_dir):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        results = []\n+\n+        for i in range(3):\n+            recorder.start()\n+            callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+            if callback is None:\n+                callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+            loud = np.full((1600, 1), 5000, dtype=\"int16\")\n+            for _ in range(10):\n+                callback(loud, 1600, None, None)\n+            wav_path = recorder.stop()\n+            results.append(wav_path)\n+\n+        assert all(r is not None for r in results)\n+        assert os.path.isfile(results[-1])\n+\n+\n+# ============================================================================\n+# Audio level indicator\n+# ============================================================================\n+\n+class TestAudioLevelIndicator:\n+    \"\"\"Verify current_rms property updates in real-time for UI feedback.\"\"\"\n+\n+    def test_rms_updates_with_audio_chunks(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        assert recorder.current_rms == 0\n+\n+        loud = np.full((1600, 1), 5000, dtype=\"int16\")\n+        callback(loud, 1600, None, None)\n+        assert recorder.current_rms == 5000\n+\n+        quiet = np.full((1600, 1), 100, dtype=\"int16\")\n+        callback(quiet, 1600, None, None)\n+        assert recorder.current_rms == 100\n+\n+        recorder.cancel()\n+\n+    def test_peak_rms_tracks_maximum(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+\n+        recorder = AudioRecorder()\n+        recorder.start()\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        frames = [\n+            np.full((1600, 1), 100, dtype=\"int16\"),\n+            np.full((1600, 1), 8000, dtype=\"int16\"),\n+            np.full((1600, 1), 500, dtype=\"int16\"),\n+            np.full((1600, 1), 3000, dtype=\"int16\"),\n+        ]\n+        for frame in frames:\n+            callback(frame, 1600, None, None)\n+\n+        assert recorder._peak_rms == 8000\n+        assert recorder.current_rms == 3000\n+\n+        recorder.cancel()\n+\n+\n+# ============================================================================\n+# Configurable silence parameters\n+# ============================================================================\n+\n+class TestConfigurableSilenceParams:\n+    \"\"\"Verify that silence detection params can be configured.\"\"\"\n+\n+    def test_custom_threshold_and_duration(self, mock_sd):\n+        np = pytest.importorskip(\"numpy\")\n+\n+        mock_stream = MagicMock()\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+        import threading\n+\n+        recorder = AudioRecorder()\n+        recorder._silence_threshold = 5000\n+        recorder._silence_duration = 0.05\n+        recorder._min_speech_duration = 0.05\n+\n+        fired = threading.Event()\n+        recorder.start(on_silence_stop=lambda: fired.set())\n+        callback = mock_sd.InputStream.call_args.kwargs.get(\"callback\")\n+        if callback is None:\n+            callback = mock_sd.InputStream.call_args[1][\"callback\"]\n+\n+        # Audio at RMS 1000 -- below custom threshold (5000)\n+        moderate = np.full((1600, 1), 1000, dtype=\"int16\")\n+        for _ in range(5):\n+            callback(moderate, 1600, None, None)\n+            time.sleep(0.02)\n+\n+        assert recorder._has_spoken is False\n+        assert fired.wait(timeout=0.2) is False\n+\n+        # Now send really loud audio (above 5000 threshold)\n+        very_loud = np.full((1600, 1), 8000, dtype=\"int16\")\n+        callback(very_loud, 1600, None, None)\n+        time.sleep(0.06)\n+        callback(very_loud, 1600, None, None)\n+        assert recorder._has_spoken is True\n+\n+        recorder.cancel()\n+\n+\n+# ============================================================================\n+# Bugfix regression tests\n+# ============================================================================\n+\n+\n+class TestSubprocessTimeoutKill:\n+    \"\"\"Bug: proc.wait(timeout) raised TimeoutExpired but process was not killed.\"\"\"\n+\n+    def test_timeout_kills_process(self):\n+        import subprocess, os\n+        proc = subprocess.Popen([\"sleep\", \"600\"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n+        pid = proc.pid\n+        assert proc.poll() is None\n+\n+        try:\n+            proc.wait(timeout=0.1)\n+        except subprocess.TimeoutExpired:\n+            proc.kill()\n+            proc.wait()\n+\n+        assert proc.poll() is not None\n+        assert proc.returncode is not None\n+\n+\n+class TestStreamLeakOnStartFailure:\n+    \"\"\"Bug: stream.start() failure left stream unclosed.\"\"\"\n+\n+    def test_stream_closed_on_start_failure(self, mock_sd):\n+        mock_stream = MagicMock()\n+        mock_stream.start.side_effect = OSError(\"Audio device busy\")\n+        mock_sd.InputStream.return_value = mock_stream\n+\n+        from tools.voice_mode import AudioRecorder\n+        recorder = AudioRecorder()\n+\n+        with pytest.raises(RuntimeError, match=\"Failed to open audio input stream\"):\n+            recorder._ensure_stream()\n+\n+        mock_stream.close.assert_called_once()\n+\n+\n+class TestSilenceCallbackLock:\n+    \"\"\"Bug: _on_silence_stop was read/written without lock in audio callback.\"\"\"\n+\n+    def test_fire_block_acquires_lock(self):\n+        import inspect\n+        from tools.voice_mode import AudioRecorder\n+\n+        source = inspect.getsource(AudioRecorder._ensure_stream)\n+        # Verify lock is used before reading _on_silence_stop in fire block\n+        assert \"with self._lock:\" in source\n+        assert \"cb = self._on_silence_stop\" in source\n+        lock_pos = source.index(\"with self._lock:\")\n+        cb_pos = source.index(\"cb = self._on_silence_stop\")\n+        assert lock_pos < cb_pos\n+\n+    def test_cancel_clears_callback_under_lock(self, mock_sd):\n+        from tools.voice_mode import AudioRecorder\n+        recorder = AudioRecorder()\n+        mock_sd.InputStream.return_value = MagicMock()\n+\n+        cb = lambda: None\n+        recorder.start(on_silence_stop=cb)\n+        assert recorder._on_silence_stop is cb\n+\n+        recorder.cancel()\n+        with recorder._lock:\n+            assert recorder._on_silence_stop is None"]},{"file":"tools/browser_tool.py","hunks":["@@ -224,24 +224,14 @@ def _emergency_cleanup_all_sessions():\n         logger.error(\"Emergency cleanup error: %s\", e)\n \n \n-def _signal_handler(signum, frame):\n-    \"\"\"Handle interrupt signals to cleanup sessions before exit.\"\"\"\n-    logger.warning(\"Received signal %s, cleaning up...\", signum)\n-    _emergency_cleanup_all_sessions()\n-    sys.exit(128 + signum)\n-\n-\n-# Register cleanup handlers\n+# Register cleanup via atexit only.  Previous versions installed SIGINT/SIGTERM\n+# handlers that called sys.exit(), but this conflicts with prompt_toolkit's\n+# async event loop — a SystemExit raised inside a key-binding callback\n+# corrupts the coroutine state and makes the process unkillable.  atexit\n+# handlers run on any normal exit (including sys.exit), so browser sessions\n+# are still cleaned up without hijacking signals.\n atexit.register(_emergency_cleanup_all_sessions)\n \n-# Only register signal handlers in main process (not in multiprocessing workers)\n-try:\n-    if os.getpid() == os.getpgrp():  # Main process check\n-        signal.signal(signal.SIGINT, _signal_handler)\n-        signal.signal(signal.SIGTERM, _signal_handler)\n-except (OSError, AttributeError):\n-    pass  # Signal handling not available (e.g., Windows or worker process)\n-\n \n # =============================================================================\n # Inactivity Cleanup Functions"]},{"file":"tools/transcription_tools.py","hunks":["@@ -2,11 +2,12 @@\n \"\"\"\n Transcription Tools Module\n \n-Provides speech-to-text transcription with two providers:\n+Provides speech-to-text transcription with three providers:\n \n   - **local** (default, free) — faster-whisper running locally, no API key needed.\n     Auto-downloads the model (~150 MB for ``base``) on first use.\n-  - **openai** — OpenAI Whisper API, requires ``VOICE_TOOLS_OPENAI_KEY``.\n+  - **groq** (free tier) — Groq Whisper API, requires ``GROQ_API_KEY``.\n+  - **openai** (paid) — OpenAI Whisper API, requires ``VOICE_TOOLS_OPENAI_KEY``.\n \n Used by the messaging gateway to automatically transcribe voice messages\n sent by users on Telegram, Discord, WhatsApp, Slack, and Signal.","@@ -33,18 +34,9 @@\n # Optional imports — graceful degradation\n # ---------------------------------------------------------------------------\n \n-try:\n-    from faster_whisper import WhisperModel\n-    _HAS_FASTER_WHISPER = True\n-except ImportError:\n-    _HAS_FASTER_WHISPER = False\n-    WhisperModel = None  # type: ignore[assignment,misc]\n-\n-try:\n-    from openai import OpenAI, APIError, APIConnectionError, APITimeoutError\n-    _HAS_OPENAI = True\n-except ImportError:\n-    _HAS_OPENAI = False\n+import importlib.util as _ilu\n+_HAS_FASTER_WHISPER = _ilu.find_spec(\"faster_whisper\") is not None\n+_HAS_OPENAI = _ilu.find_spec(\"openai\") is not None\n \n # ---------------------------------------------------------------------------\n # Constants","@@ -52,13 +44,21 @@\n \n DEFAULT_PROVIDER = \"local\"\n DEFAULT_LOCAL_MODEL = \"base\"\n-DEFAULT_OPENAI_MODEL = \"whisper-1\"\n+DEFAULT_STT_MODEL = os.getenv(\"STT_OPENAI_MODEL\", \"whisper-1\")\n+DEFAULT_GROQ_STT_MODEL = os.getenv(\"STT_GROQ_MODEL\", \"whisper-large-v3-turbo\")\n+\n+GROQ_BASE_URL = os.getenv(\"GROQ_BASE_URL\", \"https://api.groq.com/openai/v1\")\n+OPENAI_BASE_URL = os.getenv(\"STT_OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n \n SUPPORTED_FORMATS = {\".mp3\", \".mp4\", \".mpeg\", \".mpga\", \".m4a\", \".wav\", \".webm\", \".ogg\"}\n MAX_FILE_SIZE = 25 * 1024 * 1024  # 25 MB\n \n+# Known model sets for auto-correction\n+OPENAI_MODELS = {\"whisper-1\", \"gpt-4o-mini-transcribe\", \"gpt-4o-transcribe\"}\n+GROQ_MODELS = {\"whisper-large-v3\", \"whisper-large-v3-turbo\", \"distil-whisper-large-v3-en\"}\n+\n # Singleton for the local model — loaded once, reused across calls\n-_local_model: Optional[\"WhisperModel\"] = None\n+_local_model: Optional[object] = None\n _local_model_name: Optional[str] = None\n \n # ---------------------------------------------------------------------------","@@ -66,6 +66,24 @@\n # ---------------------------------------------------------------------------\n \n \n+def get_stt_model_from_config() -> Optional[str]:\n+    \"\"\"Read the STT model name from ~/.hermes/config.yaml.\n+\n+    Returns the value of ``stt.model`` if present, otherwise ``None``.\n+    Silently returns ``None`` on any error (missing file, bad YAML, etc.).\n+    \"\"\"\n+    try:\n+        import yaml\n+        cfg_path = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\")) / \"config.yaml\"\n+        if cfg_path.exists():\n+            with open(cfg_path) as f:\n+                data = yaml.safe_load(f) or {}\n+            return data.get(\"stt\", {}).get(\"model\")\n+    except Exception:\n+        pass\n+    return None\n+\n+\n def _load_stt_config() -> dict:\n     \"\"\"Load the ``stt`` section from user config, falling back to defaults.\"\"\"\n     try:","@@ -80,7 +98,7 @@ def _get_provider(stt_config: dict) -> str:\n \n     Priority:\n       1. Explicit config value  (``stt.provider``)\n-      2. Auto-detect: local if faster-whisper available, else openai if key set\n+      2. Auto-detect: local > groq (free) > openai (paid)\n       3. Disabled (returns \"none\")\n     \"\"\"\n     provider = stt_config.get(\"provider\", DEFAULT_PROVIDER)","@@ -88,19 +106,37 @@ def _get_provider(stt_config: dict) -> str:\n     if provider == \"local\":\n         if _HAS_FASTER_WHISPER:\n             return \"local\"\n-        # Local requested but not available — fall back to openai if possible\n+        # Local requested but not available — fall back to groq, then openai\n+        if _HAS_OPENAI and os.getenv(\"GROQ_API_KEY\"):\n+            logger.info(\"faster-whisper not installed, falling back to Groq Whisper API\")\n+            return \"groq\"\n         if _HAS_OPENAI and os.getenv(\"VOICE_TOOLS_OPENAI_KEY\"):\n             logger.info(\"faster-whisper not installed, falling back to OpenAI Whisper API\")\n             return \"openai\"\n         return \"none\"\n \n+    if provider == \"groq\":\n+        if _HAS_OPENAI and os.getenv(\"GROQ_API_KEY\"):\n+            return \"groq\"\n+        # Groq requested but no key — fall back\n+        if _HAS_FASTER_WHISPER:\n+            logger.info(\"GROQ_API_KEY not set, falling back to local faster-whisper\")\n+            return \"local\"\n+        if _HAS_OPENAI and os.getenv(\"VOICE_TOOLS_OPENAI_KEY\"):\n+            logger.info(\"GROQ_API_KEY not set, falling back to OpenAI Whisper API\")\n+            return \"openai\"\n+        return \"none\"\n+\n     if provider == \"openai\":\n         if _HAS_OPENAI and os.getenv(\"VOICE_TOOLS_OPENAI_KEY\"):\n             return \"openai\"\n-        # OpenAI requested but no key — fall back to local if possible\n+        # OpenAI requested but no key — fall back\n         if _HAS_FASTER_WHISPER:\n             logger.info(\"VOICE_TOOLS_OPENAI_KEY not set, falling back to local faster-whisper\")\n             return \"local\"\n+        if _HAS_OPENAI and os.getenv(\"GROQ_API_KEY\"):\n+            logger.info(\"VOICE_TOOLS_OPENAI_KEY not set, falling back to Groq Whisper API\")\n+            return \"groq\"\n         return \"none\"\n \n     return provider  # Unknown — let it fail downstream","@@ -150,6 +186,7 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]:\n         return {\"success\": False, \"transcript\": \"\", \"error\": \"faster-whisper not installed\"}\n \n     try:\n+        from faster_whisper import WhisperModel\n         # Lazy-load the model (downloads on first use, ~150 MB for 'base')\n         if _local_model is None or _local_model_name != model_name:\n             logger.info(\"Loading faster-whisper model '%s' (first load downloads the model)...\", model_name)","@@ -164,12 +201,60 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]:\n             Path(file_path).name, model_name, info.language, info.duration,\n         )\n \n-        return {\"success\": True, \"transcript\": transcript}\n+        return {\"success\": True, \"transcript\": transcript, \"provider\": \"local\"}\n \n     except Exception as e:\n         logger.error(\"Local transcription failed: %s\", e, exc_info=True)\n         return {\"success\": False, \"transcript\": \"\", \"error\": f\"Local transcription failed: {e}\"}\n \n+# ---------------------------------------------------------------------------\n+# Provider: groq (Whisper API — free tier)\n+# ---------------------------------------------------------------------------\n+\n+\n+def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:\n+    \"\"\"Transcribe using Groq Whisper API (free tier available).\"\"\"\n+    api_key = [redacted:secret-assignment]\"GROQ_API_KEY\")\n+    if not api_key:\n+        return {\"success\": False, \"transcript\": \"\", \"error\": \"GROQ_API_KEY not set\"}\n+\n+    if not _HAS_OPENAI:\n+        return {\"success\": False, \"transcript\": \"\", \"error\": \"openai package not installed\"}\n+\n+    # Auto-correct model if caller passed an OpenAI-only model\n+    if model_name in OPENAI_MODELS:\n+        logger.info(\"Model %s not available on Groq, using %s\", model_name, DEFAULT_GROQ_STT_MODEL)\n+        model_name = DEFAULT_GROQ_STT_MODEL\n+\n+    try:\n+        from openai import OpenAI, APIError, APIConnectionError, APITimeoutError\n+        client = OpenAI(api_key=[redacted:secret-assignment] base_url=GROQ_BASE_URL, timeout=30, max_retries=0)\n+\n+        with open(file_path, \"rb\") as audio_file:\n+            transcription = client.audio.transcriptions.create(\n+                model=model_name,\n+                file=audio_file,\n+                response_format=\"text\",\n+            )\n+\n+        transcript_text = str(transcription).strip()\n+        logger.info(\"Transcribed %s via Groq API (%s, %d chars)\",\n+                     Path(file_path).name, model_name, len(transcript_text))\n+\n+        return {\"success\": True, \"transcript\": transcript_text, \"provider\": \"groq\"}\n+\n+    except PermissionError:\n+        return {\"success\": False, \"transcript\": \"\", \"error\": f\"Permission denied: {file_path}\"}\n+    except APIConnectionError as e:\n+        return {\"success\": False, \"transcript\": \"\", \"error\": f\"Connection error: {e}\"}\n+    except APITimeoutError as e:\n+        return {\"success\": False, \"transcript\": \"\", \"error\": f\"Request timeout: {e}\"}\n+    except APIError as e:\n+        return {\"success\": False, \"transcript\": \"\", \"error\": f\"API error: {e}\"}\n+    except Exception as e:\n+        logger.error(\"Groq transcription failed: %s\", e, exc_info=True)\n+        return {\"success\": False, \"transcript\": \"\", \"error\": f\"Transcription failed: {e}\"}\n+\n # ---------------------------------------------------------------------------\n # Provider: openai (Whisper API)\n # ---------------------------------------------------------------------------","@@ -184,8 +269,14 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:\n     if not _HAS_OPENAI:\n         return {\"success\": False, \"transcript\": \"\", \"error\": \"openai package not installed\"}\n \n+    # Auto-correct model if caller passed a Groq-only model\n+    if model_name in GROQ_MODELS:\n+        logger.info(\"Model %s not available on OpenAI, using %s\", model_name, DEFAULT_STT_MODEL)\n+        model_name = DEFAULT_STT_MODEL\n+\n     try:\n-        client = OpenAI(api_key=[redacted:secret-assignment] base_url=\"https://api.openai.com/v1\")\n+        from openai import OpenAI, APIError, APIConnectionError, APITimeoutError\n+        client = OpenAI(api_key=[redacted:secret-assignment] base_url=OPENAI_BASE_URL, timeout=30, max_retries=0)\n \n         with open(file_path, \"rb\") as audio_file:\n             transcription = client.audio.transcriptions.create(","@@ -198,7 +289,7 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:\n         logger.info(\"Transcribed %s via OpenAI API (%s, %d chars)\",\n                      Path(file_path).name, model_name, len(transcript_text))\n \n-        return {\"success\": True, \"transcript\": transcript_text}\n+        return {\"success\": True, \"transcript\": transcript_text, \"provider\": \"openai\"}\n \n     except PermissionError:\n         return {\"success\": False, \"transcript\": \"\", \"error\": f\"Permission denied: {file_path}\"}","@@ -223,7 +314,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A\n \n     Provider priority:\n       1. User config (``stt.provider`` in config.yaml)\n-      2. Auto-detect: local faster-whisper if available, else OpenAI API\n+      2. Auto-detect: local faster-whisper (free) > Groq (free tier) > OpenAI (paid)\n \n     Args:\n         file_path: Absolute path to the audio file to transcribe.","@@ -234,6 +325,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A\n           - \"success\" (bool): Whether transcription succeeded\n           - \"transcript\" (str): The transcribed text (empty on failure)\n           - \"error\" (str, optional): Error message if success is False\n+          - \"provider\" (str, optional): Which provider was used\n     \"\"\"\n     # Validate input\n     error = _validate_audio_file(file_path)","@@ -249,9 +341,13 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A\n         model_name = model or local_cfg.get(\"model\", DEFAULT_LOCAL_MODEL)\n         return _transcribe_local(file_path, model_name)\n \n+    if provider == \"groq\":\n+        model_name = model or DEFAULT_GROQ_STT_MODEL\n+        return _transcribe_groq(file_path, model_name)\n+\n     if provider == \"openai\":\n         openai_cfg = stt_config.get(\"openai\", {})\n-        model_name = model or openai_cfg.get(\"model\", DEFAULT_OPENAI_MODEL)\n+        model_name = model or openai_cfg.get(\"model\", DEFAULT_STT_MODEL)\n         return _transcribe_openai(file_path, model_name)\n \n     # No provider available","@@ -260,6 +356,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A\n         \"transcript\": \"\",\n         \"error\": (\n             \"No STT provider available. Install faster-whisper for free local \"\n-            \"transcription, or set VOICE_TOOLS_OPENAI_KEY for the OpenAI Whisper API.\"\n+            \"transcription, set GROQ_API_KEY for free Groq Whisper, \"\n+            \"or set VOICE_TOOLS_OPENAI_KEY for the OpenAI Whisper API.\"\n         ),\n     }"]},{"file":"tools/tts_tool.py","hunks":["@@ -25,35 +25,41 @@\n import json\n import logging\n import os\n+import queue\n+import re\n import shutil\n import subprocess\n import tempfile\n+import threading\n from pathlib import Path\n-from typing import Dict, Any, Optional\n+from typing import Callable, Dict, Any, Optional\n \n logger = logging.getLogger(__name__)\n \n # ---------------------------------------------------------------------------\n-# Optional imports -- providers degrade gracefully if not installed\n+# Lazy imports -- providers are imported only when actually used to avoid\n+# crashing in headless environments (SSH, Docker, WSL, no PortAudio).\n # ---------------------------------------------------------------------------\n-try:\n+\n+def _import_edge_tts():\n+    \"\"\"Lazy import edge_tts. Returns the module or raises ImportError.\"\"\"\n     import edge_tts\n-    _HAS_EDGE_TTS = True\n-except ImportError:\n-    _HAS_EDGE_TTS = False\n+    return edge_tts\n \n-try:\n+def _import_elevenlabs():\n+    \"\"\"Lazy import ElevenLabs client. Returns the class or raises ImportError.\"\"\"\n     from elevenlabs.client import ElevenLabs\n-    _HAS_ELEVENLABS = True\n-except ImportError:\n-    _HAS_ELEVENLABS = False\n+    return ElevenLabs\n \n-# openai is a core dependency, but guard anyway\n-try:\n+def _import_openai_client():\n+    \"\"\"Lazy import OpenAI client. Returns the class or raises ImportError.\"\"\"\n     from openai import OpenAI as OpenAIClient\n-    _HAS_OPENAI = True\n-except ImportError:\n-    _HAS_OPENAI = False\n+    return OpenAIClient\n+\n+def _import_sounddevice():\n+    \"\"\"Lazy import sounddevice. Returns the module or raises ImportError/OSError.\"\"\"\n+    import sounddevice as sd\n+    return sd\n \n \n # ===========================================================================","@@ -63,6 +69,7 @@\n DEFAULT_EDGE_VOICE = \"en-US-AriaNeural\"\n DEFAULT_ELEVENLABS_VOICE_ID = \"pNInz6obpgDQGcFmaJgB\"  # Adam\n DEFAULT_ELEVENLABS_MODEL_ID = \"eleven_multilingual_v2\"\n+DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = \"eleven_flash_v2_5\"\n DEFAULT_OPENAI_MODEL = \"gpt-4o-mini-tts\"\n DEFAULT_OPENAI_VOICE = \"alloy\"\n DEFAULT_OUTPUT_DIR = str(Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\")) / \"audio_cache\")","@@ -154,10 +161,11 @@ async def _generate_edge_tts(text: str, output_path: str, tts_config: Dict[str,\n     Returns:\n         Path to the saved audio file.\n     \"\"\"\n+    _edge_tts = _import_edge_tts()\n     edge_config = tts_config.get(\"edge\", {})\n     voice = edge_config.get(\"voice\", DEFAULT_EDGE_VOICE)\n \n-    communicate = edge_tts.Communicate(text, voice)\n+    communicate = _edge_tts.Communicate(text, voice)\n     await communicate.save(output_path)\n     return output_path\n ","@@ -191,6 +199,7 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any]\n     else:\n         output_format = \"mp3_44100_128\"\n \n+    ElevenLabs = _import_elevenlabs()\n     client = ElevenLabs(api_key=[redacted:secret-assignment]\n     audio_generator = client.text_to_speech.convert(\n         text=text,","@@ -236,6 +245,7 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]\n     else:\n         response_format = \"mp3\"\n \n+    OpenAIClient = _import_openai_client()\n     client = OpenAIClient(api_key=[redacted:secret-assignment] base_url=\"https://api.openai.com/v1\")\n     response = client.audio.speech.create(\n         model=model,","@@ -311,7 +321,9 @@ def text_to_speech_tool(\n     try:\n         # Generate audio with the configured provider\n         if provider == \"elevenlabs\":\n-            if not _HAS_ELEVENLABS:\n+            try:\n+                _import_elevenlabs()\n+            except ImportError:\n                 return json.dumps({\n                     \"success\": False,\n                     \"error\": \"ElevenLabs provider selected but 'elevenlabs' package not installed. Run: pip install elevenlabs\"","@@ -320,7 +332,9 @@ def text_to_speech_tool(\n             _generate_elevenlabs(text, file_str, tts_config)\n \n         elif provider == \"openai\":\n-            if not _HAS_OPENAI:\n+            try:\n+                _import_openai_client()\n+            except ImportError:\n                 return json.dumps({\n                     \"success\": False,\n                     \"error\": \"OpenAI provider selected but 'openai' package not installed.\"","@@ -330,7 +344,9 @@ def text_to_speech_tool(\n \n         else:\n             # Default: Edge TTS (free)\n-            if not _HAS_EDGE_TTS:\n+            try:\n+                _import_edge_tts()\n+            except ImportError:\n                 return json.dumps({\n                     \"success\": False,\n                     \"error\": \"Edge TTS not available. Run: pip install edge-tts\"","@@ -411,15 +427,262 @@ def check_tts_requirements() -> bool:\n     Returns:\n         bool: True if at least one provider can work.\n     \"\"\"\n-    if _HAS_EDGE_TTS:\n-        return True\n-    if _HAS_ELEVENLABS and os.getenv(\"ELEVENLABS_API_KEY\"):\n-        return True\n-    if _HAS_OPENAI and os.getenv(\"VOICE_TOOLS_OPENAI_KEY\"):\n+    try:\n+        _import_edge_tts()\n         return True\n+    except ImportError:\n+        pass\n+    try:\n+        _import_elevenlabs()\n+        if os.getenv(\"ELEVENLABS_API_KEY\"):\n+            return True\n+    except ImportError:\n+        pass\n+    try:\n+        _import_openai_client()\n+        if os.getenv(\"VOICE_TOOLS_OPENAI_KEY\"):\n+            return True\n+    except ImportError:\n+        pass\n     return False\n \n \n+# ===========================================================================\n+# Streaming TTS: sentence-by-sentence pipeline for ElevenLabs\n+# ===========================================================================\n+# Sentence boundary pattern: punctuation followed by space or newline\n+_SENTENCE_BOUNDARY_RE = re.compile(r'(?<=[.!?])(?:\\s|\\n)|(?:\\n\\n)')\n+\n+# Markdown stripping patterns (same as cli.py _voice_speak_response)\n+_MD_CODE_BLOCK = re.compile(r'```[\\s\\S]*?```')\n+_MD_LINK = re.compile(r'\\[([^\\]]+)\\]\\([^)]+\\)')\n+_MD_URL = re.compile(r'https?://\\S+')\n+_MD_BOLD = re.compile(r'\\*\\*(.+?)\\*\\*')\n+_MD_ITALIC = re.compile(r'\\*(.+?)\\*')\n+_MD_INLINE_CODE = re.compile(r'`(.+?)`')\n+_MD_HEADER = re.compile(r'^#+\\s*', flags=re.MULTILINE)\n+_MD_LIST_ITEM = re.compile(r'^\\s*[-*]\\s+', flags=re.MULTILINE)\n+_MD_HR = re.compile(r'---+')\n+_MD_EXCESS_NL = re.compile(r'\\n{3,}')\n+\n+\n+def _strip_markdown_for_tts(text: str) -> str:\n+    \"\"\"Remove markdown formatting that shouldn't be spoken aloud.\"\"\"\n+    text = _MD_CODE_BLOCK.sub(' ', text)\n+    text = _MD_LINK.sub(r'\\1', text)\n+    text = _MD_URL.sub('', text)\n+    text = _MD_BOLD.sub(r'\\1', text)\n+    text = _MD_ITALIC.sub(r'\\1', text)\n+    text = _MD_INLINE_CODE.sub(r'\\1', text)\n+    text = _MD_HEADER.sub('', text)\n+    text = _MD_LIST_ITEM.sub('', text)\n+    text = _MD_HR.sub('', text)\n+    text = _MD_EXCESS_NL.sub('\\n\\n', text)\n+    return text.strip()\n+\n+\n+def stream_tts_to_speaker(\n+    text_queue: queue.Queue,\n+    stop_event: threading.Event,\n+    tts_done_event: threading.Event,\n+    display_callback: Optional[Callable[[str], None]] = None,\n+):\n+    \"\"\"Consume text deltas from *text_queue*, buffer them into sentences,\n+    and stream each sentence through ElevenLabs TTS to the speaker in\n+    real-time.\n+\n+    Protocol:\n+        * The producer puts ``str`` deltas onto *text_queue*.\n+        * A ``None`` sentinel signals end-of-text (flush remaining buffer).\n+        * *stop_event* can be set to abort early (e.g. user interrupt).\n+        * *tts_done_event* is **set** in the ``finally`` block so callers\n+          waiting on it (continuous voice mode) know playback is finished.\n+    \"\"\"\n+    tts_done_event.clear()\n+\n+    try:\n+        # --- TTS client setup (optional -- display_callback works without it) ---\n+        client = None\n+        output_stream = None\n+        voice_id = DEFAULT_ELEVENLABS_VOICE_ID\n+        model_id = DEFAULT_ELEVENLABS_STREAMING_MODEL_ID\n+\n+        tts_config = _load_tts_config()\n+        el_config = tts_config.get(\"elevenlabs\", {})\n+        voice_id = el_config.get(\"voice_id\", voice_id)\n+        model_id = el_config.get(\"streaming_model_id\",\n+                                 el_config.get(\"model_id\", model_id))\n+\n+        api_key = [redacted:secret-assignment]\"ELEVENLABS_API_KEY\", \"\")\n+        if not api_key:\n+            logger.warning(\"ELEVENLABS_API_KEY not set; streaming TTS audio disabled\")\n+        else:\n+            try:\n+                ElevenLabs = _import_elevenlabs()\n+                client = ElevenLabs(api_key=[redacted:secret-assignment]\n+            except ImportError:\n+                logger.warning(\"elevenlabs package not installed; streaming TTS disabled\")\n+\n+            # Open a single sounddevice output stream for the lifetime of\n+            # this function.  ElevenLabs pcm_24000 produces signed 16-bit\n+            # little-endian mono PCM at 24 kHz.\n+            if client is not None:\n+                try:\n+                    sd = _import_sounddevice()\n+                    import numpy as _np\n+                    output_stream = sd.OutputStream(\n+                        samplerate=24000, channels=1, dtype=\"int16\",\n+                    )\n+                    output_stream.start()\n+                except (ImportError, OSError) as exc:\n+                    logger.debug(\"sounddevice not available: %s\", exc)\n+                    output_stream = None\n+                except Exception as exc:\n+                    logger.warning(\"sounddevice OutputStream failed: %s\", exc)\n+                    output_stream = None\n+\n+        sentence_buf = \"\"\n+        min_sentence_len = 20\n+        long_flush_len = 100\n+        queue_timeout = 0.5\n+        _spoken_sentences: list[str] = []  # track spoken sentences to skip duplicates\n+        # Regex to strip complete <think>...</think> blocks from buffer\n+        _think_block_re = re.compile(r'<think[\\s>].*?</think>', flags=re.DOTALL)\n+\n+        def _speak_sentence(sentence: str):\n+            \"\"\"Display sentence and optionally generate + play audio.\"\"\"\n+            if stop_event.is_set():\n+                return\n+            cleaned = _strip_markdown_for_tts(sentence).strip()\n+            if not cleaned:\n+                return\n+            # Skip duplicate/near-duplicate sentences (LLM repetition)\n+            cleaned_lower = cleaned.lower().rstrip(\".!,\")\n+            for prev in _spoken_sentences:\n+                if prev.lower().rstrip(\".!,\") == cleaned_lower:\n+                    return\n+            _spoken_sentences.append(cleaned)\n+            # Display raw sentence on screen before TTS processing\n+            if display_callback is not None:\n+                display_callback(sentence)\n+            # Skip audio generation if no TTS client available\n+            if client is None:\n+                return\n+            # Truncate very long sentences\n+            if len(cleaned) > MAX_TEXT_LENGTH:\n+                cleaned = cleaned[:MAX_TEXT_LENGTH]\n+            try:\n+                audio_iter = client.text_to_speech.convert(\n+                    text=cleaned,\n+                    voice_id=voice_id,\n+                    model_id=model_id,\n+                    output_format=\"pcm_24000\",\n+                )\n+                if output_stream is not None:\n+                    for chunk in audio_iter:\n+                        if stop_event.is_set():\n+                            break\n+                        import numpy as _np\n+                        audio_array = _np.frombuffer(chunk, dtype=_np.int16)\n+                        output_stream.write(audio_array.reshape(-1, 1))\n+                else:\n+                    # Fallback: write chunks to temp file and play via system player\n+                    _play_via_tempfile(audio_iter, stop_event)\n+            except Exception as exc:\n+                logger.warning(\"Streaming TTS sentence failed: %s\", exc)\n+\n+        def _play_via_tempfile(audio_iter, stop_evt):\n+            \"\"\"Write PCM chunks to a temp WAV file and play it.\"\"\"\n+            tmp_path = None\n+            try:\n+                import wave\n+                tmp = tempfile.NamedTemporaryFile(suffix=\".wav\", delete=False)\n+                tmp_path = tmp.name\n+                with wave.open(tmp, \"wb\") as wf:\n+                    wf.setnchannels(1)\n+                    wf.setsampwidth(2)  # 16-bit\n+                    wf.setframerate(24000)\n+                    for chunk in audio_iter:\n+                        if stop_evt.is_set():\n+                            break\n+                        wf.writeframes(chunk)\n+                from tools.voice_mode import play_audio_file\n+                play_audio_file(tmp_path)\n+            except Exception as exc:\n+                logger.warning(\"Temp-file TTS fallback failed: %s\", exc)\n+            finally:\n+                if tmp_path:\n+                    try:\n+                        os.unlink(tmp_path)\n+                    except OSError:\n+                        pass\n+\n+        while not stop_event.is_set():\n+            # Read next delta from queue\n+            try:\n+                delta = text_queue.get(timeout=queue_timeout)\n+            except queue.Empty:\n+                # Timeout: if we have accumulated a long buffer, flush it\n+                if len(sentence_buf) > long_flush_len:\n+                    _speak_sentence(sentence_buf)\n+                    sentence_buf = \"\"\n+                continue\n+\n+            if delta is None:\n+                # End-of-text sentinel: strip any remaining think blocks, flush\n+                sentence_buf = _think_block_re.sub('', sentence_buf)\n+                if sentence_buf.strip():\n+                    _speak_sentence(sentence_buf)\n+                break\n+\n+            sentence_buf += delta\n+\n+            # --- Think block filtering ---\n+            # Strip complete <think>...</think> blocks from buffer.\n+            # Works correctly even when tags span multiple deltas.\n+            sentence_buf = _think_block_re.sub('', sentence_buf)\n+\n+            # If an incomplete <think tag is at the end, wait for more data\n+            # before extracting sentences (the closing tag may arrive next).\n+            if '<think' in sentence_buf and '</think>' not in sentence_buf:\n+                continue\n+\n+            # Check for sentence boundaries\n+            while True:\n+                m = _SENTENCE_BOUNDARY_RE.search(sentence_buf)\n+                if m is None:\n+                    break\n+                end_pos = m.end()\n+                sentence = sentence_buf[:end_pos]\n+                sentence_buf = sentence_buf[end_pos:]\n+                # Merge short fragments into the next sentence\n+                if len(sentence.strip()) < min_sentence_len:\n+                    sentence_buf = sentence + sentence_buf\n+                    break\n+                _speak_sentence(sentence)\n+\n+        # Drain any remaining items from the queue\n+        while True:\n+            try:\n+                text_queue.get_nowait()\n+            except queue.Empty:\n+                break\n+\n+        # output_stream is closed in the finally block below\n+\n+    except Exception as exc:\n+        logger.warning(\"Streaming TTS pipeline error: %s\", exc)\n+    finally:\n+        # Always close the audio output stream to avoid locking the device\n+        if output_stream is not None:\n+            try:\n+                output_stream.stop()\n+                output_stream.close()\n+            except Exception:\n+                pass\n+        tts_done_event.set()\n+\n+\n # ===========================================================================\n # Main -- quick diagnostics\n # ===========================================================================","@@ -427,12 +690,19 @@ def check_tts_requirements() -> bool:\n     print(\"🔊 Text-to-Speech Tool Module\")\n     print(\"=\" * 50)\n \n+    def _check(importer, label):\n+        try:\n+            importer()\n+            return True\n+        except ImportError:\n+            return False\n+\n     print(f\"\\nProvider availability:\")\n-    print(f\"  Edge TTS:   {'✅ installed' if _HAS_EDGE_TTS else '❌ not installed (pip install edge-tts)'}\")\n-    print(f\"  ElevenLabs: {'✅ installed' if _HAS_ELEVENLABS else '❌ not installed (pip install elevenlabs)'}\")\n-    print(f\"    API Key:  {'✅ set' if os.getenv('ELEVENLABS_API_KEY') else '❌ not set'}\")\n-    print(f\"  OpenAI:     {'✅ installed' if _HAS_OPENAI else '❌ not installed'}\")\n-    print(f\"    API Key:  {'✅ set' if os.getenv('VOICE_TOOLS_OPENAI_KEY') else '❌ not set (VOICE_TOOLS_OPENAI_KEY)'}\")\n+    print(f\"  Edge TTS:   {'installed' if _check(_import_edge_tts, 'edge') else 'not installed (pip install edge-tts)'}\")\n+    print(f\"  ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}\")\n+    print(f\"    API Key:  {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}\")\n+    print(f\"  OpenAI:     {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}\")\n+    print(f\"    API Key:  {'set' if os.getenv('VOICE_TOOLS_OPENAI_KEY') else 'not set (VOICE_TOOLS_OPENAI_KEY)'}\")\n     print(f\"  ffmpeg:     {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}\")\n     print(f\"\\n  Output dir: {DEFAULT_OUTPUT_DIR}\")\n "]},{"file":"tools/voice_mode.py","hunks":["@@ -0,0 +1,783 @@\n+\"\"\"Voice Mode -- Push-to-talk audio recording and playback for the CLI.\n+\n+Provides audio capture via sounddevice, WAV encoding via stdlib wave,\n+STT dispatch via tools.transcription_tools, and TTS playback via\n+sounddevice or system audio players.\n+\n+Dependencies (optional):\n+    pip install sounddevice numpy\n+    or: pip install hermes-agent[voice]\n+\"\"\"\n+\n+import logging\n+import os\n+import platform\n+import re\n+import shutil\n+import subprocess\n+import tempfile\n+import threading\n+import time\n+import wave\n+from pathlib import Path\n+from typing import Any, Dict, List, Optional\n+\n+logger = logging.getLogger(__name__)\n+\n+# ---------------------------------------------------------------------------\n+# Lazy audio imports -- never imported at module level to avoid crashing\n+# in headless environments (SSH, Docker, WSL, no PortAudio).\n+# ---------------------------------------------------------------------------\n+\n+def _import_audio():\n+    \"\"\"Lazy-import sounddevice and numpy.  Returns (sd, np).\n+\n+    Raises ImportError or OSError if the libraries are not available\n+    (e.g. PortAudio missing on headless servers).\n+    \"\"\"\n+    import sounddevice as sd\n+    import numpy as np\n+    return sd, np\n+\n+\n+def _audio_available() -> bool:\n+    \"\"\"Return True if audio libraries can be imported.\"\"\"\n+    try:\n+        _import_audio()\n+        return True\n+    except (ImportError, OSError):\n+        return False\n+\n+\n+def detect_audio_environment() -> dict:\n+    \"\"\"Detect if the current environment supports audio I/O.\n+\n+    Returns dict with 'available' (bool) and 'warnings' (list of strings).\n+    \"\"\"\n+    warnings = []\n+\n+    # SSH detection\n+    if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):\n+        warnings.append(\"Running over SSH -- no audio devices available\")\n+\n+    # Docker detection\n+    if os.path.exists('/.dockerenv'):\n+        warnings.append(\"Running inside Docker container -- no audio devices\")\n+\n+    # WSL detection\n+    try:\n+        with open('/proc/version', 'r') as f:\n+            if 'microsoft' in f.read().lower():\n+                warnings.append(\"Running in WSL -- audio requires PulseAudio bridge to Windows\")\n+    except (FileNotFoundError, PermissionError, OSError):\n+        pass\n+\n+    # Check audio libraries\n+    try:\n+        sd, _ = _import_audio()\n+        try:\n+            devices = sd.query_devices()\n+            if not devices:\n+                warnings.append(\"No audio input/output devices detected\")\n+        except Exception:\n+            warnings.append(\"Audio subsystem error (PortAudio cannot query devices)\")\n+    except (ImportError, OSError):\n+        warnings.append(\"Audio libraries not installed (pip install sounddevice numpy)\")\n+\n+    return {\n+        \"available\": len(warnings) == 0,\n+        \"warnings\": warnings,\n+    }\n+\n+# ---------------------------------------------------------------------------\n+# Recording parameters\n+# ---------------------------------------------------------------------------\n+SAMPLE_RATE = 16000  # Whisper native rate\n+CHANNELS = 1  # Mono\n+DTYPE = \"int16\"  # 16-bit PCM\n+SAMPLE_WIDTH = 2  # bytes per sample (int16)\n+MAX_RECORDING_SECONDS = 120  # Safety cap\n+\n+# Silence detection defaults\n+SILENCE_RMS_THRESHOLD = 200  # RMS below this = silence (int16 range 0-32767)\n+SILENCE_DURATION_SECONDS = 3.0  # Seconds of continuous silence before auto-stop\n+\n+# Temp directory for voice recordings\n+_TEMP_DIR = os.path.join(tempfile.gettempdir(), \"hermes_voice\")\n+\n+\n+# ============================================================================\n+# Audio cues (beep tones)\n+# ============================================================================\n+def play_beep(frequency: int = 880, duration: float = 0.12, count: int = 1) -> None:\n+    \"\"\"Play a short beep tone using numpy + sounddevice.\n+\n+    Args:\n+        frequency: Tone frequency in Hz (default 880 = A5).\n+        duration: Duration of each beep in seconds.\n+        count: Number of beeps to play (with short gap between).\n+    \"\"\"\n+    try:\n+        sd, np = _import_audio()\n+    except (ImportError, OSError):\n+        return\n+    try:\n+        gap = 0.06  # seconds between beeps\n+        samples_per_beep = int(SAMPLE_RATE * duration)\n+        samples_per_gap = int(SAMPLE_RATE * gap)\n+\n+        parts = []\n+        for i in range(count):\n+            t = np.linspace(0, duration, samples_per_beep, endpoint=False)\n+            # Apply fade in/out to avoid click artifacts\n+            tone = np.sin(2 * np.pi * frequency * t)\n+            fade_len = min(int(SAMPLE_RATE * 0.01), samples_per_beep // 4)\n+            tone[:fade_len] *= np.linspace(0, 1, fade_len)\n+            tone[-fade_len:] *= np.linspace(1, 0, fade_len)\n+            parts.append((tone * 0.3 * 32767).astype(np.int16))\n+            if i < count - 1:\n+                parts.append(np.zeros(samples_per_gap, dtype=np.int16))\n+\n+        audio = np.concatenate(parts)\n+        sd.play(audio, samplerate=SAMPLE_RATE)\n+        # sd.wait() calls Event.wait() without timeout — hangs forever if the\n+        # audio device stalls.  Poll with a 2s ceiling and force-stop.\n+        deadline = time.monotonic() + 2.0\n+        while sd.get_stream() and sd.get_stream().active and time.monotonic() < deadline:\n+            time.sleep(0.01)\n+        sd.stop()\n+    except Exception as e:\n+        logger.debug(\"Beep playback failed: %s\", e)\n+\n+\n+# ============================================================================\n+# AudioRecorder\n+# ============================================================================\n+class AudioRecorder:\n+    \"\"\"Thread-safe audio recorder using sounddevice.InputStream.\n+\n+    Usage::\n+\n+        recorder = AudioRecorder()\n+        recorder.start(on_silence_stop=my_callback)\n+        # ... user speaks ...\n+        wav_path = recorder.stop()   # returns path to WAV file\n+        # or\n+        recorder.cancel()            # discard without saving\n+\n+    If ``on_silence_stop`` is provided, recording automatically stops when\n+    the user is silent for ``silence_duration`` seconds and calls the callback.\n+    \"\"\"\n+\n+    def __init__(self) -> None:\n+        self._lock = threading.Lock()\n+        self._stream: Any = None\n+        self._frames: List[Any] = []\n+        self._recording = False\n+        self._start_time: float = 0.0\n+        # Silence detection state\n+        self._has_spoken = False\n+        self._speech_start: float = 0.0  # When speech attempt began\n+        self._dip_start: float = 0.0  # When current below-threshold dip began\n+        self._min_speech_duration: float = 0.3  # Seconds of speech needed to confirm\n+        self._max_dip_tolerance: float = 0.3  # Max dip duration before resetting speech\n+        self._silence_start: float = 0.0\n+        self._resume_start: float = 0.0  # Tracks sustained speech after silence starts\n+        self._resume_dip_start: float = 0.0  # Dip tolerance tracker for resume detection\n+        self._on_silence_stop = None\n+        self._silence_threshold: int = SILENCE_RMS_THRESHOLD\n+        self._silence_duration: float = SILENCE_DURATION_SECONDS\n+        self._max_wait: float = 15.0  # Max seconds to wait for speech before auto-stop\n+        # Peak RMS seen during recording (for speech presence check in stop())\n+        self._peak_rms: int = 0\n+        # Live audio level (read by UI for visual feedback)\n+        self._current_rms: int = 0\n+\n+    # -- public properties ---------------------------------------------------\n+\n+    @property\n+    def is_recording(self) -> bool:\n+        return self._recording\n+\n+    @property\n+    def elapsed_seconds(self) -> float:\n+        if not self._recording:\n+            return 0.0\n+        return time.monotonic() - self._start_time\n+\n+    @property\n+    def current_rms(self) -> int:\n+        \"\"\"Current audio input RMS level (0-32767). Updated each audio chunk.\"\"\"\n+        return self._current_rms\n+\n+    # -- public methods ------------------------------------------------------\n+\n+    def _ensure_stream(self) -> None:\n+        \"\"\"Create the audio InputStream once and keep it alive.\n+\n+        The stream stays open for the lifetime of the recorder.  Between\n+        recordings the callback simply discards audio chunks (``_recording``\n+        is ``False``).  This avoids the CoreAudio bug where closing and\n+        re-opening an ``InputStream`` hangs indefinitely on macOS.\n+        \"\"\"\n+        if self._stream is not None:\n+            return  # already alive\n+\n+        sd, np = _import_audio()\n+\n+        def _callback(indata, frames, time_info, status):  # noqa: ARG001\n+            if status:\n+                logger.debug(\"sounddevice status: %s\", status)\n+            # When not recording the stream is idle — discard audio.\n+            if not self._recording:\n+                return\n+            self._frames.append(indata.copy())\n+\n+            # Compute RMS for level display and silence detection\n+            rms = int(np.sqrt(np.mean(indata.astype(np.float64) ** 2)))\n+            self._current_rms = rms\n+            if rms > self._peak_rms:\n+                self._peak_rms = rms\n+\n+            # Silence detection\n+            if self._on_silence_stop is not None:\n+                now = time.monotonic()\n+                elapsed = now - self._start_time\n+\n+                if rms > self._silence_threshold:\n+                    # Audio is above threshold -- this is speech (or noise).\n+                    self._dip_start = 0.0  # Reset dip tracker\n+                    if self._speech_start == 0.0:\n+                        self._speech_start = now\n+                    elif not self._has_spoken and now - self._speech_start >= self._min_speech_duration:\n+                        self._has_spoken = True\n+                        logger.debug(\"Speech confirmed (%.2fs above threshold)\",\n+                                     now - self._speech_start)\n+                    # After speech is confirmed, only reset silence timer if\n+                    # speech is sustained (>0.3s above threshold).  Brief\n+                    # spikes from ambient noise should NOT reset the timer.\n+                    if not self._has_spoken:\n+                        self._silence_start = 0.0\n+                    else:\n+                        # Track resumed speech with dip tolerance.\n+                        # Brief dips below threshold are normal during speech,\n+                        # so we mirror the initial speech detection pattern:\n+                        # start tracking, tolerate short dips, confirm after 0.3s.\n+                        self._resume_dip_start = 0.0  # Above threshold — no dip\n+                        if self._resume_start == 0.0:\n+                            self._resume_start = now\n+                        elif now - self._resume_start >= self._min_speech_duration:\n+                            self._silence_start = 0.0\n+                            self._resume_start = 0.0\n+                elif self._has_spoken:\n+                    # Below threshold after speech confirmed.\n+                    # Use dip tolerance before resetting resume tracker —\n+                    # natural speech has brief dips below threshold.\n+                    if self._resume_start > 0:\n+                        if self._resume_dip_start == 0.0:\n+                            self._resume_dip_start = now\n+                        elif now - self._resume_dip_start >= self._max_dip_tolerance:\n+                            # Sustained dip — user actually stopped speaking\n+                            self._resume_start = 0.0\n+                            self._resume_dip_start = 0.0\n+                elif self._speech_start > 0:\n+                    # We were in a speech attempt but RMS dipped.\n+                    # Tolerate brief dips (micro-pauses between syllables).\n+                    if self._dip_start == 0.0:\n+                        self._dip_start = now\n+                    elif now - self._dip_start >= self._max_dip_tolerance:\n+                        # Dip lasted too long -- genuine silence, reset\n+                        logger.debug(\"Speech attempt reset (dip lasted %.2fs)\",\n+                                     now - self._dip_start)\n+                        self._speech_start = 0.0\n+                        self._dip_start = 0.0\n+\n+                # Fire silence callback when:\n+                # 1. User spoke then went silent for silence_duration, OR\n+                # 2. No speech detected at all for max_wait seconds\n+                should_fire = False\n+                if self._has_spoken and rms <= self._silence_threshold:\n+                    # User was speaking and now is silent\n+                    if self._silence_start == 0.0:\n+                        self._silence_start = now\n+                    elif now - self._silence_start >= self._silence_duration:\n+                        logger.info(\"Silence detected (%.1fs), auto-stopping\",\n+                                    self._silence_duration)\n+                        should_fire = True\n+                elif not self._has_spoken and elapsed >= self._max_wait:\n+                    logger.info(\"No speech within %.0fs, auto-stopping\",\n+                                self._max_wait)\n+                    should_fire = True\n+\n+                if should_fire:\n+                    with self._lock:\n+                        cb = self._on_silence_stop\n+                        self._on_silence_stop = None  # fire only once\n+                    if cb:\n+                        def _safe_cb():\n+                            try:\n+                                cb()\n+                            except Exception as e:\n+                                logger.error(\"Silence callback failed: %s\", e, exc_info=True)\n+                        threading.Thread(target=_safe_cb, daemon=True).start()\n+\n+        # Create stream — may block on CoreAudio (first call only).\n+        stream = None\n+        try:\n+            stream = sd.InputStream(\n+                samplerate=SAMPLE_RATE,\n+                channels=CHANNELS,\n+                dtype=DTYPE,\n+                callback=_callback,\n+            )\n+            stream.start()\n+        except Exception as e:\n+            if stream is not None:\n+                try:\n+                    stream.close()\n+                except Exception:\n+                    pass\n+            raise RuntimeError(\n+                f\"Failed to open audio input stream: {e}. \"\n+                \"Check that a microphone is connected and accessible.\"\n+            ) from e\n+        self._stream = stream\n+\n+    def start(self, on_silence_stop=None) -> None:\n+        \"\"\"Start capturing audio from the default input device.\n+\n+        The underlying InputStream is created once and kept alive across\n+        recordings.  Subsequent calls simply reset detection state and\n+        toggle frame collection via ``_recording``.\n+\n+        Args:\n+            on_silence_stop: Optional callback invoked (in a daemon thread) when\n+                silence is detected after speech. The callback receives no arguments.\n+                Use this to auto-stop recording and trigger transcription.\n+\n+        Raises ``RuntimeError`` if sounddevice/numpy are not installed\n+        or if a recording is already in progress.\n+        \"\"\"\n+        try:\n+            _import_audio()\n+        except (ImportError, OSError) as e:\n+            raise RuntimeError(\n+                \"Voice mode requires sounddevice and numpy.\\n\"\n+                \"Install with: pip install sounddevice numpy\\n\"\n+                \"Or: pip install hermes-agent[voice]\"\n+            ) from e\n+\n+        with self._lock:\n+            if self._recording:\n+                return  # already recording\n+\n+            self._frames = []\n+            self._start_time = time.monotonic()\n+            self._has_spoken = False\n+            self._speech_start = 0.0\n+            self._dip_start = 0.0\n+            self._silence_start = 0.0\n+            self._resume_start = 0.0\n+            self._resume_dip_start = 0.0\n+            self._peak_rms = 0\n+            self._current_rms = 0\n+            self._on_silence_stop = on_silence_stop\n+\n+        # Ensure the persistent stream is alive (no-op after first call).\n+        self._ensure_stream()\n+\n+        with self._lock:\n+            self._recording = True\n+        logger.info(\"Voice recording started (rate=%d, channels=%d)\", SAMPLE_RATE, CHANNELS)\n+\n+    def _close_stream_with_timeout(self, timeout: float = 3.0) -> None:\n+        \"\"\"Close the audio stream with a timeout to prevent CoreAudio hangs.\"\"\"\n+        if self._stream is None:\n+            return\n+\n+        stream = self._stream\n+        self._stream = None\n+\n+        def _do_close():\n+            try:\n+                stream.stop()\n+                stream.close()\n+            except Exception:\n+                pass\n+\n+        t = threading.Thread(target=_do_close, daemon=True)\n+        t.start()\n+        # Poll in short intervals so Ctrl+C is not blocked\n+        deadline = __import__(\"time\").monotonic() + timeout\n+        while t.is_alive() and __import__(\"time\").monotonic() < deadline:\n+            t.join(timeout=0.1)\n+        if t.is_alive():\n+            logger.warning(\"Audio stream close timed out after %.1fs — forcing ahead\", timeout)\n+\n+    def stop(self) -> Optional[str]:\n+        \"\"\"Stop recording and write captured audio to a WAV file.\n+\n+        The underlying stream is kept alive for reuse — only frame\n+        collection is stopped.\n+\n+        Returns:\n+            Path to the WAV file, or ``None`` if no audio was captured.\n+        \"\"\"\n+        with self._lock:\n+            if not self._recording:\n+                return None\n+\n+            self._recording = False\n+            self._current_rms = 0\n+            # Stream stays alive — no close needed.\n+\n+            if not self._frames:\n+                return None\n+\n+            # Concatenate frames and write WAV\n+            _, np = _import_audio()\n+            audio_data = np.concatenate(self._frames, axis=0)\n+            self._frames = []\n+\n+            elapsed = time.monotonic() - self._start_time\n+            logger.info(\"Voice recording stopped (%.1fs, %d samples)\", elapsed, len(audio_data))\n+\n+            # Skip very short recordings (< 0.3s of audio)\n+            min_samples = int(SAMPLE_RATE * 0.3)\n+            if len(audio_data) < min_samples:\n+                logger.debug(\"Recording too short (%d samples), discarding\", len(audio_data))\n+                return None\n+\n+            # Skip silent recordings using peak RMS (not overall average, which\n+            # gets diluted by silence at the end of the recording).\n+            if self._peak_rms < SILENCE_RMS_THRESHOLD:\n+                logger.info(\"Recording too quiet (peak RMS=%d < %d), discarding\",\n+                            self._peak_rms, SILENCE_RMS_THRESHOLD)\n+                return None\n+\n+            return self._write_wav(audio_data)\n+\n+    def cancel(self) -> None:\n+        \"\"\"Stop recording and discard all captured audio.\n+\n+        The underlying stream is kept alive for reuse.\n+        \"\"\"\n+        with self._lock:\n+            self._recording = False\n+            self._frames = []\n+            self._on_silence_stop = None\n+            self._current_rms = 0\n+        logger.info(\"Voice recording cancelled\")\n+\n+    def shutdown(self) -> None:\n+        \"\"\"Release the audio stream.  Call when voice mode is disabled.\"\"\"\n+        with self._lock:\n+            self._recording = False\n+            self._frames = []\n+            self._on_silence_stop = None\n+        # Close stream OUTSIDE the lock to avoid deadlock with audio callback\n+        self._close_stream_with_timeout()\n+        logger.info(\"AudioRecorder shut down\")\n+\n+    # -- private helpers -----------------------------------------------------\n+\n+    @staticmethod\n+    def _write_wav(audio_data) -> str:\n+        \"\"\"Write numpy int16 audio data to a WAV file.\n+\n+        Returns the file path.\n+        \"\"\"\n+        os.makedirs(_TEMP_DIR, exist_ok=True)\n+        timestamp = time.strftime(\"%Y%m%d_%H%M%S\")\n+        wav_path = os.path.join(_TEMP_DIR, f\"recording_{timestamp}.wav\")\n+\n+        with wave.open(wav_path, \"wb\") as wf:\n+            wf.setnchannels(CHANNELS)\n+            wf.setsampwidth(SAMPLE_WIDTH)\n+            wf.setframerate(SAMPLE_RATE)\n+            wf.writeframes(audio_data.tobytes())\n+\n+        file_size = os.path.getsize(wav_path)\n+        logger.info(\"WAV written: %s (%d bytes)\", wav_path, file_size)\n+        return wav_path\n+\n+\n+# ============================================================================\n+# Whisper hallucination filter\n+# ============================================================================\n+# Whisper commonly hallucinates these phrases on silent/near-silent audio.\n+WHISPER_HALLUCINATIONS = {\n+    \"thank you.\",\n+    \"thank you\",\n+    \"thanks for watching.\",\n+    \"thanks for watching\",\n+    \"subscribe to my channel.\",\n+    \"subscribe to my channel\",\n+    \"like and subscribe.\",\n+    \"like and subscribe\",\n+    \"please subscribe.\",\n+    \"please subscribe\",\n+    \"thank you for watching.\",\n+    \"thank you for watching\",\n+    \"bye.\",\n+    \"bye\",\n+    \"you\",\n+    \"the end.\",\n+    \"the end\",\n+    # Non-English hallucinations (common on silence)\n+    \"продолжение следует\",\n+    \"продолжение следует...\",\n+    \"sous-titres\",\n+    \"sous-titres réalisés par la communauté d'amara.org\",\n+    \"sottotitoli creati dalla comunità amara.org\",\n+    \"untertitel von stephanie geiges\",\n+    \"amara.org\",\n+    \"www.mooji.org\",\n+    \"ご視聴ありがとうございました\",\n+}\n+\n+# Regex patterns for repetitive hallucinations (e.g. \"Thank you. Thank you. Thank you.\")\n+_HALLUCINATION_REPEAT_RE = re.compile(\n+    r'^(?:thank you|thanks|bye|you|ok|okay|the end|\\.|\\s|,|!)+$',\n+    flags=re.IGNORECASE,\n+)\n+\n+\n+def is_whisper_hallucination(transcript: str) -> bool:\n+    \"\"\"Check if a transcript is a known Whisper hallucination on silence.\"\"\"\n+    cleaned = transcript.strip().lower()\n+    if not cleaned:\n+        return True\n+    # Exact match against known phrases\n+    if cleaned.rstrip('.!') in WHISPER_HALLUCINATIONS or cleaned in WHISPER_HALLUCINATIONS:\n+        return True\n+    # Repetitive patterns (e.g. \"Thank you. Thank you. Thank you. you\")\n+    if _HALLUCINATION_REPEAT_RE.match(cleaned):\n+        return True\n+    return False\n+\n+\n+# ============================================================================\n+# STT dispatch\n+# ============================================================================\n+def transcribe_recording(wav_path: str, model: Optional[str] = None) -> Dict[str, Any]:\n+    \"\"\"Transcribe a WAV recording using the existing Whisper pipeline.\n+\n+    Delegates to ``tools.transcription_tools.transcribe_audio()``.\n+    Filters out known Whisper hallucinations on silent audio.\n+\n+    Args:\n+        wav_path: Path to the WAV file.\n+        model: Whisper model name (default: from config or ``whisper-1``).\n+\n+    Returns:\n+        Dict with ``success``, ``transcript``, and optionally ``error``.\n+    \"\"\"\n+    from tools.transcription_tools import transcribe_audio\n+\n+    result = transcribe_audio(wav_path, model=model)\n+\n+    # Filter out Whisper hallucinations (common on silent/near-silent audio)\n+    if result.get(\"success\") and is_whisper_hallucination(result.get(\"transcript\", \"\")):\n+        logger.info(\"Filtered Whisper hallucination: %r\", result[\"transcript\"])\n+        return {\"success\": True, \"transcript\": \"\", \"filtered\": True}\n+\n+    return result\n+\n+\n+# ============================================================================\n+# Audio playback (interruptable)\n+# ============================================================================\n+\n+# Global reference to the active playback process so it can be interrupted.\n+_active_playback: Optional[subprocess.Popen] = None\n+_playback_lock = threading.Lock()\n+\n+\n+def stop_playback() -> None:\n+    \"\"\"Interrupt the currently playing audio (if any).\"\"\"\n+    global _active_playback\n+    with _playback_lock:\n+        proc = _active_playback\n+        _active_playback = None\n+    if proc and proc.poll() is None:\n+        try:\n+            proc.terminate()\n+            logger.info(\"Audio playback interrupted\")\n+        except Exception:\n+            pass\n+    # Also stop sounddevice playback if active\n+    try:\n+        sd, _ = _import_audio()\n+        sd.stop()\n+    except Exception:\n+        pass\n+\n+\n+def play_audio_file(file_path: str) -> bool:\n+    \"\"\"Play an audio file through the default output device.\n+\n+    Strategy:\n+    1. WAV files via ``sounddevice.play()`` when available.\n+    2. System commands: ``afplay`` (macOS), ``ffplay`` (cross-platform),\n+       ``aplay`` (Linux ALSA).\n+\n+    Playback can be interrupted by calling ``stop_playback()``.\n+\n+    Returns:\n+        ``True`` if playback succeeded, ``False`` otherwise.\n+    \"\"\"\n+    global _active_playback\n+\n+    if not os.path.isfile(file_path):\n+        logger.warning(\"Audio file not found: %s\", file_path)\n+        return False\n+\n+    # Try sounddevice for WAV files\n+    if file_path.endswith(\".wav\"):\n+        try:\n+            sd, np = _import_audio()\n+            with wave.open(file_path, \"rb\") as wf:\n+                frames = wf.readframes(wf.getnframes())\n+                audio_data = np.frombuffer(frames, dtype=np.int16)\n+                sample_rate = wf.getframerate()\n+\n+            sd.play(audio_data, samplerate=sample_rate)\n+            # sd.wait() calls Event.wait() without timeout — hangs forever if\n+            # the audio device stalls.  Poll with a ceiling and force-stop.\n+            duration_secs = len(audio_data) / sample_rate\n+            deadline = time.monotonic() + duration_secs + 2.0\n+            while sd.get_stream() and sd.get_stream().active and time.monotonic() < deadline:\n+                time.sleep(0.01)\n+            sd.stop()\n+            return True\n+        except (ImportError, OSError):\n+            pass  # audio libs not available, fall through to system players\n+        except Exception as e:\n+            logger.debug(\"sounddevice playback failed: %s\", e)\n+\n+    # Fall back to system audio players (using Popen for interruptability)\n+    system = platform.system()\n+    players = []\n+\n+    if system == \"Darwin\":\n+        players.append([\"afplay\", file_path])\n+    players.append([\"ffplay\", \"-nodisp\", \"-autoexit\", \"-loglevel\", \"quiet\", file_path])\n+    if system == \"Linux\":\n+        players.append([\"aplay\", \"-q\", file_path])\n+\n+    for cmd in players:\n+        exe = shutil.which(cmd[0])\n+        if exe:\n+            try:\n+                proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n+                with _playback_lock:\n+                    _active_playback = proc\n+                proc.wait(timeout=300)\n+                with _playback_lock:\n+                    _active_playback = None\n+                return True\n+            except subprocess.TimeoutExpired:\n+                logger.warning(\"System player %s timed out, killing process\", cmd[0])\n+                proc.kill()\n+                proc.wait()\n+                with _playback_lock:\n+                    _active_playback = None\n+            except Exception as e:\n+                logger.debug(\"System player %s failed: %s\", cmd[0], e)\n+                with _playback_lock:\n+                    _active_playback = None\n+\n+    logger.warning(\"No audio player available for %s\", file_path)\n+    return False\n+\n+\n+# ============================================================================\n+# Requirements check\n+# ============================================================================\n+def check_voice_requirements() -> Dict[str, Any]:\n+    \"\"\"Check if all voice mode requirements are met.\n+\n+    Returns:\n+        Dict with ``available``, ``audio_available``, ``stt_available``,\n+        ``missing_packages``, and ``details``.\n+    \"\"\"\n+    # Determine STT provider availability\n+    from tools.transcription_tools import _get_provider, _load_stt_config, _HAS_FASTER_WHISPER\n+    stt_config = _load_stt_config()\n+    stt_provider = _get_provider(stt_config)\n+    stt_available = stt_provider != \"none\"\n+\n+    missing: List[str] = []\n+    has_audio = _audio_available()\n+\n+    if not has_audio:\n+        missing.extend([\"sounddevice\", \"numpy\"])\n+\n+    # Environment detection\n+    env_check = detect_audio_environment()\n+\n+    available = has_audio and stt_available and env_check[\"available\"]\n+    details_parts = []\n+\n+    if has_audio:\n+        details_parts.append(\"Audio capture: OK\")\n+    else:\n+        details_parts.append(\"Audio capture: MISSING (pip install sounddevice numpy)\")\n+\n+    if stt_provider == \"local\":\n+        details_parts.append(\"STT provider: OK (local faster-whisper)\")\n+    elif stt_provider == \"groq\":\n+        details_parts.append(\"STT provider: OK (Groq)\")\n+    elif stt_provider == \"openai\":\n+        details_parts.append(\"STT provider: OK (OpenAI)\")\n+    else:\n+        details_parts.append(\n+            \"STT provider: MISSING (pip install faster-whisper, \"\n+            \"or set GROQ_API_KEY / VOICE_TOOLS_OPENAI_KEY)\"\n+        )\n+\n+    for warning in env_check[\"warnings\"]:\n+        details_parts.append(f\"Environment: {warning}\")\n+\n+    return {\n+        \"available\": available,\n+        \"audio_available\": has_audio,\n+        \"stt_available\": stt_available,\n+        \"missing_packages\": missing,\n+        \"details\": \"\\n\".join(details_parts),\n+        \"environment\": env_check,\n+    }\n+\n+\n+# ============================================================================\n+# Temp file cleanup\n+# ============================================================================\n+def cleanup_temp_recordings(max_age_seconds: int = 3600) -> int:\n+    \"\"\"Remove old temporary voice recording files.\n+\n+    Args:\n+        max_age_seconds: Delete files older than this (default: 1 hour).\n+\n+    Returns:\n+        Number of files deleted.\n+    \"\"\"\n+    if not os.path.isdir(_TEMP_DIR):\n+        return 0\n+\n+    deleted = 0\n+    now = time.time()\n+\n+    for entry in os.scandir(_TEMP_DIR):\n+        if entry.is_file() and entry.name.startswith(\"recording_\") and entry.name.endswith(\".wav\"):\n+            try:\n+                age = now - entry.stat().st_mtime\n+                if age > max_age_seconds:\n+                    os.unlink(entry.path)\n+                    deleted += 1\n+            except OSError:\n+                pass\n+\n+    if deleted:\n+        logger.debug(\"Cleaned up %d old voice recordings\", deleted)\n+    return deleted"]},{"file":"website/docs/user-guide/features/voice-mode.md","hunks":["@@ -0,0 +1,487 @@\n+---\n+sidebar_position: 10\n+title: \"Voice Mode\"\n+description: \"Real-time voice conversations with Hermes Agent — CLI, Telegram, Discord (DMs, text channels, and voice channels)\"\n+---\n+\n+# Voice Mode\n+\n+Hermes Agent supports full voice interaction across CLI and messaging platforms. Talk to the agent using your microphone, hear spoken replies, and have live voice conversations in Discord voice channels.\n+\n+## Prerequisites\n+\n+Before using voice features, make sure you have:\n+\n+1. **Hermes Agent installed** — `pip install hermes-agent` (see [Getting Started](../../getting-started.md))\n+2. **An LLM provider configured** — set `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and `LLM_MODEL` in `~/.hermes/.env`\n+3. **A working base setup** — run `hermes` to verify the agent responds to text before enabling voice\n+\n+:::tip\n+The `~/.hermes/` directory and default `config.yaml` are created automatically the first time you run `hermes`. You only need to create `~/.hermes/.env` manually for API keys.\n+:::\n+\n+## Overview\n+\n+| Feature | Platform | Description |\n+|---------|----------|-------------|\n+| **Interactive Voice** | CLI | Press Ctrl+B to record, agent auto-detects silence and responds |\n+| **Auto Voice Reply** | Telegram, Discord | Agent sends spoken audio alongside text responses |\n+| **Voice Channel** | Discord | Bot joins VC, listens to users speaking, speaks replies back |\n+\n+## Requirements\n+\n+### Python Packages\n+\n+```bash\n+# CLI voice mode (microphone + audio playback)\n+pip install hermes-agent[voice]\n+\n+# Discord + Telegram messaging (includes discord.py[voice] for VC support)\n+pip install hermes-agent[messaging]\n+\n+# Premium TTS (ElevenLabs)\n+pip install hermes-agent[tts-premium]\n+\n+# Everything at once\n+pip install hermes-agent[all]\n+```\n+\n+| Extra | Packages | Required For |\n+|-------|----------|-------------|\n+| `voice` | `sounddevice`, `numpy` | CLI voice mode |\n+| `messaging` | `discord.py[voice]`, `python-telegram-bot`, `aiohttp` | Discord & Telegram bots |\n+| `tts-premium` | `elevenlabs` | ElevenLabs TTS provider |\n+\n+:::info\n+`discord.py[voice]` installs **PyNaCl** (for voice encryption) and **opus bindings** automatically. This is required for Discord voice channel support.\n+:::\n+\n+### System Dependencies\n+\n+```bash\n+# macOS\n+brew install portaudio ffmpeg opus\n+\n+# Ubuntu/Debian\n+sudo apt install portaudio19-dev ffmpeg libopus0\n+```\n+\n+| Dependency | Purpose | Required For |\n+|-----------|---------|-------------|\n+| **PortAudio** | Microphone input and audio playback | CLI voice mode |\n+| **ffmpeg** | Audio format conversion (MP3 → Opus, PCM → WAV) | All platforms |\n+| **Opus** | Discord voice codec | Discord voice channels |\n+\n+### API Keys\n+\n+Add to `~/.hermes/.env`:\n+\n+```bash\n+# Speech-to-Text — local provider needs NO key at all\n+# pip install faster-whisper          # Free, runs locally, recommended\n+GROQ_API_KEY=[redacted:secret-assignment]                 # Groq Whisper — fast, free tier (cloud)\n+VOICE_TOOLS_OPENAI_KEY=your-key       # OpenAI Whisper — paid (cloud)\n+\n+# Text-to-Speech (optional — Edge TTS works without any key)\n+ELEVENLABS_API_KEY=[redacted:secret-assignment]           # ElevenLabs — premium quality\n+```\n+\n+:::tip\n+If `faster-whisper` is installed, voice mode works with **zero API keys** for STT. The model (~150 MB for `base`) downloads automatically on first use.\n+:::\n+\n+---\n+\n+## CLI Voice Mode\n+\n+### Quick Start\n+\n+Start the CLI and enable voice mode:\n+\n+```bash\n+hermes                # Start the interactive CLI\n+```\n+\n+Then use these commands inside the CLI:\n+\n+```\n+/voice          Toggle voice mode on/off\n+/voice on       Enable voice mode\n+/voice off      Disable voice mode\n+/voice tts      Toggle TTS output\n+/voice status   Show current state\n+```\n+\n+### How It Works\n+\n+1. Start the CLI with `hermes` and enable voice mode with `/voice on`\n+2. **Press Ctrl+B** — a beep plays (880Hz), recording starts\n+3. **Speak** — a live audio level bar shows your input: `● [▁▂▃▅▇▇▅▂] ❯`\n+4. **Stop speaking** — after 3 seconds of silence, recording auto-stops\n+5. **Two beeps** play (660Hz) confirming the recording ended\n+6. Audio is transcribed via Whisper and sent to the agent\n+7. If TTS is enabled, the agent's reply is spoken aloud\n+8. Recording **automatically restarts** — speak again without pressing any key\n+\n+This loop continues until you press **Ctrl+B** during recording (exits continuous mode) or 3 consecutive recordings detect no speech.\n+\n+:::tip\n+The record key is configurable via `voice.record_key` in `~/.hermes/config.yaml` (default: `ctrl+b`).\n+:::\n+\n+### Silence Detection\n+\n+Two-stage algorithm detects when you've finished speaking:\n+\n+1. **Speech confirmation** — waits for audio above the RMS threshold (200) for at least 0.3s, tolerating brief dips between syllables\n+2. **End detection** — once speech is confirmed, triggers after 3.0 seconds of continuous silence\n+\n+If no speech is detected at all for 15 seconds, recording stops automatically.\n+\n+Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`.\n+\n+### Streaming TTS\n+\n+When TTS is enabled, the agent speaks its reply **sentence-by-sentence** as it generates text — you don't wait for the full response:\n+\n+1. Buffers text deltas into complete sentences (min 20 chars)\n+2. Strips markdown formatting and `<think>` blocks\n+3. Generates and plays audio per sentence in real-time\n+\n+### Hallucination Filter\n+\n+Whisper sometimes generates phantom text from silence or background noise (\"Thank you for watching\", \"Subscribe\", etc.). The agent filters these out using a set of 26 known hallucination phrases across multiple languages, plus a regex pattern that catches repetitive variations.\n+\n+---\n+\n+## Gateway Voice Reply (Telegram & Discord)\n+\n+If you haven't set up your messaging bots yet, see the platform-specific guides:\n+- [Telegram Setup Guide](../messaging/telegram.md)\n+- [Discord Setup Guide](../messaging/discord.md)\n+\n+Start the gateway to connect to your messaging platforms:\n+\n+```bash\n+hermes gateway        # Start the gateway (connects to configured platforms)\n+hermes gateway setup  # Interactive setup wizard for first-time configuration\n+```\n+\n+### Discord: Channels vs DMs\n+\n+The bot supports two interaction modes on Discord:\n+\n+| Mode | How to Talk | Mention Required | Setup |\n+|------|------------|-----------------|-------|\n+| **Direct Message (DM)** | Open the bot's profile → \"Message\" | No | Works immediately |\n+| **Server Channel** | Type in a text channel where the bot is present | Yes (`@botname`) | Bot must be invited to the server |\n+\n+**DM (recommended for personal use):** Just open a DM with the bot and type — no @mention needed. Voice replies and all commands work the same as in channels.\n+\n+**Server channels:** The bot only responds when you @mention it (e.g. `@hermesbyt4 hello`). Make sure you select the **bot user** from the mention popup, not the role with the same name.\n+\n+:::tip\n+To disable the mention requirement in server channels, add to `~/.hermes/.env`:\n+```bash\n+DISCORD_REQUIRE_MENTION=false\n+```\n+Or set specific channels as free-response (no mention needed):\n+```bash\n+DISCORD_FREE_RESPONSE_CHANNELS=123456789,987654321\n+```\n+:::\n+\n+### Commands\n+\n+These work in both Telegram and Discord (DMs and text channels):\n+\n+```\n+/voice          Toggle voice mode on/off\n+/voice on       Voice replies only when you send a voice message\n+/voice tts      Voice replies for ALL messages\n+/voice off      Disable voice replies\n+/voice status   Show current setting\n+```\n+\n+### Modes\n+\n+| Mode | Command | Behavior |\n+|------|---------|----------|\n+| `off` | `/voice off` | Text only (default) |\n+| `voice_only` | `/voice on` | Speaks reply only when you send a voice message |\n+| `all` | `/voice tts` | Speaks reply to every message |\n+\n+Voice mode setting is persisted across gateway restarts.\n+\n+### Platform Delivery\n+\n+| Platform | Format | Notes |\n+|----------|--------|-------|\n+| **Telegram** | Voice bubble (Opus/OGG) | Plays inline in chat. ffmpeg converts MP3 → Opus if needed |\n+| **Discord** | Native voice bubble (Opus/OGG) | Plays inline like a user voice message. Falls back to file attachment if voice bubble API fails |\n+\n+---\n+\n+## Discord Voice Channels\n+\n+The most immersive voice feature: the bot joins a Discord voice channel, listens to users speaking, transcribes their speech, processes through the agent, and speaks the reply back in the voice channel.\n+\n+### Setup\n+\n+#### 1. Discord Bot Permissions\n+\n+If you already have a Discord bot set up for text (see [Discord Setup Guide](../messaging/discord.md)), you need to add voice permissions.\n+\n+Go to the [Discord Developer Portal](https://discord.com/developers/applications) → your application → **Installation** → **Default Install Settings** → **Guild Install**:\n+\n+**Add these permissions to the existing text permissions:**\n+\n+| Permission | Purpose | Required |\n+|-----------|---------|----------|\n+| **Connect** | Join voice channels | Yes |\n+| **Speak** | Play TTS audio in voice channels | Yes |\n+| **Use Voice Activity** | Detect when users are speaking | Recommended |\n+\n+**Updated Permissions Integer:**\n+\n+| Level | Integer | What's Included |\n+|-------|---------|----------------|\n+| Text only | `274878286912` | View Channels, Send Messages, Read History, Embeds, Attachments, Threads, Reactions |\n+| Text + Voice | `274881432640` | All above + Connect, Speak |\n+\n+**Re-invite the bot** with the updated permissions URL:\n+\n+```\n+https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=274881432640\n+```\n+\n+Replace `YOUR_APP_ID` with your Application ID from the Developer Portal.\n+\n+:::warning\n+Re-inviting the bot to a server it's already in will update its permissions without removing it. You won't lose any data or configuration.\n+:::\n+\n+#### 2. Privileged Gateway Intents\n+\n+In the [Developer Portal](https://discord.com/developers/applications) → your application → **Bot** → **Privileged Gateway Intents**, enable all three:\n+\n+| Intent | Purpose |\n+|--------|---------|\n+| **Presence Intent** | Detect user online/offline status |\n+| **Server Members Intent** | Map voice SSRC identifiers to Discord user IDs |\n+| **Message Content Intent** | Read text message content in channels |\n+\n+All three are required for full voice channel functionality. **Server Members Intent** is especially critical — without it, the bot cannot identify who is speaking in the voice channel.\n+\n+#### 3. Opus Codec\n+\n+The Opus codec library must be installed on the machine running the gateway:\n+\n+```bash\n+# macOS (Homebrew)\n+brew install opus\n+\n+# Ubuntu/Debian\n+sudo apt install libopus0\n+```\n+\n+The bot auto-loads the codec from:\n+- **macOS:** `/opt/homebrew/lib/libopus.dylib`\n+- **Linux:** `libopus.so.0`\n+\n+#### 4. Environment Variables\n+\n+```bash\n+# ~/.hermes/.env\n+\n+# Discord bot (already configured for text)\n+DISCORD_BOT_TOKEN=[redacted:secret-assignment]\n+DISCORD_ALLOWED_USERS=your-user-id\n+\n+# STT — local provider needs no key (pip install faster-whisper)\n+# GROQ_API_KEY=[redacted:secret-assignment]            # Alternative: cloud-based, fast, free tier\n+\n+# TTS — optional, Edge TTS (free) is the default\n+# ELEVENLABS_API_KEY=[redacted:secret-assignment]      # Premium quality\n+```\n+\n+### Start the Gateway\n+\n+```bash\n+hermes gateway        # Start with existing configuration\n+```\n+\n+The bot should come online in Discord within a few seconds.\n+\n+### Commands\n+\n+Use these in the Discord text channel where the bot is present:\n+\n+```\n+/voice join      Bot joins your current voice channel\n+/voice channel   Alias for /voice join\n+/voice leave     Bot disconnects from voice channel\n+/voice status    Show voice mode and connected channel\n+```\n+\n+:::info\n+You must be in a voice channel before running `/voice join`. The bot joins the same VC you're in.\n+:::\n+\n+### How It Works\n+\n+When the bot joins a voice channel, it:\n+\n+1. **Listens** to each user's audio stream independently\n+2. **Detects silence** — 1.5s of silence after at least 0.5s of speech triggers processing\n+3. **Transcribes** the audio via Whisper STT (local, Groq, or OpenAI)\n+4. **Processes** through the full agent pipeline (session, tools, memory)\n+5. **Speaks** the reply back in the voice channel via TTS\n+\n+### Text Channel Integration\n+\n+When the bot is in a voice channel:\n+\n+- Transcripts appear in the text channel: `[Voice] @user: what you said`\n+- Agent responses are sent as text in the channel AND spoken in the VC\n+- The text channel is the one where `/voice join` was issued\n+\n+### Echo Prevention\n+\n+The bot automatically pauses its audio listener while playing TTS replies, preventing it from hearing and re-processing its own output.\n+\n+### Access Control\n+\n+Only users listed in `DISCORD_ALLOWED_USERS` can interact via voice. Other users' audio is silently ignored.\n+\n+```bash\n+# ~/.hermes/.env\n+DISCORD_ALLOWED_USERS=284102345871466496\n+```\n+\n+---\n+\n+## Configuration Reference\n+\n+### config.yaml\n+\n+```yaml\n+# Voice recording (CLI)\n+voice:\n+  record_key: \"ctrl+b\"            # Key to start/stop recording\n+  max_recording_seconds: 120       # Maximum recording length\n+  auto_tts: false                  # Auto-enable TTS when voice mode starts\n+  silence_threshold: 200           # RMS level (0-32767) below which counts as silence\n+  silence_duration: 3.0            # Seconds of silence before auto-stop\n+\n+# Speech-to-Text\n+stt:\n+  provider: \"local\"                  # \"local\" (free) | \"groq\" | \"openai\"\n+  local:\n+    model: \"base\"                    # tiny, base, small, medium, large-v3\n+  # model: \"whisper-1\"              # Legacy: used when provider is not set\n+\n+# Text-to-Speech\n+tts:\n+  provider: \"edge\"                 # \"edge\" (free) | \"elevenlabs\" | \"openai\"\n+  edge:\n+    voice: \"en-US-AriaNeural\"      # 322 voices, 74 languages\n+  elevenlabs:\n+    voice_id: \"pNInz6obpgDQGcFmaJgB\"    # Adam\n+    model_id: \"eleven_multilingual_v2\"\n+  openai:\n+    model: \"gpt-4o-mini-tts\"\n+    voice: \"alloy\"                 # alloy, echo, fable, onyx, nova, shimmer\n+```\n+\n+### Environment Variables\n+\n+```bash\n+# Speech-to-Text providers (local needs no key)\n+# pip install faster-whisper        # Free local STT — no API key needed\n+GROQ_API_KEY=...                    # Groq Whisper (fast, free tier)\n+VOICE_TOOLS_OPENAI_KEY=...         # OpenAI Whisper (paid)\n+\n+# STT advanced overrides (optional)\n+STT_GROQ_MODEL=whisper-large-v3-turbo    # Override default Groq STT model\n+STT_OPENAI_MODEL=whisper-1               # Override default OpenAI STT model\n+GROQ_BASE_URL=https://api.groq.com/openai/v1     # Custom Groq endpoint\n+STT_OPENAI_BASE_URL=https://api.openai.com/v1    # Custom OpenAI STT endpoint\n+\n+# Text-to-Speech providers (Edge TTS needs no key)\n+ELEVENLABS_API_KEY=...             # ElevenLabs (premium quality)\n+# OpenAI TTS uses VOICE_TOOLS_OPENAI_KEY\n+\n+# Discord voice channel\n+DISCORD_BOT_TOKEN=...\n+DISCORD_ALLOWED_USERS=...\n+```\n+\n+### STT Provider Comparison\n+\n+| Provider | Model | Speed | Quality | Cost | API Key |\n+|----------|-------|-------|---------|------|---------|\n+| **Local** | `base` | Fast (depends on CPU/GPU) | Good | Free | No |\n+| **Local** | `small` | Medium | Better | Free | No |\n+| **Local** | `large-v3` | Slow | Best | Free | No |\n+| **Groq** | `whisper-large-v3-turbo` | Very fast (~0.5s) | Good | Free tier | Yes |\n+| **Groq** | `whisper-large-v3` | Fast (~1s) | Better | Free tier | Yes |\n+| **OpenAI** | `whisper-1` | Fast (~1s) | Good | Paid | Yes |\n+| **OpenAI** | `gpt-4o-transcribe` | Medium (~2s) | Best | Paid | Yes |\n+\n+Provider priority (automatic fallback): **local** > **groq** > **openai**\n+\n+### TTS Provider Comparison\n+\n+| Provider | Quality | Cost | Latency | Key Required |\n+|----------|---------|------|---------|-------------|\n+| **Edge TTS** | Good | Free | ~1s | No |\n+| **ElevenLabs** | Excellent | Paid | ~2s | Yes |\n+| **OpenAI TTS** | Good | Paid | ~1.5s | Yes |\n+\n+---\n+\n+## Troubleshooting\n+\n+### \"No audio device found\" (CLI)\n+\n+PortAudio is not installed:\n+\n+```bash\n+brew install portaudio    # macOS\n+sudo apt install portaudio19-dev  # Ubuntu\n+```\n+\n+### Bot doesn't respond in Discord server channels\n+\n+The bot requires an @mention by default in server channels. Make sure you:\n+\n+1. Type `@` and select the **bot user** (with the #discriminator), not the **role** with the same name\n+2. Or use DMs instead — no mention needed\n+3. Or set `DISCORD_REQUIRE_MENTION=false` in `~/.hermes/.env`\n+\n+### Bot joins VC but doesn't hear me\n+\n+- Check your Discord user ID is in `DISCORD_ALLOWED_USERS`\n+- Make sure you're not muted in Discord\n+- The bot needs a SPEAKING event from Discord before it can map your audio — start speaking within a few seconds of joining\n+\n+### Bot hears me but doesn't respond\n+\n+- Verify STT is available: install `faster-whisper` (no key needed) or set `GROQ_API_KEY` / `VOICE_TOOLS_OPENAI_KEY`\n+- Check the LLM model is configured and accessible\n+- Review gateway logs: `tail -f ~/.hermes/logs/gateway.log`\n+\n+### Bot responds in text but not in voice channel\n+\n+- TTS provider may be failing — check API key and quota\n+- Edge TTS (free, no key) is the default fallback\n+- Check logs for TTS errors\n+\n+### Whisper returns garbage text\n+\n+The hallucination filter catches most cases automatically. If you're still getting phantom transcripts:\n+\n+- Use a quieter environment\n+- Adjust `silence_threshold` in config (higher = less sensitive)\n+- Try a different STT model"]},{"file":"website/docs/user-guide/messaging/discord.md","hunks":["@@ -210,8 +210,8 @@ Replace the ID with the actual channel ID (right-click → Copy Channel ID with\n \n Hermes Agent supports Discord voice messages:\n \n-- **Incoming voice messages** are automatically transcribed using Whisper (requires `VOICE_TOOLS_OPENAI_KEY` to be set in your environment).\n-- **Text-to-speech**: When TTS is enabled, the bot can send spoken responses as MP3 file attachments.\n+- **Incoming voice messages** are automatically transcribed using Whisper (requires `GROQ_API_KEY` or `VOICE_TOOLS_OPENAI_KEY` to be set in your environment).\n+- **Text-to-speech**: Use `/voice tts` to have the bot send spoken audio responses alongside text replies.\n \n ## Troubleshooting\n "]},{"file":"website/docs/user-guide/messaging/index.md","hunks":["@@ -1,12 +1,12 @@\n ---\n sidebar_position: 1\n title: \"Messaging Gateway\"\n-description: \"Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Email, or Home Assistant — architecture and setup overview\"\n+description: \"Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, or your browser — architecture and setup overview\"\n ---\n \n # Messaging Gateway\n \n-Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Email, or Home Assistant. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.\n+Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.\n \n ## Architecture\n ","@@ -15,24 +15,24 @@ Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Email, or Home\n │                              Hermes Gateway                                   │\n ├───────────────────────────────────────────────────────────────────────────────┤\n │                                                                               │\n-│  ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌────┐│\n-│  │ Telegram │ │ Discord │ │ WhatsApp │ │ Slack │ │Signal │ │ Email │ │ HA ││\n-│  │ Adapter  │ │ Adapter │ │ Adapter  │ │Adapter│ │Adapter│ │Adapter│ │Adpt││\n-│  └────┬─────┘ └────┬────┘ └────┬─────┘ └──┬────┘ └──┬────┘ └──┬────┘ └─┬──┘│\n-│       │             │           │           │         │         │        │    │\n-│       └─────────────┴───────────┴───────────┴─────────┴─────────┴────────┘    │\n-│                                     │                                         │\n-│                            ┌────────▼────────┐                                │\n-│                            │  Session Store  │                                │\n-│                            │  (per-chat)     │                                │\n-│                            └────────┬────────┘                                │\n-│                                     │                                         │\n-│                            ┌────────▼────────┐                                │\n-│                            │   AIAgent       │                                │\n-│                            │   (run_agent)   │                                │\n-│                            └─────────────────┘                                │\n-│                                                                               │\n-└───────────────────────────────────────────────────────────────────────────────┘\n+│  ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌────┐       │\n+│  │ Telegram │ │ Discord │ │ WhatsApp │ │ Slack │ │Signal │ │ Email │ │ HA │       │\n+│  │ Adapter  │ │ Adapter │ │ Adapter  │ │Adapter│ │Adapter│ │Adapter│ │Adpt│       │\n+│  └────┬─────┘ └────┬────┘ └────┬─────┘ └──┬────┘ └──┬────┘ └──┬────┘ └─┬──┘       │\n+│       │             │           │           │         │         │        │           │\n+│       └─────────────┴───────────┴───────────┴─────────┴─────────┴────────┘           │\n+│                                     │                                                │\n+│                            ┌────────▼────────┐                                       │\n+│                            │  Session Store  │                                       │\n+│                            │  (per-chat)     │                                       │\n+│                            └────────┬────────┘                                       │\n+│                                     │                                                │\n+│                            ┌────────▼────────┐                                       │\n+│                            │   AIAgent       │                                       │\n+│                            │   (run_agent)   │                                       │\n+│                            └─────────────────┘                                       │\n+│                                                                                      │\n+└───────────────────────────────────────────────────────────────────────────────────────┘\n ```\n \n Each platform adapter receives messages, routes them through a per-chat session store, and dispatches them to the AIAgent for processing. The gateway also runs the cron scheduler, ticking every 60 seconds to execute any due jobs.\n"]}]},{"kind":"pr","number":1299,"title":"fix: salvage PR #327 voice mode onto current main","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".env.example","cli.py","gateway/platforms/base.py","gateway/platforms/discord.py","gateway/platforms/slack.py","gateway/platforms/telegram.py","gateway/run.py","gateway/session.py","hermes_cli/commands.py","hermes_cli/config.py","pyproject.toml","run_agent.py","tests/fakes/fake_ha_server.py","tests/gateway/test_background_command.py","tests/gateway/test_discord_free_response.py","tests/gateway/test_discord_opus.py","tests/gateway/test_discord_slash_commands.py","tests/gateway/test_resume_command.py","tests/gateway/test_run_progress_topics.py","tests/gateway/test_session_hygiene.py","tests/gateway/test_title_command.py","tests/gateway/test_update_command.py","tests/gateway/test_voice_command.py","tests/hermes_cli/test_commands.py","tests/test_cli_init.py","tests/test_cli_skin_integration.py","tests/test_run_agent.py","tests/tools/test_transcription.py","tests/tools/test_transcription_tools.py","tests/tools/test_voice_cli_integration.py","tests/tools/test_voice_mode.py","tools/browser_tool.py","tools/transcription_tools.py","tools/tts_tool.py","tools/voice_mode.py","website/docs/user-guide/features/voice-mode.md","website/docs/user-guide/messaging/discord.md","website/docs/user-guide/messaging/index.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}