From 5f43452e91e9f8350426001a936b1d8db16830fa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 21:54:45 -0500 Subject: [PATCH 01/46] feat(voice): add "Hey Hermes" wake word to start a hands-free session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in, on-device hotword listener for the CLI. With wake_word.enabled (or /wake on), Hermes listens in the background for a wake phrase; on detection it starts a fresh session, captures one utterance through the existing voice pipeline, and answers — the "Hey Siri" pattern. - tools/wake_word.py: provider-pluggable detector (openWakeWord, free local default; Porcupine, premium) over the shared 16 kHz sounddevice capture path. Background daemon thread with pause/resume so it yields the mic during a voice turn. - CLI wiring: startup listener (off-thread), on-wake flow, an idle watchdog that resumes the detector after each turn, cleanup hook, and a /wake [on|off|status] command. - config.yaml wake_word section; PORCUPINE_ACCESS_KEY as an optional secret. Engines lazy-install via the [wake] extra. - Hands a transcript to the input queue exactly like voice mode, so no system-prompt/cache mutation. No new core model tool. - Tests (mocked, no live audio/network) + feature docs. --- cli.py | 197 +++++++- hermes_cli/cli_commands_mixin.py | 23 + hermes_cli/commands.py | 3 + hermes_cli/config.py | 30 ++ pyproject.toml | 10 + tests/tools/test_wake_word.py | 208 +++++++++ tools/lazy_deps.py | 15 + tools/wake_word.py | 431 ++++++++++++++++++ website/docs/user-guide/features/overview.md | 1 + website/docs/user-guide/features/wake-word.md | 148 ++++++ 10 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_wake_word.py create mode 100644 tools/wake_word.py create mode 100644 website/docs/user-guide/features/wake-word.md diff --git a/cli.py b/cli.py index af020b9692b..8fe26d43a4b 100644 --- a/cli.py +++ b/cli.py @@ -1177,6 +1177,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True): # can't skip the reset (#36823). No-op unless the TUI actually ran. _reset_terminal_input_modes_on_exit() + try: + from tools.wake_word import stop_listening as _stop_wake_word + _stop_wake_word() + except Exception: + pass try: _cleanup_all_terminals() except Exception: @@ -9919,6 +9924,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._handle_skin_command(cmd_original) elif canonical == "voice": self._handle_voice_command(cmd_original) + elif canonical == "wake": + self._handle_wake_command(cmd_original) elif canonical == "busy": self._handle_busy_command(cmd_original) else: @@ -12161,6 +12168,184 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f"\n{_DIM}Voice mode disabled.{_RST}") + # ── Wake word ("Hey Hermes") ───────────────────────────────────────── + # + # An always-on hotword listener (tools/wake_word.py) that, on detecting + # the wake phrase, starts a fresh session and captures one utterance via + # the existing voice pipeline — the "Hey Siri" pattern, fully on-device. + # + # The detector holds the microphone, so it must be paused while a voice + # turn records (two input streams on one device is unreliable). On wake we + # pause it and mark the system suspended; a lightweight watchdog resumes it + # once the turn finishes and the CLI is idle again — covering every exit + # path (transcript submitted, no speech, or transcription error) without + # threading resume logic through the voice machinery. + + def _maybe_start_wake_word(self): + """Start the wake-word listener at CLI startup if enabled in config.""" + try: + from tools.wake_word import load_wake_word_config + if not load_wake_word_config().get("enabled"): + return + except Exception: + return + self._start_wake_word_listener(announce=True) + + def _start_wake_word_listener(self, announce: bool = False) -> bool: + """Build + start the hotword detector. Returns True on success.""" + if getattr(self, "_wake_word_active", False): + if announce: + _cprint(f"{_DIM}Wake word is already listening.{_RST}") + return True + try: + from tools.wake_word import ( + check_wake_word_requirements, + load_wake_word_config, + start_listening, + ) + except Exception as e: + if announce: + _cprint(f"{_DIM}Wake word unavailable: {e}{_RST}") + return False + + cfg = load_wake_word_config() + reqs = check_wake_word_requirements(cfg) + if not reqs["available"]: + if announce: + _cprint(f"\n{_ACCENT}Wake word requirements not met:{_RST}") + if reqs.get("hint"): + _cprint(f" {_DIM}{reqs['hint']}{_RST}") + return False + + self._wake_start_new_session = bool(cfg.get("start_new_session", True)) + try: + start_listening(self._on_wake_word, config=cfg) + except Exception as e: + if announce: + _cprint(f"\n{_DIM}Failed to start wake word: {e}{_RST}") + return False + + self._wake_word_active = True + self._wake_suspended = False + self._start_wake_watchdog() + if announce: + _cprint(f"\n{_ACCENT}Wake word listening{_RST} " + f"{_DIM}(say \"{reqs['phrase']}\" — /wake off to stop){_RST}") + return True + + def _stop_wake_word_listener(self, announce: bool = False): + """Stop and tear down the hotword detector.""" + was_active = getattr(self, "_wake_word_active", False) + self._wake_word_active = False + self._wake_suspended = False + try: + from tools.wake_word import stop_listening + stop_listening() + except Exception: + pass + if announce: + if was_active: + _cprint(f"{_DIM}Wake word stopped.{_RST}") + else: + _cprint(f"{_DIM}Wake word is not running.{_RST}") + + def _on_wake_word(self): + """Fired (on the detector thread) when the wake phrase is heard.""" + if getattr(self, "_should_exit", False): + return + # Ignore wake while a turn is in flight or the mic is already in use. + if self._agent_running or self._voice_recording or getattr(self, "_voice_processing", False): + return + + # Release the mic so STT can capture the command utterance. + try: + from tools.wake_word import pause_listening + pause_listening() + except Exception: + pass + self._wake_suspended = True + + _cprint(f"\n{_ACCENT}✦ Wake word detected — listening...{_RST}") + if getattr(self, "_app", None): + try: + self._app.invalidate() + except Exception: + pass + + if getattr(self, "_wake_start_new_session", True): + try: + self.new_session(silent=True) + except Exception as e: + logger.debug("wake word new_session failed: %s", e) + + # Single-utterance capture (not continuous) via the voice pipeline; + # VAD auto-stop transcribes and queues the transcript for process_loop. + with self._voice_lock: + self._voice_mode = True + self._voice_continuous = False + try: + self._voice_start_recording() + except Exception as e: + _cprint(f"{_DIM}Wake capture failed: {e}{_RST}") + # Leave _wake_suspended set; the watchdog resumes once idle. + + def _start_wake_watchdog(self): + """Resume the paused detector when the CLI returns to a stable idle.""" + if getattr(self, "_wake_watchdog_started", False): + return + self._wake_watchdog_started = True + + def _loop(): + idle_polls = 0 + try: + while getattr(self, "_wake_word_active", False) and not getattr(self, "_should_exit", False): + time.sleep(0.25) + if not getattr(self, "_wake_suspended", False): + idle_polls = 0 + continue + busy = ( + self._agent_running + or self._voice_recording + or getattr(self, "_voice_processing", False) + or not self._pending_input.empty() + ) + if busy: + idle_polls = 0 + continue + # Require a few consecutive idle polls (~0.75s) so we don't + # resume in the gap between VAD stop and the agent starting. + idle_polls += 1 + if idle_polls >= 3: + idle_polls = 0 + try: + from tools.wake_word import resume_listening + resume_listening() + self._wake_suspended = False + except Exception as e: + logger.debug("wake word resume failed: %s", e) + finally: + self._wake_watchdog_started = False + + threading.Thread(target=_loop, daemon=True, name="wake-watchdog").start() + + def _show_wake_word_status(self): + """Show current wake-word listener status.""" + from tools.wake_word import check_wake_word_requirements, load_wake_word_config + + cfg = load_wake_word_config() + reqs = check_wake_word_requirements(cfg) + active = getattr(self, "_wake_word_active", False) + + _cprint(f"\n{_BOLD}Wake Word Status{_RST}") + _cprint(f" State: {'LISTENING' if active else 'OFF'}") + _cprint(f" Phrase: \"{reqs['phrase']}\"") + _cprint(f" Provider: {reqs['provider']}") + _cprint(f" New session: {'yes' if cfg.get('start_new_session', True) else 'no'}") + if not reqs["available"] and reqs.get("hint"): + _cprint(f" {_DIM}{reqs['hint']}{_RST}") + if not active: + _cprint(f" {_DIM}Enable with /wake on{_RST}") + def _toggle_voice_tts(self): """Toggle TTS output for voice mode.""" if not self._voice_mode: @@ -16325,7 +16510,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Start processing thread process_thread = threading.Thread(target=process_loop, daemon=True) process_thread.start() - + + # Wake word ("Hey Hermes") — start the always-on hotword listener if + # enabled. Off-thread so a first-run engine install never blocks the + # prompt; best-effort, so deps/mic/key gaps are surfaced, never fatal. + def _wake_startup(): + try: + self._maybe_start_wake_word() + except Exception as e: + logger.debug("wake-word startup skipped: %s", e) + threading.Thread(target=_wake_startup, daemon=True, name="wake-startup").start() + # Register atexit cleanup so resources are freed even on unexpected exit atexit.register(_run_cleanup) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 1891ca268fd..f2312296078 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -3189,3 +3189,26 @@ class CLICommandsMixin: else: _cprint(f"Unknown voice subcommand: {subcommand}") _cprint("Usage: /voice [on|off|tts|status]") + + def _handle_wake_command(self, command: str): + """Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener.""" + from cli import _cprint + parts = command.strip().split(maxsplit=1) + subcommand = parts[1].lower().strip() if len(parts) > 1 else "" + + if subcommand == "on": + self._start_wake_word_listener(announce=True) + elif subcommand == "off": + self._stop_wake_word_listener(announce=True) + elif subcommand in ("", "status"): + if subcommand == "": + # Bare /wake toggles. + if getattr(self, "_wake_word_active", False): + self._stop_wake_word_listener(announce=True) + else: + self._start_wake_word_listener(announce=True) + else: + self._show_wake_word_status() + else: + _cprint(f"Unknown wake subcommand: {subcommand}") + _cprint("Usage: /wake [on|off|status]") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 2a5f82cb388..939c407370d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -181,6 +181,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ subcommands=("kaomoji", "emoji", "unicode", "ascii")), CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), + CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration", + cli_only=True, args_hint="[on|off|status]", + subcommands=("on", "off", "status")), CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration", cli_only=True, args_hint="[queue|steer|interrupt|status]", subcommands=("queue", "steer", "interrupt", "status")), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 2a37a6d63fb..bc96780e4f8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2373,6 +2373,29 @@ DEFAULT_CONFIG = { # surrounding punctuation ignored. Set [] to disable. "stop_phrases": ["stop"], }, + + # "Hey Hermes" hands-free wake word (CLI). Always-on, on-device hotword + # detection that starts a fresh voice session — the "Hey Siri" pattern. + # Off by default; toggle with /wake or `wake_word.enabled: true`. + "wake_word": { + "enabled": False, + "provider": "openwakeword", # "openwakeword" (free, local) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) + "phrase": "hey jarvis", # cosmetic label only; detection is keyed by the engine model/keyword below + "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) + "start_new_session": True, # start a fresh session on wake vs. continue the current one + "openwakeword": { + # Built-in model name ("hey_jarvis", "alexa", "hey_mycroft", ...) or + # a path to a custom .onnx/.tflite model. Train a "hey hermes" model + # and point this at it — see the wake-word docs. + "model": "hey_jarvis", + "inference_framework": "onnx", # "onnx" | "tflite" + }, + "porcupine": { + # Built-in keyword ("jarvis", "computer", "bumblebee", ...) or a path + # to a custom .ppn from the Picovoice Console. + "keyword": "jarvis", + }, + }, "human_delay": { "mode": "off", @@ -4412,6 +4435,13 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "tool", }, + "PORCUPINE_ACCESS_KEY": { + "description": "Picovoice access key for the Porcupine 'Hey Hermes' wake word engine (optional; openWakeWord is the free default)", + "prompt": "Picovoice access key", + "url": "https://console.picovoice.ai/", + "password": True, + "category": "tool", + }, "GITHUB_TOKEN": { "description": "GitHub token for Skills Hub (higher API rate limits, skill publish)", "prompt": "GitHub Token", diff --git a/pyproject.toml b/pyproject.toml index 02c00a09675..d757cd0fc9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,6 +175,16 @@ voice = [ "sounddevice==0.5.5", "numpy==2.4.3", ] +# "Hey Hermes" wake word — on-device hotword detection. Both engines are +# optional; openWakeWord (ONNX) is the free default, Porcupine the premium +# alternative. Lazy-installed on first /wake; mirrored in tools/lazy_deps.py. +wake = [ + "openwakeword==0.6.0", + "onnxruntime==1.27.0", + "pvporcupine==4.0.3", + "sounddevice==0.5.5", + "numpy==2.4.3", +] honcho = ["honcho-ai==2.2.0"] # Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py # (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py new file mode 100644 index 00000000000..30ece72368b --- /dev/null +++ b/tests/tools/test_wake_word.py @@ -0,0 +1,208 @@ +"""Tests for tools.wake_word — the "Hey Hermes" hotword detector. + +No live audio or network: the sounddevice import is faked, engines are stubbed, +and lazy-dep availability is monkeypatched. Covers config resolution, engine +dispatch, the requirements probe, the detector fire/cooldown loop, and the +process-wide singleton lifecycle. +""" + +import time +import types + +import pytest + +import tools.wake_word as ww + + +# ── Config helpers ─────────────────────────────────────────────────────── + + +def test_config_defaults_and_clamping(): + assert ww._provider({}) == "openwakeword" + assert ww._provider({"provider": "Porcupine"}) == "porcupine" + assert ww._sensitivity({"sensitivity": 5}) == 1.0 + assert ww._sensitivity({"sensitivity": -1}) == 0.0 + assert ww._sensitivity({"sensitivity": "nope"}) == 0.5 + assert ww.wake_phrase({"phrase": "hey hermes"}) == "hey hermes" + assert ww.wake_phrase({}) == "hey jarvis" + + +def test_looks_like_path(): + assert ww._looks_like_path("models/hey_hermes.onnx") + assert ww._looks_like_path("custom.ppn") + assert not ww._looks_like_path("hey_jarvis") + + +def test_load_wake_word_config_is_a_dict_with_defaults(): + # Wired into DEFAULT_CONFIG, so a real load returns the section shape. + cfg = ww.load_wake_word_config() + assert isinstance(cfg, dict) + assert cfg.get("enabled") is False + assert cfg.get("provider") == "openwakeword" + + +def test_load_wake_word_config_guards_non_dict(monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {"wake_word": "oops"} + ) + assert ww.load_wake_word_config() == {} + + +# ── Engine dispatch ────────────────────────────────────────────────────── + + +def test_build_engine_dispatch(monkeypatch): + monkeypatch.setattr(ww, "_OpenWakeWordEngine", lambda cfg: "oww") + monkeypatch.setattr(ww, "_PorcupineEngine", lambda cfg: "pv") + assert ww._build_engine({"provider": "openwakeword"}) == "oww" + assert ww._build_engine({"provider": "porcupine"}) == "pv" + with pytest.raises(ValueError): + ww._build_engine({"provider": "bogus"}) + + +# ── Requirements probe ─────────────────────────────────────────────────── + + +def test_requirements_openwakeword_available(monkeypatch): + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + r = ww.check_wake_word_requirements( + {"provider": "openwakeword", "phrase": "hey hermes"} + ) + assert r["available"] is True + assert r["provider"] == "openwakeword" + assert r["phrase"] == "hey hermes" + + +def test_requirements_porcupine_needs_access_key(monkeypatch): + monkeypatch.delenv("PORCUPINE_ACCESS_KEY", raising=False) + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + r = ww.check_wake_word_requirements({"provider": "porcupine"}) + assert r["available"] is False + assert r["access_key_set"] is False + assert "PORCUPINE_ACCESS_KEY" in r["hint"] + + +def test_requirements_unavailable_without_audio(monkeypatch): + monkeypatch.setattr(ww, "_audio_available", lambda: False) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["audio_available"] is False + + +# ── Detector loop ──────────────────────────────────────────────────────── + + +class _FakeStream: + """Always-readable input stream that yields trivial frames.""" + + def __init__(self, **_kw): + self.closed = False + + def start(self): + pass + + def read(self, n): + time.sleep(0.01) + return [0] * n, False + + def stop(self): + pass + + def close(self): + self.closed = True + + +class _FakeEngine: + frame_length = 4 + + def __init__(self, fire=True): + self._fire = fire + self.closed = False + + def process(self, frame): + return self._fire + + def close(self): + self.closed = True + + +def _fake_audio(monkeypatch): + fake_sd = types.SimpleNamespace(InputStream=lambda **kw: _FakeStream(**kw)) + monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None)) + + +def test_detector_fires_once_under_cooldown(monkeypatch): + _fake_audio(monkeypatch) + calls = [] + eng = _FakeEngine(fire=True) + det = ww.WakeWordDetector(eng, lambda: calls.append(1), cooldown=10.0) + det.start() + time.sleep(0.25) + det.stop() + assert len(calls) == 1 # high cooldown suppresses repeats + assert eng.closed is True + assert det.running is False + + +def test_detector_refires_after_cooldown(monkeypatch): + _fake_audio(monkeypatch) + calls = [] + det = ww.WakeWordDetector(_FakeEngine(fire=True), lambda: calls.append(1), cooldown=0.05) + det.start() + time.sleep(0.3) + det.stop() + assert len(calls) >= 2 + + +def test_detector_no_fire_when_engine_quiet(monkeypatch): + _fake_audio(monkeypatch) + calls = [] + det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: calls.append(1)) + det.start() + time.sleep(0.15) + det.stop() + assert calls == [] + + +def test_detector_pause_resume(monkeypatch): + _fake_audio(monkeypatch) + det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: None) + det.start() + time.sleep(0.05) + assert det.running is True + det.pause() + assert det.running is False + det.resume() + time.sleep(0.05) + assert det.running is True + det.stop() + assert det.running is False + + +# ── Singleton lifecycle ────────────────────────────────────────────────── + + +def test_singleton_lifecycle(monkeypatch): + _fake_audio(monkeypatch) + monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False)) + + assert ww.is_listening() is False + det = ww.start_listening(lambda: None, config={}) + time.sleep(0.05) + assert ww.is_listening() is True + + # Re-entrant start returns the same detector and re-arms it. + det2 = ww.start_listening(lambda: None, config={}) + assert det2 is det + + ww.pause_listening() + assert ww.is_listening() is False + ww.resume_listening() + time.sleep(0.05) + assert ww.is_listening() is True + + ww.stop_listening() + assert ww.is_listening() is False diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 0cabac21b2d..d92ab158b58 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -137,6 +137,21 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "numpy==2.4.3", ), + # ─── Wake word ("Hey Hermes") engines ────────────────────────────────── + # Keep in sync with the `wake` extra in pyproject.toml. openWakeWord is the + # free, local default (ONNX runtime); Porcupine is the premium engine. + "wake.openwakeword": ( + "openwakeword==0.6.0", + "onnxruntime==1.27.0", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + "wake.porcupine": ( + "pvporcupine==4.0.3", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + # ─── Image generation backends ───────────────────────────────────────── "image.fal": ("fal-client==0.13.1",), diff --git a/tools/wake_word.py b/tools/wake_word.py new file mode 100644 index 00000000000..6dc473b211c --- /dev/null +++ b/tools/wake_word.py @@ -0,0 +1,431 @@ +"""Wake-word ("Hey Hermes") detection — hands-free session trigger for the CLI. + +A lightweight, always-on hotword listener that fires a callback when a wake +phrase is spoken — the "Hey Siri" / "Alexa" pattern. The CLI uses it to start a +fresh voice session without touching the keyboard: say the wake word, Hermes +opens the mic, captures one utterance via the existing voice pipeline, and +answers. + +Two engines, both fully on-device (no audio leaves the machine for detection): + +* **openwakeword** (default, free, no API key) — loads a pretrained or custom + ONNX model. Ships with ``hey_jarvis``, ``alexa``, ``hey_mycroft``, … ; point + ``wake_word.openwakeword.model`` at a custom ``.onnx`` to detect a real + "hey hermes" (training guide in the wake-word docs). +* **porcupine** (premium) — Picovoice's engine. Needs ``PORCUPINE_ACCESS_KEY``; + supports built-in keywords and custom ``.ppn`` files from the Picovoice + Console. + +Audio capture reuses the same 16 kHz mono int16 ``sounddevice`` path as voice +mode. The detector runs on its own daemon thread; callers ``pause()`` it while a +voice turn holds the microphone and ``resume()`` it once the system is idle +again (two input streams on one device is unreliable cross-platform). + +Nothing here mutates agent context or the prompt cache — on wake we hand a plain +string to the caller, exactly like a voice transcript. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Any, Callable, Dict, Optional + +logger = logging.getLogger(__name__) + +# 16 kHz mono int16 — Whisper-native and what both engines expect. +SAMPLE_RATE = 16000 + +# Minimum gap between two consecutive wake fires, so one "hey hermes" can't +# retrigger across several frames while the caller is still reacting. +_FIRE_COOLDOWN_SECONDS = 2.0 + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +_DEFAULTS: Dict[str, Any] = { + "enabled": False, + "provider": "openwakeword", + "phrase": "hey jarvis", + "sensitivity": 0.5, + "start_new_session": True, +} + + +def load_wake_word_config() -> Dict[str, Any]: + """Return the ``wake_word`` config section, shape-guarded to a dict.""" + try: + from hermes_cli.config import load_config + + cfg = load_config().get("wake_word") + except Exception: + cfg = None + return cfg if isinstance(cfg, dict) else {} + + +def _get(cfg: Dict[str, Any], key: str) -> Any: + val = cfg.get(key, _DEFAULTS.get(key)) + return _DEFAULTS.get(key) if val is None else val + + +def _provider(cfg: Dict[str, Any]) -> str: + return str(_get(cfg, "provider")).strip().lower() or "openwakeword" + + +def _sensitivity(cfg: Dict[str, Any]) -> float: + raw = _get(cfg, "sensitivity") + try: + s = float(raw) + except (TypeError, ValueError): + s = 0.5 + return min(max(s, 0.0), 1.0) + + +def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str: + """Human-facing wake phrase label (purely cosmetic; engine keys detection).""" + cfg = cfg if cfg is not None else load_wake_word_config() + return str(_get(cfg, "phrase")) or "hey jarvis" + + +# --------------------------------------------------------------------------- +# Audio capture (lazy — never import sounddevice at module load) +# --------------------------------------------------------------------------- + +def _import_audio(): + import numpy as np + import sounddevice as sd + + return sd, np + + +def _audio_available() -> bool: + try: + _import_audio() + return True + except (ImportError, OSError): + return False + + +# --------------------------------------------------------------------------- +# Engines +# --------------------------------------------------------------------------- + +class _Engine: + """Minimal hotword-engine contract: feed int16 frames, get a bool.""" + + frame_length: int = 1280 # 80 ms at 16 kHz + + def process(self, frame) -> bool: # frame: 1-D int16 ndarray + raise NotImplementedError + + def close(self) -> None: + pass + + +def _looks_like_path(value: str) -> bool: + return ( + os.sep in value + or value.endswith((".onnx", ".tflite", ".ppn")) + or os.path.exists(value) + ) + + +class _OpenWakeWordEngine(_Engine): + """openWakeWord — free, local ONNX hotword detection.""" + + # openWakeWord recommends 80 ms frames (1280 samples) for efficiency. + frame_length = 1280 + + def __init__(self, cfg: Dict[str, Any]): + from tools import lazy_deps + + lazy_deps.ensure("wake.openwakeword", prompt=False) + + import openwakeword + from openwakeword.model import Model + + sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {} + model_ref = str(sub.get("model") or "hey_jarvis").strip() + framework = str(sub.get("inference_framework") or "onnx").strip().lower() + self._threshold = _sensitivity(cfg) + + if _looks_like_path(model_ref): + models = [model_ref] + else: + # Pretrained name (e.g. "hey_jarvis"). Best-effort one-time fetch + # of the bundled models; harmless if already present / offline. + try: + openwakeword.utils.download_models([model_ref]) + except Exception as e: # pragma: no cover - network/path dependent + logger.debug("openwakeword model download skipped: %s", e) + models = [model_ref] + + self._model = Model(wakeword_models=models, inference_framework=framework) + self._labels = list(self._model.models.keys()) + + def process(self, frame) -> bool: + scores = self._model.predict(frame) + return any(score >= self._threshold for score in scores.values()) + + def close(self) -> None: + try: + self._model.reset() + except Exception: + pass + + +class _PorcupineEngine(_Engine): + """Picovoice Porcupine — premium, on-device, needs an access key.""" + + def __init__(self, cfg: Dict[str, Any]): + from tools import lazy_deps + + lazy_deps.ensure("wake.porcupine", prompt=False) + + import pvporcupine + + access_key = (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip() + if not access_key: + raise RuntimeError( + "Porcupine wake word requires PORCUPINE_ACCESS_KEY " + "(get a free key at https://console.picovoice.ai)." + ) + + sub = cfg.get("porcupine") if isinstance(cfg.get("porcupine"), dict) else {} + keyword = str(sub.get("keyword") or "jarvis").strip() + sensitivity = _sensitivity(cfg) + + kwargs: Dict[str, Any] = {"access_key": access_key, "sensitivities": [sensitivity]} + if _looks_like_path(keyword): + kwargs["keyword_paths"] = [keyword] + else: + kwargs["keywords"] = [keyword] + + self._porcupine = pvporcupine.create(**kwargs) + self.frame_length = self._porcupine.frame_length + + def process(self, frame) -> bool: + # pvporcupine wants a plain list/sequence of int16 samples. + return self._porcupine.process(frame) >= 0 + + def close(self) -> None: + try: + self._porcupine.delete() + except Exception: + pass + + +def _build_engine(cfg: Dict[str, Any]) -> _Engine: + provider = _provider(cfg) + if provider == "porcupine": + return _PorcupineEngine(cfg) + if provider in ("openwakeword", "oww", "local"): + return _OpenWakeWordEngine(cfg) + raise ValueError(f"Unknown wake_word provider: {provider!r}") + + +# --------------------------------------------------------------------------- +# Requirements probe (for /wake status + enable path) +# --------------------------------------------------------------------------- + +def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Report whether wake-word detection can run, with a remediation hint.""" + cfg = cfg if cfg is not None else load_wake_word_config() + provider = _provider(cfg) + from tools import lazy_deps + + feature = "wake.porcupine" if provider == "porcupine" else "wake.openwakeword" + deps_ok = lazy_deps.is_available(feature) + audio_ok = _audio_available() + key_ok = True + hint = "" + + if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): + key_ok = False + hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)." + elif not deps_ok: + hint = lazy_deps.feature_install_command(feature) or "" + elif not audio_ok: + hint = "Microphone capture needs sounddevice + numpy and a working audio device." + + return { + "available": audio_ok and (deps_ok or lazy_deps._allow_lazy_installs()) and key_ok, + "provider": provider, + "deps_available": deps_ok, + "audio_available": audio_ok, + "access_key_set": key_ok, + "phrase": wake_phrase(cfg), + "hint": hint, + } + + +# --------------------------------------------------------------------------- +# Detector +# --------------------------------------------------------------------------- + +class WakeWordDetector: + """Background hotword listener. Fires ``on_wake()`` when the phrase is heard. + + The engine is built once and kept alive across pause/resume; only the audio + stream + reader thread cycle, so toggling the mic for a voice turn is cheap. + """ + + def __init__(self, engine: _Engine, on_wake: Callable[[], None], + cooldown: float = _FIRE_COOLDOWN_SECONDS): + self.engine = engine + self.on_wake = on_wake + self.cooldown = cooldown + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._last_fire = 0.0 + self._lock = threading.Lock() + + @property + def running(self) -> bool: + t = self._thread + return t is not None and t.is_alive() + + def start(self) -> None: + """Open the mic and begin listening. Idempotent.""" + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, daemon=True, name="wake-word" + ) + self._thread.start() + + # pause/resume keep the engine; stop tears it down. + def pause(self) -> None: + self._halt_thread() + + def resume(self) -> None: + self.start() + + def stop(self) -> None: + self._halt_thread() + self.engine.close() + + def _halt_thread(self) -> None: + with self._lock: + t, self._thread = self._thread, None + if t is not None and t is not threading.current_thread(): + self._stop.set() + t.join(timeout=2.0) + + def _run(self) -> None: + try: + sd, np = _import_audio() + except (ImportError, OSError) as e: + logger.error("wake word: audio libraries unavailable: %s", e) + return + + frame_length = self.engine.frame_length + try: + stream = sd.InputStream( + samplerate=SAMPLE_RATE, + channels=1, + dtype="int16", + blocksize=frame_length, + ) + stream.start() + except Exception as e: + logger.error("wake word: failed to open microphone: %s", e) + return + + logger.debug("wake word: listening (frame=%d)", frame_length) + try: + while not self._stop.is_set(): + try: + data, _overflow = stream.read(frame_length) + except Exception as e: + logger.debug("wake word: stream read error: %s", e) + break + frame = data[:, 0] if getattr(data, "ndim", 1) == 2 else data + try: + fired = self.engine.process(frame) + except Exception as e: + logger.debug("wake word: engine error: %s", e) + continue + if fired: + now = time.monotonic() + if now - self._last_fire >= self.cooldown: + self._last_fire = now + try: + self.on_wake() + except Exception as e: + logger.warning("wake word callback failed: %s", e) + finally: + try: + stream.stop() + stream.close() + except Exception: + pass + logger.debug("wake word: stream closed") + + +# --------------------------------------------------------------------------- +# Process-wide singleton (mirrors hermes_cli.voice's continuous API) +# --------------------------------------------------------------------------- + +_detector: Optional[WakeWordDetector] = None +_detector_lock = threading.Lock() + + +def start_listening( + on_wake: Callable[[], None], + *, + config: Optional[Dict[str, Any]] = None, +) -> WakeWordDetector: + """Build (once) and start the wake-word detector. Idempotent. + + Raises if engine construction fails (missing deps / access key / model); + callers should probe :func:`check_wake_word_requirements` first. + """ + global _detector + with _detector_lock: + if _detector is not None: + _detector.on_wake = on_wake + _detector.resume() + return _detector + cfg = config if config is not None else load_wake_word_config() + engine = _build_engine(cfg) + _detector = WakeWordDetector(engine, on_wake) + _detector.start() + return _detector + + +def pause_listening() -> None: + """Release the microphone without tearing down the engine.""" + with _detector_lock: + det = _detector + if det is not None: + det.pause() + + +def resume_listening() -> None: + """Re-open the microphone after a pause. No-op if not initialised.""" + with _detector_lock: + det = _detector + if det is not None: + det.resume() + + +def stop_listening() -> None: + """Fully stop and discard the detector (closes the engine).""" + global _detector + with _detector_lock: + det, _detector = _detector, None + if det is not None: + det.stop() + + +def is_listening() -> bool: + with _detector_lock: + det = _detector + return det is not None and det.running diff --git a/website/docs/user-guide/features/overview.md b/website/docs/user-guide/features/overview.md index 75f365b3892..cb3eef22109 100644 --- a/website/docs/user-guide/features/overview.md +++ b/website/docs/user-guide/features/overview.md @@ -32,6 +32,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch ## Media & Web - **[Voice Mode](voice-mode.md)** — 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. +- **[Wake Word](wake-word.md)** — Hands-free "Hey Hermes" trigger for the CLI. An on-device hotword listener starts a fresh voice session when you speak the wake phrase, the "Hey Siri" way. - **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome/Brave/Chromium/Edge via CDP, or local Chromium. Navigate websites, fill forms, and extract information. - **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model. - **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Eleven models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo, Krea V2 Medium/Large); pick one via `hermes tools`. diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md new file mode 100644 index 00000000000..21d1456cb07 --- /dev/null +++ b/website/docs/user-guide/features/wake-word.md @@ -0,0 +1,148 @@ +--- +sidebar_position: 11 +title: "Wake Word" +description: "Hands-free 'Hey Hermes' wake word — start a voice session by speaking, the 'Hey Siri' way" +--- + +# Wake Word ("Hey Hermes") + +The wake word turns Hermes into a hands-free assistant in the CLI: with one +setting on, Hermes listens in the background for a spoken trigger phrase. Say it, +and Hermes starts a fresh session, opens the microphone, captures your command +via the normal [voice pipeline](/user-guide/features/voice-mode), and answers — +exactly like "Hey Siri" or "Alexa". + +Detection runs **entirely on-device**. The always-on listener only watches for +the wake phrase; no audio leaves your machine until you actually speak a command +to the agent. + +## How it works + +1. With `wake_word.enabled: true` (or after `/wake on`), a lightweight hotword + detector listens on your default microphone. +2. When it hears the wake phrase it pauses itself (freeing the mic), starts a new + session, and records one utterance with voice mode's silence detection. +3. Your speech is transcribed and sent to the agent. After it replies, the + listener resumes automatically and waits for the next wake word. + +It is **off by default** — nothing listens until you turn it on. + +## Engines + +| Engine | Cost | API key | Notes | +|--------|------|---------|-------| +| **openWakeWord** (default) | Free | None | Local ONNX models. Ships with `hey_jarvis`, `alexa`, `hey_mycroft`, … | +| **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files | + +Both are lazy-installed the first time you enable the wake word. To install ahead +of time: + +```bash +uv pip install 'hermes-agent[wake]' # or: pip install 'hermes-agent[wake]' +``` + +## Quick start + +```bash +# In an interactive `hermes` session: +/wake on # start listening (installs the engine on first use) +/wake status # show phrase, provider, and state +/wake off # stop listening +``` + +Or enable it permanently in `~/.hermes/config.yaml`: + +```yaml +wake_word: + enabled: true +``` + +## Configuration + +```yaml +wake_word: + enabled: false + provider: openwakeword # "openwakeword" (free, local) | "porcupine" + phrase: "hey jarvis" # cosmetic label only — detection is keyed by the model/keyword below + sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers + start_new_session: true # start a fresh session on wake vs. continue the current one + openwakeword: + model: hey_jarvis # built-in name OR path to a custom .onnx/.tflite + inference_framework: onnx # "onnx" | "tflite" + porcupine: + keyword: jarvis # built-in keyword OR path to a custom .ppn +``` + +`sensitivity`, `phrase`, and `start_new_session` apply to both engines. The +`openwakeword` and `porcupine` blocks select the actual detection model. + +## Using a real "Hey Hermes" + +The bundled openWakeWord models do **not** include "hey hermes" — `hey_jarvis` +is the free, instantly-working default. To detect the literal phrase you supply +your own model and point the config at it: + +### Option A — openWakeWord (free) + +Train a custom model (≈75–90 min on a free/Colab GPU), then drop the `.onnx` +file somewhere and reference it: + +```yaml +wake_word: + enabled: true + provider: openwakeword + phrase: "hey hermes" + openwakeword: + model: ~/.hermes/wakewords/hey_hermes.onnx +``` + +Training references: + +- openWakeWord — +- 2026 training Colab — + +:::tip Pick a distinctive phrase +Wake phrases that don't collide with everyday speech generalize best. Two +syllables with an uncommon word ("hermes" qualifies) beat common words like +"hello" or "stop". +::: + +### Option B — Porcupine (custom keyword in seconds) + +Create a "Hey Hermes" keyword in the [Picovoice Console](https://console.picovoice.ai/), +download the `.ppn`, and: + +```yaml +wake_word: + enabled: true + provider: porcupine + phrase: "hey hermes" + porcupine: + keyword: ~/.hermes/wakewords/hey_hermes.ppn +``` + +Set your access key in `~/.hermes/.env`: + +```bash +PORCUPINE_ACCESS_KEY=your-key-here +``` + +## Requirements + +- A working microphone and the `sounddevice` + `numpy` audio stack (shared with + voice mode). +- An STT provider for transcribing the spoken command — local `faster-whisper` + works out of the box; see [Voice Mode](/user-guide/features/voice-mode) for the + full provider list. +- The wake engine deps (auto-installed, or `hermes-agent[wake]`). + +`/wake status` reports exactly what's missing if the listener won't start. + +## Notes & limits + +- **CLI only.** The wake word lives in the interactive `hermes` CLI, where a + local microphone is available. It does not run in the messaging gateway. +- **One mic at a time.** The detector releases the microphone while a command is + recording and reclaims it once the turn ends, so it won't fight voice capture. +- **Privacy.** Hotword detection is local. Set `sensitivity` higher if you get + false triggers, lower if it misses you. From 86d5b8b90f801754ca30c986c2bb1794e64e6e5d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 22:07:34 -0500 Subject: [PATCH 02/46] feat(voice): extend "Hey Hermes" wake word to TUI + desktop GUI Makes the wake word a tri-surface feature with one configurable owner. - wake_word.surface ("auto" | "cli" | "tui" | "gui") + shared wake_surface_enabled() gate consulted by every surface, so exactly one place owns the listener and the new session it opens. - tui_gateway: wake.start/stop/pause/resume/status RPCs + a wake.detected event, sharing one server-side detector for both TUI and desktop. The detector yields the mic to voice.record (pause on capture start, resume on terminal) and to the desktop's browser mic (wake.pause/resume). - TUI (Ink): arm wake.start on gateway.ready; on wake.detected open a fresh session and start voice capture. - Desktop (Electron): arm wake.start on connect; on wake.detected open a fresh session. - CLI now gates on wake_surface_enabled("cli"); /wake status shows surface. - Tests for the surface gate; docs cover the surface knob + cross-surface. --- apps/desktop/src/app/desktop-controller.tsx | 1435 +++++++++++++++++ cli.py | 7 +- hermes_cli/config.py | 1 + tests/tools/test_wake_word.py | 15 + tools/wake_word.py | 18 + tui_gateway/server.py | 160 +- ui-tui/src/app/createGatewayEventHandler.ts | 21 + ui-tui/src/gatewayTypes.ts | 1 + website/docs/user-guide/features/wake-word.md | 34 +- 9 files changed, 1678 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/app/desktop-controller.tsx diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx new file mode 100644 index 00000000000..6623636f187 --- /dev/null +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -0,0 +1,1435 @@ +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' +import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' + +import { BootFailureOverlay } from '@/components/boot-failure-overlay' +import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' +import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' +import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' +import { Pane, PaneMain } from '@/components/pane-shell' +import { RemoteDisplayBanner } from '@/components/remote-display-banner' +import { useMediaQuery } from '@/hooks/use-media-query' +import { cn } from '@/lib/utils' +import { useSkinCommand } from '@/themes/use-skin-command' + +import { formatRefValue } from '../components/assistant-ui/directive-text' +import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' +import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' +import { storedSessionIdForNotification } from '../lib/session-ids' +import { + isMessagingSource, + LOCAL_SESSION_SOURCE_IDS, + MESSAGING_SESSION_SOURCE_IDS, + normalizeSessionSource +} from '../lib/session-source' +import { latestSessionTodos } from '../lib/todos' +import { setCronFocusJobId, setCronJobs } from '../store/cron' +import { + $fileBrowserOpen, + $panesFlipped, + $pinnedSessionIds, + $sessionsLimit, + bumpSessionsLimit, + FILE_BROWSER_DEFAULT_WIDTH, + FILE_BROWSER_MAX_WIDTH, + FILE_BROWSER_MIN_WIDTH, + pinSession, + PREVIEW_PANE_ID, + restoreWorktree, + setSidebarOverlayMounted, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MAX_WIDTH, + SIDEBAR_SESSIONS_PAGE_SIZE, + unpinSession +} from '../store/layout' +import { respondToApprovalAction } from '../store/native-notifications' +import { $paneOpen } from '../store/panes' +import { setPetActivity } from '../store/pet' +import { setPetScale } from '../store/pet-gallery' +import { + setPetOverlayOpenAppHandler, + setPetOverlayScaleHandler, + setPetOverlaySubmitHandler +} from '../store/pet-overlay' +import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' +import { + $activeGatewayProfile, + $freshSessionRequest, + $profileScope, + ALL_PROFILES, + normalizeProfileKey, + refreshActiveProfile +} from '../store/profile' +import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' +import { $reviewOpen, REVIEW_PANE_ID } from '../store/review' +import { + $activeSessionId, + $attentionSessionIds, + $currentCwd, + $freshDraftReady, + $gatewayState, + $messages, + $messagingSessions, + $resumeExhaustedSessionId, + $resumeFailedSessionId, + $selectedStoredSessionId, + $sessions, + $workingSessionIds, + CRON_SECTION_LIMIT, + getRecentlySettledSessionIds, + getRememberedSessionId, + mergeSessionPage, + MESSAGING_SECTION_LIMIT, + sessionPinId, + setAwaitingResponse, + setBusy, + setCronSessions, + setCurrentBranch, + setCurrentCwd, + setCurrentModel, + setCurrentProvider, + setMessages, + setMessagingPlatformTotals, + setMessagingSessions, + setMessagingTruncated, + setRememberedSessionId, + setSessionProfileTotals, + setSessions, + setSessionsLoading, + setSessionsTotal +} from '../store/session' +import { onSessionsChanged } from '../store/session-sync' +import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos' +import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' +import { isSecondaryWindow } from '../store/windows' + +import { ChatView } from './chat' +import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' +import { useComposerActions } from './chat/hooks/use-composer-actions' +import { + ChatPreviewRail, + PREVIEW_RAIL_MAX_WIDTH, + PREVIEW_RAIL_MIN_WIDTH, + PREVIEW_RAIL_PANE_WIDTH +} from './chat/right-rail' +import { ChatSidebar } from './chat/sidebar' +import { CommandPalette } from './command-palette' +import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' +import { useGatewayRequest } from './gateway/hooks/use-gateway-request' +import { useKeybinds } from './hooks/use-keybinds' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' +import { ModelPickerOverlay } from './model-picker-overlay' +import { ModelVisibilityOverlay } from './model-visibility-overlay' +import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' +import { RightSidebarPane } from './right-sidebar' +import { FileActionDialogs } from './right-sidebar/file-actions' +import { ReviewPane } from './right-sidebar/review' +import { $terminalTakeover } from './right-sidebar/store' +import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' +import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' +import { SessionPickerOverlay } from './session-picker-overlay' +import { SessionSwitcher } from './session-switcher' +import { useContextSuggestions } from './session/hooks/use-context-suggestions' +import { useCwdActions } from './session/hooks/use-cwd-actions' +import { useHermesConfig } from './session/hooks/use-hermes-config' +import { useMessageStream } from './session/hooks/use-message-stream' +import { useModelControls } from './session/hooks/use-model-controls' +import { usePreviewRouting } from './session/hooks/use-preview-routing' +import { usePromptActions } from './session/hooks/use-prompt-actions' +import { useRouteResume } from './session/hooks/use-route-resume' +import { useSessionActions } from './session/hooks/use-session-actions' +import { useSessionStateCache } from './session/hooks/use-session-state-cache' +import { AppShell } from './shell/app-shell' +import { useOverlayRouting } from './shell/hooks/use-overlay-routing' +import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' +import { useStatusbarItems } from './shell/hooks/use-statusbar-items' +import { ModelMenuPanel } from './shell/model-menu-panel' +import type { StatusbarItem } from './shell/statusbar-controls' +import type { TitlebarTool } from './shell/titlebar-controls' +import { useGroupRegistry } from './shell/use-group-registry' +import { UpdatesOverlay } from './updates-overlay' + +const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView })) +const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) +const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) +const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) +const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) +const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) +const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) +const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) + +// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The +// Cron sessions are written by a background scheduler tick (the desktop +// backend), so no user action signals the UI. Poll the bounded cron list on +// this cadence while the app is open + visible so new runs surface promptly +// instead of waiting for the next user-triggered refreshSessions(). +const CRON_POLL_INTERVAL_MS = 30_000 +// The recents list is local-only: cron rows have their own section, and each +// messaging platform (telegram, discord, …) is fetched separately into its own +// self-managed sidebar section (refreshMessagingSessions). Excluding both here +// keeps "Load more" paging through interactive local chats instead of +// interleaving gateway threads that bury them. +const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] +// The messaging slice is the inverse: drop cron + every local source so only +// external-platform conversations remain, then split per platform in the UI. +const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] + +// Cheap signature compare so the poll only swaps the atom (and re-renders the +// sidebar) when the visible cron rows actually changed. +function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { + if (a.length !== b.length) { + return false + } + + return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title) +} + +// Rows a session refresh must preserve even if the aggregator omits them: +// in-flight first turns (message_count 0), pinned rows aged off the page, the +// actively-viewed chat (its "working" flag clears a beat before the aggregator +// sees the persisted row), and sessions whose turn just settled (same race, but +// for a chat the user has already navigated away from). Pass `scope` to only +// keep the active row when it belongs to the profile being paged. +function sessionsToKeep(scope?: string): Set { + const keep = new Set([ + ...$workingSessionIds.get(), + ...$pinnedSessionIds.get(), + ...getRecentlySettledSessionIds() + ]) + + const active = $selectedStoredSessionId.get() + + if (active) { + const session = scope ? $sessions.get().find(s => s.id === active) : null + + if (!scope || !session || normalizeProfileKey(session.profile) === scope) { + keep.add(active) + } + } + + return keep +} + +export function DesktopController() { + const queryClient = useQueryClient() + const location = useLocation() + const navigate = useNavigate() + + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + const refreshSessionsRequestRef = useRef(0) + + const gatewayState = useStore($gatewayState) + const activeSessionId = useStore($activeSessionId) + const currentCwd = useStore($currentCwd) + const freshDraftReady = useStore($freshDraftReady) + const resumeFailedSessionId = useStore($resumeFailedSessionId) + const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + const filePreviewTarget = useStore($filePreviewTarget) + const previewTarget = useStore($previewTarget) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const terminalTakeover = useStore($terminalTakeover) + const reviewOpen = useStore($reviewOpen) + const fileBrowserOpen = useStore($fileBrowserOpen) + const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) + const panesFlipped = useStore($panesFlipped) + const profileScope = useStore($profileScope) + // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — + // collapse both sidebars (without touching their stored open state) so the + // hover-reveal overlay becomes the way in. Restores once it's wide again. + const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) + + const routedSessionId = routeSessionId(location.pathname) + const routeToken = `${location.pathname}:${location.search}:${location.hash}` + const routeTokenRef = useRef(routeToken) + routeTokenRef.current = routeToken + const getRouteToken = useCallback(() => routeTokenRef.current, []) + + const { + agentsOpen, + chatOpen, + closeOverlayToPreviousRoute, + commandCenterInitialSection, + commandCenterOpen, + cronOpen, + currentView, + openAgents, + openCommandCenterSection, + profilesOpen, + settingsOpen, + toggleCommandCenter + } = useOverlayRouting() + + const terminalSidebarOpen = chatOpen && terminalTakeover + + const titlebarToolGroups = useGroupRegistry() + const statusbarItemGroups = useGroupRegistry() + const setTitlebarToolGroup = titlebarToolGroups.set + const setStatusbarItemGroup = statusbarItemGroups.set + + const { + activeSessionIdRef, + ensureSessionState, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + } = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages + }) + + const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() + + useEffect(() => { + window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget))) + }, [chatOpen, filePreviewTarget, previewTarget]) + + useEffect(() => { + startUpdatePoller() + const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) + + return () => { + unsubscribe?.() + stopUpdatePoller() + } + }, []) + + // Remember the open chat so a relaunch reopens it instead of an empty new-chat. + useEffect(() => { + if (routedSessionId) { + setRememberedSessionId(routedSessionId) + } + }, [routedSessionId]) + + // Restore that chat once, on cold start only (we're at the new-chat route and + // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch + // below, so we never boot-loop into an error screen. + const restoredLastSessionRef = useRef(false) + useEffect(() => { + if (restoredLastSessionRef.current) { + return + } + + restoredLastSessionRef.current = true + const last = getRememberedSessionId() + + if (last && location.pathname === NEW_CHAT_ROUTE) { + navigate(sessionRoute(last), { replace: true }) + } + }, [location.pathname, navigate]) + + useEffect(() => { + if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { + setRememberedSessionId(null) + } + }, [resumeExhaustedSessionId]) + + // Notification click: the main process already focused the window; jump to its + // session. Notifications are tagged with the gateway *runtime* session id, but + // the chat route is keyed by the *stored* id — navigating with the runtime id + // resumes a non-existent stored session ("session not found") and strands the + // user. Translate runtime -> stored before navigating. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { + if (sessionId) { + navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current))) + } + }) + + return () => unsubscribe?.() + }, [navigate, runtimeIdByStoredSessionIdRef]) + + // Notification action button (Approve/Reject) — resolve in place, no navigation. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => { + void respondToApprovalAction(sessionId ?? null, actionId) + }) + + return () => unsubscribe?.() + }, []) + + // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint). + // Build the equivalent /blueprint slash command from the payload and drop + // it into the composer — the user reviews/edits, then sends; the agent (or + // the shared command handler) creates the job. Signal readiness so a link + // that arrived during boot is flushed exactly once. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { + if (!payload || payload.kind !== 'blueprint' || !payload.name) { + return + } + + const slots = Object.entries(payload.params || {}) + .map(([k, v]) => { + const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v + + return `${k}=${sval}` + }) + .join(' ') + + const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` + requestComposerInsert(command, { mode: 'block', target: 'main' }) + requestComposerFocus('main') + }) + + // Tell the main process the renderer is ready to receive deep links. + void window.hermesDesktop?.signalDeepLinkReady?.() + + return () => unsubscribe?.() + }, []) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!$filePreviewTarget.get() && !$previewTarget.get()) { + return + } + + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') { + event.preventDefault() + event.stopPropagation() + closeActiveRightRailTab() + } + } + + const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab) + + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => { + unsubscribe?.() + window.removeEventListener('keydown', onKeyDown, { capture: true }) + } + }, []) + + // Cron-job sessions as their own list (latest N). Independent of the recents + // page so the two never compete for slots. Cheap + bounded. Kept (even though + // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run + // still resolves into the Pinned section via sessionByAnyId. + const refreshCronSessions = useCallback(async () => { + try { + const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + source: 'cron' + }) + + setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions)) + } catch { + // Non-fatal: the cron section just stays empty/stale. + } + }, []) + + // Messaging-platform sessions as their own slice, fetched separately from + // local recents so each platform renders a self-managed section and never + // competes with local chats for the recents page budget. One combined fetch + // seeds every platform; the sidebar splits the rows per source. + const refreshMessagingSessions = useCallback(async () => { + try { + const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { + excludeSources: MESSAGING_EXCLUDED_SOURCES + }) + + // Drop any non-messaging source the broad exclude didn't catch (custom + // sources) — those stay in local recents, not a platform section. + const rows = result.sessions.filter(s => isMessagingSource(s.source)) + + setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) + // Hit the cap → at least one platform may have more on disk than loaded, + // so platform sections offer their own per-platform "load more". + setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) + } catch { + // Non-fatal: the messaging sections just stay empty/stale. + } + }, []) + + // Page a single platform's section independently (mirrors the per-profile + // pager): fetch that source's next window and merge it back in place, leaving + // every other platform's rows untouched. Resolves the platform's exact total. + const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { + const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform + const loaded = $messagingSessions.get().filter(inPlatform).length + + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { + source: platform + }) + + const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) + + setMessagingSessions(prev => [ + ...prev.filter(s => !inPlatform(s)), + ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) + ]) + + const total = result.total ?? incoming.length + setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) + }, []) + + // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created + // synchronously (agent tool call or the cron UI), so refreshing here right + // after an agent turn surfaces a new job immediately; the interval poll keeps + // next-run/state fresh as the scheduler advances them. + const refreshCronJobs = useCallback(async () => { + try { + const jobs = await getCronJobs() + + setCronJobs(jobs) + } catch { + // Non-fatal: the cron section just keeps its last-known jobs. + } + }, []) + + const refreshSessions = useCallback(async () => { + const requestId = refreshSessionsRequestRef.current + 1 + refreshSessionsRequestRef.current = requestId + setSessionsLoading(true) + + try { + const limit = $sessionsLimit.get() + + // Require at least one message so abandoned/empty "Untitled" drafts (one + // was created per TUI/desktop launch before the lazy-create fix) don't + // clutter the sidebar. + // Unified cross-profile list (served read-only off each profile's + // state.db; no per-profile backend is spawned). Single-profile users get + // the same rows tagged profile="default". Cron sessions are excluded here + // and fetched separately (refreshCronSessions) so the scheduler's + // always-newest rows can't consume the recents page budget. + // Scope the fetch to the active profile (not always 'all') so a profile + // with few recent sessions isn't windowed out of the cross-profile + // recency page — the empty-history-on-profile-switch bug. + const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + + const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { + excludeSources: SIDEBAR_EXCLUDED_SOURCES + }) + + if (refreshSessionsRequestRef.current === requestId) { + setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) + setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) + setSessionProfileTotals(result.profile_totals ?? {}) + } + } finally { + if (refreshSessionsRequestRef.current === requestId) { + setSessionsLoading(false) + } + } + + void refreshCronSessions() + void refreshCronJobs() + void refreshMessagingSessions() + }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions]) + + const loadMoreSessions = useCallback(async () => { + bumpSessionsLimit() + await refreshSessions() + }, [refreshSessions]) + + // Another window mutated the shared session list (e.g. a chat started in the + // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so + // only real windows bother. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) + }, [refreshSessions]) + + // ALL-profiles view pages one profile at a time: fetch that profile's next + // page and merge it in place, leaving every other profile's rows untouched. + const loadMoreSessionsForProfile = useCallback(async (profile: string) => { + const key = normalizeProfileKey(profile) + const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key + const loaded = $sessions.get().filter(inKey).length + + const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, { + excludeSources: SIDEBAR_EXCLUDED_SOURCES + }) + + const keep = sessionsToKeep(key) + + setSessions(prev => [ + ...prev.filter(s => !inKey(s)), + ...mergeSessionPage(prev.filter(inKey), result.sessions, keep) + ]) + + const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length + setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) })) + }, []) + + const toggleSelectedPin = useCallback(() => { + const sessionId = $selectedStoredSessionId.get() + + if (!sessionId) { + return + } + + // Pin on the durable lineage-root id so the pin survives auto-compression. + const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) + const pinId = session ? sessionPinId(session) : sessionId + + if ($pinnedSessionIds.get().includes(pinId)) { + unpinSession(pinId) + } else { + pinSession(pinId) + } + }, []) + + const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) + + const updateActiveSessionRuntimeInfo = useCallback( + (info: { branch?: string; cwd?: string }) => { + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + return + } + + updateSessionState(sessionId, state => ({ + ...state, + branch: info.branch ?? state.branch, + cwd: info.cwd ?? state.cwd + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + const { refreshProjectBranch } = useCwdActions({ + activeSessionId, + activeSessionIdRef, + onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, + requestGateway + }) + + const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ + activeSessionIdRef, + refreshProjectBranch + }) + + const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ + activeSessionId, + queryClient, + requestGateway + }) + + const openProviderSettings = useCallback(() => { + navigate(`${SETTINGS_ROUTE}?tab=providers`) + }, [navigate]) + + const modelMenuContent = useMemo( + () => + gatewayState === 'open' ? ( + + ) : null, + [gatewayRef, gatewayState, requestGateway, selectModel] + ) + + useContextSuggestions({ + activeSessionId, + activeSessionIdRef, + currentCwd, + gatewayState, + requestGateway + }) + + const hydrateFromStoredSession = useCallback( + async ( + attempts = 1, + storedSessionId = selectedStoredSessionIdRef.current, + runtimeSessionId = activeSessionIdRef.current + ) => { + if (!storedSessionId || !runtimeSessionId) { + return + } + + const storedProfile = $sessions + .get() + .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile + + for (let index = 0; index < Math.max(1, attempts); index += 1) { + try { + const latest = await getSessionMessages(storedSessionId, storedProfile) + const messages = toChatMessages(latest.messages) + updateSessionState( + runtimeSessionId, + state => ({ + ...state, + messages: preserveLocalAssistantErrors(messages, state.messages) + }), + storedSessionId + ) + + // Seed the status stack's todo group from history — but only while + // the plan is still in flight, so reopening an old chat doesn't pin + // its finished todo list above the composer forever. + const todos = latestSessionTodos(messages) + + if (todos && todoListActive(todos)) { + setSessionTodos(runtimeSessionId, todos) + } else { + clearSessionTodos(runtimeSessionId) + } + + return + } catch { + // Best-effort fallback when live stream payloads are empty. + } + + if (index < attempts - 1) { + await new Promise(resolve => window.setTimeout(resolve, 250)) + } + } + }, + [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] + ) + + const { handleGatewayEvent } = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession, + queryClient, + refreshHermesConfig, + refreshSessions, + sessionStateByRuntimeIdRef, + updateSessionState + }) + + const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ + activeSessionIdRef, + baseHandleGatewayEvent: handleGatewayEvent, + currentCwd, + currentView, + requestGateway, + routedSessionId, + selectedStoredSessionId + }) + + const { + archiveSession, + branchCurrentSession, + branchStoredSession, + createBackendSessionForSend, + openSettings, + removeSession, + resumeSession, + selectSidebarItem, + startFreshSessionDraft + } = useSessionActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + }) + + // Single global listener for every rebindable hotkey (incl. profile switching) + // plus the on-screen keybind editor's capture mode. + useKeybinds({ + startFreshSession: startFreshSessionDraft, + toggleCommandCenter, + toggleSelectedPin + }) + + // A profile switch/create drops to a fresh new-session draft so the previously + // open session doesn't bleed across contexts. Skip the initial value. + const freshSessionRequest = useStore($freshSessionRequest) + const lastFreshRef = useRef(freshSessionRequest) + + useEffect(() => { + if (freshSessionRequest === lastFreshRef.current) { + return + } + + lastFreshRef.current = freshSessionRequest + startFreshSessionDraft() + }, [freshSessionRequest, startFreshSessionDraft]) + + // Swapping the live gateway to another profile must re-pull that profile's + // global model + active-profile pill. Both are nanostores, so the blanket + // invalidateQueries() the profile store fires on swap doesn't touch them — + // without this the statusbar keeps showing the previous profile's model + // (the "forgets the LLM setting" report). gatewayState stays 'open' across a + // swap (background sockets persist), so the open→open effect won't re-run. + const activeGatewayProfile = useStore($activeGatewayProfile) + const lastGatewayProfileRef = useRef(activeGatewayProfile) + + useEffect(() => { + if (activeGatewayProfile === lastGatewayProfileRef.current) { + return + } + + lastGatewayProfileRef.current = activeGatewayProfile + // Force: the new profile has its own default, so reseed even if the composer + // already shows the previous profile's model. + void refreshCurrentModel(true) + void refreshActiveProfile() + }, [activeGatewayProfile, refreshCurrentModel]) + + const composer = useComposerActions({ + activeSessionId, + currentCwd, + requestGateway + }) + + const branchInNewChat = useCallback( + async (messageId?: string) => { + const branched = await branchCurrentSession(messageId) + + if (branched) { + await refreshSessions().catch(() => undefined) + } + + return branched + }, + [branchCurrentSession, refreshSessions] + ) + + // Clear a failed turn's red error banner from the transcript. Errors are + // renderer-local state (never persisted), so dismissing is purely a view + + // session-cache edit. A message that errored before emitting any visible + // text is a bare error placeholder → drop it entirely; one that streamed + // partial output then failed keeps its content and just sheds the error. + // Both the per-runtime cache AND the live $messages view must be updated: + // `preserveLocalAssistantErrors` re-grafts any still-errored message it + // finds in the view onto the next session.info flush, so clearing only the + // cache would let the heartbeat resurrect the banner. + const dismissError = useCallback( + (messageId: string) => { + const runtimeSessionId = activeSessionIdRef.current + + if (!runtimeSessionId) { + return + } + + const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => + messages.flatMap(message => { + if (message.id !== messageId || !message.error) { + return [message] + } + + if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { + return [] + } + + return [{ ...message, error: undefined, pending: false }] + }) + + // View first: the flush below reads $messages as the "current" baseline + // for error preservation, so the banner must be gone from it before the + // cache update triggers a re-sync. + setMessages(clearErrorIn($messages.get())) + + updateSessionState(runtimeSessionId, state => ({ + ...state, + messages: clearErrorIn(state.messages) + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + const startSessionInWorkspace = useCallback( + (path: null | string) => { + startFreshSessionDraft() + + // A worktree lane carries its own path; the trunk "+" can be path-less (the + // main checkout is implicit), so fall back to the active project's root + // instead of no-op'ing on null — that was "+ on main does nothing". + const target = path?.trim() || resolveNewSessionCwd() + + if (!target) { + return + } + + // The next message creates the backend session in $currentCwd, so seed + // it (and the branch) from the workspace the user clicked the + on. + setCurrentCwd(target) + void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) + .then(info => { + const resolved = info.cwd || target + + setCurrentCwd(resolved) + setCurrentBranch(info.branch || '') + + // An EXPLICIT target (a worktree/lane path — e.g. just-created via + // "convert a branch" / "new worktree") drills the sidebar into that + // project so the new lane is visible at once. Without this, a brand-new + // worktree session is invisible from the all-projects overview (the + // live overlay skips `.worktrees` rows, and the session.info cwd-follow + // only fires on a same-session move, not a fresh session). The + // path-less trunk "+" keeps the current scope untouched. + if (path?.trim()) { + restoreWorktree(resolved) + void followActiveSessionCwd(resolved) + } + }) + .catch(() => undefined) + }, + [requestGateway, startFreshSessionDraft] + ) + + // Composer "branch off into a new worktree": the composer already created the + // worktree and cleared its draft; open a fresh session anchored to that tree, + // then prefill the task that kicked it off. startSessionInWorkspace owns the + // reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp + // the cwd back to the default), so the prefill is dispatched right after — its + // deferred event lands once the fresh composer has remounted and rebound. + const startWorkSessionRequest = useStore($startWorkSessionRequest) + const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) + + useEffect(() => { + if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { + return + } + + lastStartWorkTokenRef.current = startWorkSessionRequest.token + startSessionInWorkspace(startWorkSessionRequest.path) + + if (startWorkSessionRequest.draft) { + requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) + } + }, [startSessionInWorkspace, startWorkSessionRequest]) + + const handleSkinCommand = useSkinCommand() + + const { + cancelRun, + editMessage, + handleThreadMessagesChange, + reloadFromMessage, + restoreToMessage, + steerPrompt, + submitText, + transcribeVoiceAudio + } = usePromptActions({ + activeSessionId, + activeSessionIdRef, + branchCurrentSession: branchInNewChat, + busyRef, + createBackendSessionForSend, + handleSkinCommand, + refreshSessions, + requestGateway, + resumeStoredSession: resumeSession, + selectedStoredSessionIdRef, + startFreshSessionDraft, + sttEnabled, + updateSessionState + }) + + // The popped-out pet drives two actions back into the app: send a prompt, and + // open the most recent thread. Both are registered ONCE through refs that track + // the latest callbacks — re-registering on every `submitText`/`resumeSession` + // identity change left a brief window where the handler was nulled (cleanup + // before re-register), which could drop a submit fired from the overlay (e.g. + // creating a session from the new-session screen). The ref form keeps a stable, + // always-current handler. Primary window only — it owns the overlay. + const submitTextRef = useRef(submitText) + submitTextRef.current = submitText + const resumeSessionRef = useRef(resumeSession) + resumeSessionRef.current = resumeSession + const requestGatewayRef = useRef(requestGateway) + requestGatewayRef.current = requestGateway + + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) + // Alt+wheel resize from the popped-out pet — persist it through this + // window's gateway (the overlay has none) so it survives restart. + setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) + // Mail icon: $sessions is ordered most-recent-first; the pet is global (not + // per session) so "most recent" is the right target. main.cjs already raised + // the window before forwarding this. + setPetOverlayOpenAppHandler(() => { + const recent = $sessions.get()[0] + + if (recent?.id) { + void resumeSessionRef.current(recent.id) + } + }) + + return () => { + setPetOverlaySubmitHandler(null) + setPetOverlayOpenAppHandler(null) + setPetOverlayScaleHandler(null) + } + }, []) + + // Mirror "a session is blocked on the user" (clarify/approval) into the pet's + // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so + // it rides the same atom the pop-out overlay mirrors — no session list needed + // there. Every window keeps its own in-window pet in sync. + useEffect(() => { + const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) + + sync() + + return $attentionSessionIds.listen(sync) + }, []) + + useGatewayBoot({ + handleGatewayEvent: handleDesktopGatewayEvent, + onConnectionReady: c => { + connectionRef.current = c + }, + onGatewayReady: g => { + gatewayRef.current = g + }, + refreshHermesConfig, + refreshSessions + }) + + useEffect(() => { + if (gatewayState === 'open') { + void refreshCurrentModel() + void refreshActiveProfile() + void refreshSessions().catch(() => undefined) + } + }, [gatewayState, refreshCurrentModel, refreshSessions]) + + // "Hey Hermes" wake word: arm the server-side detector for this surface + // (gated on config) and open a fresh session when it fires. Idempotent and + // self-cleaning across reconnects. + useEffect(() => { + if (gatewayState !== 'open') { + return + } + void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined) + const gw = gatewayRef.current + if (!gw) { + return + } + return gw.on('wake.detected', () => startFreshSessionDraft()) + }, [gatewayState, requestGateway, gatewayRef, startFreshSessionDraft]) + + // Keep the cron jobs section live without a user action: the scheduler ticks + // in the background (advancing next-run/state and creating runs), so poll the + // job list on an interval (and on tab re-focus) while connected. + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + const tick = () => { + if (document.visibilityState === 'visible') { + void refreshCronJobs() + } + } + + const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS) + document.addEventListener('visibilitychange', tick) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', tick) + } + }, [gatewayState, refreshCronJobs]) + + useEffect(() => { + if (gatewayState === 'open' && !activeSessionId && freshDraftReady) { + void refreshCurrentModel() + void refreshHermesConfig() + } + }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig]) + + useRouteResume({ + activeSessionId, + activeSessionIdRef, + creatingSessionRef, + currentView, + freshDraftReady, + gatewayState, + locationPathname: location.pathname, + resumeSession, + resumeFailedSessionId, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft + }) + + const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ + agentsOpen, + chatOpen, + commandCenterOpen, + extraLeftItems: statusbarItemGroups.flat.left, + extraRightItems: statusbarItemGroups.flat.right, + gatewayLogLines, + gatewayState, + inferenceStatus, + openAgents, + freshDraftReady, + openCommandCenterSection, + requestGateway, + statusSnapshot, + toggleCommandCenter + }) + + const sidebar = ( + void archiveSession(sessionId)} + onBranchSession={sessionId => void branchStoredSession(sessionId)} + onDeleteSession={sessionId => void removeSession(sessionId)} + onLoadMoreMessaging={loadMoreMessagingForPlatform} + onLoadMoreProfileSessions={loadMoreSessionsForProfile} + onLoadMoreSessions={loadMoreSessions} + onManageCronJob={jobId => { + setCronFocusJobId(jobId) + navigate(CRON_ROUTE) + }} + onNavigate={selectSidebarItem} + onNewSessionInWorkspace={startSessionInWorkspace} + onResumeSession={sessionId => navigate(sessionRoute(sessionId))} + onTriggerCronJob={jobId => { + void triggerCronJob(jobId) + .then(() => refreshCronJobs()) + .catch(() => undefined) + }} + /> + ) + + // One PTY-backed terminal mounted forever; placeholders decide + // where it shows. Lives in main's stacking context (not the root overlay layer) + // so pane resize handles still paint above it. Toggling never rebuilds the shell. + const mainOverlays = ( + + ) + + const overlays = ( + <> + + {!isSecondaryWindow() && } + {!isSecondaryWindow() && ( + { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + requestGateway={requestGateway} + /> + )} + + + + + + + + + + + + {settingsOpen && ( + + { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + onMainModelChanged={(provider, model) => { + setCurrentProvider(provider) + setCurrentModel(model) + updateModelOptionsCache(provider, model, true) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + /> + + )} + + {commandCenterOpen && ( + + navigate(path)} + onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + /> + + )} + + {agentsOpen && ( + + + + )} + + {cronOpen && ( + + navigate(sessionRoute(sessionId))} + /> + + )} + + {profilesOpen && ( + + + + )} + + ) + + const chatView = ( + composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} + onAttachDroppedItems={composer.attachDroppedItems} + onAttachImageBlob={composer.attachImageBlob} + onBranchInNewChat={branchInNewChat} + onCancel={cancelRun} + onDeleteSelectedSession={() => { + if (selectedStoredSessionId) { + void removeSession(selectedStoredSessionId) + } + }} + onDismissError={dismissError} + onEdit={editMessage} + onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} + onPickFiles={() => void composer.pickContextPaths('file')} + onPickFolders={() => void composer.pickContextPaths('folder')} + onPickImages={() => void composer.pickImages()} + onReload={reloadFromMessage} + onRemoveAttachment={id => void composer.removeAttachment(id)} + onRestoreToMessage={restoreToMessage} + onRetryResume={sessionId => void resumeSession(sessionId, true)} + onSteer={steerPrompt} + onSubmit={submitText} + onThreadMessagesChange={handleThreadMessagesChange} + onToggleSelectedPin={toggleSelectedPin} + onTranscribeAudio={transcribeVoiceAudio} + /> + ) + + // Flipped layout mirrors the default: sessions sidebar → right, file + // browser + preview rail → left. Same panes, swapped sides. + const sidebarSide = panesFlipped ? 'right' : 'left' + const railSide = panesFlipped ? 'left' : 'right' + + // Other sidebars docked as real columns on the terminal's rail. Force-collapsed + // hover-reveal overlays (narrow window) don't take a column, so they don't count. + const railColumnOpen = + (chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) || + (chatOpen && !narrowViewport && fileBrowserOpen) || + (chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen) + + // Once the terminal would share its rail with another sidebar, drop it to a + // full-width row beneath them rather than cramming in one more skinny column. + const terminalAsRow = terminalSidebarOpen && railColumnOpen + + const previewPane = ( + + {chatOpen ? ( + + ) : null} + + ) + + const fileBrowserPane = ( + + {/* Key on the project (cwd) so switching projects unmounts the old tree and + mounts a fresh one straight into its skeleton — no stale-then-blip. */} + composer.insertContextPathInlineRef(path)} + onActivateFolder={path => composer.insertContextPathInlineRef(path, true)} + /> + + ) + + const reviewPane = ( + + + + ) + + const terminalPane = ( + + {/* As a column the terminal clears the titlebar; as a bottom row it sits + below the rail's panes (so it fills its row edge-to-edge) and gets a + left border separating it from the chat — the column-mode separator + lives on the resize sash, which moves to the top edge as a row. */} +
+ +
+
+ ) + + return ( + + {!isSecondaryWindow() && ( + + {sidebar} + + )} + + + + + + + + } + path="skills" + /> + + + + } + path="messaging" + /> + + + + } + path="artifacts" + /> + + + + + + } path="new" /> + } path="sessions/:sessionId" /> + } path="*" /> + + + {/* + Order within a side maps to column order. Default (rail on the right): + main | terminal | preview | file-browser. Flipped (rail on the left): + mirror to file-browser | preview | terminal | main so terminal stays + adjacent to the chat. + */} + {panesFlipped ? fileBrowserPane : terminalPane} + {previewPane} + {reviewPane} + {panesFlipped ? terminalPane : fileBrowserPane} + + ) +} + +function LegacySessionRedirect() { + const { sessionId } = useParams() + + return +} diff --git a/cli.py b/cli.py index 8fe26d43a4b..042b888e271 100644 --- a/cli.py +++ b/cli.py @@ -12182,10 +12182,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # threading resume logic through the voice machinery. def _maybe_start_wake_word(self): - """Start the wake-word listener at CLI startup if enabled in config.""" + """Start the wake-word listener at CLI startup if this surface owns it.""" try: - from tools.wake_word import load_wake_word_config - if not load_wake_word_config().get("enabled"): + from tools.wake_word import wake_surface_enabled + if not wake_surface_enabled("cli"): return except Exception: return @@ -12340,6 +12340,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f" State: {'LISTENING' if active else 'OFF'}") _cprint(f" Phrase: \"{reqs['phrase']}\"") _cprint(f" Provider: {reqs['provider']}") + _cprint(f" Surface: {cfg.get('surface', 'auto')}") _cprint(f" New session: {'yes' if cfg.get('start_new_session', True) else 'no'}") if not reqs["available"] and reqs.get("hint"): _cprint(f" {_DIM}{reqs['hint']}{_RST}") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bc96780e4f8..4881ddd2900 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2379,6 +2379,7 @@ DEFAULT_CONFIG = { # Off by default; toggle with /wake or `wake_word.enabled: true`. "wake_word": { "enabled": False, + "surface": "auto", # which surface owns the listener / opens the new session: "auto" (the running one) | "cli" | "tui" | "gui" "provider": "openwakeword", # "openwakeword" (free, local) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) "phrase": "hey jarvis", # cosmetic label only; detection is keyed by the engine model/keyword below "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 30ece72368b..658b4e1e7c9 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -27,6 +27,21 @@ def test_config_defaults_and_clamping(): assert ww.wake_phrase({}) == "hey jarvis" +def test_wake_surface_enabled_gate(): + # Disabled → never, regardless of surface. + assert ww.wake_surface_enabled("cli", {"enabled": False, "surface": "cli"}) is False + # auto → every surface. + for s in ("cli", "tui", "gui"): + assert ww.wake_surface_enabled(s, {"enabled": True, "surface": "auto"}) is True + # Pinned surface → only that one. + cfg = {"enabled": True, "surface": "tui"} + assert ww.wake_surface_enabled("tui", cfg) is True + assert ww.wake_surface_enabled("cli", cfg) is False + assert ww.wake_surface_enabled("gui", cfg) is False + # Missing/blank surface defaults to auto. + assert ww.wake_surface_enabled("gui", {"enabled": True}) is True + + def test_looks_like_path(): assert ww._looks_like_path("models/hey_hermes.onnx") assert ww._looks_like_path("custom.ppn") diff --git a/tools/wake_word.py b/tools/wake_word.py index 6dc473b211c..574bd4394cf 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -49,12 +49,16 @@ _FIRE_COOLDOWN_SECONDS = 2.0 _DEFAULTS: Dict[str, Any] = { "enabled": False, + "surface": "auto", "provider": "openwakeword", "phrase": "hey jarvis", "sensitivity": 0.5, "start_new_session": True, } +# Surfaces that can host the listener. "auto" means whichever one is running. +SURFACES = ("cli", "tui", "gui") + def load_wake_word_config() -> Dict[str, Any]: """Return the ``wake_word`` config section, shape-guarded to a dict.""" @@ -91,6 +95,20 @@ def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str: return str(_get(cfg, "phrase")) or "hey jarvis" +def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool: + """Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener? + + True when the wake word is enabled and the configured ``surface`` is either + ``auto`` or this exact surface — the single gate every surface consults so + only one place owns the wake word and the new session it opens. + """ + cfg = cfg if cfg is not None else load_wake_word_config() + if not cfg.get("enabled"): + return False + want = str(_get(cfg, "surface")).strip().lower() or "auto" + return want == "auto" or want == surface.strip().lower() + + # --------------------------------------------------------------------------- # Audio capture (lazy — never import sounddevice at module load) # --------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1619181086e..60d17a865a5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -925,6 +925,11 @@ def _close_sessions_for_transport( def _shutdown_sessions() -> None: + try: + from tools.wake_word import stop_listening as _stop_wake + _stop_wake() + except Exception: + pass with _sessions_lock: sids = list(_sessions) for sid in sids: @@ -17587,6 +17592,136 @@ def _voice_record_key() -> str: return str(record_key) if isinstance(record_key, str) and record_key else "ctrl+b" +# ── Wake word ("Hey Hermes") ────────────────────────────────────────────── +# The detector is process-global (one mic), like voice. It runs server-side so +# both the TUI and desktop GUI share it; clients pass their surface identity to +# wake.start and the shared gate (wake_surface_enabled) decides whether to arm. +# On detection we emit wake.detected; the client opens a new session and starts +# its own voice capture. The detector yields the mic to gateway voice.record +# (pause/resume below) and to the desktop's browser mic (wake.pause/resume RPCs). +_wake_lock = threading.Lock() +_wake_active = False +_wake_event_sid = "" + + +def _wake_is_active() -> bool: + with _wake_lock: + return _wake_active + + +def _wake_resume_if_active() -> None: + if not _wake_is_active(): + return + try: + from tools.wake_word import resume_listening + resume_listening() + except Exception as e: + logger.debug("wake resume failed: %s", e) + + +def _wake_on_detect() -> None: + """Detector-thread callback: tell the client to open a fresh voice session.""" + with _wake_lock: + sid = _wake_event_sid + try: + from tools.wake_word import wake_phrase + phrase = wake_phrase() + except Exception: + phrase = "" + _emit("wake.detected", sid, {"phrase": phrase}) + + +@method("wake.start") +def _(rid, params: dict) -> dict: + """Arm the wake-word listener for the calling surface ("tui" | "gui"). + + Idempotent and gated: returns ``{started: False, reason}`` when the wake + word is disabled, scoped to another surface, or its deps/mic aren't ready. + """ + global _wake_active, _wake_event_sid + surface = str(params.get("surface") or "auto").strip().lower() + try: + from tools.wake_word import ( + check_wake_word_requirements, + load_wake_word_config, + start_listening, + wake_surface_enabled, + ) + except Exception as e: + return _err(rid, 5026, f"wake module unavailable: {e}") + + cfg = load_wake_word_config() + if not wake_surface_enabled(surface, cfg): + return _ok(rid, {"started": False, "reason": "disabled_for_surface"}) + reqs = check_wake_word_requirements(cfg) + if not reqs["available"]: + return _ok(rid, {"started": False, "reason": reqs.get("hint") or "unavailable"}) + + with _wake_lock: + _wake_event_sid = params.get("session_id") or _wake_event_sid + try: + start_listening(_wake_on_detect, config=cfg) + except Exception as e: + return _err(rid, 5026, str(e)) + with _wake_lock: + _wake_active = True + return _ok(rid, {"started": True, "phrase": reqs["phrase"], "provider": reqs["provider"]}) + + +@method("wake.stop") +def _(rid, params: dict) -> dict: + global _wake_active + with _wake_lock: + _wake_active = False + try: + from tools.wake_word import stop_listening + stop_listening() + except Exception: + pass + return _ok(rid, {"stopped": True}) + + +@method("wake.pause") +def _(rid, params: dict) -> dict: + """Release the mic (e.g. while the desktop's browser captures audio).""" + try: + from tools.wake_word import pause_listening + pause_listening() + except Exception: + pass + return _ok(rid, {"paused": True}) + + +@method("wake.resume") +def _(rid, params: dict) -> dict: + """Reclaim the mic after a pause; no-op if the listener isn't armed.""" + active = _wake_is_active() + if active: + _wake_resume_if_active() + return _ok(rid, {"resumed": active}) + + +@method("wake.status") +def _(rid, params: dict) -> dict: + try: + from tools.wake_word import ( + check_wake_word_requirements, + is_listening, + load_wake_word_config, + ) + cfg = load_wake_word_config() + reqs = check_wake_word_requirements(cfg) + return _ok(rid, { + "listening": _wake_is_active() and is_listening(), + "phrase": reqs["phrase"], + "provider": reqs["provider"], + "available": reqs["available"], + "hint": reqs.get("hint", ""), + }) + except Exception as e: + return _err(rid, 5026, str(e)) + + @method("voice.toggle") def _(rid, params: dict) -> dict: """CLI parity for the ``/voice`` slash command. @@ -17735,12 +17870,28 @@ def _(rid, params: dict) -> dict: if isinstance(duration, (int, float)) and not isinstance(duration, bool) else 3.0 ) + # Hand the mic to STT if the wake-word detector holds it; resume + # once a terminal capture event fires (one-shot transcript / silence + # limit), so wake-triggered and manual captures both coexist. + if _wake_is_active(): + try: + from tools.wake_word import pause_listening + pause_listening() + except Exception: + pass + + def _on_transcript(t): + _voice_emit("voice.transcript", {"text": t}) + _wake_resume_if_active() + + def _on_silent(): + _voice_emit("voice.transcript", {"no_speech_limit": True}) + _wake_resume_if_active() + started = start_continuous( - on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}), + on_transcript=_on_transcript, on_status=lambda s: _voice_emit("voice.status", {"state": s}), - on_silent_limit=lambda: _voice_emit( - "voice.transcript", {"no_speech_limit": True} - ), + on_silent_limit=_on_silent, silence_threshold=safe_threshold, silence_duration=safe_duration, auto_restart=False, @@ -17756,6 +17907,7 @@ def _(rid, params: dict) -> dict: from hermes_cli.voice import stop_continuous stop_continuous(force_transcribe=True) + _wake_resume_if_active() return _ok(rid, {"status": "stopped"}) except ImportError: return _err( diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index bbd82613860..d52e6b03ee9 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -621,6 +621,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // "too many re-renders" guard in embedded dashboard PTYs. ensureAgentsNudgeConfig() + // Arm "Hey Hermes" if this surface owns it (server gates on config). + // Fire-and-forget + idempotent server-side, so reconnects are harmless. + void rpc('wake.start', { surface: 'tui' }) + rpc('commands.catalog', {}) .then(r => { if (!r?.pairs) { @@ -936,6 +940,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } + case 'wake.detected': { + // "Hey Hermes": open a fresh session, then arm voice capture so the + // user can speak their request hands-free. Mirrors the CLI flow. + void (async () => { + await newSession() + const sid = getUiState().sid + if (!sid) { + return + } + setVoiceEnabled(true) + await rpc('voice.toggle', { action: 'on' }) + await rpc('voice.record', { action: 'start', session_id: sid }) + })() + + return + } + case 'gateway.start_timeout': { const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {} const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : '' diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 41c5063295e..30a6efd241b 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -589,6 +589,7 @@ export type GatewayEvent = } | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } + | { payload?: { phrase?: string }; session_id?: string; type: 'wake.detected' } | { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } | { diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 21d1456cb07..604631f86aa 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -6,11 +6,12 @@ description: "Hands-free 'Hey Hermes' wake word — start a voice session by spe # Wake Word ("Hey Hermes") -The wake word turns Hermes into a hands-free assistant in the CLI: with one -setting on, Hermes listens in the background for a spoken trigger phrase. Say it, -and Hermes starts a fresh session, opens the microphone, captures your command -via the normal [voice pipeline](/user-guide/features/voice-mode), and answers — -exactly like "Hey Siri" or "Alexa". +The wake word turns Hermes into a hands-free assistant across the CLI, TUI, and +desktop app: with one setting on, Hermes listens in the background for a spoken +trigger phrase. Say it, and Hermes starts a fresh session, opens the microphone, +captures your command via the normal [voice pipeline](/user-guide/features/voice-mode), +and answers — exactly like "Hey Siri" or "Alexa". Use `surface` to pick which +one listens. Detection runs **entirely on-device**. The always-on listener only watches for the wake phrase; no audio leaves your machine until you actually speak a command @@ -62,6 +63,7 @@ wake_word: ```yaml wake_word: enabled: false + surface: auto # which surface owns the listener: "auto" | "cli" | "tui" | "gui" provider: openwakeword # "openwakeword" (free, local) | "porcupine" phrase: "hey jarvis" # cosmetic label only — detection is keyed by the model/keyword below sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers @@ -76,6 +78,23 @@ wake_word: `sensitivity`, `phrase`, and `start_new_session` apply to both engines. The `openwakeword` and `porcupine` blocks select the actual detection model. +### Surfaces (CLI, TUI, GUI) + +The wake word works in all three Hermes surfaces, and `surface` picks which one +owns the listener and opens the new session when it fires: + +| `surface` | Behavior | +|-----------|----------| +| `auto` (default) | Whichever surface you launch arms the listener. | +| `cli` | Only the classic `hermes` CLI. | +| `tui` | Only `hermes --tui`. | +| `gui` | Only the desktop app. | + +The detector is on-device and single-mic, so only one surface listens at a time +— `surface` is how you pin it. The TUI and desktop GUI share the same Python +backend (`tui_gateway`), which runs the detector server-side and yields the mic +to voice capture while a command records. + ## Using a real "Hey Hermes" The bundled openWakeWord models do **not** include "hey hermes" — `hey_jarvis` @@ -140,8 +159,9 @@ PORCUPINE_ACCESS_KEY=your-key-here ## Notes & limits -- **CLI only.** The wake word lives in the interactive `hermes` CLI, where a - local microphone is available. It does not run in the messaging gateway. +- **Local surfaces only.** The wake word runs in the CLI, TUI, and desktop GUI — + wherever a local microphone is available. It does not run in the messaging + gateway (Telegram, Discord, …), which has no mic. - **One mic at a time.** The detector releases the microphone while a command is recording and reclaims it once the turn ends, so it won't fight voice capture. - **Privacy.** Hotword detection is local. Set `sensitivity` higher if you get From 8a4d58287e4849cc5f9f8424396d9cfa874d4539 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 22:10:16 -0500 Subject: [PATCH 03/46] feat(desktop): full back-and-forth voice on "Hey Hermes" wake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On wake, the desktop GUI now opens a fresh session AND starts the browser voice conversation (continuous, with TTS), matching the CLI/TUI hands-free flow instead of just opening a session. - Add an explicit requestVoiceStart() intent to the composer bus (idempotent start; toggle could stop an active loop). - Composer owns mic hand-off: pause the server-side wake detector while the browser voice loop is live, resume after (server no-ops when the wake word isn't armed) — via the $gateway store accessor. - Controller fires startFreshSessionDraft() + requestVoiceStart() on wake.detected. --- apps/desktop/src/app/chat/composer/focus.ts | 9 ++++++ .../chat/composer/hooks/use-composer-voice.ts | 31 ++++++++++++++++++- apps/desktop/src/app/desktop-controller.tsx | 9 ++++-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 7120fde2b1a..3ddc096156c 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -42,6 +42,7 @@ const INSERT_EVENT = 'hermes:composer-insert' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' +const VOICE_START_EVENT = 'hermes:composer-voice-start' interface SubmitDetail { target: ComposerTarget @@ -150,6 +151,14 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) => subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target)) +/** Explicitly START (never stop) the active composer's voice conversation — + * used by the "Hey Hermes" wake word so a fresh session begins back-and-forth + * voice without the toggle risking an immediate stop. */ +export const requestVoiceStart = () => dispatch<{ at: number }>(VOICE_START_EVENT, { at: Date.now() }) + +export const onComposerVoiceStartRequest = (handler: () => void) => + subscribe<{ at: number }>(VOICE_START_EVENT, () => handler()) + /** * Focus a composer input across React commit + browser focus restore. * diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 8e53096f322..5817c502ca0 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -4,11 +4,12 @@ import { useI18n } from '@/i18n' import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' import { resetBrowseState } from '@/store/composer-input-history' +import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import type { ComposerTarget } from '../focus' -import { onComposerVoiceToggleRequest } from '../focus' +import { onComposerVoiceStartRequest, onComposerVoiceToggleRequest } from '../focus' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -142,6 +143,34 @@ export function useComposerVoice({ [target, toggleVoiceConversation] ) + useEffect( + () => + onComposerVoiceStartRequest(() => { + if (target === 'main' && !disabled && !voiceConversationActive) { + setVoiceConversationActive(true) + } + }), + [disabled, target, voiceConversationActive] + ) + + const wakePausedRef = useRef(false) + + useEffect(() => { + const gateway = $gateway.get() + + if (!gateway) { + return + } + + if (voiceConversationActive) { + wakePausedRef.current = true + void gateway.request('wake.pause', {}).catch(() => undefined) + } else if (wakePausedRef.current) { + wakePausedRef.current = false + void gateway.request('wake.resume', {}).catch(() => undefined) + } + }, [voiceConversationActive]) + // Explicit start/end for the on-screen conversation controls (the hotkey uses // the gated toggle above). const startConversation = useCallback(() => setVoiceConversationActive(true), []) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 6623636f187..08e27c0cf22 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -105,7 +105,7 @@ import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store import { isSecondaryWindow } from '../store/windows' import { ChatView } from './chat' -import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' +import { requestComposerFocus, requestComposerInsert, requestVoiceStart } from './chat/composer/focus' import { useComposerActions } from './chat/hooks/use-composer-actions' import { ChatPreviewRail, @@ -1018,7 +1018,12 @@ export function DesktopController() { if (!gw) { return } - return gw.on('wake.detected', () => startFreshSessionDraft()) + return gw.on('wake.detected', () => { + startFreshSessionDraft() + // Begin hands-free back-and-forth voice in the fresh session. The bus + // defers a tick, so the new composer is mounted before voice starts. + requestVoiceStart() + }) }, [gatewayState, requestGateway, gatewayRef, startFreshSessionDraft]) // Keep the cron jobs section live without a user action: the scheduler ticks From d2fab75ffb8c24a2b2881e00b7ca9b623191205a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 22:18:10 -0500 Subject: [PATCH 04/46] chore(voice): log wake-word lifecycle at INFO for diagnosability The detector logged listen/detect/close at debug, invisible at the default level. Promote listen-start, phrase-detected, stream-closed, and the wake.start outcome (disabled / unavailable / listening) to INFO, and log wake.detected emission, so a non-triggering setup is diagnosable from gateway/gui.log without flipping global log levels. --- tools/wake_word.py | 9 ++++++--- tui_gateway/server.py | 6 ++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tools/wake_word.py b/tools/wake_word.py index 574bd4394cf..75d98fc8fca 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -356,13 +356,13 @@ class WakeWordDetector: logger.error("wake word: failed to open microphone: %s", e) return - logger.debug("wake word: listening (frame=%d)", frame_length) + logger.info("wake word: listening (frame=%d, rate=%d)", frame_length, SAMPLE_RATE) try: while not self._stop.is_set(): try: data, _overflow = stream.read(frame_length) except Exception as e: - logger.debug("wake word: stream read error: %s", e) + logger.warning("wake word: stream read error: %s", e) break frame = data[:, 0] if getattr(data, "ndim", 1) == 2 else data try: @@ -374,17 +374,20 @@ class WakeWordDetector: now = time.monotonic() if now - self._last_fire >= self.cooldown: self._last_fire = now + logger.info("wake word: phrase detected — firing callback") try: self.on_wake() except Exception as e: logger.warning("wake word callback failed: %s", e) + else: + logger.debug("wake word: detection within cooldown — ignored") finally: try: stream.stop() stream.close() except Exception: pass - logger.debug("wake word: stream closed") + logger.info("wake word: stream closed") # --------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 60d17a865a5..34d05a04cff 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17628,6 +17628,7 @@ def _wake_on_detect() -> None: phrase = wake_phrase() except Exception: phrase = "" + logger.info("wake.detected: emitting to sid=%r", sid) _emit("wake.detected", sid, {"phrase": phrase}) @@ -17652,9 +17653,12 @@ def _(rid, params: dict) -> dict: cfg = load_wake_word_config() if not wake_surface_enabled(surface, cfg): + logger.info("wake.start(%s): disabled for surface (enabled=%s, surface=%s)", + surface, cfg.get("enabled"), cfg.get("surface")) return _ok(rid, {"started": False, "reason": "disabled_for_surface"}) reqs = check_wake_word_requirements(cfg) if not reqs["available"]: + logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint")) return _ok(rid, {"started": False, "reason": reqs.get("hint") or "unavailable"}) with _wake_lock: @@ -17662,9 +17666,11 @@ def _(rid, params: dict) -> dict: try: start_listening(_wake_on_detect, config=cfg) except Exception as e: + logger.warning("wake.start(%s): failed to start listener: %s", surface, e) return _err(rid, 5026, str(e)) with _wake_lock: _wake_active = True + logger.info("wake.start(%s): listening for %r (%s)", surface, reqs["phrase"], reqs["provider"]) return _ok(rid, {"started": True, "phrase": reqs["phrase"], "provider": reqs["provider"]}) From a6aada24f5162ae8bfa63b87bdcd86e588c32278 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 22:38:21 -0500 Subject: [PATCH 05/46] fix(desktop): handle wake.detected on the canonical event pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GUI armed the detector (wake.start) and the gateway fired wake.detected, but the desktop never reacted: detection was wired through a side-registered gatewayRef.current.on('wake.detected', …) listener that was instance/timing-fragile (and silently dead across reconnects/HMR), even though the raw events were arriving on the socket. Route wake.detected through handleGatewayEventWithWake — the same onEvent pipeline every gateway socket already feeds via useGatewayBoot — and open a fresh session + start back-and-forth voice there. Drop the separate .on() listener; the open-effect now only arms wake.start. --- apps/desktop/src/app/desktop-controller.tsx | 33 ++++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 08e27c0cf22..c6d9086608b 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -739,6 +739,21 @@ export function DesktopController() { updateSessionState }) + // "Hey Hermes": handle the wake event on the canonical onEvent pipeline (the + // path every gateway socket already feeds), not a side-registered listener — + // open a fresh session and begin back-and-forth voice. + const handleGatewayEventWithWake = useCallback( + (event: Parameters[0]) => { + if (event.type === 'wake.detected') { + startFreshSessionDraft() + requestVoiceStart() + return + } + handleDesktopGatewayEvent(event) + }, + [handleDesktopGatewayEvent, startFreshSessionDraft] + ) + // Single global listener for every rebindable hotkey (incl. profile switching) // plus the on-screen keybind editor's capture mode. useKeybinds({ @@ -987,7 +1002,7 @@ export function DesktopController() { }, []) useGatewayBoot({ - handleGatewayEvent: handleDesktopGatewayEvent, + handleGatewayEvent: handleGatewayEventWithWake, onConnectionReady: c => { connectionRef.current = c }, @@ -1007,24 +1022,14 @@ export function DesktopController() { }, [gatewayState, refreshCurrentModel, refreshSessions]) // "Hey Hermes" wake word: arm the server-side detector for this surface - // (gated on config) and open a fresh session when it fires. Idempotent and - // self-cleaning across reconnects. + // (gated on config). Detection arrives as a wake.detected event handled in + // handleGatewayEventWithWake. Idempotent server-side, so reconnects are safe. useEffect(() => { if (gatewayState !== 'open') { return } void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined) - const gw = gatewayRef.current - if (!gw) { - return - } - return gw.on('wake.detected', () => { - startFreshSessionDraft() - // Begin hands-free back-and-forth voice in the fresh session. The bus - // defers a tick, so the new composer is mounted before voice starts. - requestVoiceStart() - }) - }, [gatewayState, requestGateway, gatewayRef, startFreshSessionDraft]) + }, [gatewayState, requestGateway]) // Keep the cron jobs section live without a user action: the scheduler ticks // in the background (advancing next-run/state and creating runs), so poll the From 813d9ffad0c32efbf2b0b0abedaa73e05cfea786 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 26 Jun 2026 23:19:13 -0500 Subject: [PATCH 06/46] fix(desktop): deliver wake.detected over the websocket, not stdio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_json routes via the request-scoped transport ContextVar, but the wake detector's callback runs on a background thread where that var is unset — so wake.detected fell back to _stdio_transport and was dumped to the backend's stdout (visible as raw [hermes] {...} frames in desktop logs) instead of crossing the desktop/dashboard websocket. The TUI was unaffected because it IS stdio. Capture the arming request's transport at wake.start and bind it around the emit in _wake_on_detect so the background thread routes to the right peer. Re-armed on each wake.start, so reconnects pick up the new socket. --- tui_gateway/server.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 34d05a04cff..d679324b1d4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17602,6 +17602,12 @@ def _voice_record_key() -> str: _wake_lock = threading.Lock() _wake_active = False _wake_event_sid = "" +# Transport captured at wake.start time. The detector callback fires on a +# background thread where the request-scoped transport ContextVar is unset, so +# write_json would fall back to stdio and the event would never cross the +# desktop's websocket (#wake-detected-not-delivered). We pin the arming +# request's transport here and bind it for the emit. +_wake_transport: "Optional[Transport]" = None def _wake_is_active() -> bool: @@ -17623,13 +17629,23 @@ def _wake_on_detect() -> None: """Detector-thread callback: tell the client to open a fresh voice session.""" with _wake_lock: sid = _wake_event_sid + transport = _wake_transport try: from tools.wake_word import wake_phrase phrase = wake_phrase() except Exception: phrase = "" - logger.info("wake.detected: emitting to sid=%r", sid) - _emit("wake.detected", sid, {"phrase": phrase}) + logger.info("wake.detected: emitting to sid=%r (transport=%s)", + sid, type(transport).__name__ if transport else None) + # Bind the arming request's transport so write_json reaches the right peer + # (WS for desktop/dashboard) instead of falling back to stdio on this + # background thread. + token = bind_transport(transport) if transport is not None else None + try: + _emit("wake.detected", sid, {"phrase": phrase}) + finally: + if token is not None: + reset_transport(token) @method("wake.start") @@ -17639,7 +17655,7 @@ def _(rid, params: dict) -> dict: Idempotent and gated: returns ``{started: False, reason}`` when the wake word is disabled, scoped to another surface, or its deps/mic aren't ready. """ - global _wake_active, _wake_event_sid + global _wake_active, _wake_event_sid, _wake_transport surface = str(params.get("surface") or "auto").strip().lower() try: from tools.wake_word import ( @@ -17663,6 +17679,9 @@ def _(rid, params: dict) -> dict: with _wake_lock: _wake_event_sid = params.get("session_id") or _wake_event_sid + # Capture the live transport (WS for desktop) so the background detector + # thread can route wake.detected back to this client, not stdio. + _wake_transport = current_transport() or _wake_transport try: start_listening(_wake_on_detect, config=cfg) except Exception as e: From dc4c2414f94c758da7bf4fdb77339e0cf149a07d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 27 Jun 2026 00:25:21 -0500 Subject: [PATCH 07/46] fix(desktop): re-arm wake detector after a manual voice end Ending a voice conversation manually left the wake detector paused for good, so the wake word couldn't be used again. The composer paused the detector on voice start but only resumed on the voiceConversationActive -> false render; if ending voice tore the composer down first, that render never landed and the resume was skipped. Resume on unmount as well (latched on wakePausedRef so it fires exactly once), and stop early-returning when the $gateway atom is momentarily null. Add wake.pause/resume INFO logs for visibility. --- .../chat/composer/hooks/use-composer-voice.ts | 23 +++++++++++-------- tui_gateway/server.py | 8 +++++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 5817c502ca0..e2bc1b0382c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -154,22 +154,25 @@ export function useComposerVoice({ ) const wakePausedRef = useRef(false) - - useEffect(() => { - const gateway = $gateway.get() - - if (!gateway) { + const resumeWakeIfPaused = useCallback(() => { + if (!wakePausedRef.current) { return } + wakePausedRef.current = false + void $gateway.get()?.request('wake.resume', {}).catch(() => undefined) + }, []) + + useEffect(() => { if (voiceConversationActive) { wakePausedRef.current = true - void gateway.request('wake.pause', {}).catch(() => undefined) - } else if (wakePausedRef.current) { - wakePausedRef.current = false - void gateway.request('wake.resume', {}).catch(() => undefined) + void $gateway.get()?.request('wake.pause', {}).catch(() => undefined) + } else { + resumeWakeIfPaused() } - }, [voiceConversationActive]) + }, [resumeWakeIfPaused, voiceConversationActive]) + + useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused]) // Explicit start/end for the on-screen conversation controls (the hotkey uses // the gated toggle above). diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d679324b1d4..5f637863ab8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17712,8 +17712,9 @@ def _(rid, params: dict) -> dict: try: from tools.wake_word import pause_listening pause_listening() - except Exception: - pass + logger.info("wake.pause: detector paused") + except Exception as e: + logger.debug("wake.pause failed: %s", e) return _ok(rid, {"paused": True}) @@ -17723,6 +17724,9 @@ def _(rid, params: dict) -> dict: active = _wake_is_active() if active: _wake_resume_if_active() + logger.info("wake.resume: detector resumed") + else: + logger.info("wake.resume: ignored (listener not armed)") return _ok(rid, {"resumed": active}) From c597b4c47b0528ed88ff3095e8ee640ec73aa5f5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 27 Jun 2026 00:38:34 -0500 Subject: [PATCH 08/46] fix(desktop): start voice on wake via a latched store, not a window event Wake opened a fresh session but voice didn't start: the start intent was a fire-once window CustomEvent, and the fresh-session remount tore down / recreated the composer's subscription, so the deferred dispatch landed in the gap and was lost. Replace it with a latched nanostore ($voiceConversationStartRequest + takeVoiceConversationStart): the controller sets it on wake.detected, and the composer claims it once on (re)mount when the gateway is open, waiting out any transient `disabled`. Drop the now-unused composer voice-start window event. --- apps/desktop/src/app/chat/composer/focus.ts | 9 ------- .../chat/composer/hooks/use-composer-voice.ts | 24 +++++++++++-------- apps/desktop/src/app/desktop-controller.tsx | 5 ++-- apps/desktop/src/store/composer.ts | 19 +++++++++++++++ 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 3ddc096156c..7120fde2b1a 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -42,7 +42,6 @@ const INSERT_EVENT = 'hermes:composer-insert' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' -const VOICE_START_EVENT = 'hermes:composer-voice-start' interface SubmitDetail { target: ComposerTarget @@ -151,14 +150,6 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) => subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target)) -/** Explicitly START (never stop) the active composer's voice conversation — - * used by the "Hey Hermes" wake word so a fresh session begins back-and-forth - * voice without the toggle risking an immediate stop. */ -export const requestVoiceStart = () => dispatch<{ at: number }>(VOICE_START_EVENT, { at: Date.now() }) - -export const onComposerVoiceStartRequest = (handler: () => void) => - subscribe<{ at: number }>(VOICE_START_EVENT, () => handler()) - /** * Focus a composer input across React commit + browser focus restore. * diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index e2bc1b0382c..1223f1bbd57 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -1,15 +1,17 @@ +import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' +import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import type { ComposerTarget } from '../focus' -import { onComposerVoiceStartRequest, onComposerVoiceToggleRequest } from '../focus' +import { onComposerVoiceToggleRequest } from '../focus' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -55,6 +57,7 @@ export function useComposerVoice({ const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) + const voiceStartRequest = useStore($voiceConversationStartRequest) const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ focusInput, @@ -143,15 +146,16 @@ export function useComposerVoice({ [target, toggleVoiceConversation] ) - useEffect( - () => - onComposerVoiceStartRequest(() => { - if (target === 'main' && !disabled && !voiceConversationActive) { - setVoiceConversationActive(true) - } - }), - [disabled, target, voiceConversationActive] - ) + useEffect(() => { + if ( + target === 'main' && + !disabled && + takeVoiceConversationStart(voiceStartRequest) && + !voiceConversationActive + ) { + setVoiceConversationActive(true) + } + }, [disabled, target, voiceConversationActive, voiceStartRequest]) const wakePausedRef = useRef(false) const resumeWakeIfPaused = useCallback(() => { diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index c6d9086608b..72cd6d10a4a 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -52,6 +52,7 @@ import { setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay' +import { requestVoiceConversationStart } from '../store/composer' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' import { $activeGatewayProfile, @@ -105,7 +106,7 @@ import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store import { isSecondaryWindow } from '../store/windows' import { ChatView } from './chat' -import { requestComposerFocus, requestComposerInsert, requestVoiceStart } from './chat/composer/focus' +import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' import { useComposerActions } from './chat/hooks/use-composer-actions' import { ChatPreviewRail, @@ -746,7 +747,7 @@ export function DesktopController() { (event: Parameters[0]) => { if (event.type === 'wake.detected') { startFreshSessionDraft() - requestVoiceStart() + requestVoiceConversationStart() return } handleDesktopGatewayEvent(event) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index b984fe8bfc3..a9df29b6df7 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,6 +21,25 @@ export const $composerDraft = atom('') export const $composerAttachments = atom([]) export const $composerTerminalSelections = atom>({}) +// Latched because opening a fresh session may remount the main composer before +// it can start voice. Session-tile composers deliberately never consume this. +export const $voiceConversationStartRequest = atom(0) +let nextVoiceStartRequest = 0 +let handledVoiceStartRequest = 0 + +export const requestVoiceConversationStart = (): void => + $voiceConversationStartRequest.set(++nextVoiceStartRequest) + +export const takeVoiceConversationStart = (current: number): boolean => { + if (current <= handledVoiceStartRequest) { + return false + } + + handledVoiceStartRequest = current + + return true +} + // --------------------------------------------------------------------------- // Composer scopes — one live attachment set PER MOUNTED COMPOSER. The main // chat's scope wraps the module-level atom above (all existing readers keep From edb12bf4237fa76acdd9067cd40238663cdbfae7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 27 Jun 2026 11:31:37 -0500 Subject: [PATCH 09/46] fix(voice): stop wake re-fire loop and empty-transcript error spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced by the desktop wake conversation: 1. Runaway loop: wake -> voice -> resume -> wake fired again within ~200ms. openWakeWord keeps its rolling feature buffer across pause/resume, so on resume it immediately re-scored the "hey jarvis" captured before the pause and re-fired, reopening a session and restarting voice in a tight cycle. Reset the engine buffer on every detector (re)start so resume begins from clean audio. 2. Empty-transcript toast: a silent re-listen returns success:false / "… STT returned empty transcript", which the desktop transcribe endpoint turned into a 400 -> thrown error -> "Voice transcription failed" notification on every silent gap. Treat an empty transcript as no-speech: return {ok, transcript: ""} so the voice loop quietly re-listens. Real failures still 4xx/5xx. --- hermes_cli/web_server.py | 13 +++++++++---- tests/tools/test_wake_word.py | 19 +++++++++++++++++++ tools/wake_word.py | 19 ++++++++++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 497d740e1c1..358000458a6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4417,10 +4417,15 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest): pass if not result.get("success"): - raise HTTPException( - status_code=400, - detail=result.get("error") or "Transcription failed", - ) + err = result.get("error") or "Transcription failed" + # An empty transcript means no speech was detected — a normal outcome + # for VAD/continuous voice loops (e.g. a wake-word conversation + # re-listening on silence), not an error. Return an empty transcript so + # the client quietly re-listens instead of surfacing a "transcription + # failed" toast on every silent gap. + if "empty transcript" in err.lower(): + return {"ok": True, "transcript": "", "provider": result.get("provider")} + raise HTTPException(status_code=400, detail=err) return { "ok": True, diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 658b4e1e7c9..e765e4ac5ee 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -136,10 +136,14 @@ class _FakeEngine: def __init__(self, fire=True): self._fire = fire self.closed = False + self.resets = 0 def process(self, frame): return self._fire + def reset(self): + self.resets += 1 + def close(self): self.closed = True @@ -182,6 +186,21 @@ def test_detector_no_fire_when_engine_quiet(monkeypatch): assert calls == [] +def test_detector_resets_engine_on_each_start(monkeypatch): + # Clearing the engine buffer on (re)start is what stops a resume right after + # a voice turn from re-firing on stale audio (the runaway wake loop). + _fake_audio(monkeypatch) + eng = _FakeEngine(fire=False) + det = ww.WakeWordDetector(eng, lambda: None) + det.start() + time.sleep(0.05) + det.pause() + det.resume() + time.sleep(0.05) + det.stop() + assert eng.resets >= 2 # initial start + resume + + def test_detector_pause_resume(monkeypatch): _fake_audio(monkeypatch) det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: None) diff --git a/tools/wake_word.py b/tools/wake_word.py index 75d98fc8fca..b899a56d06f 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -140,6 +140,10 @@ class _Engine: def process(self, frame) -> bool: # frame: 1-D int16 ndarray raise NotImplementedError + def reset(self) -> None: + """Clear any internal audio/feature buffer (called on every (re)start).""" + pass + def close(self) -> None: pass @@ -189,12 +193,17 @@ class _OpenWakeWordEngine(_Engine): scores = self._model.predict(frame) return any(score >= self._threshold for score in scores.values()) - def close(self) -> None: + def reset(self) -> None: + # Clears openWakeWord's rolling feature/prediction buffer so stale audio + # captured before a pause can't re-fire the moment we resume. try: self._model.reset() except Exception: pass + def close(self) -> None: + self.reset() + class _PorcupineEngine(_Engine): """Picovoice Porcupine — premium, on-device, needs an access key.""" @@ -356,6 +365,14 @@ class WakeWordDetector: logger.error("wake word: failed to open microphone: %s", e) return + # Drop any buffered audio/feature state so a resume right after a voice + # turn can't immediately re-fire on audio captured before the pause (the + # wake → voice → resume → wake runaway loop). + try: + self.engine.reset() + except Exception: + pass + logger.info("wake word: listening (frame=%d, rate=%d)", frame_length, SAMPLE_RATE) try: while not self._stop.is_set(): From c01c3f4b28773ad32ec8a87ae817e0c6f10c43d7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 27 Jun 2026 11:38:55 -0500 Subject: [PATCH 10/46] =?UTF-8?q?chore(voice):=20tidy=20wake=5Fword=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20SURFACES,=20unused=20np,=20stale=20do?= =?UTF-8?q?cstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/wake_word.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tools/wake_word.py b/tools/wake_word.py index b899a56d06f..641a20bda81 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -1,10 +1,10 @@ -"""Wake-word ("Hey Hermes") detection — hands-free session trigger for the CLI. +"""Wake-word ("Hey Hermes") detection — hands-free session trigger. A lightweight, always-on hotword listener that fires a callback when a wake -phrase is spoken — the "Hey Siri" / "Alexa" pattern. The CLI uses it to start a -fresh voice session without touching the keyboard: say the wake word, Hermes -opens the mic, captures one utterance via the existing voice pipeline, and -answers. +phrase is spoken — the "Hey Siri" / "Alexa" pattern. Shared by the CLI, TUI, and +desktop GUI (one of them owns it, gated by ``wake_surface_enabled``): say the +wake word, Hermes opens a fresh session and captures voice via the existing +pipeline, then answers. Two engines, both fully on-device (no audio leaves the machine for detection): @@ -56,9 +56,6 @@ _DEFAULTS: Dict[str, Any] = { "start_new_session": True, } -# Surfaces that can host the listener. "auto" means whichever one is running. -SURFACES = ("cli", "tui", "gui") - def load_wake_word_config() -> Dict[str, Any]: """Return the ``wake_word`` config section, shape-guarded to a dict.""" @@ -347,7 +344,7 @@ class WakeWordDetector: def _run(self) -> None: try: - sd, np = _import_audio() + sd, _ = _import_audio() except (ImportError, OSError) as e: logger.error("wake word: audio libraries unavailable: %s", e) return From 8e155bdcc85f46d649d9faf18f9206c7e777ab65 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 27 Jun 2026 11:43:18 -0500 Subject: [PATCH 11/46] fix(voice): honor wake_word.start_new_session on every surface start_new_session was respected only by the CLI; the TUI and desktop GUI always opened a fresh session on wake, ignoring the config. The gateway now carries the flag in the wake.detected payload and both clients honor it (open a fresh session vs. continue the current one), matching the CLI. --- apps/desktop/src/app/desktop-controller.tsx | 5 ++++- tui_gateway/server.py | 13 ++++++++----- ui-tui/src/app/createGatewayEventHandler.ts | 8 +++++--- ui-tui/src/gatewayTypes.ts | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 72cd6d10a4a..fb4589bdadb 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -746,7 +746,10 @@ export function DesktopController() { const handleGatewayEventWithWake = useCallback( (event: Parameters[0]) => { if (event.type === 'wake.detected') { - startFreshSessionDraft() + const payload = event.payload as { start_new_session?: boolean } | undefined + if (payload?.start_new_session !== false) { + startFreshSessionDraft() + } requestVoiceConversationStart() return } diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5f637863ab8..399c2d7068d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17630,19 +17630,22 @@ def _wake_on_detect() -> None: with _wake_lock: sid = _wake_event_sid transport = _wake_transport + phrase, new_session = "", True try: - from tools.wake_word import wake_phrase - phrase = wake_phrase() + from tools.wake_word import load_wake_word_config, wake_phrase + cfg = load_wake_word_config() + phrase = wake_phrase(cfg) + new_session = bool(cfg.get("start_new_session", True)) except Exception: - phrase = "" + pass logger.info("wake.detected: emitting to sid=%r (transport=%s)", sid, type(transport).__name__ if transport else None) # Bind the arming request's transport so write_json reaches the right peer # (WS for desktop/dashboard) instead of falling back to stdio on this - # background thread. + # background thread. Carry start_new_session so every surface honors it. token = bind_transport(transport) if transport is not None else None try: - _emit("wake.detected", sid, {"phrase": phrase}) + _emit("wake.detected", sid, {"phrase": phrase, "start_new_session": new_session}) finally: if token is not None: reset_transport(token) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d52e6b03ee9..9d4132e93a9 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -941,10 +941,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'wake.detected': { - // "Hey Hermes": open a fresh session, then arm voice capture so the - // user can speak their request hands-free. Mirrors the CLI flow. + // "Hey Hermes": optionally open a fresh session (start_new_session), + // then arm voice capture so the user can speak hands-free. Mirrors CLI. void (async () => { - await newSession() + if (ev.payload?.start_new_session !== false) { + await newSession() + } const sid = getUiState().sid if (!sid) { return diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 30a6efd241b..db7503828b6 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -589,7 +589,7 @@ export type GatewayEvent = } | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } - | { payload?: { phrase?: string }; session_id?: string; type: 'wake.detected' } + | { payload?: { phrase?: string; start_new_session?: boolean }; session_id?: string; type: 'wake.detected' } | { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } | { From e43d1418fda6b7b0feb5b6dfff3f53455ede18e2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 17:38:24 -0500 Subject: [PATCH 12/46] fix(ci): sync uv.lock and repair wake-word docs MDX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate uv.lock for the [wake] extra (openwakeword, pvporcupine, onnxruntime) so uv lock --check passes. Replace angle-bracket URLs in wake-word.md with markdown links — MDX treats as JSX. --- website/docs/user-guide/features/wake-word.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 604631f86aa..57c5b5457d8 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -117,8 +117,8 @@ wake_word: Training references: -- openWakeWord — -- 2026 training Colab — +- [openWakeWord](https://github.com/dscripka/openWakeWord) +- [2026 training Colab](https://github.com/alfiedennen/openwakeword-colab-2026) :::tip Pick a distinctive phrase Wake phrases that don't collide with everyday speech generalize best. Two From 5839aad13dbf9ee7b58066e219e1be24b0c44c29 Mon Sep 17 00:00:00 2001 From: Omid Saadat Date: Wed, 15 Jul 2026 22:41:31 +0200 Subject: [PATCH 13/46] fix(wake-word): enforce single-owner lifecycle --- .../chat/composer/hooks/use-composer-voice.ts | 18 +- apps/desktop/src/app/contrib/wiring.tsx | 22 +- apps/desktop/src/app/desktop-controller.tsx | 1449 ----------------- apps/desktop/src/store/composer.test.ts | 16 + apps/desktop/src/store/composer.ts | 3 +- cli.py | 57 +- hermes_cli/config.py | 4 +- tests/test_tui_gateway_server.py | 168 ++ tests/test_tui_gateway_ws.py | 14 + tests/tools/test_wake_word.py | 144 +- tools/wake_word.py | 232 ++- tui_gateway/server.py | 251 ++- tui_gateway/ws.py | 5 + .../createGatewayEventHandler.test.ts | 60 + ui-tui/src/app/createGatewayEventHandler.ts | 13 +- website/docs/user-guide/features/overview.md | 2 +- website/docs/user-guide/features/wake-word.md | 16 +- 17 files changed, 851 insertions(+), 1623 deletions(-) delete mode 100644 apps/desktop/src/app/desktop-controller.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 1223f1bbd57..0709b3d02ee 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -147,30 +147,32 @@ export function useComposerVoice({ ) useEffect(() => { - if ( - target === 'main' && - !disabled && - takeVoiceConversationStart(voiceStartRequest) && - !voiceConversationActive - ) { + if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) { setVoiceConversationActive(true) } }, [disabled, target, voiceConversationActive, voiceStartRequest]) const wakePausedRef = useRef(false) + const resumeWakeIfPaused = useCallback(() => { if (!wakePausedRef.current) { return } wakePausedRef.current = false - void $gateway.get()?.request('wake.resume', {}).catch(() => undefined) + void $gateway + .get() + ?.request('wake.resume', {}) + .catch(() => undefined) }, []) useEffect(() => { if (voiceConversationActive) { wakePausedRef.current = true - void $gateway.get()?.request('wake.pause', {}).catch(() => undefined) + void $gateway + .get() + ?.request('wake.pause', {}) + .catch(() => undefined) } else { resumeWakeIfPaused() } diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 9bd6db5d80b..a8359290af2 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -30,6 +30,7 @@ import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' import { $billingSettingsRequest } from '@/store/billing-block' +import { requestVoiceConversationStart } from '@/store/composer' import { setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $previewTarget } from '@/store/preview' @@ -662,9 +663,22 @@ export function ContribWiring({ children }: { children: ReactNode }) { const handleGatewayEventWithPlugins = useCallback( (event: Parameters[0]) => { emitGatewayEvent(event) + + if (event.type === 'wake.detected') { + const payload = event.payload as { start_new_session?: boolean } | undefined + + if (payload?.start_new_session !== false) { + startFreshSessionDraft() + } + + requestVoiceConversationStart() + + return + } + handleDesktopGatewayEvent(event) }, - [handleDesktopGatewayEvent] + [handleDesktopGatewayEvent, startFreshSessionDraft] ) useGatewayBoot({ @@ -685,6 +699,12 @@ export function ContribWiring({ children }: { children: ReactNode }) { refreshSessions }) + useEffect(() => { + if (gatewayState === 'open') { + void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined) + } + }, [gatewayState, requestGateway]) + // Only the open messaging transcript needs its own poll — local chats are // live over the websocket already. const activeIsMessaging = diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx deleted file mode 100644 index fb4589bdadb..00000000000 --- a/apps/desktop/src/app/desktop-controller.tsx +++ /dev/null @@ -1,1449 +0,0 @@ -import { useStore } from '@nanostores/react' -import { useQueryClient } from '@tanstack/react-query' -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' -import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' - -import { BootFailureOverlay } from '@/components/boot-failure-overlay' -import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' -import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' -import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' -import { Pane, PaneMain } from '@/components/pane-shell' -import { RemoteDisplayBanner } from '@/components/remote-display-banner' -import { useMediaQuery } from '@/hooks/use-media-query' -import { cn } from '@/lib/utils' -import { useSkinCommand } from '@/themes/use-skin-command' - -import { formatRefValue } from '../components/assistant-ui/directive-text' -import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' -import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' -import { storedSessionIdForNotification } from '../lib/session-ids' -import { - isMessagingSource, - LOCAL_SESSION_SOURCE_IDS, - MESSAGING_SESSION_SOURCE_IDS, - normalizeSessionSource -} from '../lib/session-source' -import { latestSessionTodos } from '../lib/todos' -import { setCronFocusJobId, setCronJobs } from '../store/cron' -import { - $fileBrowserOpen, - $panesFlipped, - $pinnedSessionIds, - $sessionsLimit, - bumpSessionsLimit, - FILE_BROWSER_DEFAULT_WIDTH, - FILE_BROWSER_MAX_WIDTH, - FILE_BROWSER_MIN_WIDTH, - pinSession, - PREVIEW_PANE_ID, - restoreWorktree, - setSidebarOverlayMounted, - SIDEBAR_DEFAULT_WIDTH, - SIDEBAR_MAX_WIDTH, - SIDEBAR_SESSIONS_PAGE_SIZE, - unpinSession -} from '../store/layout' -import { respondToApprovalAction } from '../store/native-notifications' -import { $paneOpen } from '../store/panes' -import { setPetActivity } from '../store/pet' -import { setPetScale } from '../store/pet-gallery' -import { - setPetOverlayOpenAppHandler, - setPetOverlayScaleHandler, - setPetOverlaySubmitHandler -} from '../store/pet-overlay' -import { requestVoiceConversationStart } from '../store/composer' -import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' -import { - $activeGatewayProfile, - $freshSessionRequest, - $profileScope, - ALL_PROFILES, - normalizeProfileKey, - refreshActiveProfile -} from '../store/profile' -import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' -import { $reviewOpen, REVIEW_PANE_ID } from '../store/review' -import { - $activeSessionId, - $attentionSessionIds, - $currentCwd, - $freshDraftReady, - $gatewayState, - $messages, - $messagingSessions, - $resumeExhaustedSessionId, - $resumeFailedSessionId, - $selectedStoredSessionId, - $sessions, - $workingSessionIds, - CRON_SECTION_LIMIT, - getRecentlySettledSessionIds, - getRememberedSessionId, - mergeSessionPage, - MESSAGING_SECTION_LIMIT, - sessionPinId, - setAwaitingResponse, - setBusy, - setCronSessions, - setCurrentBranch, - setCurrentCwd, - setCurrentModel, - setCurrentProvider, - setMessages, - setMessagingPlatformTotals, - setMessagingSessions, - setMessagingTruncated, - setRememberedSessionId, - setSessionProfileTotals, - setSessions, - setSessionsLoading, - setSessionsTotal -} from '../store/session' -import { onSessionsChanged } from '../store/session-sync' -import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos' -import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' -import { isSecondaryWindow } from '../store/windows' - -import { ChatView } from './chat' -import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' -import { useComposerActions } from './chat/hooks/use-composer-actions' -import { - ChatPreviewRail, - PREVIEW_RAIL_MAX_WIDTH, - PREVIEW_RAIL_MIN_WIDTH, - PREVIEW_RAIL_PANE_WIDTH -} from './chat/right-rail' -import { ChatSidebar } from './chat/sidebar' -import { CommandPalette } from './command-palette' -import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' -import { useGatewayRequest } from './gateway/hooks/use-gateway-request' -import { useKeybinds } from './hooks/use-keybinds' -import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' -import { ModelPickerOverlay } from './model-picker-overlay' -import { ModelVisibilityOverlay } from './model-visibility-overlay' -import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' -import { RightSidebarPane } from './right-sidebar' -import { FileActionDialogs } from './right-sidebar/file-actions' -import { ReviewPane } from './right-sidebar/review' -import { $terminalTakeover } from './right-sidebar/store' -import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' -import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' -import { SessionPickerOverlay } from './session-picker-overlay' -import { SessionSwitcher } from './session-switcher' -import { useContextSuggestions } from './session/hooks/use-context-suggestions' -import { useCwdActions } from './session/hooks/use-cwd-actions' -import { useHermesConfig } from './session/hooks/use-hermes-config' -import { useMessageStream } from './session/hooks/use-message-stream' -import { useModelControls } from './session/hooks/use-model-controls' -import { usePreviewRouting } from './session/hooks/use-preview-routing' -import { usePromptActions } from './session/hooks/use-prompt-actions' -import { useRouteResume } from './session/hooks/use-route-resume' -import { useSessionActions } from './session/hooks/use-session-actions' -import { useSessionStateCache } from './session/hooks/use-session-state-cache' -import { AppShell } from './shell/app-shell' -import { useOverlayRouting } from './shell/hooks/use-overlay-routing' -import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' -import { useStatusbarItems } from './shell/hooks/use-statusbar-items' -import { ModelMenuPanel } from './shell/model-menu-panel' -import type { StatusbarItem } from './shell/statusbar-controls' -import type { TitlebarTool } from './shell/titlebar-controls' -import { useGroupRegistry } from './shell/use-group-registry' -import { UpdatesOverlay } from './updates-overlay' - -const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView })) -const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) -const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) -const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) -const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) -const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) -const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) -const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) - -// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The -// Cron sessions are written by a background scheduler tick (the desktop -// backend), so no user action signals the UI. Poll the bounded cron list on -// this cadence while the app is open + visible so new runs surface promptly -// instead of waiting for the next user-triggered refreshSessions(). -const CRON_POLL_INTERVAL_MS = 30_000 -// The recents list is local-only: cron rows have their own section, and each -// messaging platform (telegram, discord, …) is fetched separately into its own -// self-managed sidebar section (refreshMessagingSessions). Excluding both here -// keeps "Load more" paging through interactive local chats instead of -// interleaving gateway threads that bury them. -const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] -// The messaging slice is the inverse: drop cron + every local source so only -// external-platform conversations remain, then split per platform in the UI. -const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] - -// Cheap signature compare so the poll only swaps the atom (and re-renders the -// sidebar) when the visible cron rows actually changed. -function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { - if (a.length !== b.length) { - return false - } - - return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title) -} - -// Rows a session refresh must preserve even if the aggregator omits them: -// in-flight first turns (message_count 0), pinned rows aged off the page, the -// actively-viewed chat (its "working" flag clears a beat before the aggregator -// sees the persisted row), and sessions whose turn just settled (same race, but -// for a chat the user has already navigated away from). Pass `scope` to only -// keep the active row when it belongs to the profile being paged. -function sessionsToKeep(scope?: string): Set { - const keep = new Set([ - ...$workingSessionIds.get(), - ...$pinnedSessionIds.get(), - ...getRecentlySettledSessionIds() - ]) - - const active = $selectedStoredSessionId.get() - - if (active) { - const session = scope ? $sessions.get().find(s => s.id === active) : null - - if (!scope || !session || normalizeProfileKey(session.profile) === scope) { - keep.add(active) - } - } - - return keep -} - -export function DesktopController() { - const queryClient = useQueryClient() - const location = useLocation() - const navigate = useNavigate() - - const busyRef = useRef(false) - const creatingSessionRef = useRef(false) - const refreshSessionsRequestRef = useRef(0) - - const gatewayState = useStore($gatewayState) - const activeSessionId = useStore($activeSessionId) - const currentCwd = useStore($currentCwd) - const freshDraftReady = useStore($freshDraftReady) - const resumeFailedSessionId = useStore($resumeFailedSessionId) - const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) - const filePreviewTarget = useStore($filePreviewTarget) - const previewTarget = useStore($previewTarget) - const selectedStoredSessionId = useStore($selectedStoredSessionId) - const terminalTakeover = useStore($terminalTakeover) - const reviewOpen = useStore($reviewOpen) - const fileBrowserOpen = useStore($fileBrowserOpen) - const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) - const panesFlipped = useStore($panesFlipped) - const profileScope = useStore($profileScope) - // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — - // collapse both sidebars (without touching their stored open state) so the - // hover-reveal overlay becomes the way in. Restores once it's wide again. - const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) - - const routedSessionId = routeSessionId(location.pathname) - const routeToken = `${location.pathname}:${location.search}:${location.hash}` - const routeTokenRef = useRef(routeToken) - routeTokenRef.current = routeToken - const getRouteToken = useCallback(() => routeTokenRef.current, []) - - const { - agentsOpen, - chatOpen, - closeOverlayToPreviousRoute, - commandCenterInitialSection, - commandCenterOpen, - cronOpen, - currentView, - openAgents, - openCommandCenterSection, - profilesOpen, - settingsOpen, - toggleCommandCenter - } = useOverlayRouting() - - const terminalSidebarOpen = chatOpen && terminalTakeover - - const titlebarToolGroups = useGroupRegistry() - const statusbarItemGroups = useGroupRegistry() - const setTitlebarToolGroup = titlebarToolGroups.set - const setStatusbarItemGroup = statusbarItemGroups.set - - const { - activeSessionIdRef, - ensureSessionState, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef, - syncSessionStateToView, - updateSessionState - } = useSessionStateCache({ - activeSessionId, - busyRef, - selectedStoredSessionId, - setAwaitingResponse, - setBusy, - setMessages - }) - - const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() - - useEffect(() => { - window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget))) - }, [chatOpen, filePreviewTarget, previewTarget]) - - useEffect(() => { - startUpdatePoller() - const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) - - return () => { - unsubscribe?.() - stopUpdatePoller() - } - }, []) - - // Remember the open chat so a relaunch reopens it instead of an empty new-chat. - useEffect(() => { - if (routedSessionId) { - setRememberedSessionId(routedSessionId) - } - }, [routedSessionId]) - - // Restore that chat once, on cold start only (we're at the new-chat route and - // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch - // below, so we never boot-loop into an error screen. - const restoredLastSessionRef = useRef(false) - useEffect(() => { - if (restoredLastSessionRef.current) { - return - } - - restoredLastSessionRef.current = true - const last = getRememberedSessionId() - - if (last && location.pathname === NEW_CHAT_ROUTE) { - navigate(sessionRoute(last), { replace: true }) - } - }, [location.pathname, navigate]) - - useEffect(() => { - if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { - setRememberedSessionId(null) - } - }, [resumeExhaustedSessionId]) - - // Notification click: the main process already focused the window; jump to its - // session. Notifications are tagged with the gateway *runtime* session id, but - // the chat route is keyed by the *stored* id — navigating with the runtime id - // resumes a non-existent stored session ("session not found") and strands the - // user. Translate runtime -> stored before navigating. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { - if (sessionId) { - navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current))) - } - }) - - return () => unsubscribe?.() - }, [navigate, runtimeIdByStoredSessionIdRef]) - - // Notification action button (Approve/Reject) — resolve in place, no navigation. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => { - void respondToApprovalAction(sessionId ?? null, actionId) - }) - - return () => unsubscribe?.() - }, []) - - // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint). - // Build the equivalent /blueprint slash command from the payload and drop - // it into the composer — the user reviews/edits, then sends; the agent (or - // the shared command handler) creates the job. Signal readiness so a link - // that arrived during boot is flushed exactly once. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { - if (!payload || payload.kind !== 'blueprint' || !payload.name) { - return - } - - const slots = Object.entries(payload.params || {}) - .map(([k, v]) => { - const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v - - return `${k}=${sval}` - }) - .join(' ') - - const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` - requestComposerInsert(command, { mode: 'block', target: 'main' }) - requestComposerFocus('main') - }) - - // Tell the main process the renderer is ready to receive deep links. - void window.hermesDesktop?.signalDeepLinkReady?.() - - return () => unsubscribe?.() - }, []) - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (!$filePreviewTarget.get() && !$previewTarget.get()) { - return - } - - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') { - event.preventDefault() - event.stopPropagation() - closeActiveRightRailTab() - } - } - - const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab) - - window.addEventListener('keydown', onKeyDown, { capture: true }) - - return () => { - unsubscribe?.() - window.removeEventListener('keydown', onKeyDown, { capture: true }) - } - }, []) - - // Cron-job sessions as their own list (latest N). Independent of the recents - // page so the two never compete for slots. Cheap + bounded. Kept (even though - // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run - // still resolves into the Pinned section via sessionByAnyId. - const refreshCronSessions = useCallback(async () => { - try { - const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { - source: 'cron' - }) - - setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions)) - } catch { - // Non-fatal: the cron section just stays empty/stale. - } - }, []) - - // Messaging-platform sessions as their own slice, fetched separately from - // local recents so each platform renders a self-managed section and never - // competes with local chats for the recents page budget. One combined fetch - // seeds every platform; the sidebar splits the rows per source. - const refreshMessagingSessions = useCallback(async () => { - try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { - excludeSources: MESSAGING_EXCLUDED_SOURCES - }) - - // Drop any non-messaging source the broad exclude didn't catch (custom - // sources) — those stay in local recents, not a platform section. - const rows = result.sessions.filter(s => isMessagingSource(s.source)) - - setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) - // Hit the cap → at least one platform may have more on disk than loaded, - // so platform sections offer their own per-platform "load more". - setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) - } catch { - // Non-fatal: the messaging sections just stay empty/stale. - } - }, []) - - // Page a single platform's section independently (mirrors the per-profile - // pager): fetch that source's next window and merge it back in place, leaving - // every other platform's rows untouched. Resolves the platform's exact total. - const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { - const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform - const loaded = $messagingSessions.get().filter(inPlatform).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { - source: platform - }) - - const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) - - setMessagingSessions(prev => [ - ...prev.filter(s => !inPlatform(s)), - ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) - ]) - - const total = result.total ?? incoming.length - setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) - }, []) - - // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created - // synchronously (agent tool call or the cron UI), so refreshing here right - // after an agent turn surfaces a new job immediately; the interval poll keeps - // next-run/state fresh as the scheduler advances them. - const refreshCronJobs = useCallback(async () => { - try { - const jobs = await getCronJobs() - - setCronJobs(jobs) - } catch { - // Non-fatal: the cron section just keeps its last-known jobs. - } - }, []) - - const refreshSessions = useCallback(async () => { - const requestId = refreshSessionsRequestRef.current + 1 - refreshSessionsRequestRef.current = requestId - setSessionsLoading(true) - - try { - const limit = $sessionsLimit.get() - - // Require at least one message so abandoned/empty "Untitled" drafts (one - // was created per TUI/desktop launch before the lazy-create fix) don't - // clutter the sidebar. - // Unified cross-profile list (served read-only off each profile's - // state.db; no per-profile backend is spawned). Single-profile users get - // the same rows tagged profile="default". Cron sessions are excluded here - // and fetched separately (refreshCronSessions) so the scheduler's - // always-newest rows can't consume the recents page budget. - // Scope the fetch to the active profile (not always 'all') so a profile - // with few recent sessions isn't windowed out of the cross-profile - // recency page — the empty-history-on-profile-switch bug. - const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope - - const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { - excludeSources: SIDEBAR_EXCLUDED_SOURCES - }) - - if (refreshSessionsRequestRef.current === requestId) { - setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) - setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) - setSessionProfileTotals(result.profile_totals ?? {}) - } - } finally { - if (refreshSessionsRequestRef.current === requestId) { - setSessionsLoading(false) - } - } - - void refreshCronSessions() - void refreshCronJobs() - void refreshMessagingSessions() - }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions]) - - const loadMoreSessions = useCallback(async () => { - bumpSessionsLimit() - await refreshSessions() - }, [refreshSessions]) - - // Another window mutated the shared session list (e.g. a chat started in the - // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so - // only real windows bother. - useEffect(() => { - if (isSecondaryWindow()) { - return - } - - return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) - }, [refreshSessions]) - - // ALL-profiles view pages one profile at a time: fetch that profile's next - // page and merge it in place, leaving every other profile's rows untouched. - const loadMoreSessionsForProfile = useCallback(async (profile: string) => { - const key = normalizeProfileKey(profile) - const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key - const loaded = $sessions.get().filter(inKey).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, { - excludeSources: SIDEBAR_EXCLUDED_SOURCES - }) - - const keep = sessionsToKeep(key) - - setSessions(prev => [ - ...prev.filter(s => !inKey(s)), - ...mergeSessionPage(prev.filter(inKey), result.sessions, keep) - ]) - - const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length - setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) })) - }, []) - - const toggleSelectedPin = useCallback(() => { - const sessionId = $selectedStoredSessionId.get() - - if (!sessionId) { - return - } - - // Pin on the durable lineage-root id so the pin survives auto-compression. - const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) - const pinId = session ? sessionPinId(session) : sessionId - - if ($pinnedSessionIds.get().includes(pinId)) { - unpinSession(pinId) - } else { - pinSession(pinId) - } - }, []) - - const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) - - const updateActiveSessionRuntimeInfo = useCallback( - (info: { branch?: string; cwd?: string }) => { - const sessionId = activeSessionIdRef.current - - if (!sessionId) { - return - } - - updateSessionState(sessionId, state => ({ - ...state, - branch: info.branch ?? state.branch, - cwd: info.cwd ?? state.cwd - })) - }, - [activeSessionIdRef, updateSessionState] - ) - - const { refreshProjectBranch } = useCwdActions({ - activeSessionId, - activeSessionIdRef, - onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, - requestGateway - }) - - const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ - activeSessionIdRef, - refreshProjectBranch - }) - - const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ - activeSessionId, - queryClient, - requestGateway - }) - - const openProviderSettings = useCallback(() => { - navigate(`${SETTINGS_ROUTE}?tab=providers`) - }, [navigate]) - - const modelMenuContent = useMemo( - () => - gatewayState === 'open' ? ( - - ) : null, - [gatewayRef, gatewayState, requestGateway, selectModel] - ) - - useContextSuggestions({ - activeSessionId, - activeSessionIdRef, - currentCwd, - gatewayState, - requestGateway - }) - - const hydrateFromStoredSession = useCallback( - async ( - attempts = 1, - storedSessionId = selectedStoredSessionIdRef.current, - runtimeSessionId = activeSessionIdRef.current - ) => { - if (!storedSessionId || !runtimeSessionId) { - return - } - - const storedProfile = $sessions - .get() - .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile - - for (let index = 0; index < Math.max(1, attempts); index += 1) { - try { - const latest = await getSessionMessages(storedSessionId, storedProfile) - const messages = toChatMessages(latest.messages) - updateSessionState( - runtimeSessionId, - state => ({ - ...state, - messages: preserveLocalAssistantErrors(messages, state.messages) - }), - storedSessionId - ) - - // Seed the status stack's todo group from history — but only while - // the plan is still in flight, so reopening an old chat doesn't pin - // its finished todo list above the composer forever. - const todos = latestSessionTodos(messages) - - if (todos && todoListActive(todos)) { - setSessionTodos(runtimeSessionId, todos) - } else { - clearSessionTodos(runtimeSessionId) - } - - return - } catch { - // Best-effort fallback when live stream payloads are empty. - } - - if (index < attempts - 1) { - await new Promise(resolve => window.setTimeout(resolve, 250)) - } - } - }, - [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] - ) - - const { handleGatewayEvent } = useMessageStream({ - activeSessionIdRef, - hydrateFromStoredSession, - queryClient, - refreshHermesConfig, - refreshSessions, - sessionStateByRuntimeIdRef, - updateSessionState - }) - - const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ - activeSessionIdRef, - baseHandleGatewayEvent: handleGatewayEvent, - currentCwd, - currentView, - requestGateway, - routedSessionId, - selectedStoredSessionId - }) - - const { - archiveSession, - branchCurrentSession, - branchStoredSession, - createBackendSessionForSend, - openSettings, - removeSession, - resumeSession, - selectSidebarItem, - startFreshSessionDraft - } = useSessionActions({ - activeSessionId, - activeSessionIdRef, - busyRef, - creatingSessionRef, - ensureSessionState, - getRouteToken, - navigate, - requestGateway, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionId, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef, - syncSessionStateToView, - updateSessionState - }) - - // "Hey Hermes": handle the wake event on the canonical onEvent pipeline (the - // path every gateway socket already feeds), not a side-registered listener — - // open a fresh session and begin back-and-forth voice. - const handleGatewayEventWithWake = useCallback( - (event: Parameters[0]) => { - if (event.type === 'wake.detected') { - const payload = event.payload as { start_new_session?: boolean } | undefined - if (payload?.start_new_session !== false) { - startFreshSessionDraft() - } - requestVoiceConversationStart() - return - } - handleDesktopGatewayEvent(event) - }, - [handleDesktopGatewayEvent, startFreshSessionDraft] - ) - - // Single global listener for every rebindable hotkey (incl. profile switching) - // plus the on-screen keybind editor's capture mode. - useKeybinds({ - startFreshSession: startFreshSessionDraft, - toggleCommandCenter, - toggleSelectedPin - }) - - // A profile switch/create drops to a fresh new-session draft so the previously - // open session doesn't bleed across contexts. Skip the initial value. - const freshSessionRequest = useStore($freshSessionRequest) - const lastFreshRef = useRef(freshSessionRequest) - - useEffect(() => { - if (freshSessionRequest === lastFreshRef.current) { - return - } - - lastFreshRef.current = freshSessionRequest - startFreshSessionDraft() - }, [freshSessionRequest, startFreshSessionDraft]) - - // Swapping the live gateway to another profile must re-pull that profile's - // global model + active-profile pill. Both are nanostores, so the blanket - // invalidateQueries() the profile store fires on swap doesn't touch them — - // without this the statusbar keeps showing the previous profile's model - // (the "forgets the LLM setting" report). gatewayState stays 'open' across a - // swap (background sockets persist), so the open→open effect won't re-run. - const activeGatewayProfile = useStore($activeGatewayProfile) - const lastGatewayProfileRef = useRef(activeGatewayProfile) - - useEffect(() => { - if (activeGatewayProfile === lastGatewayProfileRef.current) { - return - } - - lastGatewayProfileRef.current = activeGatewayProfile - // Force: the new profile has its own default, so reseed even if the composer - // already shows the previous profile's model. - void refreshCurrentModel(true) - void refreshActiveProfile() - }, [activeGatewayProfile, refreshCurrentModel]) - - const composer = useComposerActions({ - activeSessionId, - currentCwd, - requestGateway - }) - - const branchInNewChat = useCallback( - async (messageId?: string) => { - const branched = await branchCurrentSession(messageId) - - if (branched) { - await refreshSessions().catch(() => undefined) - } - - return branched - }, - [branchCurrentSession, refreshSessions] - ) - - // Clear a failed turn's red error banner from the transcript. Errors are - // renderer-local state (never persisted), so dismissing is purely a view + - // session-cache edit. A message that errored before emitting any visible - // text is a bare error placeholder → drop it entirely; one that streamed - // partial output then failed keeps its content and just sheds the error. - // Both the per-runtime cache AND the live $messages view must be updated: - // `preserveLocalAssistantErrors` re-grafts any still-errored message it - // finds in the view onto the next session.info flush, so clearing only the - // cache would let the heartbeat resurrect the banner. - const dismissError = useCallback( - (messageId: string) => { - const runtimeSessionId = activeSessionIdRef.current - - if (!runtimeSessionId) { - return - } - - const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => - messages.flatMap(message => { - if (message.id !== messageId || !message.error) { - return [message] - } - - if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { - return [] - } - - return [{ ...message, error: undefined, pending: false }] - }) - - // View first: the flush below reads $messages as the "current" baseline - // for error preservation, so the banner must be gone from it before the - // cache update triggers a re-sync. - setMessages(clearErrorIn($messages.get())) - - updateSessionState(runtimeSessionId, state => ({ - ...state, - messages: clearErrorIn(state.messages) - })) - }, - [activeSessionIdRef, updateSessionState] - ) - - const startSessionInWorkspace = useCallback( - (path: null | string) => { - startFreshSessionDraft() - - // A worktree lane carries its own path; the trunk "+" can be path-less (the - // main checkout is implicit), so fall back to the active project's root - // instead of no-op'ing on null — that was "+ on main does nothing". - const target = path?.trim() || resolveNewSessionCwd() - - if (!target) { - return - } - - // The next message creates the backend session in $currentCwd, so seed - // it (and the branch) from the workspace the user clicked the + on. - setCurrentCwd(target) - void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) - .then(info => { - const resolved = info.cwd || target - - setCurrentCwd(resolved) - setCurrentBranch(info.branch || '') - - // An EXPLICIT target (a worktree/lane path — e.g. just-created via - // "convert a branch" / "new worktree") drills the sidebar into that - // project so the new lane is visible at once. Without this, a brand-new - // worktree session is invisible from the all-projects overview (the - // live overlay skips `.worktrees` rows, and the session.info cwd-follow - // only fires on a same-session move, not a fresh session). The - // path-less trunk "+" keeps the current scope untouched. - if (path?.trim()) { - restoreWorktree(resolved) - void followActiveSessionCwd(resolved) - } - }) - .catch(() => undefined) - }, - [requestGateway, startFreshSessionDraft] - ) - - // Composer "branch off into a new worktree": the composer already created the - // worktree and cleared its draft; open a fresh session anchored to that tree, - // then prefill the task that kicked it off. startSessionInWorkspace owns the - // reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp - // the cwd back to the default), so the prefill is dispatched right after — its - // deferred event lands once the fresh composer has remounted and rebound. - const startWorkSessionRequest = useStore($startWorkSessionRequest) - const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) - - useEffect(() => { - if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { - return - } - - lastStartWorkTokenRef.current = startWorkSessionRequest.token - startSessionInWorkspace(startWorkSessionRequest.path) - - if (startWorkSessionRequest.draft) { - requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) - } - }, [startSessionInWorkspace, startWorkSessionRequest]) - - const handleSkinCommand = useSkinCommand() - - const { - cancelRun, - editMessage, - handleThreadMessagesChange, - reloadFromMessage, - restoreToMessage, - steerPrompt, - submitText, - transcribeVoiceAudio - } = usePromptActions({ - activeSessionId, - activeSessionIdRef, - branchCurrentSession: branchInNewChat, - busyRef, - createBackendSessionForSend, - handleSkinCommand, - refreshSessions, - requestGateway, - resumeStoredSession: resumeSession, - selectedStoredSessionIdRef, - startFreshSessionDraft, - sttEnabled, - updateSessionState - }) - - // The popped-out pet drives two actions back into the app: send a prompt, and - // open the most recent thread. Both are registered ONCE through refs that track - // the latest callbacks — re-registering on every `submitText`/`resumeSession` - // identity change left a brief window where the handler was nulled (cleanup - // before re-register), which could drop a submit fired from the overlay (e.g. - // creating a session from the new-session screen). The ref form keeps a stable, - // always-current handler. Primary window only — it owns the overlay. - const submitTextRef = useRef(submitText) - submitTextRef.current = submitText - const resumeSessionRef = useRef(resumeSession) - resumeSessionRef.current = resumeSession - const requestGatewayRef = useRef(requestGateway) - requestGatewayRef.current = requestGateway - - useEffect(() => { - if (isSecondaryWindow()) { - return - } - - setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) - // Alt+wheel resize from the popped-out pet — persist it through this - // window's gateway (the overlay has none) so it survives restart. - setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) - // Mail icon: $sessions is ordered most-recent-first; the pet is global (not - // per session) so "most recent" is the right target. main.cjs already raised - // the window before forwarding this. - setPetOverlayOpenAppHandler(() => { - const recent = $sessions.get()[0] - - if (recent?.id) { - void resumeSessionRef.current(recent.id) - } - }) - - return () => { - setPetOverlaySubmitHandler(null) - setPetOverlayOpenAppHandler(null) - setPetOverlayScaleHandler(null) - } - }, []) - - // Mirror "a session is blocked on the user" (clarify/approval) into the pet's - // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so - // it rides the same atom the pop-out overlay mirrors — no session list needed - // there. Every window keeps its own in-window pet in sync. - useEffect(() => { - const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) - - sync() - - return $attentionSessionIds.listen(sync) - }, []) - - useGatewayBoot({ - handleGatewayEvent: handleGatewayEventWithWake, - onConnectionReady: c => { - connectionRef.current = c - }, - onGatewayReady: g => { - gatewayRef.current = g - }, - refreshHermesConfig, - refreshSessions - }) - - useEffect(() => { - if (gatewayState === 'open') { - void refreshCurrentModel() - void refreshActiveProfile() - void refreshSessions().catch(() => undefined) - } - }, [gatewayState, refreshCurrentModel, refreshSessions]) - - // "Hey Hermes" wake word: arm the server-side detector for this surface - // (gated on config). Detection arrives as a wake.detected event handled in - // handleGatewayEventWithWake. Idempotent server-side, so reconnects are safe. - useEffect(() => { - if (gatewayState !== 'open') { - return - } - void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined) - }, [gatewayState, requestGateway]) - - // Keep the cron jobs section live without a user action: the scheduler ticks - // in the background (advancing next-run/state and creating runs), so poll the - // job list on an interval (and on tab re-focus) while connected. - useEffect(() => { - if (gatewayState !== 'open') { - return - } - - const tick = () => { - if (document.visibilityState === 'visible') { - void refreshCronJobs() - } - } - - const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS) - document.addEventListener('visibilitychange', tick) - - return () => { - window.clearInterval(intervalId) - document.removeEventListener('visibilitychange', tick) - } - }, [gatewayState, refreshCronJobs]) - - useEffect(() => { - if (gatewayState === 'open' && !activeSessionId && freshDraftReady) { - void refreshCurrentModel() - void refreshHermesConfig() - } - }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig]) - - useRouteResume({ - activeSessionId, - activeSessionIdRef, - creatingSessionRef, - currentView, - freshDraftReady, - gatewayState, - locationPathname: location.pathname, - resumeSession, - resumeFailedSessionId, - resumeExhaustedSessionId, - routedSessionId, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionId, - selectedStoredSessionIdRef, - startFreshSessionDraft - }) - - const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ - agentsOpen, - chatOpen, - commandCenterOpen, - extraLeftItems: statusbarItemGroups.flat.left, - extraRightItems: statusbarItemGroups.flat.right, - gatewayLogLines, - gatewayState, - inferenceStatus, - openAgents, - freshDraftReady, - openCommandCenterSection, - requestGateway, - statusSnapshot, - toggleCommandCenter - }) - - const sidebar = ( - void archiveSession(sessionId)} - onBranchSession={sessionId => void branchStoredSession(sessionId)} - onDeleteSession={sessionId => void removeSession(sessionId)} - onLoadMoreMessaging={loadMoreMessagingForPlatform} - onLoadMoreProfileSessions={loadMoreSessionsForProfile} - onLoadMoreSessions={loadMoreSessions} - onManageCronJob={jobId => { - setCronFocusJobId(jobId) - navigate(CRON_ROUTE) - }} - onNavigate={selectSidebarItem} - onNewSessionInWorkspace={startSessionInWorkspace} - onResumeSession={sessionId => navigate(sessionRoute(sessionId))} - onTriggerCronJob={jobId => { - void triggerCronJob(jobId) - .then(() => refreshCronJobs()) - .catch(() => undefined) - }} - /> - ) - - // One PTY-backed terminal mounted forever; placeholders decide - // where it shows. Lives in main's stacking context (not the root overlay layer) - // so pane resize handles still paint above it. Toggling never rebuilds the shell. - const mainOverlays = ( - - ) - - const overlays = ( - <> - - {!isSecondaryWindow() && } - {!isSecondaryWindow() && ( - { - void refreshHermesConfig() - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - requestGateway={requestGateway} - /> - )} - - - - - - - - - - - - {settingsOpen && ( - - { - void refreshHermesConfig() - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - onMainModelChanged={(provider, model) => { - setCurrentProvider(provider) - setCurrentModel(model) - updateModelOptionsCache(provider, model, true) - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - /> - - )} - - {commandCenterOpen && ( - - navigate(path)} - onOpenSession={sessionId => navigate(sessionRoute(sessionId))} - /> - - )} - - {agentsOpen && ( - - - - )} - - {cronOpen && ( - - navigate(sessionRoute(sessionId))} - /> - - )} - - {profilesOpen && ( - - - - )} - - ) - - const chatView = ( - composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} - onAttachDroppedItems={composer.attachDroppedItems} - onAttachImageBlob={composer.attachImageBlob} - onBranchInNewChat={branchInNewChat} - onCancel={cancelRun} - onDeleteSelectedSession={() => { - if (selectedStoredSessionId) { - void removeSession(selectedStoredSessionId) - } - }} - onDismissError={dismissError} - onEdit={editMessage} - onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} - onPickFiles={() => void composer.pickContextPaths('file')} - onPickFolders={() => void composer.pickContextPaths('folder')} - onPickImages={() => void composer.pickImages()} - onReload={reloadFromMessage} - onRemoveAttachment={id => void composer.removeAttachment(id)} - onRestoreToMessage={restoreToMessage} - onRetryResume={sessionId => void resumeSession(sessionId, true)} - onSteer={steerPrompt} - onSubmit={submitText} - onThreadMessagesChange={handleThreadMessagesChange} - onToggleSelectedPin={toggleSelectedPin} - onTranscribeAudio={transcribeVoiceAudio} - /> - ) - - // Flipped layout mirrors the default: sessions sidebar → right, file - // browser + preview rail → left. Same panes, swapped sides. - const sidebarSide = panesFlipped ? 'right' : 'left' - const railSide = panesFlipped ? 'left' : 'right' - - // Other sidebars docked as real columns on the terminal's rail. Force-collapsed - // hover-reveal overlays (narrow window) don't take a column, so they don't count. - const railColumnOpen = - (chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) || - (chatOpen && !narrowViewport && fileBrowserOpen) || - (chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen) - - // Once the terminal would share its rail with another sidebar, drop it to a - // full-width row beneath them rather than cramming in one more skinny column. - const terminalAsRow = terminalSidebarOpen && railColumnOpen - - const previewPane = ( - - {chatOpen ? ( - - ) : null} - - ) - - const fileBrowserPane = ( - - {/* Key on the project (cwd) so switching projects unmounts the old tree and - mounts a fresh one straight into its skeleton — no stale-then-blip. */} - composer.insertContextPathInlineRef(path)} - onActivateFolder={path => composer.insertContextPathInlineRef(path, true)} - /> - - ) - - const reviewPane = ( - - - - ) - - const terminalPane = ( - - {/* As a column the terminal clears the titlebar; as a bottom row it sits - below the rail's panes (so it fills its row edge-to-edge) and gets a - left border separating it from the chat — the column-mode separator - lives on the resize sash, which moves to the top edge as a row. */} -
- -
-
- ) - - return ( - - {!isSecondaryWindow() && ( - - {sidebar} - - )} - - - - - - - - } - path="skills" - /> - - - - } - path="messaging" - /> - - - - } - path="artifacts" - /> - - - - - - } path="new" /> - } path="sessions/:sessionId" /> - } path="*" /> - - - {/* - Order within a side maps to column order. Default (rail on the right): - main | terminal | preview | file-browser. Flipped (rail on the left): - mirror to file-browser | preview | terminal | main so terminal stays - adjacent to the chat. - */} - {panesFlipped ? fileBrowserPane : terminalPane} - {previewPane} - {reviewPane} - {panesFlipped ? terminalPane : fileBrowserPane} - - ) -} - -function LegacySessionRedirect() { - const { sessionId } = useParams() - - return -} diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 1b3a174ba9f..d5445ea64c1 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -2,17 +2,33 @@ import { afterEach, describe, expect, it } from 'vitest' import { $composerAttachments, + $voiceConversationStartRequest, addComposerAttachment, clearSessionDraft, type ComposerAttachment, migrateSessionDraft, removeComposerAttachment, + requestVoiceConversationStart, SESSION_DRAFTS_STORAGE_KEY, stashSessionDraft, takeSessionDraft, + takeVoiceConversationStart, updateComposerAttachment } from './composer' +describe('voice conversation start requests', () => { + it('latches each request until the main composer consumes it once', () => { + requestVoiceConversationStart() + const first = $voiceConversationStartRequest.get() + + expect(takeVoiceConversationStart(first)).toBe(true) + expect(takeVoiceConversationStart(first)).toBe(false) + + requestVoiceConversationStart() + expect(takeVoiceConversationStart($voiceConversationStartRequest.get())).toBe(true) + }) +}) + function attachment(overrides: Partial & Pick): ComposerAttachment { return { kind: 'file', label: 'doc.pdf', ...overrides } } diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index a9df29b6df7..7f32348cd36 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -27,8 +27,7 @@ export const $voiceConversationStartRequest = atom(0) let nextVoiceStartRequest = 0 let handledVoiceStartRequest = 0 -export const requestVoiceConversationStart = (): void => - $voiceConversationStartRequest.set(++nextVoiceStartRequest) +export const requestVoiceConversationStart = (): void => $voiceConversationStartRequest.set(++nextVoiceStartRequest) export const takeVoiceConversationStart = (current: number): boolean => { if (current <= handledVoiceStartRequest) { diff --git a/cli.py b/cli.py index 042b888e271..472e20df902 100644 --- a/cli.py +++ b/cli.py @@ -984,6 +984,7 @@ def _cleanup_all_browsers(*args, **kwargs): # Guard to prevent cleanup from running multiple times on exit _cleanup_done = False +_cli_wake_owner = None # One-shot CLI finalization runs before process cleanup so plugins can observe # the session boundary while the agent is still attached. If a signal lands in # that narrow window, atexit cleanup must not emit that session finalize again. @@ -1179,7 +1180,8 @@ def _run_cleanup(*, notify_session_finalize: bool = True): try: from tools.wake_word import stop_listening as _stop_wake_word - _stop_wake_word() + if _cli_wake_owner is not None: + _stop_wake_word(owner=_cli_wake_owner) except Exception: pass try: @@ -12182,7 +12184,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # threading resume logic through the voice machinery. def _maybe_start_wake_word(self): - """Start the wake-word listener at CLI startup if this surface owns it.""" + """Start the wake-word listener at CLI startup if this surface is eligible.""" try: from tools.wake_word import wake_surface_enabled if not wake_surface_enabled("cli"): @@ -12193,14 +12195,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): def _start_wake_word_listener(self, announce: bool = False) -> bool: """Build + start the hotword detector. Returns True on success.""" - if getattr(self, "_wake_word_active", False): - if announce: - _cprint(f"{_DIM}Wake word is already listening.{_RST}") - return True try: from tools.wake_word import ( check_wake_word_requirements, load_wake_word_config, + owns_listener, start_listening, ) except Exception as e: @@ -12208,6 +12207,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f"{_DIM}Wake word unavailable: {e}{_RST}") return False + if getattr(self, "_wake_word_active", False) and owns_listener(self): + if announce: + _cprint(f"{_DIM}Wake word is already listening.{_RST}") + return True + self._wake_word_active = False + cfg = load_wake_word_config() reqs = check_wake_word_requirements(cfg) if not reqs["available"]: @@ -12219,7 +12224,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._wake_start_new_session = bool(cfg.get("start_new_session", True)) try: - start_listening(self._on_wake_word, config=cfg) + start_listening(self._on_wake_word, owner=self, config=cfg) except Exception as e: if announce: _cprint(f"\n{_DIM}Failed to start wake word: {e}{_RST}") @@ -12227,6 +12232,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._wake_word_active = True self._wake_suspended = False + global _cli_wake_owner + _cli_wake_owner = self self._start_wake_watchdog() if announce: _cprint(f"\n{_ACCENT}Wake word listening{_RST} " @@ -12235,14 +12242,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): def _stop_wake_word_listener(self, announce: bool = False): """Stop and tear down the hotword detector.""" + global _cli_wake_owner was_active = getattr(self, "_wake_word_active", False) self._wake_word_active = False self._wake_suspended = False try: from tools.wake_word import stop_listening - stop_listening() + stop_listening(owner=self) except Exception: pass + if _cli_wake_owner is self: + _cli_wake_owner = None if announce: if was_active: _cprint(f"{_DIM}Wake word stopped.{_RST}") @@ -12250,7 +12260,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f"{_DIM}Wake word is not running.{_RST}") def _on_wake_word(self): - """Fired (on the detector thread) when the wake phrase is heard.""" + """Fired after the detector hears the wake phrase.""" if getattr(self, "_should_exit", False): return # Ignore wake while a turn is in flight or the mic is already in use. @@ -12260,9 +12270,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Release the mic so STT can capture the command utterance. try: from tools.wake_word import pause_listening - pause_listening() - except Exception: - pass + if not pause_listening(owner=self): + self._wake_word_active = False + return + except Exception as e: + logger.debug("wake word pause failed: %s", e) + return self._wake_suspended = True _cprint(f"\n{_ACCENT}✦ Wake word detected — listening...{_RST}") @@ -12319,8 +12332,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): idle_polls = 0 try: from tools.wake_word import resume_listening - resume_listening() - self._wake_suspended = False + if resume_listening(owner=self): + self._wake_suspended = False + else: + self._wake_word_active = False except Exception as e: logger.debug("wake word resume failed: %s", e) finally: @@ -12330,21 +12345,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): def _show_wake_word_status(self): """Show current wake-word listener status.""" - from tools.wake_word import check_wake_word_requirements, load_wake_word_config + from tools.wake_word import ( + check_wake_word_requirements, + is_listening, + load_wake_word_config, + owns_listener, + ) cfg = load_wake_word_config() reqs = check_wake_word_requirements(cfg) - active = getattr(self, "_wake_word_active", False) + owned = owns_listener(self) + state = "LISTENING" if owned and is_listening() else "PAUSED" if owned else "OFF" _cprint(f"\n{_BOLD}Wake Word Status{_RST}") - _cprint(f" State: {'LISTENING' if active else 'OFF'}") + _cprint(f" State: {state}") _cprint(f" Phrase: \"{reqs['phrase']}\"") _cprint(f" Provider: {reqs['provider']}") _cprint(f" Surface: {cfg.get('surface', 'auto')}") _cprint(f" New session: {'yes' if cfg.get('start_new_session', True) else 'no'}") if not reqs["available"] and reqs.get("hint"): _cprint(f" {_DIM}{reqs['hint']}{_RST}") - if not active: + if not owned: _cprint(f" {_DIM}Enable with /wake on{_RST}") def _toggle_voice_tts(self): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4881ddd2900..15acd118536 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2374,12 +2374,12 @@ DEFAULT_CONFIG = { "stop_phrases": ["stop"], }, - # "Hey Hermes" hands-free wake word (CLI). Always-on, on-device hotword + # "Hey Hermes" hands-free wake word. Always-on, on-device hotword # detection that starts a fresh voice session — the "Hey Siri" pattern. # Off by default; toggle with /wake or `wake_word.enabled: true`. "wake_word": { "enabled": False, - "surface": "auto", # which surface owns the listener / opens the new session: "auto" (the running one) | "cli" | "tui" | "gui" + "surface": "auto", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui" "provider": "openwakeword", # "openwakeword" (free, local) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) "phrase": "hey jarvis", # cosmetic label only; detection is keyed by the engine model/keyword below "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fdb146f08c3..caf22da054e 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1276,6 +1276,7 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): assert captured["silence_duration"] == 3.0 assert captured["auto_restart"] is False + # Round-12 Copilot review regression on #19835: ``bool`` is a subclass # of ``int``, so the naive ``isinstance(threshold, (int, float))`` # guard would forward ``silence_threshold: true`` as ``1`` instead @@ -1306,6 +1307,173 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch): assert captured["auto_restart"] is False +def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatch): + from tools import wake_word + + state = {"owner": None, "callback": None, "paused": False} + voice_callbacks = {} + + def start_listening(callback, *, owner, config): + if state["owner"] is not None and state["owner"] is not owner: + raise wake_word.WakeWordInUse + state.update(owner=owner, callback=callback, paused=False) + + def pause_listening(*, owner): + if state["owner"] is not owner: + return False + state["paused"] = True + return True + + def stop_listening(*, owner): + if state["owner"] is not owner: + return False + state.update(owner=None, callback=None, paused=False) + return True + + def resume_listening(*, owner): + if state["owner"] is not owner: + return False + state["paused"] = False + return True + + def start_continuous(**callbacks): + voice_callbacks.update(callbacks) + return True + + monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: { + "enabled": True, + "phrase": "hey hermes", + "surface": "auto", + "start_new_session": True, + }) + monkeypatch.setattr(wake_word, "check_wake_word_requirements", lambda _cfg: { + "available": True, + "phrase": "hey hermes", + "provider": "test", + "hint": "", + }) + monkeypatch.setattr(wake_word, "start_listening", start_listening) + monkeypatch.setattr(wake_word, "pause_listening", pause_listening) + monkeypatch.setattr(wake_word, "stop_listening", stop_listening) + monkeypatch.setattr(wake_word, "owns_listener", lambda owner: state["owner"] is owner) + monkeypatch.setattr( + wake_word, + "is_listening", + lambda: state["owner"] is not None and not state["paused"], + ) + monkeypatch.setattr( + wake_word, + "resume_listening", + resume_listening, + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.voice", + types.SimpleNamespace( + start_continuous=start_continuous, + stop_continuous=lambda **_kwargs: None, + ), + ) + monkeypatch.setenv("HERMES_VOICE", "1") + + first = types.SimpleNamespace(_closed=False) + second = types.SimpleNamespace(_closed=False) + emitted = [] + monkeypatch.setattr( + server, + "_emit", + lambda event, sid, payload: emitted.append( + (event, sid, payload, server.current_transport()) + ), + ) + server._wake_owner_transport = None + server._wake_owner_surface = "" + try: + started = server.dispatch({ + "id": "wake-1", + "method": "wake.start", + "params": {"surface": "gui", "session_id": "first-session"}, + }, transport=first) + denied = server.dispatch({ + "id": "wake-2", + "method": "wake.start", + "params": {"surface": "tui", "session_id": "second-session"}, + }, transport=second) + denied_stop = server.dispatch({ + "id": "wake-stop-2", + "method": "wake.stop", + "params": {}, + }, transport=second) + denied_voice_stop = server.dispatch({ + "id": "voice-stop-2", + "method": "voice.record", + "params": {"action": "stop"}, + }, transport=second) + + assert started["result"]["started"] is True + assert denied["result"] == { + "started": False, + "reason": "owned", + "owner_surface": "gui", + } + assert denied_stop["result"] == {"stopped": False, "reason": "not_owner"} + assert denied_voice_stop["result"] == { + "status": "busy", + "reason": "wake_owned", + } + + state["callback"]() + assert emitted == [( + "wake.detected", + "first-session", + {"phrase": "hey hermes", "start_new_session": True}, + first, + )] + assert state["paused"] is True + + voice_started = server.dispatch({ + "id": "voice-start-1", + "method": "voice.record", + "params": {"action": "start", "session_id": "first-session"}, + }, transport=first) + assert voice_started["result"]["status"] == "recording" + voice_callbacks["on_status"]("idle") + assert state["paused"] is False + + stopped = server.dispatch({ + "id": "wake-stop-1", + "method": "wake.stop", + "params": {}, + }, transport=first) + assert stopped["result"] == {"stopped": True, "reason": None} + + reclaimed = server.dispatch({ + "id": "wake-reclaim-2", + "method": "wake.start", + "params": {"surface": "tui", "session_id": "second-session"}, + }, transport=second) + assert reclaimed["result"]["started"] is True + assert state["owner"] is second + + state["callback"]() + assert emitted[-1] == ( + "wake.detected", + "second-session", + {"phrase": "hey hermes", "start_new_session": True}, + second, + ) + + stopped_again = server.dispatch({ + "id": "wake-stop-2-after-reclaim", + "method": "wake.stop", + "params": {}, + }, transport=second) + assert stopped_again["result"] == {"stopped": True, "reason": None} + finally: + server._wake_owner_transport = None + server._wake_owner_surface = "" + + def test_voice_record_stop_forces_transcription(monkeypatch): captured: dict = {} diff --git a/tests/test_tui_gateway_ws.py b/tests/test_tui_gateway_ws.py index ad41ba9dade..8c32f9798ed 100644 --- a/tests/test_tui_gateway_ws.py +++ b/tests/test_tui_gateway_ws.py @@ -152,6 +152,20 @@ def test_ws_connection_registers_then_disconnect_unregisters_live_transport(monk server._live_transports.clear() +def test_ws_disconnect_releases_wake_word_owner(monkeypatch): + released = [] + created = [] + monkeypatch.setattr( + server, + "_release_wake_for_transport", + lambda transport: released.append(transport) or True, + ) + + _run_disconnect(monkeypatch, lambda transport: created.append(transport)) + + assert released == created + + def test_ws_write_loop_stall_does_not_latch_transport(monkeypatch): """A write that times out because the event loop is stalled (GIL-heavy agent turn) must NOT latch the transport closed — the frame is already diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index e765e4ac5ee..7c65eedadd4 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -6,8 +6,11 @@ dispatch, the requirements probe, the detector fire/cooldown loop, and the process-wide singleton lifecycle. """ +import multiprocessing +import threading import time import types +from pathlib import Path import pytest @@ -30,7 +33,7 @@ def test_config_defaults_and_clamping(): def test_wake_surface_enabled_gate(): # Disabled → never, regardless of surface. assert ww.wake_surface_enabled("cli", {"enabled": False, "surface": "cli"}) is False - # auto → every surface. + # auto → every surface is eligible; ownership still admits only one. for s in ("cli", "tui", "gui"): assert ww.wake_surface_enabled(s, {"enabled": True, "surface": "auto"}) is True # Pinned surface → only that one. @@ -219,24 +222,151 @@ def test_detector_pause_resume(monkeypatch): # ── Singleton lifecycle ────────────────────────────────────────────────── -def test_singleton_lifecycle(monkeypatch): +def test_singleton_lifecycle(monkeypatch, tmp_path): _fake_audio(monkeypatch) monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False)) + monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock") + owner = object() assert ww.is_listening() is False - det = ww.start_listening(lambda: None, config={}) + det = ww.start_listening(lambda: None, owner=owner, config={}) time.sleep(0.05) assert ww.is_listening() is True + assert ww.owns_listener(owner) is True # Re-entrant start returns the same detector and re-arms it. - det2 = ww.start_listening(lambda: None, config={}) + det2 = ww.start_listening(lambda: None, owner=owner, config={}) assert det2 is det - ww.pause_listening() + assert ww.pause_listening(owner=owner) is True assert ww.is_listening() is False - ww.resume_listening() + assert ww.resume_listening(owner=owner) is True time.sleep(0.05) assert ww.is_listening() is True - ww.stop_listening() + assert ww.stop_listening(owner=owner) is True assert ww.is_listening() is False + + +def test_second_owner_cannot_mutate_listener(monkeypatch, tmp_path): + _fake_audio(monkeypatch) + monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False)) + monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock") + owner, intruder = object(), object() + first_callback = lambda: None + + detector = ww.start_listening(first_callback, owner=owner, config={}) + with pytest.raises(ww.WakeWordInUse): + ww.start_listening(lambda: None, owner=intruder, config={}) + + assert detector.on_wake is first_callback + assert ww.pause_listening(owner=intruder) is False + assert ww.resume_listening(owner=intruder) is False + assert ww.stop_listening(owner=intruder) is False + assert ww.owns_listener(owner) is True + assert ww.stop_listening(owner=owner) is True + + +def test_detection_callback_can_pause_and_close_stream(monkeypatch, tmp_path): + streams = [] + + def _stream(**kw): + stream = _FakeStream(**kw) + streams.append(stream) + return stream + + fake_sd = types.SimpleNamespace(InputStream=_stream) + monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None)) + monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=True)) + monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock") + owner = object() + paused = threading.Event() + + def _on_wake(): + if ww.pause_listening(owner=owner): + paused.set() + + ww.start_listening(_on_wake, owner=owner, config={}) + assert paused.wait(2) + assert ww.is_listening() is False + assert streams[0].closed is True + assert ww.stop_listening(owner=owner) is True + + +def test_startup_failure_releases_owner_and_machine_lock(monkeypatch, tmp_path): + class _BrokenSoundDevice: + @staticmethod + def InputStream(**_kw): + raise OSError("no microphone") + + lock_path = tmp_path / "wake.lock" + monkeypatch.setattr(ww, "_import_audio", lambda: (_BrokenSoundDevice, None)) + monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False)) + monkeypatch.setattr(ww, "_lock_path", lambda: lock_path) + owner = object() + + with pytest.raises(RuntimeError, match="Failed to open"): + ww.start_listening(lambda: None, owner=owner, config={}) + + assert ww.owns_listener(owner) is False + handle = ww._acquire_machine_lock(lock_path) + ww._release_machine_lock(handle) + + +def test_stream_failure_releases_owner_and_machine_lock(monkeypatch, tmp_path): + class _FailingStream(_FakeStream): + def read(self, _n): + raise OSError("device disconnected") + + fake_sd = types.SimpleNamespace(InputStream=lambda **kw: _FailingStream(**kw)) + engine = _FakeEngine(fire=False) + lock_path = tmp_path / "wake.lock" + monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None)) + monkeypatch.setattr(ww, "_build_engine", lambda cfg: engine) + monkeypatch.setattr(ww, "_lock_path", lambda: lock_path) + owner = object() + + ww.start_listening(lambda: None, owner=owner, config={}) + deadline = time.time() + 2 + while ww.owns_listener(owner) and time.time() < deadline: + time.sleep(0.01) + + assert ww.owns_listener(owner) is False + assert engine.closed is True + handle = ww._acquire_machine_lock(lock_path) + ww._release_machine_lock(handle) + + +def _hold_machine_lock(path: str, ready, release) -> None: + from tools import wake_word + + handle = wake_word._acquire_machine_lock(Path(path)) + ready.set() + release.wait(10) + assert handle is not None + + +def test_machine_lock_is_released_when_owner_process_exits(tmp_path): + lock_path = tmp_path / "wake.lock" + ctx = multiprocessing.get_context("spawn") + ready = ctx.Event() + release = ctx.Event() + process = ctx.Process( + target=_hold_machine_lock, + args=(str(lock_path), ready, release), + ) + process.start() + try: + assert ready.wait(10) + with pytest.raises(ww.WakeWordInUse): + ww._acquire_machine_lock(lock_path) + release.set() + process.join(10) + assert process.exitcode == 0 + handle = ww._acquire_machine_lock(lock_path) + ww._release_machine_lock(handle) + finally: + release.set() + if process.is_alive(): + process.terminate() + process.join(10) diff --git a/tools/wake_word.py b/tools/wake_word.py index 641a20bda81..fb13e0a597a 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -31,6 +31,7 @@ import logging import os import threading import time +from pathlib import Path from typing import Any, Callable, Dict, Optional logger = logging.getLogger(__name__) @@ -41,6 +42,11 @@ SAMPLE_RATE = 16000 # Minimum gap between two consecutive wake fires, so one "hey hermes" can't # retrigger across several frames while the caller is still reacting. _FIRE_COOLDOWN_SECONDS = 2.0 +_START_TIMEOUT_SECONDS = 5.0 + + +class WakeWordInUse(RuntimeError): + """Raised when another surface or process owns the wake-word listener.""" # --------------------------------------------------------------------------- @@ -96,8 +102,8 @@ def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> """Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener? True when the wake word is enabled and the configured ``surface`` is either - ``auto`` or this exact surface — the single gate every surface consults so - only one place owns the wake word and the new session it opens. + ``auto`` or this exact surface. ``auto`` makes a surface eligible; the + process/machine ownership lock still permits only the first claimant. """ cfg = cfg if cfg is not None else load_wake_word_config() if not cfg.get("enabled"): @@ -299,12 +305,15 @@ class WakeWordDetector: """ def __init__(self, engine: _Engine, on_wake: Callable[[], None], - cooldown: float = _FIRE_COOLDOWN_SECONDS): + cooldown: float = _FIRE_COOLDOWN_SECONDS, + on_failure: Optional[Callable[["WakeWordDetector"], None]] = None): self.engine = engine self.on_wake = on_wake self.cooldown = cooldown + self.on_failure = on_failure self._thread: Optional[threading.Thread] = None self._stop = threading.Event() + self._callback_inflight = threading.Event() self._last_fire = 0.0 self._lock = threading.Lock() @@ -319,10 +328,21 @@ class WakeWordDetector: if self._thread is not None and self._thread.is_alive(): return self._stop.clear() + ready = threading.Event() + startup_errors: list[BaseException] = [] self._thread = threading.Thread( - target=self._run, daemon=True, name="wake-word" + target=self._run, + args=(ready, startup_errors), + daemon=True, + name="wake-word", ) self._thread.start() + if not ready.wait(_START_TIMEOUT_SECONDS): + self._halt_thread() + raise TimeoutError("Timed out while opening the wake-word microphone.") + if startup_errors: + self._halt_thread() + raise RuntimeError("Failed to open the wake-word microphone.") from startup_errors[0] # pause/resume keep the engine; stop tears it down. def pause(self) -> None: @@ -337,16 +357,29 @@ class WakeWordDetector: def _halt_thread(self) -> None: with self._lock: - t, self._thread = self._thread, None - if t is not None and t is not threading.current_thread(): self._stop.set() - t.join(timeout=2.0) + t = self._thread + if t is not None and t is not threading.current_thread(): + t.join(timeout=2.0) + if self._thread is t: + self._thread = None - def _run(self) -> None: + def _dispatch_wake(self) -> None: + try: + self.on_wake() + except Exception as e: + logger.warning("wake word callback failed: %s", e) + finally: + self._callback_inflight.clear() + + def _run(self, ready: threading.Event, + startup_errors: list[BaseException]) -> None: try: sd, _ = _import_audio() except (ImportError, OSError) as e: logger.error("wake word: audio libraries unavailable: %s", e) + startup_errors.append(e) + ready.set() return frame_length = self.engine.frame_length @@ -360,6 +393,8 @@ class WakeWordDetector: stream.start() except Exception as e: logger.error("wake word: failed to open microphone: %s", e) + startup_errors.append(e) + ready.set() return # Drop any buffered audio/feature state so a resume right after a voice @@ -371,12 +406,15 @@ class WakeWordDetector: pass logger.info("wake word: listening (frame=%d, rate=%d)", frame_length, SAMPLE_RATE) + ready.set() + failed = False try: while not self._stop.is_set(): try: data, _overflow = stream.read(frame_length) except Exception as e: logger.warning("wake word: stream read error: %s", e) + failed = not self._stop.is_set() break frame = data[:, 0] if getattr(data, "ndim", 1) == 2 else data try: @@ -389,10 +427,13 @@ class WakeWordDetector: if now - self._last_fire >= self.cooldown: self._last_fire = now logger.info("wake word: phrase detected — firing callback") - try: - self.on_wake() - except Exception as e: - logger.warning("wake word callback failed: %s", e) + if not self._callback_inflight.is_set(): + self._callback_inflight.set() + threading.Thread( + target=self._dispatch_wake, + daemon=True, + name="wake-word-callback", + ).start() else: logger.debug("wake word: detection within cooldown — ignored") finally: @@ -402,6 +443,8 @@ class WakeWordDetector: except Exception: pass logger.info("wake word: stream closed") + if failed and self.on_failure is not None: + self.on_failure(self) # --------------------------------------------------------------------------- @@ -409,55 +452,162 @@ class WakeWordDetector: # --------------------------------------------------------------------------- _detector: Optional[WakeWordDetector] = None +_detector_owner: object | None = None +_detector_file_lock = None _detector_lock = threading.Lock() +def _lock_path() -> Path: + from hermes_constants import get_default_hermes_root + + return get_default_hermes_root() / "runtime" / "wake-word.lock" + + +def _acquire_machine_lock(path: Optional[Path] = None): + """Acquire the cross-process microphone lease, or raise WakeWordInUse.""" + lock_path = path or _lock_path() + lock_path.parent.mkdir(parents=True, exist_ok=True) + handle = open(lock_path, "a+b") + try: + if os.name == "nt": + import msvcrt + + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except (OSError, BlockingIOError) as e: + handle.close() + raise WakeWordInUse("Wake-word microphone is already owned.") from e + return handle + + +def _release_machine_lock(handle) -> None: + if handle is None: + return + try: + if os.name == "nt": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except OSError: + pass + finally: + handle.close() + + +def _detector_failed(detector: WakeWordDetector) -> None: + """Release ownership if the active microphone stream dies unexpectedly.""" + global _detector, _detector_owner, _detector_file_lock + with _detector_lock: + if _detector is not detector: + return + lock_handle = _detector_file_lock + _detector = None + _detector_owner = None + _detector_file_lock = None + try: + detector.engine.close() + finally: + _release_machine_lock(lock_handle) + + def start_listening( on_wake: Callable[[], None], *, + owner: object, config: Optional[Dict[str, Any]] = None, ) -> WakeWordDetector: - """Build (once) and start the wake-word detector. Idempotent. + """Claim, build, and start the detector. Idempotent for the same owner. Raises if engine construction fails (missing deps / access key / model); - callers should probe :func:`check_wake_word_requirements` first. + callers should probe :func:`check_wake_word_requirements` first. A different + owner, including another process, receives :class:`WakeWordInUse`. """ - global _detector + if owner is None: + raise ValueError("wake-word owner must not be None") + + global _detector, _detector_owner, _detector_file_lock with _detector_lock: if _detector is not None: + if _detector_owner is not owner: + raise WakeWordInUse("Wake-word microphone is already owned.") _detector.on_wake = on_wake _detector.resume() return _detector - cfg = config if config is not None else load_wake_word_config() - engine = _build_engine(cfg) - _detector = WakeWordDetector(engine, on_wake) - _detector.start() - return _detector + lock_handle = _acquire_machine_lock() + try: + cfg = config if config is not None else load_wake_word_config() + engine = _build_engine(cfg) + detector = WakeWordDetector(engine, on_wake, on_failure=_detector_failed) + _detector = detector + _detector_owner = owner + _detector_file_lock = lock_handle + detector.start() + return detector + except Exception: + if _detector is not None: + try: + _detector.stop() + except Exception: + pass + _detector = None + _detector_owner = None + _detector_file_lock = None + _release_machine_lock(lock_handle) + raise -def pause_listening() -> None: - """Release the microphone without tearing down the engine.""" +def owns_listener(owner: object) -> bool: with _detector_lock: + return _detector is not None and _detector_owner is owner + + +def pause_listening(*, owner: object) -> bool: + """Release the microphone only when ``owner`` holds the lease.""" + with _detector_lock: + if _detector is None or _detector_owner is not owner: + return False + _detector.pause() + return True + + +def resume_listening(*, owner: object) -> bool: + """Re-open the microphone only when ``owner`` holds the lease.""" + with _detector_lock: + if _detector is None or _detector_owner is not owner: + return False + _detector.resume() + return True + + +def stop_listening(*, owner: object) -> bool: + """Fully stop the detector only when ``owner`` holds the lease.""" + global _detector, _detector_owner, _detector_file_lock + with _detector_lock: + if _detector is None or _detector_owner is not owner: + return False det = _detector - if det is not None: - det.pause() - - -def resume_listening() -> None: - """Re-open the microphone after a pause. No-op if not initialised.""" - with _detector_lock: - det = _detector - if det is not None: - det.resume() - - -def stop_listening() -> None: - """Fully stop and discard the detector (closes the engine).""" - global _detector - with _detector_lock: - det, _detector = _detector, None - if det is not None: - det.stop() + lock_handle = _detector_file_lock + _detector = None + _detector_owner = None + _detector_file_lock = None + try: + det.stop() + finally: + _release_machine_lock(lock_handle) + return True def is_listening() -> bool: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 399c2d7068d..e29fcae05c4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -926,8 +926,7 @@ def _close_sessions_for_transport( def _shutdown_sessions() -> None: try: - from tools.wake_word import stop_listening as _stop_wake - _stop_wake() + _release_gateway_wake_owner() except Exception: pass with _sessions_lock: @@ -17426,6 +17425,7 @@ def _(rid, params: dict) -> dict: _voice_sid_lock = threading.Lock() _voice_event_sid: str = "" +_voice_wake_owner: "Optional[Transport]" = None def _voice_emit(event: str, payload: dict | None = None) -> None: @@ -17439,6 +17439,14 @@ def _voice_emit(event: str, payload: dict | None = None) -> None: _emit(event, sid, payload) +def _resume_voice_wake() -> None: + global _voice_wake_owner + with _voice_sid_lock: + owner, _voice_wake_owner = _voice_wake_owner, None + if owner is not None: + _wake_resume_if_owner(owner) + + def _voice_mode_enabled() -> bool: """Current voice-mode flag (runtime-only, CLI parity). @@ -17593,62 +17601,51 @@ def _voice_record_key() -> str: # ── Wake word ("Hey Hermes") ────────────────────────────────────────────── -# The detector is process-global (one mic), like voice. It runs server-side so -# both the TUI and desktop GUI share it; clients pass their surface identity to -# wake.start and the shared gate (wake_surface_enabled) decides whether to arm. +# The detector is process-global (one mic), like voice. The first eligible +# transport to call wake.start owns it until stop, disconnect, or stream failure. # On detection we emit wake.detected; the client opens a new session and starts # its own voice capture. The detector yields the mic to gateway voice.record # (pause/resume below) and to the desktop's browser mic (wake.pause/resume RPCs). _wake_lock = threading.Lock() -_wake_active = False -_wake_event_sid = "" -# Transport captured at wake.start time. The detector callback fires on a -# background thread where the request-scoped transport ContextVar is unset, so -# write_json would fall back to stdio and the event would never cross the -# desktop's websocket (#wake-detected-not-delivered). We pin the arming -# request's transport here and bind it for the emit. -_wake_transport: "Optional[Transport]" = None +_wake_owner_transport: "Optional[Transport]" = None +_wake_owner_surface = "" -def _wake_is_active() -> bool: +def _wake_owner_snapshot(): with _wake_lock: - return _wake_active + return _wake_owner_transport, _wake_owner_surface -def _wake_resume_if_active() -> None: - if not _wake_is_active(): - return +def _release_wake_for_transport(transport: "Transport") -> bool: + """Release the wake lease iff ``transport`` is the current gateway owner.""" + global _wake_owner_transport, _wake_owner_surface + with _wake_lock: + if _wake_owner_transport is not transport: + return False + _wake_owner_transport = None + _wake_owner_surface = "" + try: + from tools.wake_word import stop_listening + + stop_listening(owner=transport) + except Exception as e: + logger.debug("wake stop failed: %s", e) + return True + + +def _release_gateway_wake_owner() -> bool: + owner, _surface = _wake_owner_snapshot() + return owner is not None and _release_wake_for_transport(owner) + + +def _wake_resume_if_owner(owner: "Transport") -> bool: try: from tools.wake_word import resume_listening - resume_listening() + + return resume_listening(owner=owner) except Exception as e: logger.debug("wake resume failed: %s", e) - - -def _wake_on_detect() -> None: - """Detector-thread callback: tell the client to open a fresh voice session.""" - with _wake_lock: - sid = _wake_event_sid - transport = _wake_transport - phrase, new_session = "", True - try: - from tools.wake_word import load_wake_word_config, wake_phrase - cfg = load_wake_word_config() - phrase = wake_phrase(cfg) - new_session = bool(cfg.get("start_new_session", True)) - except Exception: - pass - logger.info("wake.detected: emitting to sid=%r (transport=%s)", - sid, type(transport).__name__ if transport else None) - # Bind the arming request's transport so write_json reaches the right peer - # (WS for desktop/dashboard) instead of falling back to stdio on this - # background thread. Carry start_new_session so every surface honors it. - token = bind_transport(transport) if transport is not None else None - try: - _emit("wake.detected", sid, {"phrase": phrase, "start_new_session": new_session}) - finally: - if token is not None: - reset_transport(token) + return False @method("wake.start") @@ -17658,13 +17655,16 @@ def _(rid, params: dict) -> dict: Idempotent and gated: returns ``{started: False, reason}`` when the wake word is disabled, scoped to another surface, or its deps/mic aren't ready. """ - global _wake_active, _wake_event_sid, _wake_transport surface = str(params.get("surface") or "auto").strip().lower() + transport = current_transport() or _stdio_transport try: from tools.wake_word import ( + WakeWordInUse, check_wake_word_requirements, load_wake_word_config, + owns_listener, start_listening, + wake_phrase, wake_surface_enabled, ) except Exception as e: @@ -17678,59 +17678,113 @@ def _(rid, params: dict) -> dict: reqs = check_wake_word_requirements(cfg) if not reqs["available"]: logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint")) - return _ok(rid, {"started": False, "reason": reqs.get("hint") or "unavailable"}) + return _ok(rid, { + "started": False, + "reason": "unavailable", + "hint": reqs.get("hint") or "", + }) + + existing_owner, existing_surface = _wake_owner_snapshot() + if existing_owner is not None and ( + _transport_is_dead(existing_owner) or not owns_listener(existing_owner) + ): + _release_wake_for_transport(existing_owner) + existing_owner = None + existing_surface = "" + if existing_owner is not None and existing_owner is not transport: + return _ok(rid, { + "started": False, + "reason": "owned", + "owner_surface": existing_surface, + }) + + sid = str(params.get("session_id") or "") + phrase = wake_phrase(cfg) + new_session = bool(cfg.get("start_new_session", True)) + + def _on_detect() -> None: + from tools.wake_word import owns_listener, pause_listening + + if not pause_listening(owner=transport): + return + if not owns_listener(transport): + return + if _transport_is_dead(transport): + _release_wake_for_transport(transport) + return + logger.info("wake.detected: emitting to sid=%r (transport=%s)", + sid, type(transport).__name__) + token = bind_transport(transport) + try: + _emit("wake.detected", sid, { + "phrase": phrase, + "start_new_session": new_session, + }) + finally: + reset_transport(token) - with _wake_lock: - _wake_event_sid = params.get("session_id") or _wake_event_sid - # Capture the live transport (WS for desktop) so the background detector - # thread can route wake.detected back to this client, not stdio. - _wake_transport = current_transport() or _wake_transport try: - start_listening(_wake_on_detect, config=cfg) + start_listening(_on_detect, owner=transport, config=cfg) + except WakeWordInUse: + return _ok(rid, { + "started": False, + "reason": "owned", + "owner_surface": existing_surface or None, + }) except Exception as e: logger.warning("wake.start(%s): failed to start listener: %s", surface, e) return _err(rid, 5026, str(e)) + global _wake_owner_transport, _wake_owner_surface with _wake_lock: - _wake_active = True + _wake_owner_transport = transport + _wake_owner_surface = surface logger.info("wake.start(%s): listening for %r (%s)", surface, reqs["phrase"], reqs["provider"]) - return _ok(rid, {"started": True, "phrase": reqs["phrase"], "provider": reqs["provider"]}) + return _ok(rid, { + "started": True, + "phrase": reqs["phrase"], + "provider": reqs["provider"], + "owner_surface": surface, + }) @method("wake.stop") def _(rid, params: dict) -> dict: - global _wake_active - with _wake_lock: - _wake_active = False - try: - from tools.wake_word import stop_listening - stop_listening() - except Exception: - pass - return _ok(rid, {"stopped": True}) + transport = current_transport() or _stdio_transport + stopped = _release_wake_for_transport(transport) + return _ok(rid, { + "stopped": stopped, + "reason": None if stopped else "not_owner", + }) @method("wake.pause") def _(rid, params: dict) -> dict: """Release the mic (e.g. while the desktop's browser captures audio).""" + transport = current_transport() or _stdio_transport try: from tools.wake_word import pause_listening - pause_listening() - logger.info("wake.pause: detector paused") + + paused = pause_listening(owner=transport) + logger.info("wake.pause: detector paused=%s", paused) except Exception as e: logger.debug("wake.pause failed: %s", e) - return _ok(rid, {"paused": True}) + paused = False + return _ok(rid, { + "paused": paused, + "reason": None if paused else "not_owner", + }) @method("wake.resume") def _(rid, params: dict) -> dict: """Reclaim the mic after a pause; no-op if the listener isn't armed.""" - active = _wake_is_active() - if active: - _wake_resume_if_active() - logger.info("wake.resume: detector resumed") - else: - logger.info("wake.resume: ignored (listener not armed)") - return _ok(rid, {"resumed": active}) + transport = current_transport() or _stdio_transport + resumed = _wake_resume_if_owner(transport) + logger.info("wake.resume: detector resumed=%s", resumed) + return _ok(rid, { + "resumed": resumed, + "reason": None if resumed else "not_owner", + }) @method("wake.status") @@ -17740,11 +17794,17 @@ def _(rid, params: dict) -> dict: check_wake_word_requirements, is_listening, load_wake_word_config, + owns_listener, ) cfg = load_wake_word_config() reqs = check_wake_word_requirements(cfg) + transport = current_transport() or _stdio_transport + owner, owner_surface = _wake_owner_snapshot() + owned_by_caller = owns_listener(transport) return _ok(rid, { - "listening": _wake_is_active() and is_listening(), + "listening": owned_by_caller and is_listening(), + "owned_by_caller": owned_by_caller, + "owner_surface": owner_surface if owner is not None else None, "phrase": reqs["phrase"], "provider": reqs["provider"], "available": reqs["available"], @@ -17866,17 +17926,23 @@ def _(rid, params: dict) -> dict: captures emit ``voice.transcript`` with ``no_speech_limit=True``. """ action = params.get("action", "start") + wake_paused = False if action not in {"start", "stop"}: return _err(rid, 4019, f"unknown voice action: {action}") + transport = current_transport() or _stdio_transport + wake_owner, _surface = _wake_owner_snapshot() + if wake_owner is not None and wake_owner is not transport: + return _ok(rid, {"status": "busy", "reason": "wake_owned"}) + try: if action == "start": if not _voice_mode_enabled(): return _err(rid, 4015, "voice mode is off — enable with /voice on") with _voice_sid_lock: - global _voice_event_sid + global _voice_event_sid, _voice_wake_owner _voice_event_sid = params.get("session_id") or _voice_event_sid from hermes_cli.voice import start_continuous @@ -17905,30 +17971,39 @@ def _(rid, params: dict) -> dict: # Hand the mic to STT if the wake-word detector holds it; resume # once a terminal capture event fires (one-shot transcript / silence # limit), so wake-triggered and manual captures both coexist. - if _wake_is_active(): - try: - from tools.wake_word import pause_listening - pause_listening() - except Exception: - pass + try: + from tools.wake_word import pause_listening + + wake_paused = pause_listening(owner=transport) + except Exception: + wake_paused = False + if wake_paused: + with _voice_sid_lock: + _voice_wake_owner = transport def _on_transcript(t): _voice_emit("voice.transcript", {"text": t}) - _wake_resume_if_active() + _resume_voice_wake() def _on_silent(): _voice_emit("voice.transcript", {"no_speech_limit": True}) - _wake_resume_if_active() + _resume_voice_wake() + + def _on_status(state): + _voice_emit("voice.status", {"state": state}) + if state == "idle": + _resume_voice_wake() started = start_continuous( on_transcript=_on_transcript, - on_status=lambda s: _voice_emit("voice.status", {"state": s}), + on_status=_on_status, on_silent_limit=_on_silent, silence_threshold=safe_threshold, silence_duration=safe_duration, auto_restart=False, ) if started is False: + _resume_voice_wake() return _ok(rid, {"status": "busy"}) return _ok(rid, {"status": "recording"}) @@ -17939,13 +18014,17 @@ def _(rid, params: dict) -> dict: from hermes_cli.voice import stop_continuous stop_continuous(force_transcribe=True) - _wake_resume_if_active() + _resume_voice_wake() return _ok(rid, {"status": "stopped"}) except ImportError: + if wake_paused or action == "stop": + _resume_voice_wake() return _err( rid, 5025, "voice module not available — install audio dependencies" ) except Exception as e: + if wake_paused or action == "stop": + _resume_voice_wake() return _err(rid, 5025, str(e)) diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py index 795f85c13e7..c6057efd685 100644 --- a/tui_gateway/ws.py +++ b/tui_gateway/ws.py @@ -422,6 +422,11 @@ async def handle_ws(ws: Any) -> None: server.unregister_live_transport(transport) transport.close() + try: + await asyncio.to_thread(server._release_wake_for_transport, transport) + except Exception: + _log.exception("ws wake-word teardown failed peer=%s", peer) + # Reap sessions this transport owned (close_on_disconnect sidecar # sessions) or detach the rest to the drop sentinel so later emits # don't crash into a closed socket or fall through to desktop stdout diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 42bf75e74d2..87647079529 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -779,6 +779,7 @@ describe('createGatewayEventHandler', () => { expect(ctx.gateway.rpc).not.toHaveBeenCalled() }) +<<<<<<< HEAD it('picks the polarity-matching paired palette from gateway.ready skins', async () => { const appended: Msg[] = [] @@ -851,6 +852,65 @@ describe('createGatewayEventHandler', () => { expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined() }) + it('claims wake-word ownership when the gateway becomes ready', () => { + const ctx = buildCtx([]) + + createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) + + expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' }) + }) + + it('opens a fresh session before starting voice after wake detection', async () => { + const ctx = buildCtx([]) + ctx.session.newSession = vi.fn(async () => patchUiState({ sid: 'wake-session' })) + patchUiState({ sid: 'old-session' }) + + createGatewayEventHandler(ctx)({ + payload: { phrase: 'hey hermes', start_new_session: true }, + type: 'wake.detected' + } as any) + + await vi.waitFor(() => + expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', { + action: 'start', + session_id: 'wake-session' + }) + ) + expect(ctx.session.newSession).toHaveBeenCalledOnce() + expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(true) + }) + + it('keeps the current session when wake detection disables session creation', async () => { + const ctx = buildCtx([]) + patchUiState({ sid: 'current-session' }) + + createGatewayEventHandler(ctx)({ + payload: { phrase: 'hey hermes', start_new_session: false }, + type: 'wake.detected' + } as any) + + await vi.waitFor(() => + expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', { + action: 'start', + session_id: 'current-session' + }) + ) + expect(ctx.session.newSession).not.toHaveBeenCalled() + }) + + it('rearms wake detection when no session is available', async () => { + const ctx = buildCtx([]) + patchUiState({ sid: '' }) + + createGatewayEventHandler(ctx)({ + payload: { start_new_session: false }, + type: 'wake.detected' + } as any) + + await vi.waitFor(() => expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.resume', {})) + expect(ctx.gateway.rpc).not.toHaveBeenCalledWith('voice.record', expect.anything()) + }) + it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => { const appended: Msg[] = [] const newSession = vi.fn() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 9d4132e93a9..49c55fc358f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -623,7 +623,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // Arm "Hey Hermes" if this surface owns it (server gates on config). // Fire-and-forget + idempotent server-side, so reconnects are harmless. - void rpc('wake.start', { surface: 'tui' }) + void rpc('wake.start', { surface: 'tui' }).catch(() => undefined) rpc('commands.catalog', {}) .then(r => { @@ -947,14 +947,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (ev.payload?.start_new_session !== false) { await newSession() } + const sid = getUiState().sid + if (!sid) { + await rpc('wake.resume', {}).catch(() => undefined) + return } + setVoiceEnabled(true) await rpc('voice.toggle', { action: 'on' }) await rpc('voice.record', { action: 'start', session_id: sid }) - })() + })().catch((e: unknown) => { + sys(`wake: ${rpcErrorMessage(e)}`) + + void rpc('wake.resume', {}).catch(() => undefined) + }) return } diff --git a/website/docs/user-guide/features/overview.md b/website/docs/user-guide/features/overview.md index cb3eef22109..094b2962260 100644 --- a/website/docs/user-guide/features/overview.md +++ b/website/docs/user-guide/features/overview.md @@ -32,7 +32,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch ## Media & Web - **[Voice Mode](voice-mode.md)** — 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. -- **[Wake Word](wake-word.md)** — Hands-free "Hey Hermes" trigger for the CLI. An on-device hotword listener starts a fresh voice session when you speak the wake phrase, the "Hey Siri" way. +- **[Wake Word](wake-word.md)** — Hands-free "Hey Hermes" trigger for the CLI, TUI, and desktop app. An on-device hotword listener starts a voice session when you speak the wake phrase. - **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome/Brave/Chromium/Edge via CDP, or local Chromium. Navigate websites, fill forms, and extract information. - **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model. - **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Eleven models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo, Krea V2 Medium/Large); pick one via `hermes tools`. diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 57c5b5457d8..e9e87c87260 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -63,7 +63,7 @@ wake_word: ```yaml wake_word: enabled: false - surface: auto # which surface owns the listener: "auto" | "cli" | "tui" | "gui" + surface: auto # eligible surface: "auto" | "cli" | "tui" | "gui" provider: openwakeword # "openwakeword" (free, local) | "porcupine" phrase: "hey jarvis" # cosmetic label only — detection is keyed by the model/keyword below sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers @@ -85,15 +85,19 @@ owns the listener and opens the new session when it fires: | `surface` | Behavior | |-----------|----------| -| `auto` (default) | Whichever surface you launch arms the listener. | +| `auto` (default) | All local surfaces are eligible; the first one to arm owns the listener. | | `cli` | Only the classic `hermes` CLI. | | `tui` | Only `hermes --tui`. | | `gui` | Only the desktop app. | -The detector is on-device and single-mic, so only one surface listens at a time -— `surface` is how you pin it. The TUI and desktop GUI share the same Python -backend (`tui_gateway`), which runs the detector server-side and yields the mic -to voice capture while a command records. +The detector is on-device and single-mic, so only one surface listens at a time, +including when Hermes surfaces run in separate processes. Ownership is sticky: +the first eligible claimant keeps the listener until it stops, disconnects, or +its process exits. Hermes does not silently fail over to another open surface. +Set `surface` when you want to pin ownership instead of using first-claim wins. +The TUI and desktop GUI share the same Python backend (`tui_gateway`), which +runs the detector server-side and yields the mic to voice capture while a +command records. ## Using a real "Hey Hermes" From 9f84bc30bd73e351a2e6c99c919e09879a3b4e9d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 20:15:29 -0700 Subject: [PATCH 14/46] feat(voice): bundle the trained "hey hermes" model as the out-of-the-box default From #53378: ships hey_hermes.onnx/.tflite (openWakeWord pipeline, Apache-2.0) under tools/wakewords/, resolves the default (and hey_hermes aliases) to the bundled file, ensures openWakeWord base feature models are fetched for custom paths too, and updates config defaults + docs from hey_jarvis to hey hermes. --- hermes_cli/config.py | 11 +- tests/tools/test_wake_word.py | 86 +++- tools/wake_word.py | 50 +- tools/wakewords/README.md | 20 + tools/wakewords/hey_hermes.onnx | Bin 0 -> 205430 bytes tools/wakewords/hey_hermes.tflite | Bin 0 -> 206764 bytes .../createGatewayEventHandler.test.ts | 1 - uv.lock | 437 +++++++++++++----- website/docs/user-guide/features/wake-word.md | 29 +- 9 files changed, 476 insertions(+), 158 deletions(-) create mode 100644 tools/wakewords/README.md create mode 100644 tools/wakewords/hey_hermes.onnx create mode 100644 tools/wakewords/hey_hermes.tflite diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 15acd118536..7ef9ad267b2 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2381,14 +2381,15 @@ DEFAULT_CONFIG = { "enabled": False, "surface": "auto", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui" "provider": "openwakeword", # "openwakeword" (free, local) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) - "phrase": "hey jarvis", # cosmetic label only; detection is keyed by the engine model/keyword below + "phrase": "hey hermes", # cosmetic label only; detection is keyed by the engine model/keyword below "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) "start_new_session": True, # start a fresh session on wake vs. continue the current one "openwakeword": { - # Built-in model name ("hey_jarvis", "alexa", "hey_mycroft", ...) or - # a path to a custom .onnx/.tflite model. Train a "hey hermes" model - # and point this at it — see the wake-word docs. - "model": "hey_jarvis", + # "hey_hermes" (the bundled, works-out-of-the-box default) OR a + # built-in openWakeWord name ("hey_jarvis", "alexa", "hey_mycroft", + # ...) OR a path to a custom .onnx/.tflite model for another phrase. + # See the wake-word docs for the custom-model training guide. + "model": "hey_hermes", "inference_framework": "onnx", # "onnx" | "tflite" }, "porcupine": { diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 7c65eedadd4..d2800e099b2 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -7,6 +7,8 @@ process-wide singleton lifecycle. """ import multiprocessing +import os +import sys import threading import time import types @@ -27,7 +29,7 @@ def test_config_defaults_and_clamping(): assert ww._sensitivity({"sensitivity": -1}) == 0.0 assert ww._sensitivity({"sensitivity": "nope"}) == 0.5 assert ww.wake_phrase({"phrase": "hey hermes"}) == "hey hermes" - assert ww.wake_phrase({}) == "hey jarvis" + assert ww.wake_phrase({}) == "hey hermes" def test_wake_surface_enabled_gate(): @@ -110,6 +112,88 @@ def test_requirements_unavailable_without_audio(monkeypatch): assert r["audio_available"] is False +# ── openWakeWord engine (bundled model + base-model fetch) ─────────────── + + +def _install_fake_openwakeword(monkeypatch): + """Swap in a fake ``openwakeword`` so the engine builds with no network. + + Returns a ``calls`` dict recording every ``download_models`` invocation. + """ + calls = {"download": []} + + class _FakeModel: + def __init__(self, wakeword_models, inference_framework="onnx"): + self.wakeword_models = list(wakeword_models) + self.models = {"hey_hermes": object()} + + def predict(self, frame): + return {"hey_hermes": 0.0} + + def reset(self): + pass + + oww = types.ModuleType("openwakeword") + oww.utils = types.SimpleNamespace( + download_models=lambda names=[]: calls["download"].append(list(names)) + ) + model_mod = types.ModuleType("openwakeword.model") + model_mod.Model = _FakeModel + + monkeypatch.setitem(sys.modules, "openwakeword", oww) + monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + return calls + + +def test_openwakeword_ensures_base_models_for_custom_path(monkeypatch): + # Regression: a custom ``.onnx`` path used to skip download_models entirely, + # so a fresh install crashed at load time on a missing melspectrogram.onnx. + # The base feature models must be ensured for a custom path too. + calls = _install_fake_openwakeword(monkeypatch) + eng = ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"model": "/models/hey_hermes.onnx"}} + ) + assert calls["download"] == [["/models/hey_hermes.onnx"]] + assert eng._labels == ["hey_hermes"] + + +def test_openwakeword_fetches_builtin_by_name(monkeypatch): + calls = _install_fake_openwakeword(monkeypatch) + ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": {"model": "hey_jarvis"}}) + assert calls["download"] == [["hey_jarvis"]] + + +def test_bundled_hey_hermes_model_ships_on_disk(): + # The "hey hermes" wake word works out of the box only if the model is + # actually bundled. Both framework artifacts must exist and be non-trivial. + for framework in ("onnx", "tflite"): + path = ww._bundled_wakeword_path(framework) + assert os.path.exists(path), path + assert os.path.getsize(path) > 1024, path + + +@pytest.mark.parametrize("model_value", [None, "", "hey_hermes", "hey hermes", "HEY_HERMES"]) +def test_openwakeword_default_resolves_to_bundled_model(monkeypatch, model_value): + # The default (and any "hey_hermes" alias) must load the bundled file, not be + # passed through as a bogus built-in name that openWakeWord can't resolve. + calls = _install_fake_openwakeword(monkeypatch) + sub = {} if model_value is None else {"model": model_value} + ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": sub}) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("onnx")] + + +def test_openwakeword_bundled_model_matches_framework(monkeypatch): + calls = _install_fake_openwakeword(monkeypatch) + ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"inference_framework": "tflite"}} + ) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("tflite")] + assert downloaded[0].endswith(".tflite") + + # ── Detector loop ──────────────────────────────────────────────────────── diff --git a/tools/wake_word.py b/tools/wake_word.py index fb13e0a597a..ab365ffb0b1 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -8,10 +8,10 @@ pipeline, then answers. Two engines, both fully on-device (no audio leaves the machine for detection): -* **openwakeword** (default, free, no API key) — loads a pretrained or custom - ONNX model. Ships with ``hey_jarvis``, ``alexa``, ``hey_mycroft``, … ; point - ``wake_word.openwakeword.model`` at a custom ``.onnx`` to detect a real - "hey hermes" (training guide in the wake-word docs). +* **openwakeword** (default, free, no API key) — loads an ONNX model. Defaults + to the bundled "hey hermes" model (``tools/wakewords/``) so the wake word + works out of the box; or point ``wake_word.openwakeword.model`` at a built-in + name (``hey_jarvis``, ``alexa``, …) or a custom ``.onnx`` for another phrase. * **porcupine** (premium) — Picovoice's engine. Needs ``PORCUPINE_ACCESS_KEY``; supports built-in keywords and custom ``.ppn`` files from the Picovoice Console. @@ -57,11 +57,22 @@ _DEFAULTS: Dict[str, Any] = { "enabled": False, "surface": "auto", "provider": "openwakeword", - "phrase": "hey jarvis", + "phrase": "hey hermes", "sensitivity": 0.5, "start_new_session": True, } +# Bundled "hey hermes" model (tools/wakewords/) — the default, so the wake word +# works out of the box. Config names in _ALIASES resolve to it, not a built-in. +_BUNDLED_MODEL_NAME = "hey_hermes" +_BUNDLED_MODEL_ALIASES = frozenset({"", "hey_hermes", "hey hermes", "hermes"}) + + +def _bundled_wakeword_path(framework: str = "onnx") -> str: + """Path to the shipped hey_hermes model (.onnx/.tflite) for ``framework``.""" + ext = "tflite" if str(framework).strip().lower() == "tflite" else "onnx" + return os.path.join(os.path.dirname(__file__), "wakewords", f"{_BUNDLED_MODEL_NAME}.{ext}") + def load_wake_word_config() -> Dict[str, Any]: """Return the ``wake_word`` config section, shape-guarded to a dict.""" @@ -95,7 +106,7 @@ def _sensitivity(cfg: Dict[str, Any]) -> float: def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str: """Human-facing wake phrase label (purely cosmetic; engine keys detection).""" cfg = cfg if cfg is not None else load_wake_word_config() - return str(_get(cfg, "phrase")) or "hey jarvis" + return str(_get(cfg, "phrase")) or "hey hermes" def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool: @@ -174,20 +185,25 @@ class _OpenWakeWordEngine(_Engine): from openwakeword.model import Model sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {} - model_ref = str(sub.get("model") or "hey_jarvis").strip() + model_ref = str(sub.get("model") or _BUNDLED_MODEL_NAME).strip() framework = str(sub.get("inference_framework") or "onnx").strip().lower() self._threshold = _sensitivity(cfg) - if _looks_like_path(model_ref): - models = [model_ref] - else: - # Pretrained name (e.g. "hey_jarvis"). Best-effort one-time fetch - # of the bundled models; harmless if already present / offline. - try: - openwakeword.utils.download_models([model_ref]) - except Exception as e: # pragma: no cover - network/path dependent - logger.debug("openwakeword model download skipped: %s", e) - models = [model_ref] + # Default (or explicit "hey_hermes") → the bundled model; a built-in name + # or custom path is used as-is. + if model_ref.lower() in _BUNDLED_MODEL_ALIASES: + model_ref = _bundled_wakeword_path(framework) + + # openWakeWord needs its shared feature models (melspectrogram + embedding) + # for ANY model — download_models() fetches those first on every call, so a + # custom path must call it too, else a fresh install crashes on a missing + # melspectrogram.onnx. A built-in name additionally pulls that pretrained + # model; a path matches nothing in the catalog and is a no-op beyond base. + try: + openwakeword.utils.download_models([model_ref]) + except Exception as e: # pragma: no cover - network/path dependent + logger.debug("openwakeword model download skipped: %s", e) + models = [model_ref] self._model = Model(wakeword_models=models, inference_framework=framework) self._labels = list(self._model.models.keys()) diff --git a/tools/wakewords/README.md b/tools/wakewords/README.md new file mode 100644 index 00000000000..439bbae4aae --- /dev/null +++ b/tools/wakewords/README.md @@ -0,0 +1,20 @@ +# Bundled wake-word models + +`hey_hermes.onnx` / `hey_hermes.tflite` — the on-device "Hey Hermes" hotword +model. This is the default detector for the wake word feature (see +`website/docs/user-guide/features/wake-word.md`); no training or setup is +required to say "hey hermes". + +- **Engine:** [openWakeWord](https://github.com/dscripka/openWakeWord) (Apache-2.0). +- **Provenance:** trained with the openWakeWord training pipeline (synthetic + TTS-generated speech), which produces both the `.onnx` and `.tflite` artifacts. + Redistribution is permitted under the openWakeWord license. +- **Label:** the model registers as `hey_hermes` (matches the filename). +- **Runtime:** openWakeWord's shared feature-extraction models (melspectrogram + + embedding) are NOT bundled here — they are fetched once on first use by + `tools/wake_word.py` via `openwakeword.utils.download_models()`. + +To use a different phrase, train your own model and point +`wake_word.openwakeword.model` at its path, or set a built-in openWakeWord name +(`hey_jarvis`, `alexa`, `hey_mycroft`, …). See the wake-word docs for the +training guide. diff --git a/tools/wakewords/hey_hermes.onnx b/tools/wakewords/hey_hermes.onnx new file mode 100644 index 0000000000000000000000000000000000000000..a730b34747fa56dab1637e432f9ed6bc0fcfc783 GIT binary patch literal 205430 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

)Rf@aH-1 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 m~-w^ih5>4jZA!F61h(KPgkmSKA>l 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 >>0(Nv*eThZ;!<{_{d8 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 literal 0 HcmV?d00001 diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 87647079529..451edcfc591 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -779,7 +779,6 @@ describe('createGatewayEventHandler', () => { expect(ctx.gateway.rpc).not.toHaveBeenCalled() }) -<<<<<<< HEAD it('picks the polarity-matching paired palette from gateway.ready skins', async () => { const appended: Msg[] = [] diff --git a/uv.lock b/uv.lock index f0b075a08ee..a66e66aa999 100644 --- a/uv.lock +++ b/uv.lock @@ -249,7 +249,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0 [[package]] name = "alibabacloud-tea-openapi" -version = "0.4.5" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alibabacloud-credentials" }, @@ -258,9 +258,9 @@ dependencies = [ { name = "cryptography" }, { name = "darabonba-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" }, ] [[package]] @@ -675,14 +675,14 @@ wheels = [ [[package]] name = "click" -version = "8.4.2" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -721,47 +721,47 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, - { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -793,17 +793,15 @@ wheels = [ [[package]] name = "darabonba-core" -version = "1.0.8" +version = "1.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "alibabacloud-tea" }, { name = "requests" }, - { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/83/9321ccdb7a800c2cb97d8fa34bead5f20141f27f804594fd1fd815c4cd07/darabonba_core-1.0.8.tar.gz", hash = "sha256:f1661960b368e342d3d36434be82d264b70a01c49e843921d8a4dacd217376ae", size = 27604, upload-time = "2026-07-13T02:07:34.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/88/38800ca22f39a31fdb75c7b2867c61d3af5e2792cee0b72942a639c88a79/darabonba_core-1.0.8-py3-none-any.whl", hash = "sha256:ac093fdd40f88f2f9dfbbbfd7bc143495a3cb031f35b397c98d24edfa6b69483", size = 30957, upload-time = "2026-07-13T02:07:33.138Z" }, + { url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" }, ] [[package]] @@ -1720,6 +1718,13 @@ voice = [ { name = "numpy" }, { name = "sounddevice" }, ] +wake = [ + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "openwakeword" }, + { name = "pvporcupine" }, + { name = "sounddevice" }, +] web = [ { name = "fastapi" }, { name = "python-multipart" }, @@ -1753,7 +1758,7 @@ requires-dist = [ { name = "certifi", specifier = "==2026.5.20" }, { name = "concurrent-log-handler", marker = "sys_platform == 'win32'", specifier = "==0.9.29" }, { name = "croniter", specifier = "==6.0.0" }, - { name = "cryptography", specifier = "==48.0.1" }, + { name = "cryptography", specifier = "==46.0.7" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, { name = "defusedxml", marker = "extra == 'wecom'", specifier = "==0.7.1" }, @@ -1809,7 +1814,10 @@ requires-dist = [ { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = ">=0.5,<1.0" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, + { name = "numpy", marker = "extra == 'wake'", specifier = "==2.4.3" }, + { name = "onnxruntime", marker = "extra == 'wake'", specifier = "==1.27.0" }, { name = "openai", specifier = "==2.24.0" }, + { name = "openwakeword", marker = "extra == 'wake'", specifier = "==0.6.0" }, { name = "packaging", specifier = "==26.0" }, { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, { name = "pathspec", specifier = "==1.1.1" }, @@ -1817,13 +1825,14 @@ requires-dist = [ { name = "prompt-toolkit", specifier = "==3.0.52" }, { name = "psutil", specifier = "==7.2.2" }, { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" }, + { name = "pvporcupine", marker = "extra == 'wake'", specifier = "==4.0.3" }, { name = "pydantic", specifier = "==2.13.4" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = ">=0.0.9,<1" }, - { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.32" }, + { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.27" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306,<312" }, @@ -1843,10 +1852,11 @@ requires-dist = [ { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" }, { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, - { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.3.1" }, - { name = "starlette", marker = "extra == 'dev'", specifier = "==1.3.1" }, - { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.3.1" }, - { name = "starlette", marker = "extra == 'web'", specifier = "==1.3.1" }, + { name = "sounddevice", marker = "extra == 'wake'", specifier = "==0.5.5" }, + { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" }, + { name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" }, + { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.0.1" }, + { name = "starlette", marker = "extra == 'web'", specifier = "==1.0.1" }, { name = "supermemory", marker = "extra == 'supermemory'", specifier = "==3.50.0" }, { name = "tenacity", specifier = "==9.1.4" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, @@ -1857,22 +1867,30 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "wake", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/d0/73454ef7ca885598a3194d07d5c517d91a840753c5b35d272600d7907f64/hf_xet-1.3.1.tar.gz", hash = "sha256:513aa75f8dc39a63cc44dbc8d635ccf6b449e07cdbd8b2e2d006320d2e4be9bb", size = 641393, upload-time = "2026-02-25T00:57:56.701Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, - { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, - { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, - { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, + { url = "https://files.pythonhosted.org/packages/56/79/9b6a5614230d7a871442d8d8e1c270496821638ba3a9baac16a5b9166200/hf_xet-1.3.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:08b231260c68172c866f7aa7257c165d0c87887491aafc5efeee782731725366", size = 3759716, upload-time = "2026-02-25T00:57:41.052Z" }, + { url = "https://files.pythonhosted.org/packages/d4/de/72acb8d7702b3cf9b36a68e8380f3114bf04f9f21cf9e25317457fe31f00/hf_xet-1.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0810b69c64e96dee849036193848007f665dca2311879c9ea8693f4fc37f1795", size = 3518075, upload-time = "2026-02-25T00:57:39.605Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/ed728d8530fec28da88ee882b522fccf00dc98e9d7bae4cdb0493070cb17/hf_xet-1.3.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ecd38f98e7f0f41108e30fd4a9a5553ec30cf726df7473dd3e75a1b6d56728c2", size = 4174369, upload-time = "2026-02-25T00:57:32.697Z" }, + { url = "https://files.pythonhosted.org/packages/3c/db/785a0e20aa3086948a26573f1d4ff5c090e63564bf0a52d32eb5b4d82e8d/hf_xet-1.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65411867d46700765018b1990eb1604c3bf0bf576d9e65fc57fdcc10797a2eb9", size = 3953249, upload-time = "2026-02-25T00:57:30.096Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6a/51b669c1e3dbd9374b61356f554e8726b9e1c1d6a7bee5d727d3913b10ad/hf_xet-1.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1684c840c60da12d76c2a031ba40e4b154fdbf9593836fcf5ff090d95a033c61", size = 4152989, upload-time = "2026-02-25T00:57:48.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/31/de07e26e396f46d13a09251df69df9444190e93e06a9d30d639e96c8a0ed/hf_xet-1.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3012c0f2ce1f0863338491a2bc0fd3f84aded0e147ab25f230da1f5249547fd", size = 4390709, upload-time = "2026-02-25T00:57:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c1/fcb010b54488c2c112224f55b71f80e44d1706d9b764a0966310b283f86e/hf_xet-1.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:4eb432e1aa707a65a7e1f8455e40c5b47431d44fe0fb1b0c5d53848c27469398", size = 3634142, upload-time = "2026-02-25T00:57:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/9ef49cc601c68209979661b3e0b6659fc5a47bfb40f3ebf29eae9ee09e5c/hf_xet-1.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:e56104c84b2a88b9c7b23ba11a2d7ed0ccbe96886b3f985a50cedd2f0e99853f", size = 3494918, upload-time = "2026-02-25T00:57:57.654Z" }, + { url = "https://files.pythonhosted.org/packages/75/f8/c2da4352c0335df6ae41750cf5bab09fdbfc30d3b4deeed9d621811aa835/hf_xet-1.3.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:581d1809a016f7881069d86a072168a8199a46c839cf394ff53970a47e4f1ca1", size = 3761755, upload-time = "2026-02-25T00:57:43.621Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e5/a2f3eaae09da57deceb16a96ebe9ae1f6f7b9b94145a9cd3c3f994e7782a/hf_xet-1.3.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:329c80c86f2dda776bafd2e4813a46a3ee648dce3ac0c84625902c70d7a6ddba", size = 3523677, upload-time = "2026-02-25T00:57:42.3Z" }, + { url = "https://files.pythonhosted.org/packages/61/cd/acbbf9e51f17d8cef2630e61741228e12d4050716619353efc1ac119f902/hf_xet-1.3.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2973c3ff594c3a8da890836308cae1444c8af113c6f10fe6824575ddbc37eca7", size = 4178557, upload-time = "2026-02-25T00:57:35.399Z" }, + { url = "https://files.pythonhosted.org/packages/df/4f/014c14c4ae3461d9919008d0bed2f6f35ba1741e28b31e095746e8dac66f/hf_xet-1.3.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ed4bfd2e6d10cb86c9b0f3483df1d7dd2d0220f75f27166925253bacbc1c2dbe", size = 3958975, upload-time = "2026-02-25T00:57:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/86/50/043f5c5a26f3831c3fa2509c17fcd468fd02f1f24d363adc7745fbe661cb/hf_xet-1.3.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:713913387cc76e300116030705d843a9f15aee86158337eeffb9eb8d26f47fcd", size = 4158298, upload-time = "2026-02-25T00:57:51.14Z" }, + { url = "https://files.pythonhosted.org/packages/08/9c/b667098a636a88358dbeb2caf90e3cb9e4b961f61f6c55bb312793424def/hf_xet-1.3.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5063789c9d21f51e9ed4edbee8539655d3486e9cad37e96b7af967da20e8b16", size = 4395743, upload-time = "2026-02-25T00:57:52.783Z" }, + { url = "https://files.pythonhosted.org/packages/70/37/4db0e4e1534270800cfffd5a7e0b338f2137f8ceb5768000147650d34ea9/hf_xet-1.3.1-cp37-abi3-win_amd64.whl", hash = "sha256:607d5bbc2730274516714e2e442a26e40e3330673ac0d0173004461409147dee", size = 3638145, upload-time = "2026-02-25T00:58:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/4e/46/1ba8d36f8290a4b98f78898bdce2b0e8fe6d9a59df34a1399eb61a8d877f/hf_xet-1.3.1-cp37-abi3-win_arm64.whl", hash = "sha256:851b1be6597a87036fe7258ce7578d5df3c08176283b989c3b165f94125c5097", size = 3500490, upload-time = "2026-02-25T00:58:00.667Z" }, ] [[package]] @@ -2003,22 +2021,23 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.24.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, + { name = "shellingham" }, { name = "tqdm" }, + { name = "typer-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848, upload-time = "2026-07-17T09:54:01.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904, upload-time = "2026-07-17T09:53:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" }, ] [[package]] @@ -2141,6 +2160,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "jsonpath-python" version = "1.1.6" @@ -2450,15 +2478,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/aa/f0ffbe6bf679a597e8be692ca3cde47de6156435c2b72cf752fec719bb1f/modal-1.3.4-py3-none-any.whl", hash = "sha256:d66a851969f447936b3512f1c3708435ce1ca81171eeddc3eb0678f594493380", size = 773837, upload-time = "2026-02-23T15:44:03.635Z" }, ] -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - [[package]] name = "msal" version = "1.36.0" @@ -2601,6 +2620,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + [[package]] name = "nemo-relay" version = "0.5.0" @@ -2741,33 +2769,32 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.24.4" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flatbuffers" }, { name = "numpy" }, { name = "packaging" }, { name = "protobuf" }, - { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/60/69/6c40720201012c6af9aa7d4ecdd620e521bd806dc6269d636fdd5c5aeebe/onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2", size = 17332131, upload-time = "2026-03-17T22:05:49.005Z" }, - { url = "https://files.pythonhosted.org/packages/38/e9/8c901c150ce0c368da38638f44152fb411059c0c7364b497c9e5c957321a/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7", size = 15152472, upload-time = "2026-03-17T22:03:26.176Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993, upload-time = "2026-03-17T22:04:34.485Z" }, - { url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863, upload-time = "2026-03-17T22:05:38.749Z" }, - { url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895, upload-time = "2026-03-17T22:05:28.812Z" }, - { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, - { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, - { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, - { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, - { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, - { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1f/a2117aa3f144fce88774efa37440d0ca72d0c9144854dfc0961f2b04c6fc/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb083321af8a236a84c7c140a7f4cecbfa2a987a18c07c78db471c20cd390ef", size = 16419330, upload-time = "2026-06-15T22:42:37.58Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/74bb804170ceb622fda9111df31a07b3024f7491472256d3a90b5391a4d2/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4f7b0e90d2d212e2c2deaa6c8291616183ab815d3ec558ea12d3ac8b26d36f4", size = 18636930, upload-time = "2026-06-15T22:43:01.584Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8f/5b8e2b85e81735696887175dbaf6409f215683f5ca9d4928fbb038211d32/onnxruntime-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff050e4f6bf7f12918fa14dcb047c0b02e295f35e86d42532552be4b3d54e977", size = 13356110, upload-time = "2026-06-15T22:43:32.172Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/4f568de678126b6a371a93862f015a82138359decd97fcac61fc84b5b774/onnxruntime-1.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:75fbc1e1fb43a39a856c8209c544cca7817b5de7ac16b15b1bdf55d1cc67b9df", size = 13098635, upload-time = "2026-06-15T22:43:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/8ec9db1a4d126bb8b758992beb40d1249df171917d75f44a327eb5f20dda/onnxruntime-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:20c321cf187ba496e648acf6b4cf90b4d398b0d17c2a77fdaeba365b908cc1c1", size = 13358769, upload-time = "2026-06-15T22:43:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/fdad359dfcba7e7cd8815569b304a596531d4efa77a75d77f8b4981891a2/onnxruntime-1.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:d0d1f68868e2ef30ef70998ba9bbbc5c305e9b17041e3936751c1b8aa6aade06", size = 13104440, upload-time = "2026-06-15T22:43:22.893Z" }, + { url = "https://files.pythonhosted.org/packages/fb/2b/54208fd03ad410480bc17edf4869376362da8bbf46fe186ddf4cb5cc20fe/onnxruntime-1.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b3e5b58b8c89c2b20e086e890aa9527377e5c240dc3ecc1640d18e07705eeb1c", size = 18432958, upload-time = "2026-06-15T22:42:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/24fc51fcbb126da6d032372314e47b55c3faad58f2aa78c0e199ccd20b9c/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b3d87eb560ff6a772240506f3c78d6d27c63cafedd5c775672e1194f968cfd", size = 16438180, upload-time = "2026-06-15T22:42:43.093Z" }, + { url = "https://files.pythonhosted.org/packages/cb/19/14929c3c2fe0b79b41cce24463062bf3afa4cdd3c19dccf00319caa92bff/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6872443f236a554921cda6f318c900e2d0c226792cf3534d00e5057c6926e5d2", size = 18658445, upload-time = "2026-06-15T22:43:08.053Z" }, + { url = "https://files.pythonhosted.org/packages/7f/76/59ed932b0244acd7bbbd6449480053a6d958ea66357f022f932872e19287/onnxruntime-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:760021bca514d64a811837820d351a08a41741f16f8b4c26450da708fecf14e6", size = 13357856, upload-time = "2026-06-15T22:43:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/d1ec60ec7b1e2ae2d7340ba52b8a13529140039cd4407ba8dddbbc046582/onnxruntime-1.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:2fdfa9df40a0ded0028ce6f9cd863264237f3970559dea2b81456e9ac4622b94", size = 13104412, upload-time = "2026-06-15T22:43:27.457Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/e6bb1c6445c94f708c38cd8fbb7bf0264108c33498b9445c93e60fe6d329/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54c0c4e9202c36c4ecdb1f3443f5dfbfd5ee3b54d1362c4b4c6134110e74fb32", size = 16443331, upload-time = "2026-06-15T22:42:45.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/b18b31e806eabc41077810199fbbb36fbc2d5f19912416e5ccfbf73053d1/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b215aa662c8f983f7d6dedafe65a9be72c26e5338e0fe98b3e0422c32c85428", size = 18670967, upload-time = "2026-06-15T22:43:10.621Z" }, ] [[package]] @@ -2911,6 +2938,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, ] +[[package]] +name = "openwakeword" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "onnxruntime" }, + { name = "requests" }, + { name = "scikit-learn" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tflite-runtime", marker = "sys_platform == 'linux'" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/73b7d98b07f4e1f525ad39703e0c5f30ff61c3fa16c8bfe4d99eadc0567a/openwakeword-0.6.0.tar.gz", hash = "sha256:36858d90f1183e307485597a912a4e3c3384b14ea9923f83feaffae7c1565565", size = 70830, upload-time = "2024-02-11T20:56:17.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/33/dafd6822bebe463a9098951d06a0d88fb4f8c946ce087025bc4fa132e533/openwakeword-0.6.0-py3-none-any.whl", hash = "sha256:6f423a4e3ae9dd0e3cd12b50ff8abf69679f687b4ab349d7c82c021c0e2abc9d", size = 60690, upload-time = "2024-02-11T20:56:16.179Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -3183,6 +3228,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] +[[package]] +name = "pvporcupine" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/37/db209e19c4e1d931d1752bdf05c763f119271bb79661d482bdf5f564f662/pvporcupine-4.0.3.tar.gz", hash = "sha256:87d0e4d743a13c3a15b1fb34a9ced66e14bb1125ae079f2e2c09423364a68386", size = 3643620, upload-time = "2026-06-25T21:58:11.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/ef/1c4b8e47d8248fe1b615772028265ca65d9ff3ea98022d84cd973d46db87/pvporcupine-4.0.3-py3-none-any.whl", hash = "sha256:92796dbd3cf80a56db1ce20702cbceb151aee34f19ef730591f815eb16f2ebfb", size = 3659883, upload-time = "2026-06-25T21:58:08.805Z" }, +] + [[package]] name = "pyasn1" version = "0.6.3" @@ -3457,11 +3514,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.32" +version = "0.0.27" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] [[package]] @@ -3846,6 +3903,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -3986,15 +4166,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.3.1" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] [[package]] @@ -4014,18 +4194,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/be/caf3b7d4b21851c7d8ddec7661f22089d95bb55bc7b4bdd79dea1001604e/supermemory-3.50.0-py3-none-any.whl", hash = "sha256:f6e2dd142934ec213d561414aeb0164ee408a34d339b84ed93440f03f4ca2290", size = 155533, upload-time = "2026-06-24T09:29:12.012Z" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - [[package]] name = "synchronicity" version = "0.11.1" @@ -4065,6 +4233,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "tflite-runtime" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/a6/02d68cb62cd221589a0ff055073251d883936237c9c990e34a1d7cecd06f/tflite_runtime-2.14.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:195ab752e7e57329a68e54dd3dd5439fad888b9bff1be0f0dc042a3237a90e4d", size = 2414486, upload-time = "2023-10-03T21:15:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e9/5fc0435129c23c17551fcfadc82bd0d5482276213dfbc641f07b4420cb6d/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ce9fa5d770a9725c746dcbf6f59f3178233b3759f09982e8b2db8d2234c333b0", size = 2325913, upload-time = "2023-10-03T21:15:46.348Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/e246c39d92929655bac8878d76406d6fb0293c678237e55621e7ece4a269/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_armv7l.whl", hash = "sha256:c4e66a74165b18089c86788400af19fa551768ac782d231a9beae2f6434f7949", size = 1820588, upload-time = "2023-10-03T21:15:48.399Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tokenizers" version = "0.22.2" @@ -4168,6 +4358,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] +[[package]] +name = "typer-slim" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, +] + [[package]] name = "types-certifi" version = "2021.10.8.3" @@ -4378,15 +4580,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - [[package]] name = "websockets" version = "15.0.1" diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index e9e87c87260..cdd4499bdb1 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -32,14 +32,18 @@ It is **off by default** — nothing listens until you turn it on. | Engine | Cost | API key | Notes | |--------|------|---------|-------| -| **openWakeWord** (default) | Free | None | Local ONNX models. Ships with `hey_jarvis`, `alexa`, `hey_mycroft`, … | +| **openWakeWord** (default) | Free | None | Local ONNX models. Ships a bundled **"hey hermes"** model (default); also supports `hey_jarvis`, `alexa`, `hey_mycroft`, … and custom models | | **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files | +By default the phrase is **"hey hermes"** — a model for it ships with Hermes, so +it works out of the box with no training. (On first use, openWakeWord downloads +its shared feature-extraction models — a small one-time fetch.) + Both are lazy-installed the first time you enable the wake word. To install ahead of time: ```bash -uv pip install 'hermes-agent[wake]' # or: pip install 'hermes-agent[wake]' +cd ~/.hermes/hermes-agent && uv pip install -e ".[wake]" ``` ## Quick start @@ -65,11 +69,11 @@ wake_word: enabled: false surface: auto # eligible surface: "auto" | "cli" | "tui" | "gui" provider: openwakeword # "openwakeword" (free, local) | "porcupine" - phrase: "hey jarvis" # cosmetic label only — detection is keyed by the model/keyword below + phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers start_new_session: true # start a fresh session on wake vs. continue the current one openwakeword: - model: hey_jarvis # built-in name OR path to a custom .onnx/.tflite + model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .onnx/.tflite inference_framework: onnx # "onnx" | "tflite" porcupine: keyword: jarvis # built-in keyword OR path to a custom .ppn @@ -99,24 +103,25 @@ The TUI and desktop GUI share the same Python backend (`tui_gateway`), which runs the detector server-side and yields the mic to voice capture while a command records. -## Using a real "Hey Hermes" +## Using a different phrase -The bundled openWakeWord models do **not** include "hey hermes" — `hey_jarvis` -is the free, instantly-working default. To detect the literal phrase you supply -your own model and point the config at it: +"Hey Hermes" works out of the box — the bundled openWakeWord model +(`model: hey_hermes`) is the default. To wake on something else, either name a +built-in openWakeWord model or supply your own: ### Option A — openWakeWord (free) -Train a custom model (≈75–90 min on a free/Colab GPU), then drop the `.onnx` -file somewhere and reference it: +Name a built-in model (`hey_jarvis`, `alexa`, `hey_mycroft`, …), or train a +custom model (≈75–90 min on a free/Colab GPU), drop the `.onnx` file somewhere, +and reference it: ```yaml wake_word: enabled: true provider: openwakeword - phrase: "hey hermes" + phrase: "computer" openwakeword: - model: ~/.hermes/wakewords/hey_hermes.onnx + model: ~/.hermes/wakewords/computer.onnx # or a built-in name like hey_jarvis ``` Training references: From dcc26fa28a3054434ed48d26d3fbf47fde9fae87 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:20:56 -0700 Subject: [PATCH 15/46] chore: map contributor omid3098@gmail.com -> omid3098 --- contributors/emails/omid3098@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/omid3098@gmail.com diff --git a/contributors/emails/omid3098@gmail.com b/contributors/emails/omid3098@gmail.com new file mode 100644 index 00000000000..d5e0895e274 --- /dev/null +++ b/contributors/emails/omid3098@gmail.com @@ -0,0 +1 @@ +omid3098 From 0ae305ed4efbc3a0a34026afa185e5c063b2f051 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:39:19 -0700 Subject: [PATCH 16/46] feat(voice): open-vocabulary wake phrases via sherpa-onnx KWS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "sherpa" wake_word provider: the configured phrase is BPE-tokenized at runtime against a small streaming zipformer KWS model (~13 MB English, one-time download cached under HERMES_HOME), so ANY typed phrase works with zero training — including per-profile phrases like "hey coder". wake.sherpa lazy-dep group + [wake] extra grow sherpa-onnx/sentencepiece; requirements probe routes per provider; sensitivity maps onto sherpa keywords_threshold. E2E-verified on real audio (target phrase fires, foreign phrase stays silent, reset drops buffered state). --- hermes_cli/config.py | 9 +- pyproject.toml | 10 +- tests/tools/test_wake_word.py | 108 +++++++++++++ tools/lazy_deps.py | 9 ++ tools/wake_word.py | 148 +++++++++++++++++- uv.lock | 72 +++++++++ website/docs/user-guide/features/wake-word.md | 28 +++- 7 files changed, 371 insertions(+), 13 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7ef9ad267b2..5c443f4e818 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2380,8 +2380,8 @@ DEFAULT_CONFIG = { "wake_word": { "enabled": False, "surface": "auto", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui" - "provider": "openwakeword", # "openwakeword" (free, local) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) - "phrase": "hey hermes", # cosmetic label only; detection is keyed by the engine model/keyword below + "provider": "openwakeword", # "openwakeword" (free, local) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) + "phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label — detection is keyed by the model/keyword below "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) "start_new_session": True, # start a fresh session on wake vs. continue the current one "openwakeword": { @@ -2392,6 +2392,11 @@ DEFAULT_CONFIG = { "model": "hey_hermes", "inference_framework": "onnx", # "onnx" | "tflite" }, + "sherpa": { + # Optional path to a sherpa-onnx KWS model directory. Empty = + # auto-download the small English zipformer model on first use. + "model_dir": "", + }, "porcupine": { # Built-in keyword ("jarvis", "computer", "bumblebee", ...) or a path # to a custom .ppn from the Picovoice Console. diff --git a/pyproject.toml b/pyproject.toml index d757cd0fc9a..ac2318ea6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,12 +175,16 @@ voice = [ "sounddevice==0.5.5", "numpy==2.4.3", ] -# "Hey Hermes" wake word — on-device hotword detection. Both engines are -# optional; openWakeWord (ONNX) is the free default, Porcupine the premium -# alternative. Lazy-installed on first /wake; mirrored in tools/lazy_deps.py. +# "Hey Hermes" wake word — on-device hotword detection. All engines are +# optional; openWakeWord (ONNX) is the free default, sherpa-onnx adds +# open-vocabulary phrases (any typed phrase, zero training), Porcupine is +# the premium alternative. Lazy-installed on first /wake; mirrored in +# tools/lazy_deps.py. wake = [ "openwakeword==0.6.0", "onnxruntime==1.27.0", + "sherpa-onnx==1.13.4", + "sentencepiece==0.2.2", "pvporcupine==4.0.3", "sounddevice==0.5.5", "numpy==2.4.3", diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index d2800e099b2..c21e41def0f 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -194,6 +194,114 @@ def test_openwakeword_bundled_model_matches_framework(monkeypatch): assert downloaded[0].endswith(".tflite") +# ── sherpa-onnx open-vocabulary engine ─────────────────────────────────── + + +def _install_fake_sherpa(monkeypatch, tmp_path): + """Fake sherpa_onnx + a fake model dir so the engine builds offline.""" + calls = {"text2token": [], "spotter": [], "results": []} + + model_dir = tmp_path / "kws-model" + model_dir.mkdir() + for name in ( + "tokens.txt", + "bpe.model", + "encoder-epoch-12-avg-2-chunk-16-left-64.onnx", + "decoder-epoch-12-avg-2-chunk-16-left-64.onnx", + "joiner-epoch-12-avg-2-chunk-16-left-64.onnx", + ): + (model_dir / name).write_bytes(b"x") + + class _FakeStream: + def accept_waveform(self, sample_rate, samples): + pass + + class _FakeSpotter: + def __init__(self, **kwargs): + calls["spotter"].append(kwargs) + + def create_stream(self): + return _FakeStream() + + def is_ready(self, stream): + return bool(calls["results"]) + + def decode_stream(self, stream): + pass + + def get_result(self, stream): + return calls["results"].pop(0) if calls["results"] else "" + + def reset_stream(self, stream): + pass + + def _fake_text2token(phrases, tokens, tokens_type, bpe_model): + calls["text2token"].append(list(phrases)) + return [p.split() for p in phrases] + + sherpa = types.ModuleType("sherpa_onnx") + sherpa.KeywordSpotter = _FakeSpotter + sherpa.text2token = _fake_text2token + monkeypatch.setitem(sys.modules, "sherpa_onnx", sherpa) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + return calls, model_dir + + +def test_sherpa_engine_tokenizes_configured_phrase_at_runtime(monkeypatch, tmp_path): + # The open-vocab core: the phrase from config is tokenized at runtime — + # no per-phrase model, no training artifact. + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", + "phrase": "purple monkey dishwasher", + "sherpa": {"model_dir": str(model_dir)}, + }) + assert calls["text2token"] == [["PURPLE MONKEY DISHWASHER"]] + # keywords file was materialized with an underscored display name + with open(eng._keywords_file) as f: + line = f.read().strip() + assert line.endswith("@PURPLE_MONKEY_DISHWASHER") + eng.close() + assert not os.path.exists(eng._keywords_file) + + +def test_sherpa_engine_process_fires_and_resets(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", + "sherpa": {"model_dir": str(model_dir)}, + }) + frame = [0] * eng.frame_length + assert eng.process(frame) is False # no result queued + calls["results"].append("HEY_HERMES") + assert eng.process(frame) is True # queued result → fire + old_stream = eng._stream + eng.reset() + assert eng._stream is not old_stream # fresh decoder state + + +def test_sherpa_provider_routing(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + for alias in ("sherpa", "sherpa-onnx", "kws", "open"): + eng = ww._build_engine({ + "provider": alias, "phrase": "x", + "sherpa": {"model_dir": str(model_dir)}, + }) + assert isinstance(eng, ww._SherpaKwsEngine) + + +def test_sherpa_requirements_probe_uses_sherpa_feature(monkeypatch): + seen = {} + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr( + "tools.lazy_deps.is_available", lambda f: seen.setdefault("feature", f) or True + ) + r = ww.check_wake_word_requirements({"provider": "sherpa", "phrase": "anything at all"}) + assert seen["feature"] == "wake.sherpa" + assert r["provider"] == "sherpa" + assert r["phrase"] == "anything at all" + + # ── Detector loop ──────────────────────────────────────────────────────── diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index d92ab158b58..67c7833dfa8 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -146,6 +146,15 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "sounddevice==0.5.5", "numpy==2.4.3", ), + # Open-vocabulary keyword spotting: any typed phrase, zero training. + # sentencepiece is required by sherpa_onnx.text2token (runtime phrase + # tokenization) even though sherpa-onnx doesn't declare it. + "wake.sherpa": ( + "sherpa-onnx==1.13.4", + "sentencepiece==0.2.2", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), "wake.porcupine": ( "pvporcupine==4.0.3", "sounddevice==0.5.5", diff --git a/tools/wake_word.py b/tools/wake_word.py index ab365ffb0b1..48f68335713 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -6,12 +6,16 @@ desktop GUI (one of them owns it, gated by ``wake_surface_enabled``): say the wake word, Hermes opens a fresh session and captures voice via the existing pipeline, then answers. -Two engines, both fully on-device (no audio leaves the machine for detection): +Three engines, all fully on-device (no audio leaves the machine for detection): * **openwakeword** (default, free, no API key) — loads an ONNX model. Defaults to the bundled "hey hermes" model (``tools/wakewords/``) so the wake word works out of the box; or point ``wake_word.openwakeword.model`` at a built-in name (``hey_jarvis``, ``alexa``, …) or a custom ``.onnx`` for another phrase. +* **sherpa** (free, no API key, open vocabulary) — sherpa-onnx keyword + spotting. Detects ANY typed phrase with no training: set + ``wake_word.phrase`` and the phrase is tokenized at runtime against a small + streaming zipformer model (~13 MB English model, one-time download). * **porcupine** (premium) — Picovoice's engine. Needs ``PORCUPINE_ACCESS_KEY``; supports built-in keywords and custom ``.ppn`` files from the Picovoice Console. @@ -224,6 +228,139 @@ class _OpenWakeWordEngine(_Engine): self.reset() +# sherpa-onnx open-vocabulary KWS model: a small streaming zipformer +# transducer. English (GigaSpeech); one-time download, cached under +# HERMES_HOME. Keywords are typed phrases tokenized at RUNTIME — no +# training step, unlike openWakeWord/Porcupine custom models. +_SHERPA_KWS_MODEL_URL = ( + "https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/" + "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01.tar.bz2" +) +_SHERPA_KWS_MODEL_DIR = "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01" + + +def _sherpa_model_root() -> Path: + from hermes_constants import get_hermes_home + + return get_hermes_home() / "cache" / "wakewords" + + +def _ensure_sherpa_model(root: Optional[Path] = None) -> Path: + """Download + unpack the sherpa KWS model once; return its directory.""" + root = root or _sherpa_model_root() + target = root / _SHERPA_KWS_MODEL_DIR + if (target / "tokens.txt").exists(): + return target + import tarfile + import urllib.request + + root.mkdir(parents=True, exist_ok=True) + archive = root / f"{_SHERPA_KWS_MODEL_DIR}.tar.bz2" + logger.info("wake word: downloading sherpa KWS model (one-time, ~13 MB)") + urllib.request.urlretrieve(_SHERPA_KWS_MODEL_URL, archive) # noqa: S310 + with tarfile.open(archive, "r:bz2") as tf: + tf.extractall(root, filter="data") + archive.unlink(missing_ok=True) + if not (target / "tokens.txt").exists(): + raise RuntimeError(f"sherpa KWS model unpack failed: {target}") + return target + + +class _SherpaKwsEngine(_Engine): + """sherpa-onnx open-vocabulary keyword spotting — any typed phrase, zero training. + + The configured ``wake_word.phrase`` is BPE-tokenized at runtime against the + model's vocabulary, so "hey hermes", "hey coder", or any other phrase works + immediately. Here ``phrase`` is DETECTION config, not a cosmetic label. + """ + + # sherpa's streaming zipformer consumes arbitrary chunk sizes; 1280 + # samples (80 ms) matches the shared capture path. + frame_length = 1280 + + def __init__(self, cfg: Dict[str, Any]): + from tools import lazy_deps + + lazy_deps.ensure("wake.sherpa", prompt=False) + + import sherpa_onnx + from sherpa_onnx import text2token + + sub = cfg.get("sherpa") if isinstance(cfg.get("sherpa"), dict) else {} + model_dir = str(sub.get("model_dir") or "").strip() + d = Path(model_dir) if model_dir else _ensure_sherpa_model() + if not (d / "tokens.txt").exists(): + raise RuntimeError(f"sherpa KWS model not found at {d}") + + phrase = str(_get(cfg, "phrase") or "hey hermes").strip() + # Runtime tokenization of the arbitrary phrase — the open-vocab core. + # sherpa keyword entries reject spaces in the @display-name; underscore it. + tokens = text2token( + [phrase.upper()], + tokens=str(d / "tokens.txt"), + tokens_type="bpe", + bpe_model=str(d / "bpe.model"), + ) + import tempfile + + kw = tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", prefix="hermes-kws-", delete=False + ) + display = phrase.upper().replace(" ", "_") + kw.write(" ".join(tokens[0]) + f" @{display}\n") + kw.close() + self._keywords_file = kw.name + + # Map the shared 0..1 sensitivity onto sherpa's keywords_threshold + # (posterior probability; its default is 0.25). + threshold = 0.1 + 0.5 * _sensitivity(cfg) + + def _model_file(pattern: str) -> str: + hits = sorted(d.glob(pattern)) + if not hits: + raise RuntimeError(f"sherpa KWS model file missing: {d}/{pattern}") + return str(hits[0]) + + self._spotter = sherpa_onnx.KeywordSpotter( + tokens=str(d / "tokens.txt"), + encoder=_model_file("encoder-*[!8].onnx"), + decoder=_model_file("decoder-*[!8].onnx"), + joiner=_model_file("joiner-*[!8].onnx"), + keywords_file=self._keywords_file, + keywords_threshold=threshold, + num_threads=1, + ) + self._stream = self._spotter.create_stream() + + def process(self, frame) -> bool: + import numpy as np + + samples = np.asarray(frame, dtype=np.float32) / 32768.0 + self._stream.accept_waveform(SAMPLE_RATE, samples) + fired = False + while self._spotter.is_ready(self._stream): + self._spotter.decode_stream(self._stream) + if self._spotter.get_result(self._stream): + fired = True + # Reset decoder state so one utterance can't fire repeatedly. + self._spotter.reset_stream(self._stream) + return fired + + def reset(self) -> None: + # Fresh stream drops all buffered audio/decoder state (pause → resume + # must not re-fire on stale audio). + try: + self._stream = self._spotter.create_stream() + except Exception: + pass + + def close(self) -> None: + try: + os.unlink(self._keywords_file) + except OSError: + pass + + class _PorcupineEngine(_Engine): """Picovoice Porcupine — premium, on-device, needs an access key.""" @@ -269,6 +406,8 @@ def _build_engine(cfg: Dict[str, Any]) -> _Engine: provider = _provider(cfg) if provider == "porcupine": return _PorcupineEngine(cfg) + if provider in ("sherpa", "sherpa-onnx", "kws", "open"): + return _SherpaKwsEngine(cfg) if provider in ("openwakeword", "oww", "local"): return _OpenWakeWordEngine(cfg) raise ValueError(f"Unknown wake_word provider: {provider!r}") @@ -284,7 +423,12 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s provider = _provider(cfg) from tools import lazy_deps - feature = "wake.porcupine" if provider == "porcupine" else "wake.openwakeword" + if provider == "porcupine": + feature = "wake.porcupine" + elif provider in ("sherpa", "sherpa-onnx", "kws", "open"): + feature = "wake.sherpa" + else: + feature = "wake.openwakeword" deps_ok = lazy_deps.is_available(feature) audio_ok = _audio_available() key_ok = True diff --git a/uv.lock b/uv.lock index a66e66aa999..61ff1e6f1a9 100644 --- a/uv.lock +++ b/uv.lock @@ -1723,6 +1723,8 @@ wake = [ { name = "onnxruntime" }, { name = "openwakeword" }, { name = "pvporcupine" }, + { name = "sentencepiece" }, + { name = "sherpa-onnx" }, { name = "sounddevice" }, ] web = [ @@ -1845,7 +1847,9 @@ requires-dist = [ { name = "rich", specifier = "==14.3.3" }, { name = "ruamel-yaml", specifier = "==0.18.17" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, + { name = "sentencepiece", marker = "extra == 'wake'", specifier = "==0.2.2" }, { name = "setuptools", marker = "extra == 'dev'", specifier = "==81.0.0" }, + { name = "sherpa-onnx", marker = "extra == 'wake'", specifier = "==1.13.4" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.29.0" }, { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.29.0" }, @@ -4026,6 +4030,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, ] +[[package]] +name = "sentencepiece" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/33/ea3cb3839607eb175da835244a798f797f478c5ddf0e8ecdf57ea85a4c70/sentencepiece-0.2.2.tar.gz", hash = "sha256:3d2b5e824b5622038dc7b490897efe05ebbbb9e7350fc142f3ecc8789ef9bdf6", size = 8218435, upload-time = "2026-07-12T08:39:34.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/31/f23a2efaa0210b883574001b88fa64e499f798f0848a0b610fb9b384d162/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:69e9dc8078e128286ed3b975e37c837ba96e215a50c3ef9f3f8b7ab9e5a832a0", size = 2184255, upload-time = "2026-07-12T08:38:14.855Z" }, + { url = "https://files.pythonhosted.org/packages/96/f2/1ee0ccb772d71e822f625d6cb5f0ea825835e877f28a9ef299a1291df19e/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6dd76f3e5c8b2eb8a3a3efee787bbf5b9a66e52a048fe09cab85eca33fec6790", size = 1438545, upload-time = "2026-07-12T08:38:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/2a/92/3a6ea4a2c6dd9e7062698a5a33534ca0e20844883338ae9c6b9c122c1a9f/sentencepiece-0.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:443ac618c7a2a1377cf5c82581fbb849591d14e656d5e5a3e4682d4e36a34e4e", size = 1346997, upload-time = "2026-07-12T08:38:18.499Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3a/7839048997c7bc0c34c57526f539f835e20c7a57dc2a99f99579b11cdbef/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e2aae42960392d6dcb9a72d8e1e65a97294c965071b43c7b3429a42f350250e", size = 1324282, upload-time = "2026-07-12T08:38:20.342Z" }, + { url = "https://files.pythonhosted.org/packages/06/5f/9117bf854aef817ad0d0ee9310eed0308a7e529e7eaf2e80ad9cd281ef82/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1416b92f2f010333786fe6306ed2631121d5ea492219b0841e967b6765e64107", size = 1394242, upload-time = "2026-07-12T08:38:22.976Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/9e2569867e3dcff7ad6d89642a9615b9801b5cd698abe7df3b490361f66e/sentencepiece-0.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:70d4ca6f4d06df7f0ccab6fe4f49c8a712c8c8b6847b4f0af9a0e1dbb0e0337e", size = 1246268, upload-time = "2026-07-12T08:38:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/96/c9/5d781d4ef1124564a45c98b9ff25d531c10cdf568ec6314a2d1946f9251c/sentencepiece-0.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:252908153eeec06c3ca3a32077e64a49d572e3d89881475b4e0f02d99d9fcc7c", size = 1190702, upload-time = "2026-07-12T08:38:26.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/13/7a562289c8d5b49ebdf3f9c1e8ab67cf14a8743b1d90c8f406bfdec36b72/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1edb10e520e4bddf74d85b0f5ae74cc2d60c2b448885080bfb618bc2b3a49f6b", size = 2188384, upload-time = "2026-07-12T08:38:28.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/d1/912f14fd5eae168aba726ffb6a9a2dc1c71fe7676c53da6f5c442b886d4a/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7c06c751c19d923435a54bff4f7e66e728fad160e8da28254f133abc9725820", size = 1441553, upload-time = "2026-07-12T08:38:30.552Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/caa9cab5f261a019e2808bc5046152775dc57352ba9cbae7525e9e7a1ed4/sentencepiece-0.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38111ed1f79268f399c505028023d5eaaf0ab4e5eafceb709468b0d3323e7838", size = 1347176, upload-time = "2026-07-12T08:38:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/cd798935668cff71d309d8ff10385844ecf216b1fe454f1993ed8bf2cb91/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbce24284f51f71d10a42b7b9c964dcb9048b28f1c8e5db40bcbcb6f428cba6a", size = 1325200, upload-time = "2026-07-12T08:38:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/37e3da037318a70066ded0d51bc2a7f35491ae6338dd993d5eb1503fc3b5/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da", size = 1397736, upload-time = "2026-07-12T08:38:35.771Z" }, + { url = "https://files.pythonhosted.org/packages/8d/11/753fca2e6b109be3ab7867abf357dfe48677fe726ae5a5363d0b54ca9450/sentencepiece-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383", size = 1248030, upload-time = "2026-07-12T08:38:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0a/70efbe861ca182d7d4b6e1a20f58e043400848fa9f2915229f082e221648/sentencepiece-0.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d", size = 1187325, upload-time = "2026-07-12T08:38:39.348Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/b3b05095c174d6e80d37d5ddc2f57c2c56237333e7bbd6079cf3243c2a8a/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77c3ce990b23441e5ecfa5bce181fd6f408b564aeb6d7e1d1e7de9c5612501c8", size = 2188346, upload-time = "2026-07-12T08:38:41.089Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/72ebc4acb10a06bcf7503fbc6091c8f5db68300f6aac4356c09e6c76e0e1/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd523c4992041faa5c2b3cde62253d11a96c30d73a34afe48a486e8e2254cd1c", size = 1441434, upload-time = "2026-07-12T08:38:42.56Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a", size = 1347267, upload-time = "2026-07-12T08:38:44.389Z" }, + { url = "https://files.pythonhosted.org/packages/32/4f/31c1073314ad94466bca37d29581761d70110237ee3d46b0efece59a8c1e/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eed98514bffe5ecac37f493f91869c351fbb05629328bfdbc08502c6c094dc0", size = 1324980, upload-time = "2026-07-12T08:38:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/59/b4/a0356fa04d6a14337a6e0e443556785a0422c53ec58baae6b9568120eb0f/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64b656f025355cf8c51abe9fbe3848540756c6d7ca5e6791b1afa664bc24c7cb", size = 1397593, upload-time = "2026-07-12T08:38:48.302Z" }, + { url = "https://files.pythonhosted.org/packages/09/fa/d2d6369257fd2f0de616b1c7110b73fab409ef61b14f1b9e0010ed325914/sentencepiece-0.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:74f0ee601047c0c12a783088b51be4e6214a62ecd9e02278c477433cd16e0ed9", size = 1247987, upload-time = "2026-07-12T08:38:50.15Z" }, + { url = "https://files.pythonhosted.org/packages/17/ee/2bb594da6fd95e32f29057f1aa7fa996701b8980090923c2d8711fdc0a24/sentencepiece-0.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:b23fe17779834d3c27aaf2edac9486d04cca1a7deb8f5facda35150ac6263a91", size = 1187250, upload-time = "2026-07-12T08:38:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/58/9c/dfc82846460e7a712310f5613f23d8b553cabb4e2e648663c11d8382af56/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:72b7825b331b1b7e7c45be2e674b3e3c65af608fa376bad2d851b20aaf0cdc78", size = 2223080, upload-time = "2026-07-12T08:38:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/3ff12cebe6d31662d9ceeabfb282de20bd0d6098fa282b4a3b8305abc7e8/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d795c4ac689a57f9d4ba2288126ec7901d389ad5827d2f8b8533c883974fe563", size = 1458511, upload-time = "2026-07-12T08:38:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/5a/16d51d05360be4cee3ebfe4837c184054c4eed16cabaeb3b039524e9a000/sentencepiece-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ab3f1ae98970b5590e2209341522718900ba19bcc2c207ffaa6bd417ad960c5", size = 1361138, upload-time = "2026-07-12T08:38:58.808Z" }, + { url = "https://files.pythonhosted.org/packages/0f/af/c30ee2a9f99d51db9844acaa8fa0b611a97c2fa7116646fa43db3300b187/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec27c152a1f1b24bc9168b55a5880f3c16e2334e697da6f55a1046a22405a3d", size = 1328625, upload-time = "2026-07-12T08:39:00.849Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1a/4c6b39d03f5ba8439509adbd5a23c9538088a3cb679e7a47b911e8442bc6/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59d6588712101ccfcae9b03692be3aaae1514c2078666d7b05f15ba3a702e41b", size = 1398595, upload-time = "2026-07-12T08:39:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/9eedddcec1fd57bc70200fa3ebf792d18fa63527a5369581cd416c81f97f/sentencepiece-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:89625fb43765cccaa1443b9adb61f283e5fe4cb1536728205d06bada730caa53", size = 1259346, upload-time = "2026-07-12T08:39:04.559Z" }, + { url = "https://files.pythonhosted.org/packages/41/15/7e74c8533848866ff560b29f7d8719921b76c4ec7149592d6d28e0deee75/sentencepiece-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f0603267cd15b92b68c2c0e852a441507614b70dc7773659baa6b8c214a91fd", size = 1196596, upload-time = "2026-07-12T08:39:06.454Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -4044,6 +4084,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/f8/735244770b4bc63f85fabdad0e46d6ec1f4cc24e64f6e082c2e0fea92b8c/sherpa_onnx-1.13.4.tar.gz", hash = "sha256:29547692418513ad88034c2b5f98985e33042b2351e4ab375469f19a8de18c5f", size = 982750, upload-time = "2026-07-07T13:04:55.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/4f/6ab541c5b2a4b2a6e9970edc4b558f270f7b6624c861eccb85126ccd279c/sherpa_onnx-1.13.4-cp311-cp311-linux_armv7l.whl", hash = "sha256:8e1cdbd53b432630a81ea479ce5bad6aa8192eb4a458d8c9432c54052cb9cc7d", size = 11930819, upload-time = "2026-07-07T14:22:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/1d/41/b750ec336f882e75c5e23c9ad5d52b0902be2337cc50d13ce68cde9e4459/sherpa_onnx-1.13.4-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:5d35aeb5ad13b54cea0d6fed681660f6308acb841de981745e33d457255b9134", size = 4349088, upload-time = "2026-07-07T13:14:19.408Z" }, + { url = "https://files.pythonhosted.org/packages/41/c8/a2ff828ce9a2702b607c25f6303b21d7d41b6ad1520f660b95a0113f4051/sherpa_onnx-1.13.4-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c3e9f96a07570faefca8e3aabcfb78690e680d188d5d44d06fd8711185fe37d6", size = 2288314, upload-time = "2026-07-07T12:08:36.643Z" }, + { url = "https://files.pythonhosted.org/packages/e8/93/4385fcdb1f197521fe13fa887893cc200d27569d3cbcccf0d7b92d6a9e62/sherpa_onnx-1.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2d4337b80b54dd68f566cab941a2ad47ab6cfabb68e88002ee0c920493c16d3", size = 2100029, upload-time = "2026-07-07T12:23:26.936Z" }, + { url = "https://files.pythonhosted.org/packages/ea/cb/c80832f800719c72fc5805a94fe489e805fc67156e102927609917ad8f67/sherpa_onnx-1.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7c4a1c178eb801af92f70120128c1f956b5388b9d684f0af2a08614e05dc3047", size = 4132838, upload-time = "2026-07-07T11:55:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ac/88b4e1ce614ddebe2484e95cad4b19d7db24f3b489d04f9877667cb48ccb/sherpa_onnx-1.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbfc385b98d730080e1b12dc94c604be3867e4fb7bd6b15b830eb33cbf390111", size = 4356406, upload-time = "2026-07-07T12:42:29.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/73/2fff5d28669e91851e981d223c50aa21f90d712a4551dcb51c231b3a27fe/sherpa_onnx-1.13.4-cp311-cp311-win32.whl", hash = "sha256:1d746b8c6ed1ce9eb94868d71b5ea9c22274b8cb166420bd1772f7df470b753a", size = 1927745, upload-time = "2026-07-07T12:33:50.17Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/ae1c113ac9c67dcabbed559f50950c7220a62e49e6b5acb4c2219ab22409/sherpa_onnx-1.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:04a82d79c13a4ce2bd9ccf51de93e83cbfc7bc50520c53e5e967565100d0724d", size = 2239901, upload-time = "2026-07-07T12:22:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/179e3a6c1fec33aa6535051feddd5da36e5622d35630b12a67a2805b76b3/sherpa_onnx-1.13.4-cp312-cp312-linux_armv7l.whl", hash = "sha256:bcf64f2d853a1afe236e9e220df62f2f53ef6ad792ca7e406d6173ec003319b8", size = 11933213, upload-time = "2026-07-07T13:50:35.989Z" }, + { url = "https://files.pythonhosted.org/packages/e8/34/b6d3483b08ec8a4a141e978c4b92530fd0a61dd571a575c1fe24bee300d7/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:2257545ea170f58b7977309793979d6d078761b7fdb0528561285f8ead4169db", size = 4422157, upload-time = "2026-07-07T12:29:40.234Z" }, + { url = "https://files.pythonhosted.org/packages/82/37/07f03e97f157b206f6e62d722ec7c5ff41c7e9dc6aa2dc7de69b57e39b5a/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:02b57dc2c829976eb842e6aee6a0e4ac3b9991aeb5afa89fd44eb71d848a4ecd", size = 2345135, upload-time = "2026-07-07T12:10:19.957Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/ee999f0c3b7789077d0939716a38234573d139f851a31409aa028fe2c610/sherpa_onnx-1.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:84e58b5a074b97c5307c9b6221d1d20fbf412a1a5dff4960ca9c32bb5184219f", size = 2105219, upload-time = "2026-07-07T11:48:22.518Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/9b67ed3e7adc79daf0ba49c4936a691521488125b04fe469b64a8b5398ff/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f709e6dd02ebf7d37dcb02d5eadc5fb66c9922dd5809df770c1ef5d625ae7a44", size = 4135963, upload-time = "2026-07-07T11:58:30.98Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b1/8dfe5d1d72c92ea1c95db999a95b61bfbb9769f1c569f06e572eda095c52/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f0158f3513d3adab1ebba0c26f0c815e53ba13b96846d92ef095ae25d648860", size = 4358555, upload-time = "2026-07-07T12:58:59.654Z" }, + { url = "https://files.pythonhosted.org/packages/d4/04/cfd543933ae24430d124e533847c73a84a5f0efd60f07bbc6403032f9624/sherpa_onnx-1.13.4-cp312-cp312-win32.whl", hash = "sha256:d49928a3455bae1dd4e93f6b013cfbd2c3ccb5cde74aabae3710b656e7d79b6b", size = 1930647, upload-time = "2026-07-07T12:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/1e723ab703a1e354f390de19981ec0c347576f87be01915d826dc6fc9f41/sherpa_onnx-1.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:b8436ffe2763b3fd522fbac8fe53f47d611721c84819c241acfb65d122403d7d", size = 2244142, upload-time = "2026-07-07T13:01:42.293Z" }, + { url = "https://files.pythonhosted.org/packages/4b/36/45b17335f041f1383f6fd142ab57c2d8a337ba2386b7547b125ec9d780af/sherpa_onnx-1.13.4-cp313-cp313-linux_armv7l.whl", hash = "sha256:9e98dc5e0559ad953f227fc884958c71b10c65a93667331405e7d4441ed5f76d", size = 11932654, upload-time = "2026-07-07T14:18:59.791Z" }, + { url = "https://files.pythonhosted.org/packages/68/d7/1e9a7dedab2da8af1a8417b4f4d5f496bd7700a71b59c5e085de5e10761b/sherpa_onnx-1.13.4-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:083747c2d0362ead0501cc773a618be19862a800b3f8f259d3bd3486f1494af4", size = 4372494, upload-time = "2026-07-07T13:02:05.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/09aa9461e8bdf894ba8466e047e40fb5da8aaa6d68c19cf1e2aabe01e706/sherpa_onnx-1.13.4-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b4f54363b264b16148a724b4442f00cda97fcd4e9beeda3d75637753910e8557", size = 2307539, upload-time = "2026-07-07T12:28:22.056Z" }, + { url = "https://files.pythonhosted.org/packages/21/ed/d07787dd4be4119e6587c840f6b417c2d57c14d694d334af609d68cb5a41/sherpa_onnx-1.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ec5394b4ea73bf01e6883cf078348f87350f4eb3567d51d92cae77ea2582403", size = 2115586, upload-time = "2026-07-07T12:47:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/281d84dc9e448ea99d7fb77708cbe1cc7cfd8c7d669727dc94385a9e4ca5/sherpa_onnx-1.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a39352ceb2ec6671a1f252fb768fff75bb2f0bc849cca5f66f490e89910a860d", size = 4136268, upload-time = "2026-07-07T12:10:09.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/47/da3ea14ab647a4f6580227853fe29353e1173ff77064d42c0bb31d01b453/sherpa_onnx-1.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88af596be24eac32982dd64fcac30af99d9130ca498bfa1a0064189c8498195b", size = 4358385, upload-time = "2026-07-07T13:10:10.265Z" }, + { url = "https://files.pythonhosted.org/packages/44/90/84205ff383ba9335c3821c2cd6d514350f52199cb640ec094517f1f911a0/sherpa_onnx-1.13.4-cp313-cp313-win32.whl", hash = "sha256:0cabb508a15be22138f9fb7695d7ec5f3893ecd088ee419b9df559fed7e8f649", size = 1929707, upload-time = "2026-07-07T12:51:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/82/40/ee8a0a8c83fc6d7f5245a5a031e471d3b115e20cce867e7abb2f9d4185c9/sherpa_onnx-1.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:17050fdfb48d37ae996364f697c554a1399740d18e5a56b143c011d00cfed3e0", size = 2244504, upload-time = "2026-07-07T12:32:59.882Z" }, +] + [[package]] name = "simple-term-menu" version = "1.6.6" diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index cdd4499bdb1..582ef1e9778 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -33,6 +33,7 @@ It is **off by default** — nothing listens until you turn it on. | Engine | Cost | API key | Notes | |--------|------|---------|-------| | **openWakeWord** (default) | Free | None | Local ONNX models. Ships a bundled **"hey hermes"** model (default); also supports `hey_jarvis`, `alexa`, `hey_mycroft`, … and custom models | +| **sherpa** | Free | None | **Open vocabulary** — detects ANY typed phrase with zero training. Small English model auto-downloads on first use (~13 MB) | | **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files | By default the phrase is **"hey hermes"** — a model for it ships with Hermes, so @@ -106,14 +107,29 @@ command records. ## Using a different phrase "Hey Hermes" works out of the box — the bundled openWakeWord model -(`model: hey_hermes`) is the default. To wake on something else, either name a -built-in openWakeWord model or supply your own: +(`model: hey_hermes`) is the default. To wake on something else, the easiest +path is the open-vocabulary engine: -### Option A — openWakeWord (free) +### Option A — sherpa (any phrase, zero training) + +Type the phrase you want; it's tokenized at runtime — "hey coder", +"computer", "wake up neo", anything: + +```yaml +wake_word: + enabled: true + provider: sherpa + phrase: "hey coder" # detection key — just type your phrase +``` + +The small English KWS model (~13 MB) downloads once on first use. Each +profile can set its own phrase — "hey \" for every profile you run. + +### Option B — openWakeWord (free, trained model) Name a built-in model (`hey_jarvis`, `alexa`, `hey_mycroft`, …), or train a -custom model (≈75–90 min on a free/Colab GPU), drop the `.onnx` file somewhere, -and reference it: +custom model (≈75–90 min on a free/Colab GPU) for maximum robustness, drop +the `.onnx` file somewhere, and reference it: ```yaml wake_word: @@ -135,7 +151,7 @@ syllables with an uncommon word ("hermes" qualifies) beat common words like "hello" or "stop". ::: -### Option B — Porcupine (custom keyword in seconds) +### Option C — Porcupine (custom keyword in seconds) Create a "Hey Hermes" keyword in the [Picovoice Console](https://console.picovoice.ai/), download the `.ppn`, and: From 8177457cd1a1c0011245499f7948f87970906d33 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:39:19 -0700 Subject: [PATCH 17/46] feat(desktop): wake-word toggle button in the composer Ear icon next to the voice controls: highlighted while listening, muted when off, hidden when the backend reports the wake word unavailable. Backed by a feature-owned $wakeWord nanostore synced by both the button (wake.start/stop) and the gateway-ready auto-arm (status-then-arm), so the UI always reflects the real listener state; start refusals surface their reason as a tooltip notice. i18n en/zh/zh-hant/ja. --- .../src/app/chat/composer/controls.tsx | 49 +++- apps/desktop/src/app/contrib/wiring.tsx | 5 +- apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/lib/icons.ts | 4 + apps/desktop/src/store/wake-word.test.ts | 240 ++++++++++++++++++ apps/desktop/src/store/wake-word.ts | 183 +++++++++++++ 10 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/store/wake-word.test.ts create mode 100644 apps/desktop/src/store/wake-word.ts diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 996b962f544..647a7647ba5 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,10 +1,13 @@ +import { useStore } from '@nanostores/react' + import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' +import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $wakeWord, toggleWakeWord } from '@/store/wake-word' import type { ConversationStatus } from './hooks/use-voice-conversation' import { ModelPill } from './model-pill' @@ -80,6 +83,7 @@ export function ComposerControls({ + {busyAction === 'steer' ? ( }> + + ) +} + function DictationButton({ disabled, state, diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index a8359290af2..3ba86e4741f 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -55,6 +55,7 @@ import { setMessages } from '@/store/session' import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' +import { armWakeWord } from '@/store/wake-word' import { isSecondaryWindow } from '@/store/windows' import { useSkinCommand } from '@/themes/use-skin-command' @@ -701,7 +702,9 @@ export function ContribWiring({ children }: { children: ReactNode }) { useEffect(() => { if (gatewayState === 'open') { - void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined) + // Status-then-arm, syncing $wakeWord so the composer toggle reflects the + // same listener this auto-arm claims. + void armWakeWord(requestGateway) } }, [gatewayState, requestGateway]) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7e973fcc1b8..cb20d06c7bc 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1958,6 +1958,8 @@ export const en: Translations = { voiceDictation: 'Voice dictation', speakReplies: 'Read replies aloud', stopSpeakingReplies: 'Stop reading replies aloud', + wakeWordListening: phrase => `Wake word: "${phrase}" — listening`, + wakeWordOff: phrase => `Wake word: "${phrase}" — off`, lookupLoading: 'Looking up…', lookupNoMatches: 'No matches.', lookupTry: 'Try', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 6ec6c487200..4f70f97e691 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1817,6 +1817,8 @@ export const ja = defineLocale({ voiceDictation: '音声口述', speakReplies: '返信を読み上げる', stopSpeakingReplies: '返信の読み上げを停止', + wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`, + wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`, lookupLoading: '検索中…', lookupNoMatches: '一致なし。', lookupTry: '試す', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 56ff50769e9..a1042ce7d88 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1644,6 +1644,8 @@ export interface Translations { voiceDictation: string speakReplies: string stopSpeakingReplies: string + wakeWordListening: (phrase: string) => string + wakeWordOff: (phrase: string) => string lookupLoading: string lookupNoMatches: string lookupTry: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 10f85b9940e..c45f1cbeceb 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1760,6 +1760,8 @@ export const zhHant = defineLocale({ voiceDictation: '語音聽寫', speakReplies: '朗讀回覆', stopSpeakingReplies: '停止朗讀回覆', + wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`, + wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`, lookupLoading: '查詢中…', lookupNoMatches: '沒有相符項目。', lookupTry: '試試', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1b692d40b1d..a5b48ed368a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2151,6 +2151,8 @@ export const zh: Translations = { voiceDictation: '语音听写', speakReplies: '朗读回复', stopSpeakingReplies: '停止朗读回复', + wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`, + wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`, lookupLoading: '查找中…', lookupNoMatches: '没有匹配项。', lookupTry: '试试', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 35e6a92237d..bd5fc978bcf 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -37,6 +37,8 @@ import { IconCpu as Cpu, IconCreditCard as CreditCard, IconDownload as Download, + IconEar as Ear, + IconEarOff as EarOff, IconEgg as Egg, IconExternalLink as ExternalLink, IconEye as Eye, @@ -160,6 +162,8 @@ export { Cpu, CreditCard, Download, + Ear, + EarOff, Egg, ExternalLink, Eye, diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts new file mode 100644 index 00000000000..21a8f74b539 --- /dev/null +++ b/apps/desktop/src/store/wake-word.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $wakeWord, + applyWakeStartResult, + applyWakeStatus, + applyWakeStopResult, + armWakeWord, + resetWakeWordState, + toggleWakeWord, + type WakeRequester +} from './wake-word' + +const requester = (impl: (method: string, params?: Record) => unknown) => + vi.fn(async (method: string, params: Record = {}) => impl(method, params)) as unknown as WakeRequester + +beforeEach(() => { + resetWakeWordState() +}) + +describe('applyWakeStatus', () => { + it('syncs availability, listening and phrase from wake.status', () => { + applyWakeStatus({ + available: true, + hint: '', + listening: true, + owned_by_caller: true, + owner_surface: 'gui', + phrase: 'hey hermes', + provider: 'openwakeword' + }) + + expect($wakeWord.get()).toMatchObject({ + available: true, + listening: true, + notice: '', + phrase: 'hey hermes' + }) + }) + + it('keeps the button hidden and carries the hint when unavailable', () => { + applyWakeStatus({ available: false, hint: 'pip install openwakeword', listening: false, phrase: 'hey hermes' }) + + const state = $wakeWord.get() + expect(state.available).toBe(false) + expect(state.listening).toBe(false) + expect(state.notice).toBe('pip install openwakeword') + }) +}) + +describe('toggleWakeWord', () => { + it('starts via wake.start with surface gui when off, and flips to listening', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + const request = requester(method => { + expect(method).toBe('wake.start') + + return { owner_surface: 'gui', phrase: 'hey hermes', provider: 'porcupine', started: true } + }) + + await toggleWakeWord(request) + + expect(request).toHaveBeenCalledWith('wake.start', { surface: 'gui' }) + expect($wakeWord.get()).toMatchObject({ listening: true, notice: '', pending: false }) + }) + + it('stops via wake.stop when listening', async () => { + applyWakeStatus({ available: true, listening: true, phrase: 'hey hermes' }) + + const request = requester(method => { + expect(method).toBe('wake.stop') + + return { reason: null, stopped: true } + }) + + await toggleWakeWord(request) + + expect(request).toHaveBeenCalledWith('wake.stop', {}) + expect($wakeWord.get()).toMatchObject({ listening: false, notice: '', pending: false }) + }) + + it('does NOT flip state on {started:false, reason} and surfaces the reason', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + await toggleWakeWord(requester(() => ({ owner_surface: 'tui', reason: 'owned', started: false }))) + + const state = $wakeWord.get() + expect(state.listening).toBe(false) + expect(state.notice).toBe('owned') + expect(state.available).toBe(true) + }) + + it('marks the feature unavailable when start refuses with reason unavailable', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + await toggleWakeWord( + requester(() => ({ hint: 'Set PORCUPINE_ACCESS_KEY', reason: 'unavailable', started: false })) + ) + + const state = $wakeWord.get() + expect(state.available).toBe(false) + expect(state.listening).toBe(false) + expect(state.notice).toBe('Set PORCUPINE_ACCESS_KEY') + }) + + it('stays off and keeps the error as the notice when the RPC throws', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + await toggleWakeWord( + requester(() => { + throw new Error('Hermes gateway unavailable') + }) + ) + + expect($wakeWord.get()).toMatchObject({ + listening: false, + notice: 'Hermes gateway unavailable', + pending: false + }) + }) + + it('ignores clicks while a toggle is already in flight', async () => { + applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' }) + + let resolveStart: (value: unknown) => void = () => undefined + + const request = vi.fn( + async () => + new Promise(resolve => { + resolveStart = resolve + }) + ) as unknown as WakeRequester + + const first = toggleWakeWord(request) + await toggleWakeWord(request) + + expect(request).toHaveBeenCalledTimes(1) + + resolveStart({ phrase: 'hey hermes', started: true }) + await first + + expect($wakeWord.get().listening).toBe(true) + }) +}) + +describe('armWakeWord (gateway-ready auto-arm)', () => { + it('queries wake.status then arms and syncs the store', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.status') { + return { available: true, listening: false, phrase: 'hey hermes', provider: 'porcupine' } + } + + return { phrase: 'hey hermes', started: true } + }) + + await armWakeWord(request) + + expect(calls).toEqual(['wake.status', 'wake.start']) + expect($wakeWord.get()).toMatchObject({ available: true, listening: true, phrase: 'hey hermes' }) + }) + + it('does not attempt to arm when the wake word is unavailable', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + return { available: false, hint: 'no mic', listening: false, phrase: 'hey hermes' } + }) + + await armWakeWord(request) + + expect(calls).toEqual(['wake.status']) + expect($wakeWord.get()).toMatchObject({ available: false, listening: false, notice: 'no mic' }) + }) + + it('skips arming when this surface already listens (status sync only)', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + return { available: true, listening: true, owned_by_caller: true, phrase: 'hey hermes' } + }) + + await armWakeWord(request) + + expect(calls).toEqual(['wake.status']) + expect($wakeWord.get()).toMatchObject({ available: true, listening: true }) + }) + + it('keeps the default hidden state when the backend lacks wake.* methods', async () => { + await armWakeWord( + requester(() => { + throw new Error('Unknown method: wake.status') + }) + ) + + expect($wakeWord.get()).toMatchObject({ available: false, listening: false }) + }) + + it('keeps the toggle off when auto-arm is refused (e.g. TUI owns the mic)', async () => { + const request = requester(method => + method === 'wake.status' + ? { available: true, listening: false, owner_surface: 'tui', phrase: 'hey hermes' } + : { owner_surface: 'tui', reason: 'owned', started: false } + ) + + await armWakeWord(request) + + const state = $wakeWord.get() + expect(state.available).toBe(true) + expect(state.listening).toBe(false) + expect(state.notice).toBe('owned') + }) +}) + +describe('applyWakeStopResult', () => { + it('lands on off even when the backend says not_owner', () => { + applyWakeStatus({ available: true, listening: true, phrase: 'hey hermes' }) + + applyWakeStopResult({ reason: 'not_owner', stopped: false }) + + const state = $wakeWord.get() + expect(state.listening).toBe(false) + expect(state.notice).toBe('not_owner') + }) +}) + +describe('applyWakeStartResult', () => { + it('adopts the backend phrase when the listener starts', () => { + applyWakeStartResult({ phrase: 'computer', provider: 'porcupine', started: true }) + + expect($wakeWord.get()).toMatchObject({ available: true, listening: true, phrase: 'computer' }) + }) +}) diff --git a/apps/desktop/src/store/wake-word.ts b/apps/desktop/src/store/wake-word.ts new file mode 100644 index 00000000000..97f0c8b3c89 --- /dev/null +++ b/apps/desktop/src/store/wake-word.ts @@ -0,0 +1,183 @@ +import { atom } from 'nanostores' + +import { $gateway } from '@/store/gateway' + +// "Hey Hermes" wake-word listener state for the composer toggle. The gateway is +// the single source of truth (the listener lives in the backend and is shared +// with the TUI under a single-owner mic lease); this atom is the renderer's +// cache of that truth, refreshed from every wake.* RPC response we see. + +export interface WakeWordState { + /** Wake word can run at all (deps + mic + key). False hides the toggle. */ + available: boolean + /** The listener is armed and owned by this surface. */ + listening: boolean + /** Last failure reason/hint (start refused, unavailable, …) for the tooltip. */ + notice: string + /** A toggle RPC is in flight — guards double-clicks. */ + pending: boolean + /** Human-facing wake phrase, e.g. "hey hermes". */ + phrase: string +} + +const INITIAL_WAKE_WORD_STATE: WakeWordState = { + available: false, + listening: false, + notice: '', + pending: false, + phrase: '' +} + +export const $wakeWord = atom(INITIAL_WAKE_WORD_STATE) + +export interface WakeStatusResponse { + available?: boolean + hint?: string + listening?: boolean + owned_by_caller?: boolean + owner_surface?: string | null + phrase?: string + provider?: string +} + +export interface WakeStartResponse { + hint?: string + owner_surface?: string | null + phrase?: string + provider?: string + reason?: string + started?: boolean +} + +export interface WakeStopResponse { + reason?: string | null + stopped?: boolean +} + +/** Minimal requester shape — satisfied by both `useGatewayRequest`'s + * `requestGateway` and the `$gateway` instance wrapper below. */ +export type WakeRequester = (method: string, params?: Record) => Promise + +const gatewayRequester: WakeRequester = async (method: string, params: Record = {}) => { + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return gateway.request(method, params) +} + +const noticeFrom = (result: { hint?: string; reason?: string | null } | null | undefined): string => + result?.hint?.trim() || result?.reason?.trim() || '' + +/** Sync the atom from a `wake.status` payload (mount / gateway-ready). */ +export function applyWakeStatus(status: WakeStatusResponse | null | undefined): void { + const current = $wakeWord.get() + const listening = Boolean(status?.listening) + + $wakeWord.set({ + ...current, + available: Boolean(status?.available), + listening, + notice: listening ? '' : noticeFrom(status), + phrase: status?.phrase?.trim() || current.phrase + }) +} + +/** Sync the atom from a `wake.start` response. A `{started:false, reason}` + * refusal keeps the toggle off and surfaces the reason as the tooltip. */ +export function applyWakeStartResult(result: WakeStartResponse | null | undefined): void { + const current = $wakeWord.get() + + if (result?.started) { + $wakeWord.set({ + ...current, + available: true, + listening: true, + notice: '', + pending: false, + phrase: result.phrase?.trim() || current.phrase + }) + + return + } + + $wakeWord.set({ + ...current, + // The backend probes requirements on start; an explicit "unavailable" + // refusal means the feature can't run here, so hide the toggle. + available: result?.reason === 'unavailable' ? false : current.available, + listening: false, + notice: noticeFrom(result), + pending: false + }) +} + +/** Sync the atom from a `wake.stop` response. `{stopped:false, reason:'not_owner'}` + * still means WE are not listening, so the toggle lands on off either way. */ +export function applyWakeStopResult(result: WakeStopResponse | null | undefined): void { + const current = $wakeWord.get() + + $wakeWord.set({ + ...current, + listening: false, + notice: result?.stopped ? '' : noticeFrom(result), + pending: false + }) +} + +/** + * Gateway-ready sync + auto-arm (wiring.tsx). Queries `wake.status` first so + * the button knows availability/phrase even when arming is refused, then arms + * the listener for this surface exactly like the historical auto-arm did. + * Best-effort: a gateway without the wake.* methods leaves the atom at its + * hidden default. + */ +export async function armWakeWord(request: WakeRequester = gatewayRequester): Promise { + try { + const status = await request('wake.status', {}) + applyWakeStatus(status) + + if (!status?.available || status.listening) { + return + } + + const result = await request('wake.start', { surface: 'gui' }) + applyWakeStartResult(result) + } catch { + // Older backends / transient failures — keep whatever we last knew. + } +} + +/** The composer button's click handler: stop when listening, start otherwise. */ +export async function toggleWakeWord(request: WakeRequester = gatewayRequester): Promise { + const state = $wakeWord.get() + + if (state.pending) { + return + } + + $wakeWord.set({ ...state, pending: true }) + + try { + if (state.listening) { + applyWakeStopResult(await request('wake.stop', {})) + } else { + applyWakeStartResult(await request('wake.start', { surface: 'gui' })) + } + } catch (error) { + const current = $wakeWord.get() + + $wakeWord.set({ + ...current, + notice: error instanceof Error ? error.message : String(error), + pending: false + }) + } +} + +/** Test-only reset. */ +export function resetWakeWordState(): void { + $wakeWord.set(INITIAL_WAKE_WORD_STATE) +} From 71a2feeade179be125d7b2d1b435c9cf3715de46 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:39:19 -0700 Subject: [PATCH 18/46] feat(tui): /wake on|off|status slash command TUI-local handler over the wake.start/stop/status RPCs with friendly transcript one-liners (phrase, provider, foreign-owner note, refusal reasons, unavailability hints). /wake off sets a session-scoped opt-out the gateway.ready auto-arm respects, so the listener stays off across reconnects until /wake on. cli_only already means CLI+TUI (verified: messaging menus exclude it); zero Python changes needed. --- ui-tui/src/__tests__/wakeCommand.test.ts | 190 ++++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 7 +- ui-tui/src/app/slash/commands/wake.ts | 119 ++++++++++++ ui-tui/src/app/slash/registry.ts | 2 + ui-tui/src/app/wakeState.ts | 15 ++ ui-tui/src/gatewayTypes.ts | 26 +++ 6 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 ui-tui/src/__tests__/wakeCommand.test.ts create mode 100644 ui-tui/src/app/slash/commands/wake.ts create mode 100644 ui-tui/src/app/wakeState.ts diff --git a/ui-tui/src/__tests__/wakeCommand.test.ts b/ui-tui/src/__tests__/wakeCommand.test.ts new file mode 100644 index 00000000000..185c35d0034 --- /dev/null +++ b/ui-tui/src/__tests__/wakeCommand.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { wakeCommands } from '../app/slash/commands/wake.js' +import { isWakeUserDisabled, setWakeUserDisabled } from '../app/wakeState.js' + +const wakeCommand = wakeCommands.find(cmd => cmd.name === 'wake')! + +const guarded = + (fn: (r: T) => void) => + (r: null | T) => { + if (r) { + fn(r) + } + } + +/** Build a ctx whose rpc routes by method name to a supplied map of results. */ +const buildCtx = (results: Record) => { + const sys = vi.fn() + + const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method])) + + const ctx = { + gateway: { rpc }, + guarded, + guardedErr: vi.fn(), + sid: 'sid-1', + stale: () => false, + transcript: { page: vi.fn(), sys } + } + + const run = async (arg: string) => { + wakeCommand.run(arg, ctx as any, `/wake${arg ? ` ${arg}` : ''}`) + await rpc.mock.results[0]?.value + await Promise.resolve() + await Promise.resolve() + } + + return { ctx, rpc, run, sys } +} + +const printed = (sys: ReturnType) => sys.mock.calls.map(c => c[0]).join('\n') + +describe('/wake slash command', () => { + beforeEach(() => { + vi.clearAllMocks() + setWakeUserDisabled(false) + }) + + it('registers with usage metadata', () => { + expect(wakeCommand).toBeDefined() + expect(wakeCommand.usage).toBe('/wake [on|off|status]') + }) + + it('/wake on calls wake.start with surface tui and reports listening', async () => { + const { rpc, run, sys } = buildCtx({ + 'wake.start': { phrase: 'hey hermes', provider: 'openwakeword', started: true } + }) + + await run('on') + + expect(rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' }) + expect(printed(sys)).toContain('listening') + expect(printed(sys)).toContain('hey hermes') + expect(printed(sys)).toContain('openwakeword') + }) + + it('/wake on clears the session opt-out flag', async () => { + setWakeUserDisabled(true) + + const { run } = buildCtx({ 'wake.start': { started: true } }) + + await run('on') + + expect(isWakeUserDisabled()).toBe(false) + }) + + it('/wake on prints the reason when the gateway refuses', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { owner_surface: 'gui', reason: 'owned', started: false } + }) + + await run('on') + + const out = printed(sys) + expect(out).toContain('not started') + expect(out).toContain('another surface owns the listener') + expect(out).toContain('gui') + }) + + it('/wake on surfaces the hint when unavailable', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { hint: 'pip install openwakeword', reason: 'unavailable', started: false } + }) + + await run('on') + + const out = printed(sys) + expect(out).toContain('unavailable') + expect(out).toContain('pip install openwakeword') + }) + + it('/wake off calls wake.stop, remembers the opt-out, and reports', async () => { + const { rpc, run, sys } = buildCtx({ 'wake.stop': { stopped: true } }) + + await run('off') + + expect(rpc).toHaveBeenCalledWith('wake.stop', {}) + expect(isWakeUserDisabled()).toBe(true) + expect(printed(sys)).toContain('listener off') + }) + + it('/wake off explains a not_owner refusal but still records the opt-out', async () => { + const { run, sys } = buildCtx({ 'wake.stop': { reason: 'not_owner', stopped: false } }) + + await run('off') + + expect(isWakeUserDisabled()).toBe(true) + expect(printed(sys)).toContain('nothing to stop') + expect(printed(sys)).toContain('doesn’t own the listener') + }) + + it('/wake status prints a listening one-liner', async () => { + const { rpc, run, sys } = buildCtx({ + 'wake.status': { + available: true, + listening: true, + owned_by_caller: true, + owner_surface: 'tui', + phrase: 'hey hermes', + provider: 'openwakeword' + } + }) + + await run('status') + + expect(rpc).toHaveBeenCalledWith('wake.status', {}) + + const out = printed(sys) + expect(out).toContain('listening') + expect(out).toContain('hey hermes') + expect(out).toContain('openwakeword') + }) + + it('bare /wake behaves like /wake status', async () => { + const { rpc, run } = buildCtx({ 'wake.status': { available: true, listening: false } }) + + await run('') + + expect(rpc).toHaveBeenCalledWith('wake.status', {}) + }) + + it('status reports another surface owning the listener', async () => { + const { run, sys } = buildCtx({ + 'wake.status': { + available: true, + listening: false, + owned_by_caller: false, + owner_surface: 'gui', + phrase: 'hey hermes' + } + }) + + await run('status') + + const out = printed(sys) + expect(out).toContain('off here') + expect(out).toContain('gui') + }) + + it('status surfaces the hint when the wake word is unavailable', async () => { + const { run, sys } = buildCtx({ + 'wake.status': { available: false, hint: 'no microphone detected', listening: false } + }) + + await run('status') + + const out = printed(sys) + expect(out).toContain('unavailable') + expect(out).toContain('no microphone detected') + }) + + it('rejects unknown subcommands with usage text', async () => { + const { rpc, run, sys } = buildCtx({}) + + await run('banana') + + expect(rpc).not.toHaveBeenCalled() + expect(printed(sys)).toContain('usage: /wake [on|off|status]') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 49c55fc358f..16971e18fbf 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -32,6 +32,7 @@ import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' +import { isWakeUserDisabled } from './wakeState.js' const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i @@ -623,7 +624,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // Arm "Hey Hermes" if this surface owns it (server gates on config). // Fire-and-forget + idempotent server-side, so reconnects are harmless. - void rpc('wake.start', { surface: 'tui' }).catch(() => undefined) + // Skipped when the user explicitly ran `/wake off` this session — an + // explicit opt-out must survive gateway reconnects (see wakeState.ts). + if (!isWakeUserDisabled()) { + void rpc('wake.start', { surface: 'tui' }).catch(() => undefined) + } rpc('commands.catalog', {}) .then(r => { diff --git a/ui-tui/src/app/slash/commands/wake.ts b/ui-tui/src/app/slash/commands/wake.ts new file mode 100644 index 00000000000..d73dea4fa94 --- /dev/null +++ b/ui-tui/src/app/slash/commands/wake.ts @@ -0,0 +1,119 @@ +import type { WakeStartResponse, WakeStatusResponse, WakeStopResponse } from '../../../gatewayTypes.js' +import { setWakeUserDisabled } from '../../wakeState.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +const WAKE_SUBCOMMANDS = ['on', 'off', 'status'] as const + +type WakeSub = (typeof WAKE_SUBCOMMANDS)[number] + +const isWakeSub = (value: string): value is WakeSub => (WAKE_SUBCOMMANDS as readonly string[]).includes(value) + +// Friendly text for the gateway's wake.start refusal codes. Unknown codes +// fall through to the raw reason so new server-side codes stay visible. +const START_REASON_TEXT: Record = { + disabled_for_surface: 'disabled for this surface (config wake_word.enabled / wake_word.surface)', + not_owner: 'another surface owns the listener', + owned: 'another surface owns the listener', + unavailable: 'unavailable' +} + +const startFailureLine = (r: WakeStartResponse): string => { + const reason = r.reason ?? 'unknown' + const base = START_REASON_TEXT[reason] ?? reason + const owner = r.owner_surface ? ` (owned by ${r.owner_surface})` : '' + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: not started — ${base}${owner}${hint}` +} + +const statusLine = (r: WakeStatusResponse): string => { + const phrase = r.phrase ? ` for “${r.phrase}”` : '' + const provider = r.provider ? ` · ${r.provider}` : '' + + if (r.listening) { + return `wake: listening${phrase}${provider}` + } + + if (r.owner_surface && !r.owned_by_caller) { + return `wake: off here · listener owned by ${r.owner_surface}${phrase}${provider}` + } + + if (r.available === false) { + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: unavailable${hint}` + } + + return `wake: off${phrase}${provider} · /wake on to arm` +} + +const runOn = (ctx: SlashRunCtx): void => { + setWakeUserDisabled(false) + + ctx.gateway + .rpc('wake.start', { surface: 'tui' }) + .then( + ctx.guarded(r => { + if (!r.started) { + return ctx.transcript.sys(startFailureLine(r)) + } + + const phrase = r.phrase ? ` for “${r.phrase}”` : '' + const provider = r.provider ? ` · ${r.provider}` : '' + + ctx.transcript.sys(`wake: listening${phrase}${provider}`) + }) + ) + .catch(ctx.guardedErr) +} + +const runOff = (ctx: SlashRunCtx): void => { + // Remember the explicit opt-out so gateway reconnects don't re-arm the + // listener behind the user's back (see wakeState.ts). + setWakeUserDisabled(true) + + ctx.gateway + .rpc('wake.stop', {}) + .then( + ctx.guarded(r => { + if (r.stopped) { + return ctx.transcript.sys('wake: listener off (won’t re-arm this session)') + } + + const reason = r.reason === 'not_owner' ? 'this surface doesn’t own the listener' : (r.reason ?? 'not running') + + ctx.transcript.sys(`wake: nothing to stop — ${reason}`) + }) + ) + .catch(ctx.guardedErr) +} + +const runStatus = (ctx: SlashRunCtx): void => { + ctx.gateway + .rpc('wake.status', {}) + .then(ctx.guarded(r => ctx.transcript.sys(statusLine(r)))) + .catch(ctx.guardedErr) +} + +const WAKE_RUNNERS: Record void> = { + off: runOff, + on: runOn, + status: runStatus +} + +export const wakeCommands: SlashCommand[] = [ + { + help: "toggle the 'Hey Hermes' wake word listener [on|off|status]", + name: 'wake', + usage: '/wake [on|off|status]', + run: (arg, ctx) => { + const sub = arg.trim().toLowerCase() + + if (sub && !isWakeSub(sub)) { + return ctx.transcript.sys('usage: /wake [on|off|status]') + } + + WAKE_RUNNERS[sub && isWakeSub(sub) ? sub : 'status'](ctx) + } + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index f87d53f9bfe..6922280e16a 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -5,6 +5,7 @@ import { sessionCommands } from './commands/session.js' import { setupCommands } from './commands/setup.js' import { subscriptionCommands } from './commands/subscription.js' import { topupCommands } from './commands/topup.js' +import { wakeCommands } from './commands/wake.js' import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ @@ -13,6 +14,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ ...sessionCommands, ...subscriptionCommands, ...opsCommands, + ...wakeCommands, ...setupCommands, ...debugCommands ] diff --git a/ui-tui/src/app/wakeState.ts b/ui-tui/src/app/wakeState.ts new file mode 100644 index 00000000000..a9e527ecd24 --- /dev/null +++ b/ui-tui/src/app/wakeState.ts @@ -0,0 +1,15 @@ +// Session-scoped memory of an explicit `/wake off`. +// +// The gateway auto-arms the "Hey Hermes" listener on every `gateway.ready` +// (see createGatewayEventHandler.ts). When the user explicitly disables the +// listener with `/wake off`, a reconnect must NOT silently re-arm it — this +// module-level flag records that intent for the lifetime of the process. +// `/wake on` clears it. Deliberately not persisted: config (`wake_word.*`) +// remains the durable on/off switch; this is only per-session steering. +let wakeUserDisabled = false + +export const isWakeUserDisabled = (): boolean => wakeUserDisabled + +export const setWakeUserDisabled = (disabled: boolean): void => { + wakeUserDisabled = disabled +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index db7503828b6..42d6e18f21f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -400,6 +400,32 @@ export interface VoiceRecordResponse { text?: string } +// ── Wake word ──────────────────────────────────────────────────────── + +export interface WakeStartResponse { + hint?: string + owner_surface?: null | string + phrase?: string + provider?: string + reason?: string + started?: boolean +} + +export interface WakeStopResponse { + reason?: null | string + stopped?: boolean +} + +export interface WakeStatusResponse { + available?: boolean + hint?: string + listening?: boolean + owned_by_caller?: boolean + owner_surface?: null | string + phrase?: string + provider?: string +} + // ── Tools (TS keeps configure since it resets local history) ───────── export interface ToolsConfigureResponse { From 567f47f01feeeffa4e20ce60e363ee4e220d4d87 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:41:20 -0700 Subject: [PATCH 19/46] fix(lint): explicit utf-8 encoding on the sherpa keywords tempfile --- tools/wake_word.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/wake_word.py b/tools/wake_word.py index 48f68335713..c0a10f6fc07 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -304,7 +304,7 @@ class _SherpaKwsEngine(_Engine): import tempfile kw = tempfile.NamedTemporaryFile( - mode="w", suffix=".txt", prefix="hermes-kws-", delete=False + mode="w", suffix=".txt", prefix="hermes-kws-", delete=False, encoding="utf-8" ) display = phrase.upper().replace(" ", "_") kw.write(" ".join(tokens[0]) + f" @{display}\n") From 2a35c8f0b8a8a60bd3c442b490e2a89ac311dc2f Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:52:00 -0700 Subject: [PATCH 20/46] =?UTF-8?q?feat(voice):=20route=20wake=20phrases=20t?= =?UTF-8?q?o=20their=20profile=20=E2=80=94=20"hey=20"=20wakes=20t?= =?UTF-8?q?hat=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One sherpa listener now enrolls every wake-enabled profile's phrase (defaulting to "hey ") and reports WHICH phrase fired. wake.detected gains a profile field; the desktop live-switches to the matching profile (same path as the profile rail), opens a fresh session there, and starts hands-free voice. The single-profile CLI/TUI print the hermes -p switch command for foreign-profile phrases instead of answering as the wrong profile. Opt out per listener with wake_word.profile_routing: false. --- apps/desktop/src/app/contrib/wiring.tsx | 21 +++- cli.py | 16 +++ hermes_cli/config.py | 1 + tests/test_tui_gateway_server.py | 4 +- tests/tools/test_wake_word.py | 100 ++++++++++++++++++ tools/wake_word.py | 97 +++++++++++++++-- tui_gateway/server.py | 14 ++- ui-tui/src/app/createGatewayEventHandler.ts | 13 +++ ui-tui/src/gatewayTypes.ts | 2 +- website/docs/user-guide/features/wake-word.md | 22 ++++ 10 files changed, 274 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 3ba86e4741f..895826794a1 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -34,7 +34,7 @@ import { requestVoiceConversationStart } from '@/store/composer' import { setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $previewTarget } from '@/store/preview' -import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile' +import { $activeGatewayProfile, $freshSessionRequest, $profileScope, ensureGatewayProfile, newSessionInProfile, normalizeProfileKey, refreshActiveProfile } from '@/store/profile' import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects' import { $activeSessionId, @@ -666,9 +666,24 @@ export function ContribWiring({ children }: { children: ReactNode }) { emitGatewayEvent(event) if (event.type === 'wake.detected') { - const payload = event.payload as { start_new_session?: boolean } | undefined + const payload = event.payload as + | { profile?: null | string; start_new_session?: boolean } + | undefined - if (payload?.start_new_session !== false) { + // Multi-profile routing: a wake phrase enrolled by another profile + // re-homes the gateway to that profile first (live swap — same path + // as clicking it in the profile rail), then opens the fresh session + // and starts voice there. + const targetProfile = payload?.profile?.trim() + const activeProfile = normalizeProfileKey($activeGatewayProfile.get()) + + if (targetProfile && normalizeProfileKey(targetProfile) !== activeProfile) { + if (payload?.start_new_session !== false) { + newSessionInProfile(targetProfile) + } else { + void ensureGatewayProfile(normalizeProfileKey(targetProfile)) + } + } else if (payload?.start_new_session !== false) { startFreshSessionDraft() } diff --git a/cli.py b/cli.py index 472e20df902..4635b9c8adf 100644 --- a/cli.py +++ b/cli.py @@ -12278,6 +12278,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return self._wake_suspended = True + # Multi-profile routing: the CLI is a single-profile process, so a + # phrase enrolled by ANOTHER profile can't be routed here — print the + # switch command and re-arm rather than answering as the wrong profile. + try: + from tools.wake_word import get_last_match + _match = get_last_match() + except Exception: + _match = None + if _match and _match[1]: + from tools.wake_word import _active_profile_name + if _match[1] != _active_profile_name(): + _cprint(f"\n{_DIM}Wake phrase for profile '{_match[1]}' — " + f"run: hermes -p {_match[1]}{_RST}") + self._wake_suspended = True # watchdog resumes the listener + return + _cprint(f"\n{_ACCENT}✦ Wake word detected — listening...{_RST}") if getattr(self, "_app", None): try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5c443f4e818..8d43bc75131 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2384,6 +2384,7 @@ DEFAULT_CONFIG = { "phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label — detection is keyed by the model/keyword below "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) "start_new_session": True, # start a fresh session on wake vs. continue the current one + "profile_routing": True, # sherpa only: also listen for every wake-enabled profile's phrase and route the wake to the matching profile "openwakeword": { # "hey_hermes" (the bundled, works-out-of-the-box default) OR a # built-in openWakeWord name ("hey_jarvis", "alexa", "hey_mycroft", diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index caf22da054e..ca2139a7c13 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1426,7 +1426,7 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc assert emitted == [( "wake.detected", "first-session", - {"phrase": "hey hermes", "start_new_session": True}, + {"phrase": "hey hermes", "profile": None, "start_new_session": True}, first, )] assert state["paused"] is True @@ -1459,7 +1459,7 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc assert emitted[-1] == ( "wake.detected", "second-session", - {"phrase": "hey hermes", "start_new_session": True}, + {"phrase": "hey hermes", "profile": None, "start_new_session": True}, second, ) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index c21e41def0f..f70498a516e 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -302,6 +302,106 @@ def test_sherpa_requirements_probe_uses_sherpa_feature(monkeypatch): assert r["phrase"] == "anything at all" +# ── Multi-profile phrase routing ───────────────────────────────────────── + + +def test_sherpa_engine_enrolls_all_profile_phrases(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + monkeypatch.setattr(ww, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + ww, "enrolled_profile_phrases", + lambda: {"coder": "hey coder", "trader": "hey trader"}, + ) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", + "sherpa": {"model_dir": str(model_dir)}, + }) + with open(eng._keywords_file, encoding="utf-8") as f: + lines = f.read().strip().splitlines() + assert len(lines) == 3 + assert eng._display_to_profile == { + "HEY_HERMES": "default", + "HEY_CODER": "coder", + "HEY_TRADER": "trader", + } + eng.close() + + +def test_sherpa_engine_profile_routing_can_be_disabled(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + monkeypatch.setattr(ww, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + ww, "enrolled_profile_phrases", lambda: {"coder": "hey coder"} + ) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", "profile_routing": False, + "sherpa": {"model_dir": str(model_dir)}, + }) + assert eng._display_to_profile == {"HEY_HERMES": "default"} + eng.close() + + +def test_sherpa_engine_match_maps_back_to_profile(monkeypatch, tmp_path): + calls, model_dir = _install_fake_sherpa(monkeypatch, tmp_path) + monkeypatch.setattr(ww, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + ww, "enrolled_profile_phrases", lambda: {"coder": "hey coder"} + ) + eng = ww._SherpaKwsEngine({ + "provider": "sherpa", "phrase": "hey hermes", + "sherpa": {"model_dir": str(model_dir)}, + }) + frame = [0] * eng.frame_length + calls["results"].append("HEY_CODER") + assert eng.process(frame) is True + assert eng.last_match == ("hey coder", "coder") + calls["results"].append("HEY_HERMES") + assert eng.process(frame) is True + assert eng.last_match == ("hey hermes", "default") + + +def test_enrolled_profile_phrases_reads_profile_configs(monkeypatch, tmp_path): + profiles_root = tmp_path / "profiles" + for name, body in ( + ("coder", "wake_word:\n enabled: true\n phrase: hey coder\n"), + ("trader", "wake_word:\n enabled: true\n"), # phrase defaults + ("quiet", "wake_word:\n enabled: false\n"), # not enrolled + ("empty", ""), # no wake_word at all + ): + d = profiles_root / name + d.mkdir(parents=True) + (d / "config.yaml").write_text(body, encoding="utf-8") + + class _Info: + def __init__(self, name): + self.name = name + + import types as _types + fake_profiles = _types.ModuleType("hermes_cli.profiles") + fake_profiles.list_profiles = lambda: [ + _Info(p.name) for p in sorted(profiles_root.iterdir()) + ] + fake_profiles.get_profile_dir = lambda name: str(profiles_root / name) + fake_profiles.get_active_profile_name = lambda: "default" + monkeypatch.setitem(sys.modules, "hermes_cli.profiles", fake_profiles) + + phrases = ww.enrolled_profile_phrases() + assert phrases == {"coder": "hey coder", "trader": "hey trader"} + + +def test_get_last_match_reads_detector_engine(monkeypatch): + class _Eng: + last_match = ("hey coder", "coder") + + class _Det: + engine = _Eng() + + monkeypatch.setattr(ww, "_detector", _Det()) + assert ww.get_last_match() == ("hey coder", "coder") + monkeypatch.setattr(ww, "_detector", None) + assert ww.get_last_match() is None + + # ── Detector loop ──────────────────────────────────────────────────────── diff --git a/tools/wake_word.py b/tools/wake_word.py index c0a10f6fc07..b8a3e5f95e9 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -127,6 +127,52 @@ def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> return want == "auto" or want == surface.strip().lower() +# --------------------------------------------------------------------------- +# Multi-profile phrase enrollment (open-vocabulary routing) +# --------------------------------------------------------------------------- + +def _active_profile_name() -> str: + try: + from hermes_cli.profiles import get_active_profile_name + + return get_active_profile_name() or "default" + except Exception: + return "default" + + +def enrolled_profile_phrases() -> Dict[str, str]: + """Map ``profile name -> wake phrase`` for every wake-enabled profile. + + Reads each profile's own ``config.yaml`` raw (cheap, no full config merge). + A profile is enrolled when its ``wake_word.enabled`` is truthy; its phrase + defaults to ``"hey "`` when unset. Used by the sherpa engine to + listen for every enrolled profile's phrase at once and route the wake to + the matching profile. Best-effort: unreadable profiles are skipped. + """ + phrases: Dict[str, str] = {} + try: + import yaml + + from hermes_cli.profiles import get_profile_dir, list_profiles + + for info in list_profiles(): + name = getattr(info, "name", None) or str(info) + try: + cfg_path = Path(get_profile_dir(name)) / "config.yaml" + raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + wc = raw.get("wake_word") or {} + if not isinstance(wc, dict) or not wc.get("enabled"): + continue + phrase = str(wc.get("phrase") or f"hey {name}").strip() + if phrase: + phrases[name] = phrase + except Exception: + continue + except Exception: + pass + return phrases + + # --------------------------------------------------------------------------- # Audio capture (lazy — never import sounddevice at module load) # --------------------------------------------------------------------------- @@ -155,6 +201,12 @@ class _Engine: frame_length: int = 1280 # 80 ms at 16 kHz + #: Optional (matched phrase, profile name) of the most recent fire. + #: Multi-phrase engines (sherpa) set this for profile routing; the + #: single-phrase engines leave it None (callers fall back to the + #: configured phrase / active profile). + last_match: Optional[tuple[str, str]] = None + def process(self, frame) -> bool: # frame: 1-D int16 ndarray raise NotImplementedError @@ -292,24 +344,41 @@ class _SherpaKwsEngine(_Engine): if not (d / "tokens.txt").exists(): raise RuntimeError(f"sherpa KWS model not found at {d}") + # Phrase set: this profile's own phrase, plus — when profile routing is + # on — every other wake-enabled profile's phrase, so ONE listener can + # wake any profile ("hey hermes" / "hey coder" / ...). display-name → + # profile is kept for routing the match back. phrase = str(_get(cfg, "phrase") or "hey hermes").strip() - # Runtime tokenization of the arbitrary phrase — the open-vocab core. - # sherpa keyword entries reject spaces in the @display-name; underscore it. + own_profile = _active_profile_name() + phrase_map: Dict[str, str] = {phrase: own_profile} + if bool(cfg.get("profile_routing", True)): + for prof, p in enrolled_profile_phrases().items(): + phrase_map.setdefault(p.strip(), prof) + + phrases = list(phrase_map) + # Runtime tokenization of the arbitrary phrases — the open-vocab core. tokens = text2token( - [phrase.upper()], + [p.upper() for p in phrases], tokens=str(d / "tokens.txt"), tokens_type="bpe", bpe_model=str(d / "bpe.model"), ) import tempfile + # sherpa keyword entries reject spaces in the @display-name; underscore + # them and map display → profile for match routing. + self._display_to_profile: Dict[str, str] = {} kw = tempfile.NamedTemporaryFile( mode="w", suffix=".txt", prefix="hermes-kws-", delete=False, encoding="utf-8" ) - display = phrase.upper().replace(" ", "_") - kw.write(" ".join(tokens[0]) + f" @{display}\n") + for p, toks in zip(phrases, tokens): + display = p.upper().replace(" ", "_") + self._display_to_profile[display] = phrase_map[p] + kw.write(" ".join(toks) + f" @{display}\n") kw.close() self._keywords_file = kw.name + #: (phrase display name, profile) of the most recent fire, for routing. + self.last_match: Optional[tuple[str, str]] = None # Map the shared 0..1 sensitivity onto sherpa's keywords_threshold # (posterior probability; its default is 0.25). @@ -340,8 +409,14 @@ class _SherpaKwsEngine(_Engine): fired = False while self._spotter.is_ready(self._stream): self._spotter.decode_stream(self._stream) - if self._spotter.get_result(self._stream): + result = self._spotter.get_result(self._stream) + if result: fired = True + display = str(result) + self.last_match = ( + display.replace("_", " ").lower(), + self._display_to_profile.get(display, ""), + ) # Reset decoder state so one utterance can't fire repeatedly. self._spotter.reset_stream(self._stream) return fired @@ -774,3 +849,13 @@ def is_listening() -> bool: with _detector_lock: det = _detector return det is not None and det.running + + +def get_last_match() -> Optional[tuple[str, str]]: + """(matched phrase, profile) of the most recent wake fire, if the engine + reports per-phrase matches (sherpa multi-profile routing). None otherwise.""" + with _detector_lock: + det = _detector + if det is None: + return None + return getattr(det.engine, "last_match", None) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e29fcae05c4..a361c34e549 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17703,7 +17703,7 @@ def _(rid, params: dict) -> dict: new_session = bool(cfg.get("start_new_session", True)) def _on_detect() -> None: - from tools.wake_word import owns_listener, pause_listening + from tools.wake_word import get_last_match, owns_listener, pause_listening if not pause_listening(owner=transport): return @@ -17712,12 +17712,18 @@ def _(rid, params: dict) -> dict: if _transport_is_dead(transport): _release_wake_for_transport(transport) return - logger.info("wake.detected: emitting to sid=%r (transport=%s)", - sid, type(transport).__name__) + # Multi-phrase engines report WHICH phrase fired and the profile it + # belongs to, so one listener can wake any enrolled profile. Falls + # back to the owner's configured phrase / no profile for + # single-phrase engines. + matched_phrase, matched_profile = get_last_match() or (phrase, "") + logger.info("wake.detected: emitting to sid=%r (transport=%s, profile=%r)", + sid, type(transport).__name__, matched_profile) token = bind_transport(transport) try: _emit("wake.detected", sid, { - "phrase": phrase, + "phrase": matched_phrase or phrase, + "profile": matched_profile or None, "start_new_session": new_session, }) finally: diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 16971e18fbf..338ea7ea498 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -949,6 +949,19 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // "Hey Hermes": optionally open a fresh session (start_new_session), // then arm voice capture so the user can speak hands-free. Mirrors CLI. void (async () => { + // Multi-profile routing: the TUI is a single-profile process, so a + // phrase enrolled by ANOTHER profile can't be routed here — surface + // the switch command instead of starting voice on the wrong profile. + const wakeProfile = ev.payload?.profile?.trim() + const ownProfile = getUiState().info?.profile_name || 'default' + + if (wakeProfile && wakeProfile !== ownProfile) { + sys(`wake phrase for profile '${wakeProfile}' — run: hermes -p ${wakeProfile} --tui`) + await rpc('wake.resume', {}).catch(() => undefined) + + return + } + if (ev.payload?.start_new_session !== false) { await newSession() } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 42d6e18f21f..6a86e13b9c2 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -615,7 +615,7 @@ export type GatewayEvent = } | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } - | { payload?: { phrase?: string; start_new_session?: boolean }; session_id?: string; type: 'wake.detected' } + | { payload?: { phrase?: string; profile?: null | string; start_new_session?: boolean }; session_id?: string; type: 'wake.detected' } | { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } | { diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 582ef1e9778..794ce0246e6 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -125,6 +125,28 @@ wake_word: The small English KWS model (~13 MB) downloads once on first use. Each profile can set its own phrase — "hey \" for every profile you run. +### Waking a specific profile (desktop) + +With the sherpa engine, ONE listener can wake ANY profile. Every profile +whose config has `wake_word.enabled: true` is enrolled automatically; its +phrase defaults to `hey ` when unset. Say a profile's phrase +and the desktop app live-switches to that profile, opens a fresh session +there, and starts hands-free voice: + +- "hey hermes" → default profile +- "hey coder" → the `coder` profile +- "hey trader" → the `trader` profile + +Set `wake_word.profile_routing: false` on the listener's profile to opt out +and listen only for its own phrase. The CLI and TUI are single-profile +processes: a wake phrase belonging to another profile prints the switch +command (`hermes -p `) instead of routing. + +Names are matched acoustically by their English subword sounds: two-word +phrases with distinct, 2+ syllable names work best. Very short names, heavy +non-English phonology, or two profiles with similar-sounding names will +degrade accuracy — tune per-profile `sensitivity` if needed. + ### Option B — openWakeWord (free, trained model) Name a built-in model (`hey_jarvis`, `alexa`, `hey_mycroft`, …), or train a From f2b065658d125991d631b0ad5fcbd30887c17f3b Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:40:23 -0700 Subject: [PATCH 21/46] tune(voice): calibrate sherpa sensitivity mapping from live TTS matrix 96-utterance TTS matrix (6 enrolled profile phrases x 4 voices/accents + 4 negative phrases x 4 voices) through the real engine: the old mapping (default threshold 0.35) missed 3/24 positives; remapping so sensitivity 0.5 lands on sherpa's recommended 0.25 recovers 2 of 3 while keeping 0/16 false fires. Detection 23/24, routing accuracy 23/23. --- tools/wake_word.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/wake_word.py b/tools/wake_word.py index b8a3e5f95e9..a1bb8e43bd8 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -380,9 +380,11 @@ class _SherpaKwsEngine(_Engine): #: (phrase display name, profile) of the most recent fire, for routing. self.last_match: Optional[tuple[str, str]] = None - # Map the shared 0..1 sensitivity onto sherpa's keywords_threshold - # (posterior probability; its default is 0.25). - threshold = 0.1 + 0.5 * _sensitivity(cfg) + # Map the shared 0..1 sensitivity onto sherpa's keywords_threshold. + # 0.5 lands exactly on sherpa's recommended default (0.25); live TTS + # matrix testing showed our previous stricter mapping (0.35) missed + # ~12% of true positives while 0.25 held zero false fires. + threshold = 0.05 + 0.4 * _sensitivity(cfg) def _model_file(pattern: str) -> str: hits = sorted(d.glob(pattern)) From e136f2fdeae131f30971c6657c94dee0a6c2fe7f Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:55:42 -0700 Subject: [PATCH 22/46] docs(wake-word): sidebar entry, env-var reference row, voice-mode cross-link --- website/docs/reference/environment-variables.md | 1 + website/docs/user-guide/features/voice-mode.md | 2 ++ website/sidebars.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 948a118c97f..99f87241512 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -154,6 +154,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `KREA_API_KEY` | Krea API key for Krea 2 image generation ([krea.ai](https://krea.ai/)) | | `GROQ_API_KEY` | Groq Whisper STT API key ([groq.com](https://groq.com/)) | | `ELEVENLABS_API_KEY` | ElevenLabs premium TTS voices ([elevenlabs.io](https://elevenlabs.io/)) | +| `PORCUPINE_ACCESS_KEY` | Picovoice Porcupine wake-word engine ([console.picovoice.ai](https://console.picovoice.ai/)) — only for `wake_word.provider: porcupine`; the default openWakeWord and sherpa engines need no key | | `STT_GROQ_MODEL` | Override the Groq STT model (default: `whisper-large-v3-turbo`) | | `GROQ_BASE_URL` | Override the Groq OpenAI-compatible STT endpoint | | `STT_OPENAI_MODEL` | Override the OpenAI STT model (default: `whisper-1`) | diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index a14e63dfd9b..d2d843153b2 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -10,6 +10,8 @@ Hermes Agent supports full voice interaction across CLI and messaging platforms. If you want a practical setup walkthrough with recommended configurations and real usage patterns, see [Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes). +For hands-free session start — saying "hey hermes" (or any phrase) to open a fresh voice session on the CLI, TUI, or desktop app — see [Wake Word](/user-guide/features/wake-word). + ## Prerequisites Before using voice features, make sure you have: diff --git a/website/sidebars.ts b/website/sidebars.ts index 87f7a7e8d89..4e019021aa4 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -109,6 +109,7 @@ const sidebars: SidebarsConfig = { label: 'Media & Web', items: [ 'user-guide/features/voice-mode', + 'user-guide/features/wake-word', 'user-guide/features/web-search', 'user-guide/features/x-search', 'user-guide/features/browser', From 625d39632aec3460cc924463cf5e853c0a36a6c0 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:04:18 -0700 Subject: [PATCH 23/46] test(wake): stub numpy in the fake-sherpa fixture for hermetic CI numpy is a lazy voice-extra dep absent from CI's hermetic env; the two engine process() tests imported it for real. Stub asarray/float32 in the fixture (verified against a blocked-numpy import, matching CI). --- tests/tools/test_wake_word.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index f70498a516e..afe74ea6f4b 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -244,6 +244,19 @@ def _install_fake_sherpa(monkeypatch, tmp_path): sherpa.text2token = _fake_text2token monkeypatch.setitem(sys.modules, "sherpa_onnx", sherpa) monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + + # numpy is an optional voice-extra dep, lazy-installed at runtime — CI's + # hermetic slices don't have it. process() only calls asarray(...)/32768, + # so a minimal stub keeps these tests runnable without the real package. + if "numpy" not in sys.modules: + class _FakeArr(list): + def __truediv__(self, other): + return self + + np_stub = types.ModuleType("numpy") + np_stub.float32 = "float32" + np_stub.asarray = lambda x, dtype=None: _FakeArr(x) + monkeypatch.setitem(sys.modules, "numpy", np_stub) return calls, model_dir From 7a87c6ffd60dfee253898300030046154cfa9926 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:56:47 -0700 Subject: [PATCH 24/46] fix(wake): make the lazy-install path reachable on fresh installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_wake_word_requirements() gated 'available' on the audio probe, but the probe imports sounddevice + numpy — two of the packages the lazy installer would install. On a fresh machine deps_ok was False, so audio_ok was always False and /wake on printed the manual pip hint and bailed before the engine constructors' lazy_deps.ensure() could run. Now the audio probe only runs once deps are installed; with deps missing and lazy installs allowed (the default), /wake on proceeds and ensure() installs the pinned engine deps in-process — no restart. The manual pip hint remains for security.allow_lazy_installs=false, and a mic hint still blocks when deps are present but no audio device works. The CLI announces the one-time engine install so the pause is explained. --- cli.py | 5 ++++ tests/tools/test_wake_word.py | 47 +++++++++++++++++++++++++++++++++++ tools/wake_word.py | 16 +++++++++--- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index 4635b9c8adf..04528f20933 100644 --- a/cli.py +++ b/cli.py @@ -12222,6 +12222,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f" {_DIM}{reqs['hint']}{_RST}") return False + if announce and not reqs.get("deps_available", True): + # Fresh install: the engine constructor lazy-installs its deps + # (onnxruntime is a large wheel) — tell the user why this is slow. + _cprint(f"{_DIM}Installing wake word engine (first use — this may take a minute)...{_RST}") + self._wake_start_new_session = bool(cfg.get("start_new_session", True)) try: start_listening(self._on_wake_word, owner=self, config=cfg) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index afe74ea6f4b..45e87b36eec 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -112,6 +112,53 @@ def test_requirements_unavailable_without_audio(monkeypatch): assert r["audio_available"] is False +def test_requirements_fresh_install_lazy_allowed(monkeypatch): + """Deps missing + lazy installs allowed → available, so /wake on can + reach the engine constructor's ``lazy_deps.ensure()`` call. + + Regression: the audio probe imports sounddevice/numpy — packages the + lazy installer would fetch — so gating ``available`` on it made the + lazy-install path unreachable on a fresh machine (the /wake on handler + printed the pip hint and bailed before ensure() ever ran). + """ + def _boom(): + raise AssertionError("audio probe must not run while deps are missing") + + monkeypatch.setattr(ww, "_audio_available", _boom) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is True + assert r["deps_available"] is False + assert r["hint"] == "" + + +def test_requirements_fresh_install_lazy_disabled(monkeypatch): + """Deps missing + lazy installs disabled → unavailable, with the manual + pip command as the remediation hint.""" + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr( + "tools.lazy_deps.feature_install_command", lambda f: f"uv pip install {f}" + ) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["deps_available"] is False + assert "install" in r["hint"] + + +def test_requirements_deps_present_but_no_audio_hint(monkeypatch): + """Once deps ARE installed, a failing audio probe blocks with a mic hint + (lazy installs can't fix a missing audio device).""" + monkeypatch.setattr(ww, "_audio_available", lambda: False) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert "audio device" in r["hint"] + + # ── openWakeWord engine (bundled model + base-model fetch) ─────────────── diff --git a/tools/wake_word.py b/tools/wake_word.py index a1bb8e43bd8..e1211a61811 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -507,20 +507,28 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s else: feature = "wake.openwakeword" deps_ok = lazy_deps.is_available(feature) - audio_ok = _audio_available() + lazy_ok = lazy_deps._allow_lazy_installs() + # The audio probe imports sounddevice + numpy — two of the very packages + # the lazy installer would fetch — so it can only be trusted once the + # feature's deps are installed. On a fresh install (deps missing, lazy + # installs allowed) we defer the mic check: the engine constructors call + # ``lazy_deps.ensure()`` and the stream-open surfaces any real audio + # problem. Gating ``available`` on the probe here made the lazy-install + # path unreachable (the probe always failed before ensure() could run). + audio_ok = _audio_available() if deps_ok else False key_ok = True hint = "" if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): key_ok = False hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)." - elif not deps_ok: + elif not deps_ok and not lazy_ok: hint = lazy_deps.feature_install_command(feature) or "" - elif not audio_ok: + elif deps_ok and not audio_ok: hint = "Microphone capture needs sounddevice + numpy and a working audio device." return { - "available": audio_ok and (deps_ok or lazy_deps._allow_lazy_installs()) and key_ok, + "available": key_ok and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), "provider": provider, "deps_available": deps_ok, "audio_available": audio_ok, From e8f9d471c6c4329da297a16778dccc222ef61eb3 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:47:16 -0700 Subject: [PATCH 25/46] =?UTF-8?q?feat(wake):=20the=20toggle=20IS=20the=20c?= =?UTF-8?q?onfig=20=E2=80=94=20explicit=20on/off=20persists=20wake=5Fword.?= =?UTF-8?q?enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the desktop ear button or running /wake on|off now writes wake_word.enabled to config.yaml (live, saved for future sessions), so the feature no longer requires hand-editing config before the UI toggle works. - wake.start accepts persist:true (explicit gesture): flips wake_word.enabled on in config before arming; response reports enabled_persisted. Passive auto-arm paths (desktop gateway-ready, TUI reconnect) never pass it, so a mic can't become persistently enabled without a deliberate user action. - wake.stop accepts persist:true: writes wake_word.enabled: false so auto-arm stays off next session; reports disabled_persisted. - Split the refusal reason: 'disabled' (feature off in config — a persisted gesture turns it on) vs 'disabled_for_surface' (explicit wake_word.surface scoping, which persist does NOT override). - Classic CLI /wake on|off and bare-toggle persist the flag too (skips the write when config already matches). - Desktop tooltip now maps refusal codes to friendly text (mirrors the TUI's START_REASON_TEXT) instead of showing raw codes like disabled_for_surface. - Docs: quick-start notes the toggle persists; ear-button mention. --- apps/desktop/src/store/wake-word.test.ts | 10 +- apps/desktop/src/store/wake-word.ts | 33 ++++++- hermes_cli/cli_commands_mixin.py | 31 +++++- tests/test_tui_gateway_server.py | 97 ++++++++++++++++++- tui_gateway/server.py | 57 ++++++++++- ui-tui/src/__tests__/wakeCommand.test.ts | 22 ++++- ui-tui/src/app/slash/commands/wake.ts | 19 ++-- ui-tui/src/gatewayTypes.ts | 2 + website/docs/user-guide/features/wake-word.md | 6 +- 9 files changed, 249 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts index 21a8f74b539..0236f150e7a 100644 --- a/apps/desktop/src/store/wake-word.test.ts +++ b/apps/desktop/src/store/wake-word.test.ts @@ -60,7 +60,7 @@ describe('toggleWakeWord', () => { await toggleWakeWord(request) - expect(request).toHaveBeenCalledWith('wake.start', { surface: 'gui' }) + expect(request).toHaveBeenCalledWith('wake.start', { persist: true, surface: 'gui' }) expect($wakeWord.get()).toMatchObject({ listening: true, notice: '', pending: false }) }) @@ -75,7 +75,7 @@ describe('toggleWakeWord', () => { await toggleWakeWord(request) - expect(request).toHaveBeenCalledWith('wake.stop', {}) + expect(request).toHaveBeenCalledWith('wake.stop', { persist: true }) expect($wakeWord.get()).toMatchObject({ listening: false, notice: '', pending: false }) }) @@ -86,7 +86,7 @@ describe('toggleWakeWord', () => { const state = $wakeWord.get() expect(state.listening).toBe(false) - expect(state.notice).toBe('owned') + expect(state.notice).toBe('another surface owns the listener') expect(state.available).toBe(true) }) @@ -215,7 +215,7 @@ describe('armWakeWord (gateway-ready auto-arm)', () => { const state = $wakeWord.get() expect(state.available).toBe(true) expect(state.listening).toBe(false) - expect(state.notice).toBe('owned') + expect(state.notice).toBe('another surface owns the listener') }) }) @@ -227,7 +227,7 @@ describe('applyWakeStopResult', () => { const state = $wakeWord.get() expect(state.listening).toBe(false) - expect(state.notice).toBe('not_owner') + expect(state.notice).toBe('another surface owns the listener') }) }) diff --git a/apps/desktop/src/store/wake-word.ts b/apps/desktop/src/store/wake-word.ts index 97f0c8b3c89..69175d19a6d 100644 --- a/apps/desktop/src/store/wake-word.ts +++ b/apps/desktop/src/store/wake-word.ts @@ -41,6 +41,7 @@ export interface WakeStatusResponse { } export interface WakeStartResponse { + enabled_persisted?: boolean hint?: string owner_surface?: string | null phrase?: string @@ -50,6 +51,7 @@ export interface WakeStartResponse { } export interface WakeStopResponse { + disabled_persisted?: boolean reason?: string | null stopped?: boolean } @@ -68,8 +70,28 @@ const gatewayRequester: WakeRequester = async (method: string, params: Record return gateway.request(method, params) } -const noticeFrom = (result: { hint?: string; reason?: string | null } | null | undefined): string => - result?.hint?.trim() || result?.reason?.trim() || '' +// Friendly text for the gateway's wake refusal codes (mirrors the TUI's +// START_REASON_TEXT). Unknown codes fall through raw so new server-side +// codes stay visible instead of silently disappearing. +const REASON_TEXT: Record = { + disabled: 'click to enable', + disabled_for_surface: 'scoped to another surface (config wake_word.surface)', + not_owner: 'another surface owns the listener', + owned: 'another surface owns the listener', + unavailable: 'unavailable' +} + +const noticeFrom = (result: { hint?: string; reason?: string | null } | null | undefined): string => { + const hint = result?.hint?.trim() + + if (hint) { + return hint + } + + const reason = result?.reason?.trim() + + return reason ? (REASON_TEXT[reason] ?? reason) : '' +} /** Sync the atom from a `wake.status` payload (mount / gateway-ready). */ export function applyWakeStatus(status: WakeStatusResponse | null | undefined): void { @@ -162,9 +184,12 @@ export async function toggleWakeWord(request: WakeRequester = gatewayRequester): try { if (state.listening) { - applyWakeStopResult(await request('wake.stop', {})) + applyWakeStopResult(await request('wake.stop', { persist: true })) } else { - applyWakeStartResult(await request('wake.start', { surface: 'gui' })) + // persist: true — a deliberate click is consent, so the backend flips + // wake_word.enabled in config.yaml (on/off) and the choice sticks for + // future sessions. Auto-arm (armWakeWord) never passes it. + applyWakeStartResult(await request('wake.start', { persist: true, surface: 'gui' })) } } catch (error) { const current = $wakeWord.get() diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index f2312296078..f8c592522a3 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -3191,24 +3191,47 @@ class CLICommandsMixin: _cprint("Usage: /voice [on|off|tts|status]") def _handle_wake_command(self, command: str): - """Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener.""" + """Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener. + + The toggle IS the config: an explicit on/off (or bare toggle) also + writes ``wake_word.enabled`` to config.yaml so the choice persists + across sessions. Startup auto-arm (_maybe_start_wake_word) only reads. + """ from cli import _cprint parts = command.strip().split(maxsplit=1) subcommand = parts[1].lower().strip() if len(parts) > 1 else "" if subcommand == "on": - self._start_wake_word_listener(announce=True) + if self._start_wake_word_listener(announce=True): + self._persist_wake_word_enabled(True) elif subcommand == "off": self._stop_wake_word_listener(announce=True) + self._persist_wake_word_enabled(False) elif subcommand in ("", "status"): if subcommand == "": # Bare /wake toggles. if getattr(self, "_wake_word_active", False): self._stop_wake_word_listener(announce=True) - else: - self._start_wake_word_listener(announce=True) + self._persist_wake_word_enabled(False) + elif self._start_wake_word_listener(announce=True): + self._persist_wake_word_enabled(True) else: self._show_wake_word_status() else: _cprint(f"Unknown wake subcommand: {subcommand}") _cprint("Usage: /wake [on|off|status]") + + def _persist_wake_word_enabled(self, enabled: bool): + """Save ``wake_word.enabled`` so the /wake toggle sticks for future sessions.""" + from cli import _cprint, _DIM, _RST, save_config_value + + try: + from tools.wake_word import load_wake_word_config + + if bool(load_wake_word_config().get("enabled")) == enabled: + return # already persisted — don't rewrite config or re-announce + except Exception: + pass + if save_config_value("wake_word.enabled", enabled): + _cprint(f"{_DIM}Wake word {'enabled' if enabled else 'disabled'} in config " + f"(wake_word.enabled: {str(enabled).lower()}).{_RST}") diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ca2139a7c13..5ff4032772b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1416,7 +1416,11 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc "reason": "owned", "owner_surface": "gui", } - assert denied_stop["result"] == {"stopped": False, "reason": "not_owner"} + assert denied_stop["result"] == { + "stopped": False, + "reason": "not_owner", + "disabled_persisted": False, + } assert denied_voice_stop["result"] == { "status": "busy", "reason": "wake_owned", @@ -1445,7 +1449,11 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc "method": "wake.stop", "params": {}, }, transport=first) - assert stopped["result"] == {"stopped": True, "reason": None} + assert stopped["result"] == { + "stopped": True, + "reason": None, + "disabled_persisted": False, + } reclaimed = server.dispatch({ "id": "wake-reclaim-2", @@ -1468,7 +1476,90 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc "method": "wake.stop", "params": {}, }, transport=second) - assert stopped_again["result"] == {"stopped": True, "reason": None} + assert stopped_again["result"] == { + "stopped": True, + "reason": None, + "disabled_persisted": False, + } + finally: + server._wake_owner_transport = None + server._wake_owner_surface = "" + + +def test_wake_toggle_persists_enabled_flag_only_on_explicit_gesture(monkeypatch): + """The ear toggle / /wake on|off write wake_word.enabled; auto-arm never does.""" + from tools import wake_word + + config = {"enabled": False, "phrase": "hey hermes", "surface": "auto", + "start_new_session": True} + persisted = [] + + def fake_persist(enabled): + persisted.append(enabled) + config["enabled"] = enabled + return True + + monkeypatch.setattr(server, "_persist_wake_enabled", fake_persist) + monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: dict(config)) + monkeypatch.setattr(wake_word, "check_wake_word_requirements", lambda _cfg: { + "available": True, + "phrase": "hey hermes", + "provider": "test", + "hint": "", + }) + listener = {"owner": None} + monkeypatch.setattr( + wake_word, "start_listening", + lambda callback, *, owner, config: listener.update(owner=owner), + ) + monkeypatch.setattr( + wake_word, "stop_listening", + lambda *, owner: listener["owner"] is owner and not listener.update(owner=None), + ) + monkeypatch.setattr(wake_word, "owns_listener", lambda owner: listener["owner"] is owner) + + transport = types.SimpleNamespace(_closed=False) + server._wake_owner_transport = None + server._wake_owner_surface = "" + try: + # Passive auto-arm (no persist): refused, config untouched. + passive = server.dispatch({ + "id": "wake-passive", + "method": "wake.start", + "params": {"surface": "gui"}, + }, transport=transport) + assert passive["result"] == {"started": False, "reason": "disabled"} + assert persisted == [] + + # Explicit gesture: enables in config AND arms. + clicked = server.dispatch({ + "id": "wake-click", + "method": "wake.start", + "params": {"surface": "gui", "persist": True}, + }, transport=transport) + assert clicked["result"]["started"] is True + assert clicked["result"]["enabled_persisted"] is True + assert persisted == [True] + + # Explicit stop: disables in config. + stopped = server.dispatch({ + "id": "wake-click-off", + "method": "wake.stop", + "params": {"persist": True}, + }, transport=transport) + assert stopped["result"]["stopped"] is True + assert stopped["result"]["disabled_persisted"] is True + assert persisted == [True, False] + + # persist does NOT override an explicit surface scoping. + config.update(enabled=True, surface="tui") + scoped = server.dispatch({ + "id": "wake-scoped", + "method": "wake.start", + "params": {"surface": "gui", "persist": True}, + }, transport=transport) + assert scoped["result"] == {"started": False, "reason": "disabled_for_surface"} + assert persisted == [True, False] finally: server._wake_owner_transport = None server._wake_owner_surface = "" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a361c34e549..8c978967da6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17648,14 +17648,36 @@ def _wake_resume_if_owner(owner: "Transport") -> bool: return False +def _persist_wake_enabled(enabled: bool) -> bool: + """Write ``wake_word.enabled`` to config.yaml. + + Only called for explicit user gestures (the desktop ear toggle, ``/wake + on|off``) — never from passive auto-arm paths, so a mic can't become + persistently enabled without a deliberate click. + """ + try: + from cli import save_config_value + + return bool(save_config_value("wake_word.enabled", enabled)) + except Exception as e: + logger.warning("wake: failed to persist wake_word.enabled=%s: %s", enabled, e) + return False + + @method("wake.start") def _(rid, params: dict) -> dict: """Arm the wake-word listener for the calling surface ("tui" | "gui"). Idempotent and gated: returns ``{started: False, reason}`` when the wake word is disabled, scoped to another surface, or its deps/mic aren't ready. + + ``persist: true`` marks an explicit user gesture (toggle click, /wake on): + when the feature is disabled in config, it flips ``wake_word.enabled`` on + and saves it before arming, so the choice sticks for future sessions. + Passive auto-arm callers omit it and keep getting the config-gated refusal. """ surface = str(params.get("surface") or "auto").strip().lower() + persist = bool(params.get("persist")) transport = current_transport() or _stdio_transport try: from tools.wake_word import ( @@ -17671,10 +17693,21 @@ def _(rid, params: dict) -> dict: return _err(rid, 5026, f"wake module unavailable: {e}") cfg = load_wake_word_config() + enabled_persisted = False + if persist and not cfg.get("enabled"): + enabled_persisted = _persist_wake_enabled(True) + if enabled_persisted: + cfg = dict(cfg) + cfg["enabled"] = True if not wake_surface_enabled(surface, cfg): - logger.info("wake.start(%s): disabled for surface (enabled=%s, surface=%s)", - surface, cfg.get("enabled"), cfg.get("surface")) - return _ok(rid, {"started": False, "reason": "disabled_for_surface"}) + # Distinguish "feature off in config" (reason: disabled — a persist:true + # retry can turn it on) from "scoped to a different surface" (reason: + # disabled_for_surface — respects an explicit wake_word.surface choice, + # which persist does NOT override). + reason = "disabled" if not cfg.get("enabled") else "disabled_for_surface" + logger.info("wake.start(%s): %s (enabled=%s, surface=%s)", + surface, reason, cfg.get("enabled"), cfg.get("surface")) + return _ok(rid, {"started": False, "reason": reason}) reqs = check_wake_word_requirements(cfg) if not reqs["available"]: logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint")) @@ -17750,16 +17783,34 @@ def _(rid, params: dict) -> dict: "phrase": reqs["phrase"], "provider": reqs["provider"], "owner_surface": surface, + "enabled_persisted": enabled_persisted, }) @method("wake.stop") def _(rid, params: dict) -> dict: + """Stop this surface's listener. + + ``persist: true`` (explicit user gesture) also writes + ``wake_word.enabled: false`` to config.yaml so auto-arm stays off in + future sessions — the toggle is the config, not just the live listener. + """ transport = current_transport() or _stdio_transport stopped = _release_wake_for_transport(transport) + disabled_persisted = False + if bool(params.get("persist")): + try: + from tools.wake_word import load_wake_word_config + + currently_enabled = bool(load_wake_word_config().get("enabled")) + except Exception: + currently_enabled = True + if currently_enabled: + disabled_persisted = _persist_wake_enabled(False) return _ok(rid, { "stopped": stopped, "reason": None if stopped else "not_owner", + "disabled_persisted": disabled_persisted, }) diff --git a/ui-tui/src/__tests__/wakeCommand.test.ts b/ui-tui/src/__tests__/wakeCommand.test.ts index 185c35d0034..32f86b1a67e 100644 --- a/ui-tui/src/__tests__/wakeCommand.test.ts +++ b/ui-tui/src/__tests__/wakeCommand.test.ts @@ -58,7 +58,7 @@ describe('/wake slash command', () => { await run('on') - expect(rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' }) + expect(rpc).toHaveBeenCalledWith('wake.start', { persist: true, surface: 'tui' }) expect(printed(sys)).toContain('listening') expect(printed(sys)).toContain('hey hermes') expect(printed(sys)).toContain('openwakeword') @@ -104,11 +104,29 @@ describe('/wake slash command', () => { await run('off') - expect(rpc).toHaveBeenCalledWith('wake.stop', {}) + expect(rpc).toHaveBeenCalledWith('wake.stop', { persist: true }) expect(isWakeUserDisabled()).toBe(true) expect(printed(sys)).toContain('listener off') }) + it('/wake on reports when the gesture also enabled the config flag', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { enabled_persisted: true, phrase: 'hey hermes', provider: 'openwakeword', started: true } + }) + + await run('on') + + expect(printed(sys)).toContain('enabled in config') + }) + + it('/wake off reports when the gesture also disabled the config flag', async () => { + const { run, sys } = buildCtx({ 'wake.stop': { disabled_persisted: true, stopped: true } }) + + await run('off') + + expect(printed(sys)).toContain('disabled in config') + }) + it('/wake off explains a not_owner refusal but still records the opt-out', async () => { const { run, sys } = buildCtx({ 'wake.stop': { reason: 'not_owner', stopped: false } }) diff --git a/ui-tui/src/app/slash/commands/wake.ts b/ui-tui/src/app/slash/commands/wake.ts index d73dea4fa94..1984678f9db 100644 --- a/ui-tui/src/app/slash/commands/wake.ts +++ b/ui-tui/src/app/slash/commands/wake.ts @@ -11,7 +11,8 @@ const isWakeSub = (value: string): value is WakeSub => (WAKE_SUBCOMMANDS as read // Friendly text for the gateway's wake.start refusal codes. Unknown codes // fall through to the raw reason so new server-side codes stay visible. const START_REASON_TEXT: Record = { - disabled_for_surface: 'disabled for this surface (config wake_word.enabled / wake_word.surface)', + disabled: 'disabled (config wake_word.enabled)', + disabled_for_surface: 'scoped to another surface (config wake_word.surface)', not_owner: 'another surface owns the listener', owned: 'another surface owns the listener', unavailable: 'unavailable' @@ -50,8 +51,11 @@ const statusLine = (r: WakeStatusResponse): string => { const runOn = (ctx: SlashRunCtx): void => { setWakeUserDisabled(false) + // persist: true — an explicit /wake on writes wake_word.enabled to config + // so the choice survives restarts (the backend only persists on gesture + // paths; reconnect auto-arm never does). ctx.gateway - .rpc('wake.start', { surface: 'tui' }) + .rpc('wake.start', { persist: true, surface: 'tui' }) .then( ctx.guarded(r => { if (!r.started) { @@ -60,8 +64,9 @@ const runOn = (ctx: SlashRunCtx): void => { const phrase = r.phrase ? ` for “${r.phrase}”` : '' const provider = r.provider ? ` · ${r.provider}` : '' + const saved = r.enabled_persisted ? ' · enabled in config' : '' - ctx.transcript.sys(`wake: listening${phrase}${provider}`) + ctx.transcript.sys(`wake: listening${phrase}${provider}${saved}`) }) ) .catch(ctx.guardedErr) @@ -73,16 +78,18 @@ const runOff = (ctx: SlashRunCtx): void => { setWakeUserDisabled(true) ctx.gateway - .rpc('wake.stop', {}) + .rpc('wake.stop', { persist: true }) .then( ctx.guarded(r => { + const saved = r.disabled_persisted ? ' · disabled in config' : '' + if (r.stopped) { - return ctx.transcript.sys('wake: listener off (won’t re-arm this session)') + return ctx.transcript.sys(`wake: listener off${saved}`) } const reason = r.reason === 'not_owner' ? 'this surface doesn’t own the listener' : (r.reason ?? 'not running') - ctx.transcript.sys(`wake: nothing to stop — ${reason}`) + ctx.transcript.sys(`wake: nothing to stop — ${reason}${saved}`) }) ) .catch(ctx.guardedErr) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6a86e13b9c2..a47b904e3ab 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -403,6 +403,7 @@ export interface VoiceRecordResponse { // ── Wake word ──────────────────────────────────────────────────────── export interface WakeStartResponse { + enabled_persisted?: boolean hint?: string owner_surface?: null | string phrase?: string @@ -412,6 +413,7 @@ export interface WakeStartResponse { } export interface WakeStopResponse { + disabled_persisted?: boolean reason?: null | string stopped?: boolean } diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 794ce0246e6..86dbe967801 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -56,7 +56,11 @@ cd ~/.hermes/hermes-agent && uv pip install -e ".[wake]" /wake off # stop listening ``` -Or enable it permanently in `~/.hermes/config.yaml`: +In the desktop app, click the ear icon in the composer. + +The toggle IS the setting: turning the wake word on or off — via `/wake` or the +desktop ear button — also writes `wake_word.enabled` to `~/.hermes/config.yaml`, +so your choice persists across sessions. You can also flip it by hand: ```yaml wake_word: From 4478e76061b8768c3c3253b683f48259bf69fd67 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:04:10 -0700 Subject: [PATCH 26/46] fix(desktop): extract wake pause into a callback to satisfy the no-ref-mirror lint rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake-pause effect assigned wakePausedRef.current inside useEffect, tripping eslint's no-restricted-syntax guard against atom→ref mirroring. The ref is actually a request token (did WE issue wake.pause?), not a reactive mirror — moving the assignment into a pauseWakeForVoice callback keeps the semantics and passes the rule without a disable. --- .../chat/composer/hooks/use-composer-voice.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 0709b3d02ee..0fb5c288751 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -166,17 +166,23 @@ export function useComposerVoice({ .catch(() => undefined) }, []) + // The ref is a request token (did WE issue wake.pause?), not an atom mirror — + // it guards resumeWakeIfPaused from resuming a detector another surface owns. + const pauseWakeForVoice = useCallback(() => { + wakePausedRef.current = true + void $gateway + .get() + ?.request('wake.pause', {}) + .catch(() => undefined) + }, []) + useEffect(() => { if (voiceConversationActive) { - wakePausedRef.current = true - void $gateway - .get() - ?.request('wake.pause', {}) - .catch(() => undefined) + pauseWakeForVoice() } else { resumeWakeIfPaused() } - }, [resumeWakeIfPaused, voiceConversationActive]) + }, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive]) useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused]) From a56275771759af1b05620d07a3dc439d2f8044de Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:17:04 -0700 Subject: [PATCH 27/46] fix(wake): reconcile the listener back to config after a voice turn ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ending a voice conversation left the wake word silently off even with wake_word.enabled: true — the desktop fired one wake.resume and hoped; if the mic was still held by the just-released WebRTC capture (or the resume raced teardown), the listener stayed dead until the user re-toggled it. The wake word is a persistent setting: on is on until the user explicitly turns it off. - Desktop: resumeWakeAfterVoice() replaces the fire-and-forget resume — resume, then verify against wake.status (config 'enabled' is the authority) and re-arm via wake.start, with spaced retries to ride out mic-release latency. Passive path: never passes persist, never writes config; respects an explicit off and another surface's mic lease. - Backend: wake.status now reports 'enabled' (config truth) so clients reconcile against the setting, not runtime listener state. - Backend: _wake_resume_if_owner self-heals — a resume that throws (mic still busy) retries in a background thread for up to 15s. A False return (lease gone/moved) is final, never retried, so the retry can't steal another surface's mic. Covers the TUI/gateway voice.record path which had no recovery at all (CLI has its idle watchdog; the gateway had nothing). - 6 new vitest cases: re-arm on enabled+down, no persist on the passive path, resume-alone success, disabled stays off, owned lease yields, older-backend no-op. --- .../chat/composer/hooks/use-composer-voice.ts | 9 +- apps/desktop/src/store/wake-word.test.ts | 119 ++++++++++++++++++ apps/desktop/src/store/wake-word.ts | 56 +++++++++ tui_gateway/server.py | 60 ++++++++- 4 files changed, 235 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 0fb5c288751..414676e3c9b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -9,6 +9,7 @@ import { resetBrowseState } from '@/store/composer-input-history' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' +import { resumeWakeAfterVoice } from '@/store/wake-word' import type { ComposerTarget } from '../focus' import { onComposerVoiceToggleRequest } from '../focus' @@ -160,10 +161,10 @@ export function useComposerVoice({ } wakePausedRef.current = false - void $gateway - .get() - ?.request('wake.resume', {}) - .catch(() => undefined) + // Reconcile, don't just resume: the wake word is a persistent setting, so + // ending a voice chat must re-arm the listener whenever config says + // enabled — including when the raw resume loses the mic-release race. + void resumeWakeAfterVoice() }, []) // The ref is a request token (did WE issue wake.pause?), not an atom mirror — diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts index 0236f150e7a..f969b4cf2d4 100644 --- a/apps/desktop/src/store/wake-word.test.ts +++ b/apps/desktop/src/store/wake-word.test.ts @@ -7,6 +7,7 @@ import { applyWakeStopResult, armWakeWord, resetWakeWordState, + resumeWakeAfterVoice, toggleWakeWord, type WakeRequester } from './wake-word' @@ -238,3 +239,121 @@ describe('applyWakeStartResult', () => { expect($wakeWord.get()).toMatchObject({ available: true, listening: true, phrase: 'computer' }) }) }) + +describe('resumeWakeAfterVoice (post-voice reconcile)', () => { + it('re-arms when config says enabled but the listener is down', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { reason: 'not_owner', resumed: false } + } + + if (method === 'wake.status') { + return { available: true, enabled: true, listening: false, phrase: 'hey hermes' } + } + + return { phrase: 'hey hermes', started: true } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status', 'wake.start']) + expect($wakeWord.get()).toMatchObject({ listening: true }) + }) + + it('re-arm start never passes persist (passive path must not write config)', async () => { + const startParams: Array | undefined> = [] + + const request = vi.fn(async (method: string, params?: Record) => { + if (method === 'wake.resume') { + return { resumed: false } + } + + if (method === 'wake.status') { + return { available: true, enabled: true, listening: false } + } + + startParams.push(params) + + return { started: true } + }) as unknown as WakeRequester + + await resumeWakeAfterVoice(request) + + expect(startParams).toEqual([{ surface: 'gui' }]) + }) + + it('stops after the resume alone brings the listener back', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { resumed: true } + } + + return { available: true, enabled: true, listening: true, owned_by_caller: true } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status']) + expect($wakeWord.get()).toMatchObject({ listening: true }) + }) + + it('leaves the listener off when config says disabled', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { resumed: false } + } + + return { available: true, enabled: false, listening: false } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status']) + expect($wakeWord.get().listening).toBe(false) + }) + + it('yields when another surface owns the mic lease', async () => { + const calls: string[] = [] + + const request = requester(method => { + calls.push(method) + + if (method === 'wake.resume') { + return { resumed: false } + } + + if (method === 'wake.status') { + return { available: true, enabled: true, listening: false, owner_surface: 'tui' } + } + + return { owner_surface: 'tui', reason: 'owned', started: false } + }) + + await resumeWakeAfterVoice(request) + + expect(calls).toEqual(['wake.resume', 'wake.status', 'wake.start']) + expect($wakeWord.get().listening).toBe(false) + }) + + it('is a no-op against older backends without wake.* methods', async () => { + const request = requester(() => { + throw new Error('Unknown method: wake.resume') + }) + + await resumeWakeAfterVoice(request) + + expect($wakeWord.get()).toMatchObject({ available: false, listening: false }) + }) +}) diff --git a/apps/desktop/src/store/wake-word.ts b/apps/desktop/src/store/wake-word.ts index 69175d19a6d..9b9e61a42ae 100644 --- a/apps/desktop/src/store/wake-word.ts +++ b/apps/desktop/src/store/wake-word.ts @@ -32,6 +32,8 @@ export const $wakeWord = atom(INITIAL_WAKE_WORD_STATE) export interface WakeStatusResponse { available?: boolean + /** Config truth (wake_word.enabled) — drives post-voice re-arm. */ + enabled?: boolean hint?: string listening?: boolean owned_by_caller?: boolean @@ -202,6 +204,60 @@ export async function toggleWakeWord(request: WakeRequester = gatewayRequester): } } +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * Post-voice-turn reconcile: the wake word is a persistent setting, so ending a + * voice conversation must land the listener back where config says it belongs. + * `wake.resume` alone isn't enough — the mic can still be held by the just-torn + * -down WebRTC capture, and a fire-and-forget resume that loses that race left + * the ear silently off until the user re-toggled. Resume, then verify against + * `wake.status` (config `enabled` is the authority) and re-arm, with a couple + * of spaced retries to ride out mic-release latency. Never passes `persist` — + * this is a passive path and must not flip config. + */ +export async function resumeWakeAfterVoice(request: WakeRequester = gatewayRequester): Promise { + try { + await request('wake.resume', {}) + } catch { + // Older backend without wake.* — nothing to reconcile. + return + } + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const status = await request('wake.status', {}) + applyWakeStatus(status) + + // Config says off (or the feature can't run) — off is the correct rest + // state. A user /wake off during the voice turn stays respected. + if (!status?.enabled || !status.available) { + return + } + + if (status.listening) { + return + } + + const started = await request('wake.start', { surface: 'gui' }) + applyWakeStartResult(started) + + if (started?.started) { + return + } + + // Another surface holds the mic lease — theirs to keep. + if (started?.reason === 'owned') { + return + } + } catch { + // Transient (mic still releasing) — fall through to the next attempt. + } + + await sleep(1500) + } +} + /** Test-only reset. */ export function resetWakeWordState(): void { $wakeWord.set(INITIAL_WAKE_WORD_STATE) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8c978967da6..4eb2229af11 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17638,14 +17638,61 @@ def _release_gateway_wake_owner() -> bool: return owner is not None and _release_wake_for_transport(owner) -def _wake_resume_if_owner(owner: "Transport") -> bool: - try: - from tools.wake_word import resume_listening +_wake_resume_retry_lock = threading.Lock() +_wake_resume_retry_active = False + +def _wake_resume_if_owner(owner: "Transport", *, retry_seconds: float = 15.0, + retry_interval: float = 1.0) -> bool: + """Resume the wake detector for ``owner``; self-heal a busy microphone. + + Reopening the mic right after a voice turn can fail while the capture + device is still being released (browser WebRTC tracks release async). + The CLI covers this with its idle watchdog; the gateway had nothing, so + one failed resume left the listener silently dead until the user toggled + it by hand — despite ``wake_word.enabled: true``. On an exception (mic + open failure) we retry in a background thread until it sticks, the lease + changes hands, or ``retry_seconds`` elapses. ``False`` from + ``resume_listening`` (lease gone / different owner) is final — never + retried, so this can't steal another surface's mic. + """ + from tools.wake_word import resume_listening + + try: return resume_listening(owner=owner) except Exception as e: - logger.debug("wake resume failed: %s", e) - return False + logger.debug("wake resume failed (will retry): %s", e) + + global _wake_resume_retry_active + with _wake_resume_retry_lock: + if _wake_resume_retry_active: + return False + _wake_resume_retry_active = True + + def _retry() -> None: + global _wake_resume_retry_active + deadline = time.monotonic() + retry_seconds + try: + while time.monotonic() < deadline: + time.sleep(retry_interval) + try: + if resume_listening(owner=owner): + logger.info("wake: detector resumed after retry") + return + except Exception: + continue + # False — detector gone or lease moved: stop, don't fight it. + return + logger.warning( + "wake: could not resume detector after voice turn " + "(microphone still busy?) — toggle the wake word to re-arm" + ) + finally: + with _wake_resume_retry_lock: + _wake_resume_retry_active = False + + threading.Thread(target=_retry, daemon=True, name="wake-resume-retry").start() + return False def _persist_wake_enabled(enabled: bool) -> bool: @@ -17866,6 +17913,9 @@ def _(rid, params: dict) -> dict: "provider": reqs["provider"], "available": reqs["available"], "hint": reqs.get("hint", ""), + # Config truth: clients use this to re-arm after a voice turn + # ("permanent on") without guessing from runtime listener state. + "enabled": bool(cfg.get("enabled")), }) except Exception as e: return _err(rid, 5026, str(e)) From 514dd59cad8fc8d65137d45a34b76db6be38e3f5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:18:24 -0700 Subject: [PATCH 28/46] fix(wake): detect dead-mic streams, stop the ear freezing during first-use install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal testing (macOS): clicking the ear froze the button for ~30s, then it went blue but 'hey hermes' never fired even though STT worked. Two distinct bugs: 1. Frozen-then-timeout button: first-use wake.start lazy-installs the detection engine (onnxruntime is a large wheel), which blows the desktop's default 30s WS request timeout — the RPC 'failed' client- side while the backend kept installing and armed later on its own. wake.start now gets a 180s budget and the pending state says 'arming — first use may take a minute while the engine installs' instead of a silent disabled button. 2. Armed but deaf: macOS grants mic permission per PROCESS. The renderer having mic access (STT working) does not grant the Python backend anything — CoreAudio hands an unentitled process a 'working' stream that delivers zeros forever, so the listener looks healthy and can never hear the phrase. The detector now tracks frame peaks: 10s of consecutive near-zero frames sets audio_silent, logged with the exact macOS Settings path, cleared automatically when audio appears. Surfaced everywhere: wake.status (audio_silent + hint), desktop ear tooltip (kept visible while listening), /wake status on TUI (⚠ line) and classic CLI, plus a docs troubleshooting section. Tests: detector silent-flag set/recover cycle (fake silent/loud streams), desktop tooltip keeps the dead-mic hint while listening. 44 wake Python tests, 22 desktop + 14 TUI vitest green. --- apps/desktop/src/store/wake-word.test.ts | 14 +++++ apps/desktop/src/store/wake-word.ts | 24 +++++++- cli.py | 5 ++ tests/tools/test_wake_word.py | 58 +++++++++++++++++++ tools/wake_word.py | 48 +++++++++++++++ tui_gateway/server.py | 14 ++++- ui-tui/src/app/slash/commands/wake.ts | 6 ++ ui-tui/src/gatewayTypes.ts | 4 ++ website/docs/user-guide/features/wake-word.md | 12 ++++ 9 files changed, 180 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts index f969b4cf2d4..2234d468a7d 100644 --- a/apps/desktop/src/store/wake-word.test.ts +++ b/apps/desktop/src/store/wake-word.test.ts @@ -47,6 +47,20 @@ describe('applyWakeStatus', () => { expect(state.listening).toBe(false) expect(state.notice).toBe('pip install openwakeword') }) + + it('keeps the dead-mic hint visible while listening (audio_silent)', () => { + applyWakeStatus({ + audio_silent: true, + available: true, + hint: 'Microphone delivers only silence — grant mic access', + listening: true, + phrase: 'hey hermes' + }) + + const state = $wakeWord.get() + expect(state.listening).toBe(true) + expect(state.notice).toBe('Microphone delivers only silence — grant mic access') + }) }) describe('toggleWakeWord', () => { diff --git a/apps/desktop/src/store/wake-word.ts b/apps/desktop/src/store/wake-word.ts index 9b9e61a42ae..3e64285a703 100644 --- a/apps/desktop/src/store/wake-word.ts +++ b/apps/desktop/src/store/wake-word.ts @@ -31,6 +31,8 @@ const INITIAL_WAKE_WORD_STATE: WakeWordState = { export const $wakeWord = atom(INITIAL_WAKE_WORD_STATE) export interface WakeStatusResponse { + /** Armed but the mic delivers only silence (macOS backend-permission gap). */ + audio_silent?: boolean available?: boolean /** Config truth (wake_word.enabled) — drives post-voice re-arm. */ enabled?: boolean @@ -62,6 +64,11 @@ export interface WakeStopResponse { * `requestGateway` and the `$gateway` instance wrapper below. */ export type WakeRequester = (method: string, params?: Record) => Promise +// First-use wake.start lazy-installs the detection engine (onnxruntime is a +// large wheel) — that legitimately takes minutes. The default 30s WS timeout +// fired mid-install, leaving a dead button that went blue on its own later. +const WAKE_START_TIMEOUT_MS = 180_000 + const gatewayRequester: WakeRequester = async (method: string, params: Record = {}) => { const gateway = $gateway.get() @@ -69,7 +76,9 @@ const gatewayRequester: WakeRequester = async (method: string, params: Record throw new Error('Hermes gateway unavailable') } - return gateway.request(method, params) + return method === 'wake.start' + ? gateway.request(method, params, WAKE_START_TIMEOUT_MS) + : gateway.request(method, params) } // Friendly text for the gateway's wake refusal codes (mirrors the TUI's @@ -99,12 +108,15 @@ const noticeFrom = (result: { hint?: string; reason?: string | null } | null | u export function applyWakeStatus(status: WakeStatusResponse | null | undefined): void { const current = $wakeWord.get() const listening = Boolean(status?.listening) + // "Armed but deaf" (macOS backend without mic permission) keeps its hint + // visible in the tooltip even though the toggle shows listening. + const silent = Boolean(status?.audio_silent) $wakeWord.set({ ...current, available: Boolean(status?.available), listening, - notice: listening ? '' : noticeFrom(status), + notice: listening && !silent ? '' : noticeFrom(status), phrase: status?.phrase?.trim() || current.phrase }) } @@ -182,7 +194,13 @@ export async function toggleWakeWord(request: WakeRequester = gatewayRequester): return } - $wakeWord.set({ ...state, pending: true }) + $wakeWord.set({ + ...state, + // First arm may lazy-install the detection engine — say so instead of + // freezing a silent disabled button for the duration. + notice: state.listening ? '' : 'arming — first use may take a minute while the engine installs', + pending: true + }) try { if (state.listening) { diff --git a/cli.py b/cli.py index 04528f20933..1d22d893d05 100644 --- a/cli.py +++ b/cli.py @@ -12367,6 +12367,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): def _show_wake_word_status(self): """Show current wake-word listener status.""" from tools.wake_word import ( + audio_is_silent, check_wake_word_requirements, is_listening, load_wake_word_config, @@ -12384,6 +12385,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f" Provider: {reqs['provider']}") _cprint(f" Surface: {cfg.get('surface', 'auto')}") _cprint(f" New session: {'yes' if cfg.get('start_new_session', True) else 'no'}") + if state == "LISTENING" and audio_is_silent(): + _cprint(f" {_ACCENT}⚠ Microphone delivers only silence — the listener can't hear anything.{_RST}") + _cprint(f" {_DIM}On macOS: System Settings > Privacy & Security > Microphone — allow your" + f" terminal/Hermes, then /wake off + /wake on.{_RST}") if not reqs["available"] and reqs.get("hint"): _cprint(f" {_DIM}{reqs['hint']}{_RST}") if not owned: diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 45e87b36eec..0bcab048607 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -508,6 +508,64 @@ def _fake_audio(monkeypatch): monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None)) +class _Frame(list): + """List with numpy-ish abs()/max() so the silence probe sees real peaks.""" + + def __abs__(self): + return _Frame(abs(x) for x in self) + + def max(self): + return max(self) if self else 0 + + +class _SilentStream(_FakeStream): + """Stream that always yields near-zero frames (dead macOS mic).""" + + def read(self, n): + time.sleep(0.005) + return _Frame([0] * n), False + + +class _LoudStream(_FakeStream): + """Stream that yields audible frames.""" + + def read(self, n): + time.sleep(0.005) + return _Frame([500] * n), False + + +def test_detector_flags_silent_stream_and_recovers(monkeypatch): + """A stream of zeros sets audio_silent (macOS no-permission mode); audio clears it.""" + monkeypatch.setattr(ww, "_SILENCE_ALERT_SECONDS", 0.001) # trip on the first frame + stream_cls = {"cls": _SilentStream} + fake_sd = types.SimpleNamespace(InputStream=lambda **kw: stream_cls["cls"](**kw)) + monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None)) + + det = ww.WakeWordDetector(_FakeEngine(fire=False), lambda: None) + det.start() + try: + deadline = time.monotonic() + 2.0 + while not det.audio_silent and time.monotonic() < deadline: + time.sleep(0.01) + assert det.audio_silent is True + assert ww.audio_is_silent() is False # module accessor needs the singleton + + monkeypatch.setattr(ww, "_detector", det) + assert ww.audio_is_silent() is True + + # Audio returns (permission granted / real mic) — flag clears. + det.pause() + stream_cls["cls"] = _LoudStream + det.resume() + deadline = time.monotonic() + 2.0 + while det.audio_silent and time.monotonic() < deadline: + time.sleep(0.01) + assert det.audio_silent is False + finally: + monkeypatch.setattr(ww, "_detector", None) + det.stop() + + def test_detector_fires_once_under_cooldown(monkeypatch): _fake_audio(monkeypatch) calls = [] diff --git a/tools/wake_word.py b/tools/wake_word.py index e1211a61811..7f33a8895c9 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -48,6 +48,14 @@ SAMPLE_RATE = 16000 _FIRE_COOLDOWN_SECONDS = 2.0 _START_TIMEOUT_SECONDS = 5.0 +# Dead-mic detection: an int16 stream whose peak stays at/below this for this +# many consecutive seconds is flagged as silent. macOS grants the *app* mic +# permission per-process — a backend spawned without the entitlement gets a +# "working" CoreAudio stream that delivers zeros forever, so the listener +# looks armed but can never hear the phrase. +_SILENCE_PEAK = 10 +_SILENCE_ALERT_SECONDS = 10 + class WakeWordInUse(RuntimeError): """Raised when another surface or process owns the wake-word listener.""" @@ -561,6 +569,12 @@ class WakeWordDetector: self._callback_inflight = threading.Event() self._last_fire = 0.0 self._lock = threading.Lock() + # True when the stream is open but every frame is (near-)silence — the + # classic macOS symptom of a backend process without mic permission: + # CoreAudio "succeeds" and delivers zeros forever. Surfaced via + # wake.status / /wake status so users can tell "armed" from "deaf". + self.audio_silent = False + self._silent_frames = 0 @property def running(self) -> bool: @@ -653,6 +667,9 @@ class WakeWordDetector: logger.info("wake word: listening (frame=%d, rate=%d)", frame_length, SAMPLE_RATE) ready.set() failed = False + # ~seconds of consecutive near-zero frames before we flag the stream + # as silent (macOS no-permission streams deliver zeros forever). + silent_alert_frames = max(1, int(_SILENCE_ALERT_SECONDS * SAMPLE_RATE / max(1, frame_length))) try: while not self._stop.is_set(): try: @@ -662,6 +679,25 @@ class WakeWordDetector: failed = not self._stop.is_set() break frame = data[:, 0] if getattr(data, "ndim", 1) == 2 else data + try: + peak = int(abs(frame).max()) if len(frame) else 0 + except Exception: + peak = _SILENCE_PEAK + 1 + if peak <= _SILENCE_PEAK: + self._silent_frames += 1 + if self._silent_frames == silent_alert_frames: + self.audio_silent = True + logger.warning( + "wake word: mic delivers only silence (peak<=%d for %ds) — " + "on macOS check System Settings > Privacy & Security > " + "Microphone for the Hermes backend process", + _SILENCE_PEAK, _SILENCE_ALERT_SECONDS, + ) + elif self._silent_frames: + if self.audio_silent: + logger.info("wake word: mic audio detected — stream healthy") + self._silent_frames = 0 + self.audio_silent = False try: fired = self.engine.process(frame) except Exception as e: @@ -861,6 +897,18 @@ def is_listening() -> bool: return det is not None and det.running +def audio_is_silent() -> bool: + """True when the armed stream has delivered only silence (dead mic). + + The macOS no-permission failure mode: the stream opens fine but every + frame is zeros, so detection can never fire. Lets status surfaces show + "listening but the microphone appears silent" instead of a healthy state. + """ + with _detector_lock: + det = _detector + return det is not None and det.audio_silent + + def get_last_match() -> Optional[tuple[str, str]]: """(matched phrase, profile) of the most recent wake fire, if the engine reports per-phrase matches (sherpa multi-profile routing). None otherwise.""" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4eb2229af11..28da1643615 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17895,6 +17895,7 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from tools.wake_word import ( + audio_is_silent, check_wake_word_requirements, is_listening, load_wake_word_config, @@ -17905,17 +17906,26 @@ def _(rid, params: dict) -> dict: transport = current_transport() or _stdio_transport owner, owner_surface = _wake_owner_snapshot() owned_by_caller = owns_listener(transport) + listening = owned_by_caller and is_listening() + silent = listening and audio_is_silent() + hint = reqs.get("hint", "") + if silent and not hint: + hint = ("Microphone delivers only silence — on macOS grant the " + "Hermes backend mic access (System Settings > Privacy & " + "Security > Microphone), then toggle the wake word.") return _ok(rid, { - "listening": owned_by_caller and is_listening(), + "listening": listening, "owned_by_caller": owned_by_caller, "owner_surface": owner_surface if owner is not None else None, "phrase": reqs["phrase"], "provider": reqs["provider"], "available": reqs["available"], - "hint": reqs.get("hint", ""), + "hint": hint, # Config truth: clients use this to re-arm after a voice turn # ("permanent on") without guessing from runtime listener state. "enabled": bool(cfg.get("enabled")), + # Armed but deaf (macOS permission failure mode) — see hint. + "audio_silent": silent, }) except Exception as e: return _err(rid, 5026, str(e)) diff --git a/ui-tui/src/app/slash/commands/wake.ts b/ui-tui/src/app/slash/commands/wake.ts index 1984678f9db..8011b853809 100644 --- a/ui-tui/src/app/slash/commands/wake.ts +++ b/ui-tui/src/app/slash/commands/wake.ts @@ -32,6 +32,12 @@ const statusLine = (r: WakeStatusResponse): string => { const provider = r.provider ? ` · ${r.provider}` : '' if (r.listening) { + if (r.audio_silent) { + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: listening${phrase}${provider} · ⚠ mic delivers only silence${hint}` + } + return `wake: listening${phrase}${provider}` } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index a47b904e3ab..954190d028a 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -419,7 +419,11 @@ export interface WakeStopResponse { } export interface WakeStatusResponse { + /** Armed but the mic delivers only silence (macOS backend-permission gap). */ + audio_silent?: boolean available?: boolean + /** Config truth (wake_word.enabled). */ + enabled?: boolean hint?: string listening?: boolean owned_by_caller?: boolean diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 86dbe967801..25673fbc451 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -208,6 +208,18 @@ PORCUPINE_ACCESS_KEY=your-key-here `/wake status` reports exactly what's missing if the listener won't start. +### "Listening" but never wakes (macOS) + +macOS grants microphone access per **process**. STT working in the desktop app +proves the *renderer* has mic access — the wake listener runs in the Python +*backend*, which needs its own grant. Without it, CoreAudio hands the backend a +"working" stream that only ever delivers silence, so the ear shows listening +but the phrase never fires. Hermes detects this (`/wake status` shows +"mic delivers only silence"; the desktop ear tooltip carries the same hint). +Fix: System Settings → Privacy & Security → Microphone → enable the Hermes +backend (it may appear as your terminal, `python`, or Hermes), then toggle the +wake word off and on. + ## Notes & limits - **Local surfaces only.** The wake word runs in the CLI, TUI, and desktop GUI — From f03bb2b4ef9e7230333080cc9774ae3e39124ff4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:26:20 -0700 Subject: [PATCH 29/46] feat(wake): gate arming on STT + TTS readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake loop is wake → record → STT → agent → TTS. Arming without either end configured gives a mic that hears you and then does nothing perceivable — a useless experience. check_wake_word_requirements now probes both (same probes /voice uses: stt.enabled + provider != none; check_tts_requirements) and refuses with a pointer to `hermes tools` naming exactly which half is missing. The desktop ear hides (available: false already hides the button), /wake on prints the hint on CLI/TUI. wake.start also validates requirements BEFORE persisting wake_word.enabled, so a refused gesture can't leave config claiming on while nothing can ever arm. Tests: per-half and both-missing hint assertions; existing requirements tests pinned via _voice_loop_ready so they don't depend on the test venv's installed voice stack. E2E: stt.enabled=false in a real config -> unavailable with the speech-to-text hint. --- tests/tools/test_wake_word.py | 33 +++++++++++++ tools/wake_word.py | 47 ++++++++++++++++++- tui_gateway/server.py | 19 ++++---- website/docs/user-guide/features/wake-word.md | 3 ++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 0bcab048607..2eb99edbccb 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -83,7 +83,15 @@ def test_build_engine_dispatch(monkeypatch): # ── Requirements probe ─────────────────────────────────────────────────── +def _voice_loop_ready(monkeypatch, stt=True, tts=True): + """Pin the STT/TTS probes so requirements tests don't depend on the + test venv's installed voice stack.""" + monkeypatch.setattr(ww, "_stt_ready", lambda: stt) + monkeypatch.setattr(ww, "_tts_ready", lambda: tts) + + def test_requirements_openwakeword_available(monkeypatch): + _voice_loop_ready(monkeypatch) monkeypatch.setattr(ww, "_audio_available", lambda: True) monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) r = ww.check_wake_word_requirements( @@ -94,6 +102,30 @@ def test_requirements_openwakeword_available(monkeypatch): assert r["phrase"] == "hey hermes" +def test_requirements_need_stt_and_tts(monkeypatch): + """No STT/TTS → wake refuses to arm (mic would hear you, then nothing).""" + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + + _voice_loop_ready(monkeypatch, stt=False, tts=True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["stt_available"] is False + assert "speech-to-text" in r["hint"] + assert "text-to-speech" not in r["hint"] + + _voice_loop_ready(monkeypatch, stt=True, tts=False) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["tts_available"] is False + assert "text-to-speech" in r["hint"] + + _voice_loop_ready(monkeypatch, stt=False, tts=False) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert "speech-to-text and text-to-speech" in r["hint"] + + def test_requirements_porcupine_needs_access_key(monkeypatch): monkeypatch.delenv("PORCUPINE_ACCESS_KEY", raising=False) monkeypatch.setattr(ww, "_audio_available", lambda: True) @@ -124,6 +156,7 @@ def test_requirements_fresh_install_lazy_allowed(monkeypatch): def _boom(): raise AssertionError("audio probe must not run while deps are missing") + _voice_loop_ready(monkeypatch) monkeypatch.setattr(ww, "_audio_available", _boom) monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) diff --git a/tools/wake_word.py b/tools/wake_word.py index 7f33a8895c9..b7cf50425ae 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -502,6 +502,37 @@ def _build_engine(cfg: Dict[str, Any]) -> _Engine: # Requirements probe (for /wake status + enable path) # --------------------------------------------------------------------------- +def _stt_ready() -> bool: + """Is a speech-to-text provider configured and enabled? + + A wake without STT arms the mic but every captured utterance dies at + transcription — a useless (and confusing) experience. Same standard as + voice mode's ``check_voice_requirements``: enabled + a real provider. + """ + try: + from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled + + stt_config = _load_stt_config() + return is_stt_enabled(stt_config) and _get_provider(stt_config) != "none" + except Exception: + return False + + +def _tts_ready() -> bool: + """Can the configured text-to-speech provider actually run? + + The wake flow is fully hands-free (wake → speak → hear the reply); without + TTS the reply is silent and the loop is pointless. Mirrors /voice's use of + ``check_tts_requirements``. + """ + try: + from tools.tts_tool import check_tts_requirements + + return bool(check_tts_requirements()) + except Exception: + return False + + def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Report whether wake-word detection can run, with a remediation hint.""" cfg = cfg if cfg is not None else load_wake_word_config() @@ -525,6 +556,11 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s # path unreachable (the probe always failed before ensure() could run). audio_ok = _audio_available() if deps_ok else False key_ok = True + # The full wake loop is wake → record → STT → agent → TTS. Arming without + # either end configured gives a mic that hears you and then does nothing + # the user can perceive — refuse with a pointer instead. + stt_ok = _stt_ready() + tts_ok = _tts_ready() hint = "" if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): @@ -534,13 +570,22 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s hint = lazy_deps.feature_install_command(feature) or "" elif deps_ok and not audio_ok: hint = "Microphone capture needs sounddevice + numpy and a working audio device." + elif not stt_ok or not tts_ok: + missing = " and ".join( + name for name, ok in (("speech-to-text", stt_ok), ("text-to-speech", tts_ok)) if not ok + ) + hint = (f"Wake word needs {missing} configured — run `hermes tools` " + f"(Voice section) or see the voice-mode docs.") return { - "available": key_ok and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), + "available": key_ok and stt_ok and tts_ok + and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), "provider": provider, "deps_available": deps_ok, "audio_available": audio_ok, "access_key_set": key_ok, + "stt_available": stt_ok, + "tts_available": tts_ok, "phrase": wake_phrase(cfg), "hint": hint, } diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 28da1643615..75c3a20cc0a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -17740,6 +17740,17 @@ def _(rid, params: dict) -> dict: return _err(rid, 5026, f"wake module unavailable: {e}") cfg = load_wake_word_config() + # Requirements first: a gesture on an unarmed-able setup (no STT/TTS, no + # mic, missing key) must refuse WITHOUT flipping wake_word.enabled — else + # config says on while nothing can ever arm, and auto-arm paths churn. + reqs = check_wake_word_requirements(cfg) + if not reqs["available"]: + logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint")) + return _ok(rid, { + "started": False, + "reason": "unavailable", + "hint": reqs.get("hint") or "", + }) enabled_persisted = False if persist and not cfg.get("enabled"): enabled_persisted = _persist_wake_enabled(True) @@ -17755,14 +17766,6 @@ def _(rid, params: dict) -> dict: logger.info("wake.start(%s): %s (enabled=%s, surface=%s)", surface, reason, cfg.get("enabled"), cfg.get("surface")) return _ok(rid, {"started": False, "reason": reason}) - reqs = check_wake_word_requirements(cfg) - if not reqs["available"]: - logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint")) - return _ok(rid, { - "started": False, - "reason": "unavailable", - "hint": reqs.get("hint") or "", - }) existing_owner, existing_surface = _wake_owner_snapshot() if existing_owner is not None and ( diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 25673fbc451..577a68b5b6b 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -204,6 +204,9 @@ PORCUPINE_ACCESS_KEY=your-key-here - An STT provider for transcribing the spoken command — local `faster-whisper` works out of the box; see [Voice Mode](/user-guide/features/voice-mode) for the full provider list. +- A TTS provider for speaking the reply (the default `edge-tts` works with no + key). The wake flow is fully hands-free, so the toggle refuses to arm until + both STT and TTS are ready — `hermes tools` (Voice section) sets them up. - The wake engine deps (auto-installed, or `hermes-agent[wake]`). `/wake status` reports exactly what's missing if the listener won't start. From 46faa4f63929e65ff781ed0da2095566b3c98cdf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:45:47 -0700 Subject: [PATCH 30/46] =?UTF-8?q?fix(desktop):=20keep=20the=20wake-word=20?= =?UTF-8?q?ear=20mounted=20everywhere=20=E2=80=94=20paused=20only=20during?= =?UTF-8?q?=20voice=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ear vanished whenever a voice conversation ran (the ConversationPill replaces the whole controls row) and whenever a transient start refusal marked the feature unavailable — so a persistent, config-backed setting silently disappeared mid-session. The wake word is passive by design: it should be visibly listening no matter what the GUI is doing, with exactly one pause state — an active voice chat holding the mic. - ConversationPill now renders the ear in paused form (disabled, EarOff, 'paused during voice chat' tooltip) so voice chat shows the listener yielding the mic instead of the toggle vanishing. - WakeWordButton hides only when the feature can't run AND isn't enabled in config; $wakeWord gains 'enabled' (config truth from wake.status / start/stop responses) so transient 'unavailable' refusals no longer unmount the button. - Busy agent turns never touched the listener (it keeps listening through agent loops; wake.detected already opens a fresh session), and now they can't hide the toggle either. - New i18n key wakeWordPausedVoice across en/ja/zh/zh-hant. Tests: ear mounted during busy turn, mounted through refusal when config-enabled, hidden when unavailable+disabled, paused ear disabled inside the pill. 29 vitest green across controls + wake-word store. --- .../src/app/chat/composer/controls.test.tsx | 49 +++++++++++++++++++ .../src/app/chat/composer/controls.tsx | 36 +++++++++----- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/wake-word.ts | 12 ++++- 8 files changed, 87 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index 90d38d27495..b35c280c732 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ChatBarState } from '@/app/chat/composer/types' import { I18nProvider } from '@/i18n' +import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word' import { ComposerControls } from './controls' @@ -77,3 +78,51 @@ describe('ComposerControls shortcut tooltips', () => { await expectShortcutTooltip('Queue message', 'Ctrl+↵') }) }) + +describe('wake-word ear visibility', () => { + afterEach(() => { + resetWakeWordState() + }) + + it('stays mounted during a busy agent turn', () => { + applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' }) + renderControls({ busy: true, busyAction: 'stop' }) + + expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy() + }) + + it('stays mounted (enabled in config) even when a start was refused', () => { + applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' }) + // Transient refusal marks available false but enabled keeps it mounted. + applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false }) + renderControls() + + expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy() + }) + + it('hides only when unavailable AND not enabled in config', () => { + applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' }) + renderControls() + + expect(screen.queryByLabelText(/Wake word/)).toBeNull() + }) + + it('shows a disabled paused ear inside the voice-conversation pill', () => { + applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' }) + renderControls({ + conversation: { + active: true, + level: 0, + muted: false, + onEnd: vi.fn(), + onStart: vi.fn(), + onStopTurn: vi.fn(), + onToggleMute: vi.fn(), + status: 'listening' + } + }) + + const ear = screen.getByLabelText('Wake word: "hey hermes" — paused during voice chat') + expect((ear as HTMLButtonElement).disabled).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 647a7647ba5..6e2ec91f49c 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -185,6 +185,9 @@ function ConversationPill({ return (

+ {/* Keep the ear visible during voice chat — shown paused, since the + conversation holds the mic (the one time wake must not listen). */} + ) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index cb20d06c7bc..076bad01acb 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1960,6 +1960,7 @@ export const en: Translations = { stopSpeakingReplies: 'Stop reading replies aloud', wakeWordListening: phrase => `Wake word: "${phrase}" — listening`, wakeWordOff: phrase => `Wake word: "${phrase}" — off`, + wakeWordPausedVoice: phrase => `Wake word: "${phrase}" — paused during voice chat`, lookupLoading: 'Looking up…', lookupNoMatches: 'No matches.', lookupTry: 'Try', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4f70f97e691..632435d21a8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1819,6 +1819,7 @@ export const ja = defineLocale({ stopSpeakingReplies: '返信の読み上げを停止', wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`, wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`, + wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`, lookupLoading: '検索中…', lookupNoMatches: '一致なし。', lookupTry: '試す', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index a1042ce7d88..35124612f78 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1646,6 +1646,7 @@ export interface Translations { stopSpeakingReplies: string wakeWordListening: (phrase: string) => string wakeWordOff: (phrase: string) => string + wakeWordPausedVoice: (phrase: string) => string lookupLoading: string lookupNoMatches: string lookupTry: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c45f1cbeceb..4259890e898 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1762,6 +1762,7 @@ export const zhHant = defineLocale({ stopSpeakingReplies: '停止朗讀回覆', wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`, wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`, + wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`, lookupLoading: '查詢中…', lookupNoMatches: '沒有相符項目。', lookupTry: '試試', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a5b48ed368a..a2ff10eeba5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2153,6 +2153,7 @@ export const zh: Translations = { stopSpeakingReplies: '停止朗读回复', wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`, wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`, + wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`, lookupLoading: '查找中…', lookupNoMatches: '没有匹配项。', lookupTry: '试试', diff --git a/apps/desktop/src/store/wake-word.ts b/apps/desktop/src/store/wake-word.ts index 3e64285a703..fd120c3cb6a 100644 --- a/apps/desktop/src/store/wake-word.ts +++ b/apps/desktop/src/store/wake-word.ts @@ -8,8 +8,10 @@ import { $gateway } from '@/store/gateway' // cache of that truth, refreshed from every wake.* RPC response we see. export interface WakeWordState { - /** Wake word can run at all (deps + mic + key). False hides the toggle. */ + /** Wake word can run at all (deps + mic + key). With `enabled` false too, hides the toggle. */ available: boolean + /** Config truth (wake_word.enabled) — keeps the ear mounted through transient refusals. */ + enabled: boolean /** The listener is armed and owned by this surface. */ listening: boolean /** Last failure reason/hint (start refused, unavailable, …) for the tooltip. */ @@ -22,6 +24,7 @@ export interface WakeWordState { const INITIAL_WAKE_WORD_STATE: WakeWordState = { available: false, + enabled: false, listening: false, notice: '', pending: false, @@ -115,6 +118,7 @@ export function applyWakeStatus(status: WakeStatusResponse | null | undefined): $wakeWord.set({ ...current, available: Boolean(status?.available), + enabled: Boolean(status?.enabled), listening, notice: listening && !silent ? '' : noticeFrom(status), phrase: status?.phrase?.trim() || current.phrase @@ -130,6 +134,7 @@ export function applyWakeStartResult(result: WakeStartResponse | null | undefine $wakeWord.set({ ...current, available: true, + enabled: true, listening: true, notice: '', pending: false, @@ -142,7 +147,9 @@ export function applyWakeStartResult(result: WakeStartResponse | null | undefine $wakeWord.set({ ...current, // The backend probes requirements on start; an explicit "unavailable" - // refusal means the feature can't run here, so hide the toggle. + // refusal means the feature can't run here right now. Keep `enabled` + // (config truth) as-is so the button stays mounted through transient + // refusals instead of vanishing mid-session. available: result?.reason === 'unavailable' ? false : current.available, listening: false, notice: noticeFrom(result), @@ -157,6 +164,7 @@ export function applyWakeStopResult(result: WakeStopResponse | null | undefined) $wakeWord.set({ ...current, + enabled: result?.disabled_persisted ? false : current.enabled, listening: false, notice: result?.stopped ? '' : noticeFrom(result), pending: false From a832139ba39fad0f7c3c86b3bb76784ea33fc185 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:30:39 -0700 Subject: [PATCH 31/46] feat(wake): eager-install voice deps with the desktop; wake probes never run pip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from live testing (Teknium): 1. Desktop installs now ship the wake/voice stacks up front. install.sh + install.ps1 desktop stages run 'uv pip install -e .[wake,voice]' (best-effort, lazy-install remains the fallback) before building the app, so the first ear-click arms instantly instead of sitting through a multi-minute onnxruntime download. CLI-only installs keep the lazy path — [all] curation unchanged. 2. The vanished ear: the STT/TTS gate made wake.status call check_tts_requirements(), whose edge path runs _import_edge_tts → lazy_deps.ensure — a synchronous PIP INSTALL inside a status poll. On a venv without edge-tts that blew the desktop's 30s RPC timeout, armWakeWord caught the error, the atom never learned enabled=true, and the ear unmounted. _tts_ready is now a pure probe: deps missing + lazy installs allowed counts as ready (installs at first speak) WITHOUT touching pip; check_tts_requirements only runs once deps are present. Regression test asserts the probe never calls it while deps are missing. --- pyproject.toml | 5 ++- scripts/install.ps1 | 30 ++++++++++++++- scripts/install.sh | 33 +++++++++++++++++ tests/tools/test_wake_word.py | 37 +++++++++++++++++++ tools/wake_word.py | 35 ++++++++++++++++-- website/docs/user-guide/features/wake-word.md | 5 ++- 6 files changed, 137 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ac2318ea6dc..f84dbfcae45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,8 +178,9 @@ voice = [ # "Hey Hermes" wake word — on-device hotword detection. All engines are # optional; openWakeWord (ONNX) is the free default, sherpa-onnx adds # open-vocabulary phrases (any typed phrase, zero training), Porcupine is -# the premium alternative. Lazy-installed on first /wake; mirrored in -# tools/lazy_deps.py. +# the premium alternative. Desktop installs ([--include-desktop]) eager-install +# [wake]+[voice] so the ear works instantly; CLI-only installs lazy-install on +# first /wake; mirrored in tools/lazy_deps.py. wake = [ "openwakeword==0.6.0", "onnxruntime==1.27.0", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e02acfff881..a0bd451800e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2813,6 +2813,34 @@ function Try-RestoreElectronDist { return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror } +function Install-DesktopVoiceDeps { + # Desktop ships with working voice out of the box: eagerly install the + # wake-word + local-STT stacks ([wake] + [voice] extras) instead of + # leaving them to lazy first-use install. Policy change (Teknium, July + # 2026, #70509 testing): the first ear-click used to trigger a + # multi-minute onnxruntime pip install that froze the UI and blew RPC + # timeouts. Best-effort — lazy install remains the fallback for anything + # this step fails to fetch. + if (-not $script:UvCmd) { Resolve-UvCmd } + if (-not $script:UvCmd) { + Write-Warn "uv unavailable -- voice/wake deps will lazy-install at first use instead" + return + } + $env:VIRTUAL_ENV = "$InstallDir\venv" + Write-Info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper -- 1-3min)..." + Push-Location $InstallDir + try { + Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e ".[wake,voice]" } + if ($LASTEXITCODE -eq 0) { + Write-Success "Voice + wake-word dependencies installed" + } else { + Write-Warn "Voice/wake dependency install failed (exit $LASTEXITCODE) -- they will lazy-install at first use" + } + } finally { + Pop-Location + } +} + function Install-Desktop { # Build apps/desktop into a launchable Hermes.exe. Only called from # Stage-Desktop, which is itself only included in the manifest when @@ -3577,7 +3605,7 @@ function Stage-Repository { Install-Repository } function Stage-Venv { Resolve-UvCmd; Install-Venv } function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies } function Stage-NodeDeps { Install-NodeDeps } -function Stage-Desktop { Install-Desktop } +function Stage-Desktop { Install-DesktopVoiceDeps; Install-Desktop } function Stage-Path { Set-PathVariable } function Stage-ConfigTemplates { Copy-ConfigTemplates } function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } diff --git a/scripts/install.sh b/scripts/install.sh index df8739d62cd..ae3902d401e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2792,6 +2792,37 @@ _restore_electron_dist_with_fallback() { # (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked # via the 'desktop' stage / --include-desktop, which the Electron app's own # first-launch bootstrap never requests (it must not rebuild itself). +install_desktop_voice_deps() { + # Desktop ships with working voice out of the box: eagerly install the + # wake-word + local-STT stacks ([wake] + [voice] extras) instead of + # leaving them to lazy first-use install. Policy change (Teknium, July + # 2026, #70509 testing): the first ear-click used to trigger a + # multi-minute onnxruntime pip install that froze the UI and blew RPC + # timeouts. Lazy install remains the fallback for CLI-only installs and + # for anything this best-effort step fails to fetch. + local _prev_venv="${VIRTUAL_ENV:-}" + if [ "$USE_VENV" = true ]; then + export VIRTUAL_ENV="$INSTALL_DIR/venv" + fi + if [ -z "${UV_CMD:-}" ]; then + install_uv || true + fi + if [ -z "${UV_CMD:-}" ]; then + log_warn "uv unavailable — voice/wake deps will lazy-install at first use instead" + return 0 + fi + log_info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper — 1-3min)..." + if (cd "$INSTALL_DIR" && $UV_CMD pip install -e ".[wake,voice]") ; then + log_success "Voice + wake-word dependencies installed" + else + log_warn "Voice/wake dependency install failed — they will lazy-install at first use" + fi + if [ "$USE_VENV" = true ] && [ -z "$_prev_venv" ]; then + unset VIRTUAL_ENV + fi + return 0 +} + install_desktop() { local desktop_dir="$INSTALL_DIR/apps/desktop" @@ -3074,6 +3105,7 @@ run_stage_body() { # isn't on PATH here. check_node re-adds it (or installs if missing) # so install_desktop can find npm instead of silently skipping. check_node + install_desktop_voice_deps install_desktop ;; complete) @@ -3160,6 +3192,7 @@ main() { maybe_start_gateway if [ "$INCLUDE_DESKTOP" = true ]; then + install_desktop_voice_deps install_desktop fi diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 2eb99edbccb..01495c02ebd 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -126,6 +126,43 @@ def test_requirements_need_stt_and_tts(monkeypatch): assert "speech-to-text and text-to-speech" in r["hint"] +def test_tts_ready_is_a_probe_never_an_installer(monkeypatch): + """_tts_ready must NOT trigger lazy pip installs from a status poll. + + Regression: check_tts_requirements → _import_edge_tts → lazy_deps.ensure + ran pip inside wake.status; a slow/failed install froze the poll and + unmounted the desktop ear. Uninstalled-but-lazy-installable counts as + ready WITHOUT calling ensure/check. + """ + import types as _types + + monkeypatch.setattr( + ww, "_tts_ready", ww.__dict__["_tts_ready"] + ) # use the real implementation + fake_tts = _types.SimpleNamespace( + _get_provider=lambda cfg: "edge", + _load_tts_config=lambda: {}, + check_tts_requirements=lambda: (_ for _ in ()).throw( + AssertionError("check_tts_requirements must not run when deps are missing") + ), + ) + monkeypatch.setitem(sys.modules, "tools.tts_tool", fake_tts) + + # Deps missing + lazy installs allowed → ready (installs at first speak). + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + assert ww._tts_ready() is True + + # Deps missing + lazy installs disabled → not ready. + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + assert ww._tts_ready() is False + + # Deps present → falls through to the real requirements check. + fake_tts.check_tts_requirements = lambda: True + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + assert ww._tts_ready() is True + + def test_requirements_porcupine_needs_access_key(monkeypatch): monkeypatch.delenv("PORCUPINE_ACCESS_KEY", raising=False) monkeypatch.setattr(ww, "_audio_available", lambda: True) diff --git a/tools/wake_word.py b/tools/wake_word.py index b7cf50425ae..230bb7d9b13 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -519,12 +519,41 @@ def _stt_ready() -> bool: def _tts_ready() -> bool: - """Can the configured text-to-speech provider actually run? + """Can the configured text-to-speech provider run (or install at first use)? The wake flow is fully hands-free (wake → speak → hear the reply); without - TTS the reply is silent and the loop is pointless. Mirrors /voice's use of - ``check_tts_requirements``. + TTS the reply is silent and the loop is pointless. + + PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the + provider SDK via ``_import_*`` → ``lazy_deps.ensure`` — running that inside + a status poll froze wake.status for the length of a pip install (and a + failed install marked the wake word unavailable, unmounting the desktop + ear). When the provider's deps aren't installed yet, "installable at first + use" counts as ready and we never touch pip from here. """ + try: + from tools.tts_tool import _get_provider, _load_tts_config + + provider = _get_provider(_load_tts_config()) + except Exception: + return False + + _LAZY_TTS_FEATURES = { + "edge": "tts.edge", + "elevenlabs": "tts.elevenlabs", + "mistral": "tts.mistral", + } + feature = _LAZY_TTS_FEATURES.get(provider) + if feature is not None: + try: + from tools import lazy_deps + + if not lazy_deps.is_available(feature): + # Not installed: ready iff it can install at first speak. + return lazy_deps._allow_lazy_installs() + except Exception: + return False + try: from tools.tts_tool import check_tts_requirements diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 577a68b5b6b..6e8735d16ab 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -40,8 +40,9 @@ By default the phrase is **"hey hermes"** — a model for it ships with Hermes, it works out of the box with no training. (On first use, openWakeWord downloads its shared feature-extraction models — a small one-time fetch.) -Both are lazy-installed the first time you enable the wake word. To install ahead -of time: +Both are lazy-installed the first time you enable the wake word (desktop +installs made with `--include-desktop` pre-install them, so the ear works +instantly). To install ahead of time: ```bash cd ~/.hermes/hermes-agent && uv pip install -e ".[wake]" From 757ba253d808ee15f4dde0fafdd23a2ae5b79e76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:02:34 -0700 Subject: [PATCH 32/46] chore: retrigger CI (run 30323335182 died at startup with zero jobs) From 1398cc40cd49d7c6fcefb39a04e239b659bcb4ee Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:07:56 -0700 Subject: [PATCH 33/46] fix(install): ASCII-only comment in install.ps1 voice-deps helper test_install_ps1_is_pure_ascii guards against PowerShell 5.1 ANSI codepage misdecoding (issues #66994/#67000); the Install-DesktopVoiceDeps comment had an em-dash. --- scripts/install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a0bd451800e..aa434eed47f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2819,7 +2819,7 @@ function Install-DesktopVoiceDeps { # leaving them to lazy first-use install. Policy change (Teknium, July # 2026, #70509 testing): the first ear-click used to trigger a # multi-minute onnxruntime pip install that froze the UI and blew RPC - # timeouts. Best-effort — lazy install remains the fallback for anything + # timeouts. Best-effort -- lazy install remains the fallback for anything # this step fails to fetch. if (-not $script:UvCmd) { Resolve-UvCmd } if (-not $script:UvCmd) { From 74fae07d75ff530a62609255da04cc925de237a5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:31:07 -0700 Subject: [PATCH 34/46] =?UTF-8?q?fix(desktop):=20the=20wake-word=20ear=20A?= =?UTF-8?q?LWAYS=20shows=20=E2=80=94=20never=20hide=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teknium: the ear must always be visible so a user can click to enable passive listening. If it can't start (missing STT/TTS, deps still installing, no mic permission), the click surfaces the reason in the tooltip and the toggle stays off — but the control never disappears. Removes the 'if (!wake.available && !wake.enabled) return null' hide branch that made the button vanish on machines where a requirements probe returned false (the Windows report). The only non-idle state is paused-for-voice (disabled, in the voice-chat pill), since an active voice conversation genuinely holds the mic. Tests updated: 'stays visible when unavailable and not enabled' and 'surfaces the refusal reason in the tooltip, still visible' replace the old hide assertion. 30 vitest green, tsc + eslint clean. --- .../src/app/chat/composer/controls.test.tsx | 15 +++++++++++-- .../src/app/chat/composer/controls.tsx | 21 ++++++++----------- apps/desktop/src/store/wake-word.test.ts | 2 +- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index b35c280c732..8a84be8ea47 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -100,11 +100,22 @@ describe('wake-word ear visibility', () => { expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy() }) - it('hides only when unavailable AND not enabled in config', () => { + it('stays visible (never hides) even when unavailable and not enabled', () => { applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' }) renderControls() - expect(screen.queryByLabelText(/Wake word/)).toBeNull() + // The ear ALWAYS shows so the user can click to enable; a failed start + // surfaces its reason in the tooltip rather than hiding the control. + expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy() + }) + + it('surfaces the backend refusal reason in the tooltip, still visible', () => { + applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' }) + applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false }) + renderControls() + + const ear = screen.getByLabelText('Wake word: "hey hermes" — off') + expect(ear).toBeTruthy() }) it('shows a disabled paused ear inside the voice-conversation pill', () => { diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 6e2ec91f49c..7c1a90df4de 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -301,23 +301,20 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa ) } -// "Hey Hermes" wake-word toggle. States: listening (accent-highlighted, like -// the auto-speak toggle above), off (muted ear-off), paused-for-voice (shown -// disabled while a voice conversation holds the mic — the one legitimate -// pause), and hidden — only when the feature can't run AND isn't enabled in -// config. `enabled` keeps the ear mounted through transient refusals and busy -// agent turns, so a persistent setting never silently vanishes mid-session. -// Backend refusals ({started:false, reason}) keep the toggle off and surface -// the reason/hint in the tooltip. +// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A +// user must always be able to click it to turn passive listening on; if the +// backend can't start (missing STT/TTS, deps still installing, no mic +// permission, etc.) the click surfaces the reason in the tooltip and the +// toggle stays off. States: listening (accent-highlighted), off (muted +// ear-off), and paused-for-voice (disabled while a voice conversation holds +// the mic — the one time wake genuinely must not listen). Backend refusals +// ({started:false, reason}) keep the toggle off and put the reason/hint in +// the tooltip. function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) { const { t } = useI18n() const c = t.composer const wake = useStore($wakeWord) - if (!wake.available && !wake.enabled) { - return null - } - const phrase = wake.phrase || 'hey hermes' const label = pausedForVoice ? c.wakeWordPausedVoice(phrase) diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts index 2234d468a7d..f69321e3f5b 100644 --- a/apps/desktop/src/store/wake-word.test.ts +++ b/apps/desktop/src/store/wake-word.test.ts @@ -39,7 +39,7 @@ describe('applyWakeStatus', () => { }) }) - it('keeps the button hidden and carries the hint when unavailable', () => { + it('tracks unavailability and carries the hint for the tooltip', () => { applyWakeStatus({ available: false, hint: 'pip install openwakeword', listening: false, phrase: 'hey hermes' }) const state = $wakeWord.get() From 7d19033d2e48f52b434bad2db0b04dcf8b3b6952 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 28 Jul 2026 13:35:37 +1000 Subject: [PATCH 35/46] fix(wake): run openWakeWord on tflite on macOS ARM64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openWakeWord's ONNX backend returns near-zero scores on Apple Silicon (dscripka/openWakeWord#336), so "Hey Hermes" never crossed the 0.5 threshold: the listener armed, the microphone worked, and nothing fired. Bisecting the pipeline puts the fault in exactly one stage — feeding the same audio through both backends, the melspectrogram front-end is bit-identical (maxdiff 0.00000) and the wake classifier agrees on identical features, while the shared embedding model diverges by 45.44. Cross-feeding confirms it: tflite features scored through the *onnx* classifier give 0.9948 vs 0.000009 for onnx features. A telling secondary symptom is that scores fall as input gets louder (0.5x -> 0.00031, 8x -> 0.000066), which is garbage inference rather than a weak detection. Selecting tflite in config alone does not fix it. openWakeWord hardcodes `import tflite_runtime.interpreter` but declares tflite-runtime for `platform_system == "Linux"` only; on macOS the equivalent wheel is ai-edge-litert, so that import always fails and model.py silently downgrades back to onnx. The result is a detector that reports itself listening and can never fire. - default the backend per platform (tflite on macOS ARM64, onnx elsewhere) instead of hardcoding onnx, and pick the matching bundled model artifact - bridge tflite_runtime -> ai_edge_litert through sys.modules, in-process, with no writes to site-packages - refuse the silent onnx downgrade on macOS ARM64 and report the missing runtime through check_wake_word_requirements() so the GUI surfaces an actionable hint rather than arming a dead ear - lazy-install ai-edge-litert via its own feature key, because lazy-dep specs cannot carry PEP 508 markers (_spec_is_safe rejects ";") An explicit `inference_framework` in config still wins, so anyone pinning a backend keeps it. Verified on macOS 26.5.2 / M-series: "hey hermes" scores 0.0005 on onnx and 0.9423 on tflite from the same clip, with cross-phrase controls at 0.0003. Live over-the-air through the real microphone fires 4/4 utterances (peak 0.9532). --- hermes_cli/config.py | 6 +- pyproject.toml | 5 ++ tests/tools/test_wake_word.py | 77 +++++++++++++++- tools/lazy_deps.py | 10 +++ tools/wake_word.py | 89 ++++++++++++++++++- uv.lock | 33 ++++++- website/docs/user-guide/features/wake-word.md | 10 ++- 7 files changed, 222 insertions(+), 8 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8d43bc75131..65520f85032 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2391,7 +2391,11 @@ DEFAULT_CONFIG = { # ...) OR a path to a custom .onnx/.tflite model for another phrase. # See the wake-word docs for the custom-model training guide. "model": "hey_hermes", - "inference_framework": "onnx", # "onnx" | "tflite" + # "" (auto — tflite on macOS ARM64, onnx elsewhere) | "onnx" | "tflite". + # openWakeWord's onnx backend scores near-zero on macOS ARM64 + # (dscripka/openWakeWord#336), so auto avoids a listener that arms + # but never fires. Set explicitly only to override that choice. + "inference_framework": "", }, "sherpa": { # Optional path to a sherpa-onnx KWS model directory. Empty = diff --git a/pyproject.toml b/pyproject.toml index f84dbfcae45..359110d1c05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -189,6 +189,11 @@ wake = [ "pvporcupine==4.0.3", "sounddevice==0.5.5", "numpy==2.4.3", + # openWakeWord's onnx embedding model scores near-zero on macOS ARM64 + # (dscripka/openWakeWord#336), so the wake word runs on tflite there. + # Upstream declares tflite-runtime for Linux only; ai-edge-litert is the + # macOS equivalent, bridged in tools/wake_word.py. + "ai-edge-litert==2.1.6; platform_system == 'Darwin'", ] honcho = ["honcho-ai==2.2.0"] # Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 01495c02ebd..fccb7f5ee92 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -294,11 +294,13 @@ def test_bundled_hey_hermes_model_ships_on_disk(): def test_openwakeword_default_resolves_to_bundled_model(monkeypatch, model_value): # The default (and any "hey_hermes" alias) must load the bundled file, not be # passed through as a bogus built-in name that openWakeWord can't resolve. + # Which artifact is bundled follows the platform's default backend (tflite on + # macOS ARM64, where openWakeWord's onnx path scores near-zero). calls = _install_fake_openwakeword(monkeypatch) sub = {} if model_value is None else {"model": model_value} ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": sub}) (downloaded,) = calls["download"] - assert downloaded == [ww._bundled_wakeword_path("onnx")] + assert downloaded == [ww._bundled_wakeword_path(ww.default_inference_framework())] def test_openwakeword_bundled_model_matches_framework(monkeypatch): @@ -308,7 +310,78 @@ def test_openwakeword_bundled_model_matches_framework(monkeypatch): ) (downloaded,) = calls["download"] assert downloaded == [ww._bundled_wakeword_path("tflite")] - assert downloaded[0].endswith(".tflite") + + +# ── platform-aware backend selection (openWakeWord onnx is broken on macOS ARM64, +# upstream dscripka/openWakeWord#336) ──────────────────────────────────────── + +def test_default_framework_is_tflite_on_macos_arm64(monkeypatch): + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + assert ww.default_inference_framework() == "tflite" + + +@pytest.mark.parametrize( + "plat,machine", + [("linux", "x86_64"), ("linux", "aarch64"), ("win32", "AMD64"), ("darwin", "x86_64")], +) +def test_default_framework_is_onnx_elsewhere(monkeypatch, plat, machine): + # Only the broken platform changes behaviour; everyone else keeps onnx. + monkeypatch.setattr(ww.sys, "platform", plat) + monkeypatch.setattr("platform.machine", lambda: machine) + assert ww.default_inference_framework() == "onnx" + + +def test_explicit_framework_overrides_platform_default(monkeypatch): + # An operator who pins a backend keeps it, even on macOS ARM64. + calls = _install_fake_openwakeword(monkeypatch) + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"inference_framework": "onnx"}} + ) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("onnx")] + + +def test_macos_tflite_refuses_silent_onnx_downgrade(monkeypatch): + # openWakeWord silently falls back to onnx when no tflite runtime imports. + # On macOS ARM64 that lands on the broken backend, so we must raise instead + # of arming a listener that can never fire. + _install_fake_openwakeword(monkeypatch) + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: False) + with pytest.raises(RuntimeError, match="ai-edge-litert"): + ww._OpenWakeWordEngine({"provider": "openwakeword", "openwakeword": {}}) + + +def test_non_macos_tflite_falls_back_to_onnx(monkeypatch): + # Off macOS the onnx backend works, so a missing tflite runtime is a + # downgrade, not a hard failure. + calls = _install_fake_openwakeword(monkeypatch) + monkeypatch.setattr(ww.sys, "platform", "linux") + monkeypatch.setattr("platform.machine", lambda: "x86_64") + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: False) + ww._OpenWakeWordEngine( + {"provider": "openwakeword", "openwakeword": {"inference_framework": "tflite"}} + ) + (downloaded,) = calls["download"] + assert downloaded == [ww._bundled_wakeword_path("onnx")] + + +def test_requirements_report_missing_tflite_runtime(monkeypatch): + # A missing runtime must surface as unavailable + an actionable hint rather + # than an armed-but-deaf detector. + monkeypatch.setattr(ww.sys, "platform", "darwin") + monkeypatch.setattr("platform.machine", lambda: "arm64") + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: False) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda feature: feature != "wake.openwakeword.tflite") + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr(ww, "_audio_available", lambda: True) + reqs = ww.check_wake_word_requirements({"provider": "openwakeword", "openwakeword": {}}) + assert reqs["available"] is False + assert "ai-edge-litert" in reqs["hint"] # ── sherpa-onnx open-vocabulary engine ─────────────────────────────────── diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 67c7833dfa8..55b1fe5e418 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -140,6 +140,16 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { # ─── Wake word ("Hey Hermes") engines ────────────────────────────────── # Keep in sync with the `wake` extra in pyproject.toml. openWakeWord is the # free, local default (ONNX runtime); Porcupine is the premium engine. + # openWakeWord's ONNX embedding model returns near-zero scores on macOS + # ARM64 (dscripka/openWakeWord#336), so the wake word runs on the tflite + # backend there. Upstream declares tflite-runtime for Linux only; + # ai-edge-litert is the macOS equivalent, bridged in tools/wake_word.py. + # It lives in its own feature because lazy-dep specs cannot carry PEP 508 + # environment markers (_spec_is_safe rejects ";"), so the platform gate is + # applied by the caller instead. + "wake.openwakeword.tflite": ( + "ai-edge-litert==2.1.6", + ), "wake.openwakeword": ( "openwakeword==0.6.0", "onnxruntime==1.27.0", diff --git a/tools/wake_word.py b/tools/wake_word.py index 230bb7d9b13..ad35562a6d7 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -33,6 +33,7 @@ from __future__ import annotations import logging import os +import sys import threading import time from pathlib import Path @@ -86,6 +87,56 @@ def _bundled_wakeword_path(framework: str = "onnx") -> str: return os.path.join(os.path.dirname(__file__), "wakewords", f"{_BUNDLED_MODEL_NAME}.{ext}") +def _is_macos_arm64() -> bool: + import platform + + return sys.platform == "darwin" and platform.machine() == "arm64" + + +def default_inference_framework() -> str: + """The openWakeWord backend to use on this platform. + + openWakeWord's ONNX backend produces near-zero scores on macOS ARM64 — its + shared *embedding* model is the broken stage (the melspectrogram front-end + and the wake classifier both match tflite exactly). The detector arms, the + microphone works, and no phrase can ever cross the threshold. Prefer the + tflite backend there; ONNX stays the default everywhere else. + + Upstream: https://github.com/dscripka/openWakeWord/issues/336 + """ + return "tflite" if _is_macos_arm64() else "onnx" + + +def ensure_tflite_runtime() -> bool: + """Make ``import tflite_runtime.interpreter`` resolve, returning success. + + openWakeWord hardcodes that import but only declares ``tflite-runtime`` for + ``platform_system == "Linux"``; on macOS the equivalent wheel is + ``ai-edge-litert``. Alias the module so the upstream import succeeds. The + alias is process-local — nothing is written to site-packages. + """ + try: + import tflite_runtime.interpreter # noqa: F401 + + return True + except ImportError: + pass + + try: + from ai_edge_litert import interpreter as _litert # type: ignore[import-not-found] + except ImportError: + return False + + import types + + pkg = types.ModuleType("tflite_runtime") + pkg.__path__ = [] # type: ignore[attr-defined] # mark as package + sys.modules.setdefault("tflite_runtime", pkg) + sys.modules["tflite_runtime.interpreter"] = _litert + logger.debug("wake word: bridged tflite_runtime -> ai_edge_litert") + return True + + def load_wake_word_config() -> Dict[str, Any]: """Return the ``wake_word`` config section, shape-guarded to a dict.""" try: @@ -250,9 +301,32 @@ class _OpenWakeWordEngine(_Engine): sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {} model_ref = str(sub.get("model") or _BUNDLED_MODEL_NAME).strip() - framework = str(sub.get("inference_framework") or "onnx").strip().lower() + framework = str(sub.get("inference_framework") or "").strip().lower() + if not framework: + framework = default_inference_framework() self._threshold = _sensitivity(cfg) + # openWakeWord silently downgrades tflite -> onnx when no tflite runtime + # imports (model.py). On macOS ARM64 that lands on the backend whose + # embedding model is broken, so the listener would arm and never fire. + # Install + bridge the runtime first, and refuse the downgrade rather + # than ship a dead ear. + if framework == "tflite" and not ensure_tflite_runtime(): + # Same lazy-install contract as every other backend; the platform + # gate lives here because dep specs can't carry PEP 508 markers. + try: + lazy_deps.ensure("wake.openwakeword.tflite", prompt=False) + except Exception as e: + logger.debug("wake word: tflite runtime install failed: %s", e) + if not ensure_tflite_runtime(): + if _is_macos_arm64(): + raise RuntimeError( + "The wake word needs the tflite backend on this Mac, but its " + "runtime is missing. Install it with: pip install ai-edge-litert" + ) + logger.warning("wake word: no tflite runtime available — falling back to onnx") + framework = "onnx" + # Default (or explicit "hey_hermes") → the bundled model; a built-in name # or custom path is used as-is. if model_ref.lower() in _BUNDLED_MODEL_ALIASES: @@ -592,11 +666,22 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s tts_ok = _tts_ready() hint = "" + # The tflite backend needs a runtime openWakeWord doesn't declare off Linux. + # Report it as a real remediation instead of arming a detector that can't fire. + tflite_ok = True + if provider not in ("porcupine", "sherpa", "sherpa-onnx", "kws", "open"): + sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {} + framework = str(sub.get("inference_framework") or "").strip().lower() or default_inference_framework() + if framework == "tflite": + tflite_ok = ensure_tflite_runtime() or lazy_deps.is_available("wake.openwakeword.tflite") or lazy_ok + if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): key_ok = False hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)." elif not deps_ok and not lazy_ok: hint = lazy_deps.feature_install_command(feature) or "" + elif not tflite_ok: + hint = "The wake word needs the tflite runtime on this Mac: pip install ai-edge-litert" elif deps_ok and not audio_ok: hint = "Microphone capture needs sounddevice + numpy and a working audio device." elif not stt_ok or not tts_ok: @@ -607,7 +692,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s f"(Voice section) or see the voice-mode docs.") return { - "available": key_ok and stt_ok and tts_ok + "available": key_ok and stt_ok and tts_ok and tflite_ok and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), "provider": provider, "deps_available": deps_ok, diff --git a/uv.lock b/uv.lock index 61ff1e6f1a9..3f0bf2d0a23 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" }, ] +[[package]] +name = "ai-edge-litert" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-strenum" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3d/41a85023e1c6cc76d895f3ba6ac7c22b0785db7e08d0827ebeb8a403eefc/ai_edge_litert-2.1.6-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:edf598814004e594b40c888f52cae59e950dbeffd821e83ba45d28db0a0aa3f5", size = 10031164, upload-time = "2026-07-01T21:42:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/52/d5/164aaf69f60f72b7076900ef1cc6153bf50d82cd15202bdf1239c0dbfb1c/ai_edge_litert-2.1.6-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5adf0c9afde6151dc7f2989d039c800f3060d98d40bb5dfc95e426ad4eb3680b", size = 10033170, upload-time = "2026-07-01T21:42:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/ad/92/578b31c4c05afa9081a664cd86afe33c304fecd70de1c2a4d3a3b9ca51c9/ai_edge_litert-2.1.6-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2e8a3f92fa407690189533bea8b64d49bb1b8a9e96f707ba27e1a64a9c3cc8cf", size = 10032950, upload-time = "2026-07-01T21:42:55.102Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -472,6 +490,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "backports-strenum" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" }, +] + [[package]] name = "base58" version = "2.1.1" @@ -1719,6 +1746,7 @@ voice = [ { name = "sounddevice" }, ] wake = [ + { name = "ai-edge-litert", marker = "sys_platform == 'darwin'" }, { name = "numpy" }, { name = "onnxruntime" }, { name = "openwakeword" }, @@ -1743,6 +1771,7 @@ youtube = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, + { name = "ai-edge-litert", marker = "sys_platform == 'darwin' and extra == 'wake'", specifier = "==2.1.6" }, { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.14.1" }, { name = "aiohttp", marker = "extra == 'matrix'", specifier = "==3.14.1" }, { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.14.1" }, @@ -3949,7 +3978,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4004,7 +4033,7 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 6e8735d16ab..99fb65fbfa9 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -80,7 +80,7 @@ wake_word: start_new_session: true # start a fresh session on wake vs. continue the current one openwakeword: model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .onnx/.tflite - inference_framework: onnx # "onnx" | "tflite" + inference_framework: "" # "" (auto) | "onnx" | "tflite" porcupine: keyword: jarvis # built-in keyword OR path to a custom .ppn ``` @@ -88,6 +88,14 @@ wake_word: `sensitivity`, `phrase`, and `start_new_session` apply to both engines. The `openwakeword` and `porcupine` blocks select the actual detection model. +`inference_framework` picks the openWakeWord backend. Leave it empty (the +default) to let Hermes choose per platform: **tflite on Apple Silicon**, onnx +everywhere else. openWakeWord's onnx backend returns near-zero scores on macOS +ARM64 ([openWakeWord#336](https://github.com/dscripka/openWakeWord/issues/336)), +so a listener pinned to `onnx` there will arm, show as listening, and never +fire. The tflite backend needs `ai-edge-litert` on macOS, which Hermes installs +on demand alongside the other wake-word deps. + ### Surfaces (CLI, TUI, GUI) The wake word works in all three Hermes surfaces, and `surface` picks which one From 913aa7709bfe9214bda91dd956c8a2933e1d7d1f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:35 -0700 Subject: [PATCH 36/46] test(wake): pin tflite runtime in artifact-selection test; merge tflite_ok into available gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to benbarclay's macOS ARM64 tflite fix: - test_openwakeword_bundled_model_matches_framework stubs ensure_tflite_runtime()=True so it exercises artifact selection, not runtime availability — off-Darwin the bridge legitimately returns False and the engine falls back to onnx (that path is covered by its own tests). Was failing on Linux CI otherwise. - check_wake_word_requirements now ANDs both this branch's STT/TTS gate and Ben's tflite_ok into 'available' (cherry-pick conflict resolution). --- tests/tools/test_wake_word.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index fccb7f5ee92..0e91014286d 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -305,6 +305,10 @@ def test_openwakeword_default_resolves_to_bundled_model(monkeypatch, model_value def test_openwakeword_bundled_model_matches_framework(monkeypatch): calls = _install_fake_openwakeword(monkeypatch) + # Pin the tflite runtime as present so this exercises artifact selection, + # not runtime availability — off-Darwin the bridge legitimately returns + # False and the engine falls back to onnx (covered separately below). + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: True) ww._OpenWakeWordEngine( {"provider": "openwakeword", "openwakeword": {"inference_framework": "tflite"}} ) From 30ed3f82bdbf4ab39b40a5d43f0c2e17119448fc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:30:23 -0700 Subject: [PATCH 37/46] fix(wake): reject ambient-speech false triggers with consecutive-frame confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openWakeWord scores one ~80ms frame at a time and the detector fired the instant a SINGLE frame crossed threshold — so a stray phoneme in background conversation could trigger the wake word unintentionally (reported in testing). A real utterance of the phrase holds a high score across several consecutive frames; an ambient blip spikes just one. _OpenWakeWordEngine now requires N consecutive over-threshold frames (wake_word.confirmation_frames, default 3) before firing. The streak resets on any sub-threshold frame and on engine reset() (pause/resume), so a pre-pause frame can't count toward a post-resume fire. confirmation_frames=1 restores the old single-frame behavior; clamped 1..10. Only openWakeWord is affected — sherpa (streaming transducer) and porcupine decode the whole phrase internally and already reject single-frame spikes. - tools/wake_word.py: _confirmation_frames() accessor, streak logic in process()/reset(), config default - hermes_cli/config.py: wake_word.confirmation_frames documented default - tests: 5 new (spike rejected, sustained fires once, =1 legacy behavior, reset clears streak, config clamp) — 58 wake tests green - docs: 'Reducing false triggers on ambient speech' section --- hermes_cli/config.py | 1 + tests/tools/test_wake_word.py | 80 +++++++++++++++++++ tools/wake_word.py | 38 ++++++++- website/docs/user-guide/features/wake-word.md | 18 +++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 65520f85032..9c8bff52a39 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2383,6 +2383,7 @@ DEFAULT_CONFIG = { "provider": "openwakeword", # "openwakeword" (free, local) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) "phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label — detection is keyed by the model/keyword below "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) + "confirmation_frames": 3, # openWakeWord only: consecutive over-threshold frames required to fire (higher = fewer false triggers on ambient speech, slightly more latency; 1 = old single-frame behavior) "start_new_session": True, # start a fresh session on wake vs. continue the current one "profile_routing": True, # sherpa only: also listen for every wake-enabled profile's phrase and route the wake to the matching profile "openwakeword": { diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 0e91014286d..83097328de3 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -348,6 +348,86 @@ def test_explicit_framework_overrides_platform_default(monkeypatch): assert downloaded == [ww._bundled_wakeword_path("onnx")] +# ── ambient-speech rejection: consecutive-frame confirmation ────────────────── + +def _openwakeword_engine_with_scores(monkeypatch, cfg_wake, scores): + """Build a real _OpenWakeWordEngine whose predict() replays ``scores``.""" + seq = iter(scores) + + class _ScriptedModel: + def __init__(self, wakeword_models, inference_framework="onnx"): + self.models = {"hey_hermes": object()} + + def predict(self, frame): + return {"hey_hermes": next(seq)} + + def reset(self): + pass + + oww = types.ModuleType("openwakeword") + oww.utils = types.SimpleNamespace(download_models=lambda names=[]: None) + model_mod = types.ModuleType("openwakeword.model") + model_mod.Model = _ScriptedModel + monkeypatch.setitem(sys.modules, "openwakeword", oww) + monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: True) + return ww._OpenWakeWordEngine({"provider": "openwakeword", **cfg_wake}) + + +def test_confirmation_frames_reject_single_frame_spike(monkeypatch): + # A lone over-threshold frame (ambient phoneme) must NOT fire with the + # default 3-frame confirmation; the streak resets on the next quiet frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 3}, + [0.9, 0.0, 0.0, 0.9, 0.0], + ) + assert [eng.process(None) for _ in range(5)] == [False, False, False, False, False] + + +def test_confirmation_frames_fire_on_sustained_phrase(monkeypatch): + # Three consecutive over-threshold frames (a real utterance) fire exactly + # once, on the third frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 3}, + [0.9, 0.9, 0.9, 0.0], + ) + assert [eng.process(None) for _ in range(4)] == [False, False, True, False] + + +def test_confirmation_frames_one_restores_single_frame_behavior(monkeypatch): + # confirmation_frames=1 is the old behavior: fire on the first frame. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 1}, + [0.9, 0.0], + ) + assert eng.process(None) is True + + +def test_confirmation_streak_resets_on_engine_reset(monkeypatch): + # A pause (reset) between two over-threshold frames must not let a + # pre-pause frame count toward the post-resume streak. + eng = _openwakeword_engine_with_scores( + monkeypatch, + {"sensitivity": 0.5, "confirmation_frames": 2}, + [0.9, 0.9, 0.9], + ) + assert eng.process(None) is False # streak = 1 + eng.reset() # streak -> 0 + assert eng.process(None) is False # streak = 1 again, not 2 + assert eng.process(None) is True # streak = 2 -> fire + + +def test_confirmation_frames_config_clamped(monkeypatch): + assert ww._confirmation_frames({"confirmation_frames": 0}) == 1 + assert ww._confirmation_frames({"confirmation_frames": 99}) == 10 + assert ww._confirmation_frames({"confirmation_frames": "x"}) == ww._DEFAULT_CONFIRMATION_FRAMES + assert ww._confirmation_frames({}) == ww._DEFAULT_CONFIRMATION_FRAMES + + def test_macos_tflite_refuses_silent_onnx_downgrade(monkeypatch): # openWakeWord silently falls back to onnx when no tflite runtime imports. # On macOS ARM64 that lands on the broken backend, so we must raise instead diff --git a/tools/wake_word.py b/tools/wake_word.py index ad35562a6d7..93de79501e3 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -49,6 +49,13 @@ SAMPLE_RATE = 16000 _FIRE_COOLDOWN_SECONDS = 2.0 _START_TIMEOUT_SECONDS = 5.0 +# Ambient-speech rejection: openWakeWord scores one ~80ms frame at a time, and a +# stray phoneme in background conversation can spike a single frame over the +# threshold. A real utterance of the phrase holds the score high across several +# consecutive frames, so we require N-in-a-row above threshold before firing. +# This is the primary lever against unintended triggers on ambient talk. +_DEFAULT_CONFIRMATION_FRAMES = 3 + # Dead-mic detection: an int16 stream whose peak stays at/below this for this # many consecutive seconds is flagged as silent. macOS grants the *app* mic # permission per-process — a backend spawned without the entitlement gets a @@ -72,6 +79,7 @@ _DEFAULTS: Dict[str, Any] = { "provider": "openwakeword", "phrase": "hey hermes", "sensitivity": 0.5, + "confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES, "start_new_session": True, } @@ -166,6 +174,21 @@ def _sensitivity(cfg: Dict[str, Any]) -> float: return min(max(s, 0.0), 1.0) +def _confirmation_frames(cfg: Dict[str, Any]) -> int: + """How many consecutive over-threshold frames are required to fire. + + ``1`` restores the old single-frame behaviour; higher values reject + ambient-speech blips at the cost of a few tens of ms of extra latency. + Clamped to a sane 1..10. + """ + raw = _get(cfg, "confirmation_frames") + try: + n = int(raw) + except (TypeError, ValueError): + n = _DEFAULT_CONFIRMATION_FRAMES + return min(max(n, 1), 10) + + def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str: """Human-facing wake phrase label (purely cosmetic; engine keys detection).""" cfg = cfg if cfg is not None else load_wake_word_config() @@ -305,6 +328,8 @@ class _OpenWakeWordEngine(_Engine): if not framework: framework = default_inference_framework() self._threshold = _sensitivity(cfg) + self._confirm_needed = _confirmation_frames(cfg) + self._confirm_streak = 0 # openWakeWord silently downgrades tflite -> onnx when no tflite runtime # imports (model.py). On macOS ARM64 that lands on the backend whose @@ -348,11 +373,22 @@ class _OpenWakeWordEngine(_Engine): def process(self, frame) -> bool: scores = self._model.predict(frame) - return any(score >= self._threshold for score in scores.values()) + over = any(score >= self._threshold for score in scores.values()) + # Require N consecutive over-threshold frames: a real phrase holds the + # score high across frames, a stray ambient phoneme spikes just one. + if over: + self._confirm_streak += 1 + if self._confirm_streak >= self._confirm_needed: + self._confirm_streak = 0 + return True + return False + self._confirm_streak = 0 + return False def reset(self) -> None: # Clears openWakeWord's rolling feature/prediction buffer so stale audio # captured before a pause can't re-fire the moment we resume. + self._confirm_streak = 0 try: self._model.reset() except Exception: diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 99fb65fbfa9..50d602406e7 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -77,6 +77,7 @@ wake_word: provider: openwakeword # "openwakeword" (free, local) | "porcupine" phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers + confirmation_frames: 3 # openWakeWord only — consecutive over-threshold frames required to fire start_new_session: true # start a fresh session on wake vs. continue the current one openwakeword: model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .onnx/.tflite @@ -88,6 +89,23 @@ wake_word: `sensitivity`, `phrase`, and `start_new_session` apply to both engines. The `openwakeword` and `porcupine` blocks select the actual detection model. +### Reducing false triggers on ambient speech + +openWakeWord scores one short (~80ms) audio frame at a time, so a stray phoneme +in background conversation can occasionally spike a single frame over the +threshold and fire the wake word unintentionally. Two knobs control this: + +- **`confirmation_frames`** (default `3`, openWakeWord only) — how many + *consecutive* over-threshold frames are required before the wake fires. A real + "hey hermes" holds a high score across several frames; an ambient blip spikes + just one. Raise it (e.g. `4`–`5`) if you still get false triggers in a noisy + room; the cost is a few tens of milliseconds of extra latency. `1` restores + the old fire-on-first-frame behavior. +- **`sensitivity`** — raise toward `1.0` to demand a higher score per frame. + +The `sherpa` and `porcupine` engines decode the whole phrase internally, so they +don't have the single-frame-spike problem and ignore `confirmation_frames`. + `inference_framework` picks the openWakeWord backend. Leave it empty (the default) to let Hermes choose per platform: **tflite on Apple Silicon**, onnx everywhere else. openWakeWord's onnx backend returns near-zero scores on macOS From 3d2cc391588121a1000d5f37356de5cc03e52533 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:04:28 -0700 Subject: [PATCH 38/46] chore: regenerate uv.lock with uv 0.11.33 to match CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase's lock was produced by uv 0.9.28 (runtime venv); CI runs uv 0.11.33, whose resolver rejected it (uv sync --locked failed). Relocked with 0.11.33 — only wake-extra deps and their transitives added, no unrelated version churn. --- uv.lock | 172 ++++++++++++++++++++++++++------------------------------ 1 file changed, 81 insertions(+), 91 deletions(-) diff --git a/uv.lock b/uv.lock index 3f0bf2d0a23..d9796e09648 100644 --- a/uv.lock +++ b/uv.lock @@ -267,7 +267,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0 [[package]] name = "alibabacloud-tea-openapi" -version = "0.4.4" +version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alibabacloud-credentials" }, @@ -276,9 +276,9 @@ dependencies = [ { name = "cryptography" }, { name = "darabonba-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" }, ] [[package]] @@ -702,14 +702,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -748,47 +748,47 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, - { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, ] [[package]] @@ -820,15 +820,17 @@ wheels = [ [[package]] name = "darabonba-core" -version = "1.0.5" +version = "1.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "alibabacloud-tea" }, { name = "requests" }, + { name = "websocket-client" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/f5/83/9321ccdb7a800c2cb97d8fa34bead5f20141f27f804594fd1fd815c4cd07/darabonba_core-1.0.8.tar.gz", hash = "sha256:f1661960b368e342d3d36434be82d264b70a01c49e843921d8a4dacd217376ae", size = 27604, upload-time = "2026-07-13T02:07:34.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/38800ca22f39a31fdb75c7b2867c61d3af5e2792cee0b72942a639c88a79/darabonba_core-1.0.8-py3-none-any.whl", hash = "sha256:ac093fdd40f88f2f9dfbbbfd7bc143495a3cb031f35b397c98d24edfa6b69483", size = 30957, upload-time = "2026-07-13T02:07:33.138Z" }, ] [[package]] @@ -1789,7 +1791,7 @@ requires-dist = [ { name = "certifi", specifier = "==2026.5.20" }, { name = "concurrent-log-handler", marker = "sys_platform == 'win32'", specifier = "==0.9.29" }, { name = "croniter", specifier = "==6.0.0" }, - { name = "cryptography", specifier = "==46.0.7" }, + { name = "cryptography", specifier = "==48.0.1" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, { name = "defusedxml", marker = "extra == 'wecom'", specifier = "==0.7.1" }, @@ -1863,7 +1865,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = ">=0.0.9,<1" }, - { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.27" }, + { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.32" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306,<312" }, @@ -1886,10 +1888,10 @@ requires-dist = [ { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, { name = "sounddevice", marker = "extra == 'wake'", specifier = "==0.5.5" }, - { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" }, - { name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" }, - { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.0.1" }, - { name = "starlette", marker = "extra == 'web'", specifier = "==1.0.1" }, + { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.3.1" }, + { name = "starlette", marker = "extra == 'dev'", specifier = "==1.3.1" }, + { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.3.1" }, + { name = "starlette", marker = "extra == 'web'", specifier = "==1.3.1" }, { name = "supermemory", marker = "extra == 'supermemory'", specifier = "==3.50.0" }, { name = "tenacity", specifier = "==9.1.4" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, @@ -1904,26 +1906,18 @@ provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge [[package]] name = "hf-xet" -version = "1.3.1" +version = "1.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/d0/73454ef7ca885598a3194d07d5c517d91a840753c5b35d272600d7907f64/hf_xet-1.3.1.tar.gz", hash = "sha256:513aa75f8dc39a63cc44dbc8d635ccf6b449e07cdbd8b2e2d006320d2e4be9bb", size = 641393, upload-time = "2026-02-25T00:57:56.701Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/79/9b6a5614230d7a871442d8d8e1c270496821638ba3a9baac16a5b9166200/hf_xet-1.3.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:08b231260c68172c866f7aa7257c165d0c87887491aafc5efeee782731725366", size = 3759716, upload-time = "2026-02-25T00:57:41.052Z" }, - { url = "https://files.pythonhosted.org/packages/d4/de/72acb8d7702b3cf9b36a68e8380f3114bf04f9f21cf9e25317457fe31f00/hf_xet-1.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0810b69c64e96dee849036193848007f665dca2311879c9ea8693f4fc37f1795", size = 3518075, upload-time = "2026-02-25T00:57:39.605Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/ed728d8530fec28da88ee882b522fccf00dc98e9d7bae4cdb0493070cb17/hf_xet-1.3.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ecd38f98e7f0f41108e30fd4a9a5553ec30cf726df7473dd3e75a1b6d56728c2", size = 4174369, upload-time = "2026-02-25T00:57:32.697Z" }, - { url = "https://files.pythonhosted.org/packages/3c/db/785a0e20aa3086948a26573f1d4ff5c090e63564bf0a52d32eb5b4d82e8d/hf_xet-1.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65411867d46700765018b1990eb1604c3bf0bf576d9e65fc57fdcc10797a2eb9", size = 3953249, upload-time = "2026-02-25T00:57:30.096Z" }, - { url = "https://files.pythonhosted.org/packages/c4/6a/51b669c1e3dbd9374b61356f554e8726b9e1c1d6a7bee5d727d3913b10ad/hf_xet-1.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1684c840c60da12d76c2a031ba40e4b154fdbf9593836fcf5ff090d95a033c61", size = 4152989, upload-time = "2026-02-25T00:57:48.308Z" }, - { url = "https://files.pythonhosted.org/packages/df/31/de07e26e396f46d13a09251df69df9444190e93e06a9d30d639e96c8a0ed/hf_xet-1.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3012c0f2ce1f0863338491a2bc0fd3f84aded0e147ab25f230da1f5249547fd", size = 4390709, upload-time = "2026-02-25T00:57:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c1/fcb010b54488c2c112224f55b71f80e44d1706d9b764a0966310b283f86e/hf_xet-1.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:4eb432e1aa707a65a7e1f8455e40c5b47431d44fe0fb1b0c5d53848c27469398", size = 3634142, upload-time = "2026-02-25T00:57:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/9ef49cc601c68209979661b3e0b6659fc5a47bfb40f3ebf29eae9ee09e5c/hf_xet-1.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:e56104c84b2a88b9c7b23ba11a2d7ed0ccbe96886b3f985a50cedd2f0e99853f", size = 3494918, upload-time = "2026-02-25T00:57:57.654Z" }, - { url = "https://files.pythonhosted.org/packages/75/f8/c2da4352c0335df6ae41750cf5bab09fdbfc30d3b4deeed9d621811aa835/hf_xet-1.3.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:581d1809a016f7881069d86a072168a8199a46c839cf394ff53970a47e4f1ca1", size = 3761755, upload-time = "2026-02-25T00:57:43.621Z" }, - { url = "https://files.pythonhosted.org/packages/c0/e5/a2f3eaae09da57deceb16a96ebe9ae1f6f7b9b94145a9cd3c3f994e7782a/hf_xet-1.3.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:329c80c86f2dda776bafd2e4813a46a3ee648dce3ac0c84625902c70d7a6ddba", size = 3523677, upload-time = "2026-02-25T00:57:42.3Z" }, - { url = "https://files.pythonhosted.org/packages/61/cd/acbbf9e51f17d8cef2630e61741228e12d4050716619353efc1ac119f902/hf_xet-1.3.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2973c3ff594c3a8da890836308cae1444c8af113c6f10fe6824575ddbc37eca7", size = 4178557, upload-time = "2026-02-25T00:57:35.399Z" }, - { url = "https://files.pythonhosted.org/packages/df/4f/014c14c4ae3461d9919008d0bed2f6f35ba1741e28b31e095746e8dac66f/hf_xet-1.3.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ed4bfd2e6d10cb86c9b0f3483df1d7dd2d0220f75f27166925253bacbc1c2dbe", size = 3958975, upload-time = "2026-02-25T00:57:34.004Z" }, - { url = "https://files.pythonhosted.org/packages/86/50/043f5c5a26f3831c3fa2509c17fcd468fd02f1f24d363adc7745fbe661cb/hf_xet-1.3.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:713913387cc76e300116030705d843a9f15aee86158337eeffb9eb8d26f47fcd", size = 4158298, upload-time = "2026-02-25T00:57:51.14Z" }, - { url = "https://files.pythonhosted.org/packages/08/9c/b667098a636a88358dbeb2caf90e3cb9e4b961f61f6c55bb312793424def/hf_xet-1.3.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5063789c9d21f51e9ed4edbee8539655d3486e9cad37e96b7af967da20e8b16", size = 4395743, upload-time = "2026-02-25T00:57:52.783Z" }, - { url = "https://files.pythonhosted.org/packages/70/37/4db0e4e1534270800cfffd5a7e0b338f2137f8ceb5768000147650d34ea9/hf_xet-1.3.1-cp37-abi3-win_amd64.whl", hash = "sha256:607d5bbc2730274516714e2e442a26e40e3330673ac0d0173004461409147dee", size = 3638145, upload-time = "2026-02-25T00:58:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/4e/46/1ba8d36f8290a4b98f78898bdce2b0e8fe6d9a59df34a1399eb61a8d877f/hf_xet-1.3.1-cp37-abi3-win_arm64.whl", hash = "sha256:851b1be6597a87036fe7258ce7578d5df3c08176283b989c3b165f94125c5097", size = 3500490, upload-time = "2026-02-25T00:58:00.667Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, ] [[package]] @@ -2054,23 +2048,22 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.4.1" +version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "click" }, { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "shellingham" }, { name = "tqdm" }, - { name = "typer-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848, upload-time = "2026-07-17T09:54:01.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904, upload-time = "2026-07-17T09:53:59.106Z" }, ] [[package]] @@ -3547,11 +3540,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -4267,15 +4260,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -4459,18 +4452,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] -[[package]] -name = "typer-slim" -version = "0.24.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, -] - [[package]] name = "types-certifi" version = "2021.10.8.3" @@ -4681,6 +4662,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "15.0.1" From a8bc64a41887f49a743872e567628dfeace01be3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:35:06 -0700 Subject: [PATCH 39/46] feat(desktop): play an activation chime when the wake word fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A short, bright, rising two-note ding (G5 -> C6) plays the moment 'Hey Hermes' is detected, before voice capture starts, so it's obvious the wake registered. Deliberately distinct from the turn-end completion cue (that one settles; this one rises = 'listening'). Reuses the same lightweight WebAudio synthesis as completion-sound.ts — no asset to ship — and is gated by the shared sound-mute toggle ($hapticsMuted), so muting turn-end sounds silences it too. - apps/desktop/src/lib/wake-sound.ts: playWakeSound() - wiring.tsx: fire it at the top of the wake.detected handler - 3 tests (plays two-note chime, silent when muted, never throws with no WebAudio) --- apps/desktop/src/app/contrib/wiring.tsx | 5 ++ apps/desktop/src/lib/wake-sound.test.ts | 82 +++++++++++++++++++++++ apps/desktop/src/lib/wake-sound.ts | 88 +++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 apps/desktop/src/lib/wake-sound.test.ts create mode 100644 apps/desktop/src/lib/wake-sound.ts diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 895826794a1..0676c2da77d 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -29,6 +29,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' +import { playWakeSound } from '@/lib/wake-sound' import { $billingSettingsRequest } from '@/store/billing-block' import { requestVoiceConversationStart } from '@/store/composer' import { setCronFocusJobId } from '@/store/cron' @@ -670,6 +671,10 @@ export function ContribWiring({ children }: { children: ReactNode }) { | { profile?: null | string; start_new_session?: boolean } | undefined + // Audible confirmation that the wake registered, before voice capture + // starts. Gated by the shared sound-mute toggle. + playWakeSound() + // Multi-profile routing: a wake phrase enrolled by another profile // re-homes the gateway to that profile first (live swap — same path // as clicking it in the profile rail), then opens the fresh session diff --git a/apps/desktop/src/lib/wake-sound.test.ts b/apps/desktop/src/lib/wake-sound.test.ts new file mode 100644 index 00000000000..354eff8e44f --- /dev/null +++ b/apps/desktop/src/lib/wake-sound.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $hapticsMuted } from '@/store/haptics' + +import { playWakeSound } from './wake-sound' + +// Minimal WebAudio doubles: enough to record that playWakeSound wired +// oscillators to the destination when it should, and stayed silent when muted. +class FakeParam { + setValueAtTime = vi.fn() + exponentialRampToValueAtTime = vi.fn() +} + +class FakeOscillator { + type = 'sine' + frequency = new FakeParam() + connect = vi.fn() + start = vi.fn() + stop = vi.fn() +} + +class FakeGain { + gain = new FakeParam() + connect = vi.fn() +} + +let oscillators: FakeOscillator[] + +class FakeAudioContext { + state = 'running' + currentTime = 0 + destination = {} + resume = vi.fn().mockResolvedValue(undefined) + + createOscillator() { + const osc = new FakeOscillator() + oscillators.push(osc) + + return osc + } + + createGain() { + return new FakeGain() + } +} + +describe('playWakeSound', () => { + beforeEach(() => { + oscillators = [] + $hapticsMuted.set(false) + vi.stubGlobal('AudioContext', FakeAudioContext) + }) + + afterEach(() => { + vi.unstubAllGlobals() + $hapticsMuted.set(false) + }) + + it('plays a two-note rising chime when sound is on', () => { + playWakeSound() + + // G5 then C6 — two enveloped oscillators, both routed onward. + expect(oscillators).toHaveLength(2) + expect(oscillators[0].frequency.setValueAtTime).toHaveBeenCalledWith(783.99, expect.any(Number)) + expect(oscillators[1].frequency.setValueAtTime).toHaveBeenCalledWith(1046.5, expect.any(Number)) + for (const osc of oscillators) { + expect(osc.start).toHaveBeenCalled() + expect(osc.stop).toHaveBeenCalled() + } + }) + + it('stays silent when the shared sound-mute toggle is on', () => { + $hapticsMuted.set(true) + playWakeSound() + expect(oscillators).toHaveLength(0) + }) + + it('never throws when WebAudio is unavailable', () => { + vi.stubGlobal('AudioContext', undefined) + expect(() => playWakeSound()).not.toThrow() + }) +}) diff --git a/apps/desktop/src/lib/wake-sound.ts b/apps/desktop/src/lib/wake-sound.ts new file mode 100644 index 00000000000..3eb9f1c0d86 --- /dev/null +++ b/apps/desktop/src/lib/wake-sound.ts @@ -0,0 +1,88 @@ +// Wake-word activation chime. A short, bright, rising two-note "ding" that +// plays the moment "Hey Hermes" is detected, so it's obvious the wake +// registered before voice capture starts. Deliberately distinct from the +// turn-end completion cue (completion-sound.ts): this one RISES (open/ready), +// the completion cue settles (done). Reuses the same lightweight WebAudio +// synthesis approach — no asset file to ship. + +import { $hapticsMuted } from '@/store/haptics' + +let ctx: AudioContext | null = null + +function getCtx(): AudioContext | null { + if (typeof window === 'undefined') { + return null + } + + try { + if (!ctx) { + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return null + } + + ctx = new Ctor() + } + + // Autoplay policies can leave the context suspended until a gesture; a + // resume() here recovers it once the user has interacted with the window. + if (ctx.state === 'suspended') { + void ctx.resume().catch(() => undefined) + } + + return ctx + } catch { + return null + } +} + +// One enveloped sine voice → master. Linear-ish attack into an exponential +// decay keeps the tail smooth and avoids the click you get ramping to zero. +function ding(ac: AudioContext, master: GainNode, t0: number, freq: number, dur: number, gain: number) { + const osc = ac.createOscillator() + const env = ac.createGain() + const end = t0 + dur + + osc.type = 'sine' + osc.frequency.setValueAtTime(freq, t0) + + env.gain.setValueAtTime(0.0001, t0) + env.gain.exponentialRampToValueAtTime(Math.max(gain, 0.0002), t0 + 0.008) + env.gain.exponentialRampToValueAtTime(0.0001, end) + + osc.connect(env) + env.connect(master) + osc.start(t0) + osc.stop(end + 0.02) +} + +// Play the wake chime. Honours the shared sound-mute toggle ($hapticsMuted), +// the same gate the completion cue uses, so muting turn-end sounds also +// silences this. Best-effort: never throws into the wake-event handler. +export function playWakeSound(): void { + if ($hapticsMuted.get()) { + return + } + + const ac = getCtx() + + if (!ac) { + return + } + + try { + const master = ac.createGain() + master.gain.setValueAtTime(0.5, ac.currentTime) + master.connect(ac.destination) + + const t0 = ac.currentTime + 0.01 + // Rising perfect-fourth: G5 -> C6. Short and bright — "listening". + ding(ac, master, t0, 783.99, 0.12, 0.06) + ding(ac, master, t0 + 0.1, 1046.5, 0.28, 0.07) + } catch { + // WebAudio can throw if the context died mid-call; a missed chime must + // never break wake handling. + } +} From f106e0ebc256fadc4d1374a939ce45f5b622de14 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:43:17 -0700 Subject: [PATCH 40/46] fix(wake): raise default sensitivity to 0.6 and fix inverted Porcupine direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'hey hor' triggered the wake word: the default sensitivity was 0.5, which for openWakeWord IS the raw per-frame score threshold — openWakeWord's own permissive baseline that near-misses clear. Raised the default to 0.6 so phonetic near-misses fall short while real 'hey hermes' (typically 0.9+) still fires easily. Also fixed a real cross-engine inconsistency found while checking: the sensitivity knob is documented 'higher = stricter' and behaves that way for openWakeWord (threshold = sensitivity) and sherpa (0.05 + 0.4*s), but Porcupine's own 'sensitivities' param runs the opposite way (higher = MORE false alarms, per Picovoice). Turning sensitivity up made Porcupine looser — backwards. Now inverted (1 - sensitivity) so 'higher = stricter' holds for every engine. - tools/wake_word.py: default 0.6; _sensitivity fallback uses _DEFAULTS; Porcupine sensitivity inverted with rationale - hermes_cli/config.py + docs: default + consistent-direction note - tests: Porcupine inversion, default>=0.6 regression, fallback-to-default --- hermes_cli/config.py | 2 +- tests/tools/test_wake_word.py | 42 ++++++++++++++++++- tools/wake_word.py | 16 +++++-- website/docs/user-guide/features/wake-word.md | 14 +++++-- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9c8bff52a39..eef366e1606 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2382,7 +2382,7 @@ DEFAULT_CONFIG = { "surface": "auto", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui" "provider": "openwakeword", # "openwakeword" (free, local) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY) "phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label — detection is keyed by the model/keyword below - "sensitivity": 0.5, # 0.0-1.0 detection threshold (higher = stricter) + "sensitivity": 0.6, # 0.0-1.0 detection threshold, consistent across engines (higher = stricter, fewer false triggers) "confirmation_frames": 3, # openWakeWord only: consecutive over-threshold frames required to fire (higher = fewer false triggers on ambient speech, slightly more latency; 1 = old single-frame behavior) "start_new_session": True, # start a fresh session on wake vs. continue the current one "profile_routing": True, # sherpa only: also listen for every wake-enabled profile's phrase and route the wake to the matching profile diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 83097328de3..baca95ab91b 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -27,7 +27,9 @@ def test_config_defaults_and_clamping(): assert ww._provider({"provider": "Porcupine"}) == "porcupine" assert ww._sensitivity({"sensitivity": 5}) == 1.0 assert ww._sensitivity({"sensitivity": -1}) == 0.0 - assert ww._sensitivity({"sensitivity": "nope"}) == 0.5 + # Invalid input falls back to the configured default, not a hardcoded 0.5. + assert ww._sensitivity({"sensitivity": "nope"}) == ww._DEFAULTS["sensitivity"] + assert ww._sensitivity({}) == ww._DEFAULTS["sensitivity"] assert ww.wake_phrase({"phrase": "hey hermes"}) == "hey hermes" assert ww.wake_phrase({}) == "hey hermes" @@ -428,6 +430,44 @@ def test_confirmation_frames_config_clamped(monkeypatch): assert ww._confirmation_frames({}) == ww._DEFAULT_CONFIRMATION_FRAMES +def test_porcupine_sensitivity_is_inverted_to_match_shared_contract(monkeypatch): + # Our config contract is "higher sensitivity = stricter" for every engine. + # Porcupine's own `sensitivities` param means the OPPOSITE (higher = looser, + # more false alarms), so the engine must pass 1 - sensitivity. + captured = {} + + class _FakePorcupine: + frame_length = 512 + + def process(self, frame): + return -1 + + def _create(**kwargs): + captured.update(kwargs) + return _FakePorcupine() + + pv = types.ModuleType("pvporcupine") + pv.create = _create + monkeypatch.setitem(sys.modules, "pvporcupine", pv) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setenv("PORCUPINE_ACCESS_KEY", "test-key") + + # Strict (0.9) → Porcupine gets a low 0.1 (few false alarms). + ww._PorcupineEngine({"provider": "porcupine", "sensitivity": 0.9}) + assert captured["sensitivities"] == [pytest.approx(0.1)] + + # Loose (0.2) → Porcupine gets a high 0.8. + ww._PorcupineEngine({"provider": "porcupine", "sensitivity": 0.2}) + assert captured["sensitivities"] == [pytest.approx(0.8)] + + +def test_default_sensitivity_is_stricter_than_openwakeword_baseline(): + # Regression: the 0.5 default let near-misses ("hey hor") through. The + # default must sit above openWakeWord's permissive 0.5 baseline. + assert ww._DEFAULTS["sensitivity"] >= 0.6 + assert ww._sensitivity({}) >= 0.6 + + def test_macos_tflite_refuses_silent_onnx_downgrade(monkeypatch): # openWakeWord silently falls back to onnx when no tflite runtime imports. # On macOS ARM64 that lands on the broken backend, so we must raise instead diff --git a/tools/wake_word.py b/tools/wake_word.py index 93de79501e3..9367127fe3f 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -78,7 +78,7 @@ _DEFAULTS: Dict[str, Any] = { "surface": "auto", "provider": "openwakeword", "phrase": "hey hermes", - "sensitivity": 0.5, + "sensitivity": 0.6, "confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES, "start_new_session": True, } @@ -170,7 +170,7 @@ def _sensitivity(cfg: Dict[str, Any]) -> float: try: s = float(raw) except (TypeError, ValueError): - s = 0.5 + s = float(_DEFAULTS["sensitivity"]) return min(max(s, 0.0), 1.0) @@ -327,6 +327,10 @@ class _OpenWakeWordEngine(_Engine): framework = str(sub.get("inference_framework") or "").strip().lower() if not framework: framework = default_inference_framework() + # openWakeWord returns a 0..1 score per frame; sensitivity IS the raw + # threshold a score must clear. Higher = stricter (fewer false fires). + # Default 0.6 sits above openWakeWord's permissive 0.5 baseline, which + # let near-misses like "hey hor" through. self._threshold = _sensitivity(cfg) self._confirm_needed = _confirmation_frames(cfg) self._confirm_streak = 0 @@ -575,9 +579,13 @@ class _PorcupineEngine(_Engine): sub = cfg.get("porcupine") if isinstance(cfg.get("porcupine"), dict) else {} keyword = str(sub.get("keyword") or "jarvis").strip() - sensitivity = _sensitivity(cfg) + # Porcupine's `sensitivities` runs the OPPOSITE way to our shared knob: + # per Picovoice, higher = more true positives AND more false alarms + # (looser). Our config contract is "higher = stricter" everywhere, so + # invert it here to keep one consistent meaning across all engines. + porcupine_sensitivity = 1.0 - _sensitivity(cfg) - kwargs: Dict[str, Any] = {"access_key": access_key, "sensitivities": [sensitivity]} + kwargs: Dict[str, Any] = {"access_key": access_key, "sensitivities": [porcupine_sensitivity]} if _looks_like_path(keyword): kwargs["keyword_paths"] = [keyword] else: diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 50d602406e7..ee57d9bf45d 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -76,7 +76,7 @@ wake_word: surface: auto # eligible surface: "auto" | "cli" | "tui" | "gui" provider: openwakeword # "openwakeword" (free, local) | "porcupine" phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below - sensitivity: 0.5 # 0.0-1.0 — raise to reduce false triggers + sensitivity: 0.6 # 0.0-1.0 — higher = stricter (fewer false triggers), consistent across all engines confirmation_frames: 3 # openWakeWord only — consecutive over-threshold frames required to fire start_new_session: true # start a fresh session on wake vs. continue the current one openwakeword: @@ -101,10 +101,18 @@ threshold and fire the wake word unintentionally. Two knobs control this: just one. Raise it (e.g. `4`–`5`) if you still get false triggers in a noisy room; the cost is a few tens of milliseconds of extra latency. `1` restores the old fire-on-first-frame behavior. -- **`sensitivity`** — raise toward `1.0` to demand a higher score per frame. +- **`sensitivity`** (default `0.6`) — the detection threshold, `0.0`–`1.0`. + Higher is stricter (fewer false triggers). This direction is consistent across + **all** engines — for openWakeWord it's the raw per-frame score threshold, for + sherpa it maps onto the keyword threshold, and for Porcupine it's inverted + internally so "higher = stricter" holds there too. The `0.6` default sits + above openWakeWord's permissive `0.5` baseline, which let near-misses like + "hey hor" through; raise toward `0.8` if you still get false fires, lower it + if real "hey hermes" utterances are missed. The `sherpa` and `porcupine` engines decode the whole phrase internally, so they -don't have the single-frame-spike problem and ignore `confirmation_frames`. +don't have the single-frame-spike problem and ignore `confirmation_frames` +(but they still honor `sensitivity`). `inference_framework` picks the openWakeWord backend. Leave it empty (the default) to let Hermes choose per platform: **tflite on Apple Silicon**, onnx From 09c62d5da3509cf5d12236971fbdd1b0141b83a3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:03:11 -0700 Subject: [PATCH 41/46] feat(desktop): end a hands-free voice conversation by saying "stop" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saying 'stop' in a voice chat did nothing — the transcript was just submitted to the agent as a normal turn, so the conversation never ended. The only way out was the mouse/hotkey. That's not how a hands-free voice assistant should work. useVoiceConversation now checks each finished utterance against a spoken stop-command matcher BEFORE submitting: 'stop', 'stop listening', 'never mind', 'goodbye', 'cancel', 'that's all', etc., optionally addressed ('hey hermes, stop'). A match ends the conversation (flips enabled=false, which drives the existing end() teardown — mic close, playback stop, wake re-arm) instead of sending a turn. Deliberately conservative: only a WHOLE-utterance stop phrase matches, so substantive requests that merely contain 'stop' ('stop the docker container', 'how do I stop a process') still go through. - apps/desktop/src/lib/voice-stop-word.ts: isVoiceStopCommand() matcher - use-voice-conversation.ts: onStopWord option + intercept before submit - use-composer-voice.ts: wire onStopWord -> end the conversation - 6 matcher tests (bare/multi-word/addressed stop; substantive requests with 'stop' pass through; bare address words don't match) - docs: note the spoken-stop behavior --- .../chat/composer/hooks/use-composer-voice.ts | 5 + .../composer/hooks/use-voice-conversation.ts | 21 +++++ apps/desktop/src/lib/voice-stop-word.test.ts | 62 ++++++++++++ apps/desktop/src/lib/voice-stop-word.ts | 94 +++++++++++++++++++ website/docs/user-guide/features/wake-word.md | 6 ++ 5 files changed, 188 insertions(+) create mode 100644 apps/desktop/src/lib/voice-stop-word.test.ts create mode 100644 apps/desktop/src/lib/voice-stop-word.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 414676e3c9b..608431d2f7a 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -121,6 +121,11 @@ export function useComposerVoice({ consumePendingResponse, enabled: voiceConversationActive, onFatalError: () => setVoiceConversationActive(false), + // A spoken stop command ("stop", "never mind", "goodbye", …) ends the + // hands-free conversation. Flipping the flag is the authoritative off + // switch — the enabled=false prop + effect below drive conversation.end() + // teardown (mic close, wake re-arm). + onStopWord: () => setVoiceConversationActive(false), onSubmit: submitVoiceTurn, onTranscribeAudio, pendingResponse: pendingTurnResponse diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 33285f05aa1..58dea1b7fe2 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -9,6 +9,7 @@ import { startSpeechStream, stopVoicePlayback } from '@/lib/voice-playback' +import { isVoiceStopCommand } from '@/lib/voice-stop-word' import { notify, notifyError } from '@/store/notifications' import { useMicRecorder } from './use-mic-recorder' @@ -25,6 +26,7 @@ interface VoiceConversationOptions { busy: boolean enabled: boolean onFatalError?: () => void + onStopWord?: () => void onSubmit: (text: string) => Promise | void onTranscribeAudio?: (audio: Blob) => Promise pendingResponse: () => PendingVoiceResponse | null @@ -35,6 +37,7 @@ export function useVoiceConversation({ busy, enabled, onFatalError, + onStopWord, onSubmit, onTranscribeAudio, pendingResponse, @@ -59,6 +62,12 @@ export function useVoiceConversation({ const busyRef = useRef(busy) const statusRef = useRef('idle') const wasEnabledRef = useRef(enabled) + const onStopWordRef = useRef(onStopWord) + + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) + useEffect(() => { + onStopWordRef.current = onStopWord + }, [onStopWord]) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { @@ -132,6 +141,18 @@ export function useVoiceConversation({ return } + // A spoken "stop" (or "never mind", "goodbye", …) ends the + // conversation instead of being submitted as a turn. Only whole- + // utterance stop commands match, so "stop the container" still goes + // through as a real request. + if (isVoiceStopCommand(transcript)) { + dropSpeechSession() + setStatus('idle') + onStopWordRef.current?.() + + return + } + awaitingSpokenResponseRef.current = true dropSpeechSession() await onSubmit(transcript) diff --git a/apps/desktop/src/lib/voice-stop-word.test.ts b/apps/desktop/src/lib/voice-stop-word.test.ts new file mode 100644 index 00000000000..d1aa53b4fea --- /dev/null +++ b/apps/desktop/src/lib/voice-stop-word.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { isVoiceStopCommand } from './voice-stop-word' + +describe('isVoiceStopCommand', () => { + it('matches bare stop commands', () => { + for (const phrase of ['stop', 'Stop', 'STOP', 'stop.', 'stop!', ' stop ', 'stop…']) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('matches multi-word stop phrases', () => { + for (const phrase of [ + 'stop listening', + 'stop it', + 'please stop', + 'stop please', + "that's all", + 'that is all', + 'never mind', + 'nevermind', + 'end conversation', + 'end the conversation', + 'goodbye', + 'bye', + 'cancel' + ]) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('matches stop commands addressed to Hermes', () => { + for (const phrase of ['hermes stop', 'hey hermes stop', 'hey hermes, stop', 'ok stop', 'okay stop']) { + expect(isVoiceStopCommand(phrase)).toBe(true) + } + }) + + it('does NOT match substantive requests that merely contain "stop"', () => { + for (const phrase of [ + 'stop the docker container', + 'how do I stop a running process', + 'can you stop the deployment', + 'stop the music and play something else', + "don't stop now", + 'the bus stop is closed' + ]) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) + + it('does not match bare address words or empty input', () => { + for (const phrase of ['', ' ', 'hermes', 'hey hermes', 'ok', 'okay', 'hey']) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) + + it('does not match unrelated short utterances', () => { + for (const phrase of ['hello', 'yes', 'what time is it', 'thanks']) { + expect(isVoiceStopCommand(phrase)).toBe(false) + } + }) +}) diff --git a/apps/desktop/src/lib/voice-stop-word.ts b/apps/desktop/src/lib/voice-stop-word.ts new file mode 100644 index 00000000000..e59c9da83af --- /dev/null +++ b/apps/desktop/src/lib/voice-stop-word.ts @@ -0,0 +1,94 @@ +// Spoken stop-word detection for the voice conversation loop. +// +// When someone is in a hands-free "Hey Hermes" voice chat, the natural way to +// end it is to SAY "stop" — not reach for the mouse. Without this, a spoken +// "stop" is just transcribed and sent to the agent as a normal turn, so the +// conversation never ends (the reported bug). This matcher recognises a short +// utterance whose entire content is a stop command and ends the conversation +// instead of submitting it. +// +// Deliberately conservative: it only fires when the WHOLE utterance is a stop +// phrase (optionally addressed to Hermes), so a real turn that merely contains +// the word "stop" — e.g. "stop the docker container" or "how do I stop a +// running process" — is never swallowed. + +// Canonical stop commands. Kept short and unambiguous; each must be the entire +// spoken utterance to match. +const STOP_PHRASES: readonly string[] = [ + 'stop', + 'stop listening', + 'stop it', + 'stop please', + 'please stop', + 'stop stop', + 'that is all', + "that's all", + 'never mind', + 'nevermind', + 'end conversation', + 'end the conversation', + 'goodbye', + 'good bye', + 'bye', + 'cancel' +] + +// Optional address prefixes so "hermes stop" / "ok stop" / "hey hermes, stop" +// still count. Stripped before matching the core phrase. +const ADDRESS_PREFIXES: readonly string[] = ['hey hermes', 'hey hermes,', 'hermes', 'hermes,', 'ok', 'okay', 'hey'] + +// Normalise: lowercase, strip surrounding punctuation/whitespace, collapse +// internal runs of spaces. Trailing punctuation (".", "!", "…") is common in +// STT output and must not defeat the match. +function normalize(text: string): string { + return text + .toLowerCase() + .replace(/[.,!?;:…]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function stripAddress(text: string): string { + for (const prefix of ADDRESS_PREFIXES) { + if (text === prefix) { + // Bare address ("hermes") is not a stop command on its own. + continue + } + + if (text.startsWith(`${prefix} `)) { + return text.slice(prefix.length + 1).trim() + } + } + + return text +} + +/** + * True when the entire spoken utterance is a stop command (optionally addressed + * to Hermes). Returns false for anything that merely contains "stop" as part of + * a longer, substantive request. + */ +export function isVoiceStopCommand(transcript: string): boolean { + if (!transcript) { + return false + } + + const normalized = normalize(transcript) + + if (!normalized) { + return false + } + + // Match with the address prefix stripped, and also as-is (so a bare "stop" + // with no prefix still matches, and "please stop" — where "please" isn't a + // prefix — matches directly). + const candidates = new Set([normalized, stripAddress(normalized)]) + + for (const candidate of candidates) { + if (STOP_PHRASES.includes(candidate)) { + return true + } + } + + return false +} diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index ee57d9bf45d..e8438d765f2 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -28,6 +28,12 @@ to the agent. It is **off by default** — nothing listens until you turn it on. +On the desktop app, a hands-free voice conversation can be ended by simply +**saying "stop"** (or "never mind", "goodbye", "cancel", "that's all") — the +spoken command ends the conversation instead of being sent to the agent. Only a +whole-utterance stop command matches, so a real request like "stop the docker +container" still goes through normally. + ## Engines | Engine | Cost | API key | Notes | From ffe5a934794d87bb208e4a20489b8e0398d41b0f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:06:11 -0700 Subject: [PATCH 42/46] chore: retrigger CI (run 30381290401 died at dispatch with zero jobs) From 353578faca0577ae5b222cb77cf3b6fbeca9240f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:40:28 -0700 Subject: [PATCH 43/46] fix(config): persist runtime settings to HERMES_HOME/config.yaml, not the repo template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake-word ear reverted to disabled after every restart even when closed enabled. Root cause is general, not wake-specific: save_config_value followed load_cli_config's precedence, which falls back to the repo's checked-in cli-config.yaml when HERMES_HOME/config.yaml doesn't exist yet. On such installs (managed/desktop first launch), the toggle's persist wrote wake_word.enabled=true into cli-config.yaml and returned success — but every config reader (load_config -> get_hermes_home()/config.yaml, including load_wake_word_config) reads only HERMES_HOME/config.yaml, so the setting was invisible on the next launch. Same silent loss hit any runtime persist (model switch, /reasoning, /fast, skin) on a config-less install. save_config_value now always targets get_hermes_home()/config.yaml, creating it if absent, and never writes the shipped repo template. Also resolves HERMES_HOME live instead of the import-time _hermes_home constant (profile-safe). E2E verified: persist -> fresh module reload -> load_wake_word_config sees enabled=true and wake_surface_enabled('gui') is True, for both a fresh (no config.yaml) and an existing-config install. - cli.py save_config_value: target user config, create if absent - tests: 2 regression tests (creates user config when absent; never writes repo cli-config.yaml); fixture now sets HERMES_HOME. 11 file + 206 adjacent config/model-switch tests green --- cli.py | 18 ++++++-- tests/cli/test_cli_save_config_value.py | 56 ++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 1d22d893d05..16ddc1d6008 100644 --- a/cli.py +++ b/cli.py @@ -4020,10 +4020,20 @@ def save_config_value(key_path: str, value: any) -> bool: Returns: True if successful, False otherwise """ - # Use the same precedence as load_cli_config: user config first, then project config - user_config_path = _hermes_home / 'config.yaml' - project_config_path = Path(__file__).parent / 'cli-config.yaml' - config_path = user_config_path if user_config_path.exists() else project_config_path + # Runtime persistence ALWAYS targets the user's HERMES_HOME config.yaml, + # creating it if needed. Resolve HERMES_HOME live (not the import-time + # _hermes_home constant) so profile switches and test isolation land right. + # + # We deliberately do NOT fall back to the repo's project cli-config.yaml: + # that file is a shipped default/template, and most config readers + # (load_config → get_hermes_home()/config.yaml, including + # load_wake_word_config) never read it. Writing a user setting there means + # the reader never sees it. This was the "wake-word ear reverts to disabled + # after restart" bug — the toggle's persist wrote to cli-config.yaml (which + # exists in the checkout) while startup read HERMES_HOME/config.yaml, so the + # setting silently vanished every restart on any install whose + # HERMES_HOME/config.yaml didn't exist yet. + config_path = get_hermes_home() / 'config.yaml' try: # Ensure parent directory exists (for ~/.hermes/config.yaml on first use) diff --git a/tests/cli/test_cli_save_config_value.py b/tests/cli/test_cli_save_config_value.py index a966217065b..e820920a2b3 100644 --- a/tests/cli/test_cli_save_config_value.py +++ b/tests/cli/test_cli_save_config_value.py @@ -1,8 +1,10 @@ """Tests for save_config_value() in cli.py — atomic write behavior.""" -import yaml +from pathlib import Path from unittest.mock import MagicMock +import yaml + import pytest @@ -19,6 +21,10 @@ class TestSaveConfigValueAtomic: "model": {"default": "test-model", "provider": "openrouter"}, "display": {"skin": "default"}, })) + # save_config_value resolves the target live via get_hermes_home(), so + # point HERMES_HOME at the temp dir (the _hermes_home import-time + # constant is no longer consulted). + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setattr("cli._hermes_home", hermes_home) return config_path @@ -144,3 +150,51 @@ class TestSaveConfigValueAtomic: assert result is False assert config_env.read_text() == original_content + + +class TestSaveConfigValueTargetsUserConfig: + """Regression: persisted runtime settings must land in HERMES_HOME/config.yaml + (which config readers actually read), never the repo's cli-config.yaml. + + This was the "wake-word ear reverts to disabled after restart" bug: on an + install whose HERMES_HOME/config.yaml did not exist yet, save_config_value + fell back to the checked-in cli-config.yaml. The toggle reported success, but + startup read HERMES_HOME/config.yaml and never saw the setting.""" + + def test_creates_user_config_when_absent(self, tmp_path, monkeypatch): + # Fresh HERMES_HOME with NO config.yaml (managed/desktop first launch). + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from cli import save_config_value + + assert save_config_value("wake_word.enabled", True) is True + + config_path = hermes_home / "config.yaml" + assert config_path.exists(), "user config.yaml must be created, not skipped" + result = yaml.safe_load(config_path.read_text()) + assert result["wake_word"]["enabled"] is True + + def test_does_not_write_repo_cli_config(self, tmp_path, monkeypatch): + # Even when the repo's cli-config.yaml exists, the write goes to the + # user config, so a runtime setting is never buried in the shipped file. + import cli as cli_module + + repo_cli_config = Path(cli_module.__file__).parent / "cli-config.yaml" + before = repo_cli_config.read_text() if repo_cli_config.exists() else None + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from cli import save_config_value + + save_config_value("wake_word.enabled", True) + + # The repo template is untouched… + after = repo_cli_config.read_text() if repo_cli_config.exists() else None + assert after == before + # …and the value landed in the user config. + result = yaml.safe_load((hermes_home / "config.yaml").read_text()) + assert result["wake_word"]["enabled"] is True From 754fdbdc9863048d584b9754e36990d67935caa2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:52:28 -0700 Subject: [PATCH 44/46] test: set HERMES_HOME in _persist_model_switch tests after save_config_value fix The prior commit made save_config_value resolve its target via get_hermes_home() (live env) instead of the import-time cli._hermes_home constant. Two _persist_model_switch tests patched only cli._hermes_home and wrote/read tmp_path/config.yaml, so the write now landed in the real HERMES_HOME and the readback saw stale values. Set HERMES_HOME=tmp_path (kept the _hermes_home patch for belt-and-suspenders). No production change. --- tests/test_tui_gateway_server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 5ff4032772b..96f152e3d79 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -13944,9 +13944,9 @@ def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch "agent:\n" " system_prompt: keepme\n" ) - # save_config_value() resolves the config path from cli._hermes_home, which - # is captured at import time — patch it directly (set_hermes_home_override - # does NOT affect this snapshot). + # save_config_value() resolves the config path from get_hermes_home() (live + # env var), always targeting HERMES_HOME/config.yaml — point it at tmp_path. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr(cli, "_hermes_home", tmp_path) result = types.SimpleNamespace( @@ -13979,6 +13979,7 @@ def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch): " provider: custom:mylocal\n" " base_url: http://localhost:1234/v1\n" ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr(cli, "_hermes_home", tmp_path) # Switch to a native provider with no base_url. From f9d7be82fb7b1a43e1ecb55a7198109b9d248a3b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:01:27 -0700 Subject: [PATCH 45/46] fix(desktop): speak the first reply of a wake-started voice session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosed from a Windows desktop's logs: the backend TTS was generating and saving the reply mp3 every turn (provider: openai), so synthesis was fine — the DESKTOP wasn't playing the first reply. Root cause is Chromium's autoplay policy: audio (HTMLAudioElement.play() and AudioContext) is suspended until the frame sees a user gesture. A voice conversation started by the 'Hey Hermes' wake word has no preceding click, so the first reply's play() was rejected with NotAllowedError (silently swallowed) and only turn 2+ spoke. Manual voice-start worked because the button click WAS the gesture — exactly the 'first message in a new voice session is silent, manual start is fine' report. Fix (defense in depth): - chatWindowWebPreferences sets autoplayPolicy: 'no-user-gesture-required' on every chat window (primary + secondary), so a deliberately-launched native app never gates audio on a gesture. Primary fix. - voice-playback.ts resumes a suspended AudioContext on stream 'start' and retries HTMLAudioElement.play() once via an unlock context after a NotAllowedError — covers the dashboard-embedded surface that doesn't get the Electron policy. Tests: 16 electron session-window tests (incl. new autoplay assertion), tsc + eslint clean. --- apps/desktop/electron/session-windows.test.ts | 10 ++++ apps/desktop/electron/session-windows.ts | 13 ++++- apps/desktop/src/lib/voice-playback.ts | 52 ++++++++++++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index b5f9cc6a8be..a60ebb37621 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -208,3 +208,13 @@ test('chatWindowWebPreferences passes the preload path through and keeps the har assert.equal(prefs.sandbox, true) assert.equal(prefs.nodeIntegration, false) }) + +test('chatWindowWebPreferences allows autoplay so wake-started voice speaks its first reply', () => { + // Regression: Chromium's default autoplay policy suspends audio until a user + // gesture. A wake-word-started voice conversation has no preceding click, so + // the first reply's playback was rejected and only turn 2+ spoke. A native + // app the user launched should not gate audio on a gesture. + const prefs = chatWindowWebPreferences('/tmp/preload.cjs') + + assert.equal(prefs.autoplayPolicy, 'no-user-gesture-required') +}) diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 46871b5384e..312deb3d1e3 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -21,6 +21,16 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // occluded windows. A streaming chat app must keep painting in the // background, so every chat window opts out. The preload path is injected // because it depends on the Electron entry's __dirname. +// +// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice: +// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play() +// and AudioContext) until the user has interacted with the frame. A voice +// conversation started by the "Hey Hermes" wake word has NO preceding click, +// so the FIRST reply's audio playback was rejected (NotAllowedError, silently +// swallowed) and only turn 2+ spoke — the very "first message in a new voice +// session is silent" bug. Manual voice-start worked only because the button +// click counted as the gesture. This is a native app the user deliberately +// launched; there is no drive-by-autoplay concern to protect against. function chatWindowWebPreferences(preloadPath: string) { return { preload: preloadPath, @@ -29,7 +39,8 @@ function chatWindowWebPreferences(preloadPath: string) { sandbox: true, nodeIntegration: false, devTools: true, - backgroundThrottling: false + backgroundThrottling: false, + autoplayPolicy: 'no-user-gesture-required' as const } } diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts index 80d9a3f22a2..d98d6dace79 100644 --- a/apps/desktop/src/lib/voice-playback.ts +++ b/apps/desktop/src/lib/voice-playback.ts @@ -20,6 +20,36 @@ let currentAudio: HTMLAudioElement | null = null let currentStop: (() => void) | null = null let sequence = 0 +// A shared, lazily-created AudioContext used only to nudge the browser's +// autoplay state out of "suspended". A wake-word-started voice turn has no +// preceding user gesture, so the first HTMLAudioElement.play() can be rejected +// with NotAllowedError. resume()-ing a context is the documented way to recover +// once the app is allowed to make sound; on Electron chat windows the +// no-user-gesture-required policy means this is already unlocked, so this is a +// cheap no-op fallback for other surfaces. +let unlockCtx: AudioContext | null = null + +async function unlockAutoplay(): Promise { + if (typeof window === 'undefined') { + return + } + + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return + } + + if (!unlockCtx) { + unlockCtx = new Ctor() + } + + if (unlockCtx.state === 'suspended') { + await unlockCtx.resume() + } +} + function currentState( status: VoicePlaybackState['status'], options?: VoicePlaybackOptions, @@ -235,6 +265,14 @@ function openSpeechStream(wsUrl: string, options: VoicePlaybackOptions): SpeechS if (frame.type === 'start') { streamRate = frame.sample_rate || 24_000 context = new AudioContext() + // Autoplay policy can hand back a suspended context when playback wasn't + // started by a user gesture (e.g. a wake-word-started voice turn). Resume + // it so the first reply is audible instead of silently buffering. Electron + // chat windows also set autoplayPolicy: no-user-gesture-required, but the + // dashboard-embedded surface relies on this resume. + if (context.state === 'suspended') { + void context.resume().catch(() => undefined) + } nextStartAt = 0 } else if (frame.type === 'end') { finishWhenDrained() @@ -363,7 +401,19 @@ async function playSpeechDataUrl( audio.addEventListener('error', onError, { once: true }) audio.addEventListener('timeupdate', armStall) armStall() - void audio.play().catch(onError) + // A wake-word-started turn has no user gesture, so the autoplay policy can + // reject the first play() with NotAllowedError. Electron chat windows set + // autoplayPolicy: no-user-gesture-required to prevent this, but retry once + // after resuming a shared AudioContext as a fallback for other surfaces + // (dashboard-embedded) so the first reply isn't silently dropped. + void audio.play().catch(async () => { + try { + await unlockAutoplay() + await audio.play() + } catch { + onError() + } + }) }) if (!isCurrent()) { From eb8d88f33a65d11115b8b2e1dfc3a2b8de9c42d8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:16:54 -0700 Subject: [PATCH 46/46] fix(desktop): await wake-word mic release before opening the voice-chat mic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Clicked voice chat but it never starts listening' — a mic-device contention race. Starting a voice conversation fires wake.pause (to free the mic from the wake-word listener) and opens the conversation's own mic in two separate effects, both keyed on voiceConversationActive with no ordering between them. wake.pause was fire-and-forget, so getUserMedia often raced the wake listener's stream teardown (which joins a reader thread + closes the device in a finally). On Windows the capture device is effectively single-owner, so opening it while wake still held it failed and the conversation never started listening. Wake-word-initiated starts don't hit this: the backend's _on_detect calls pause_listening synchronously before emitting wake.detected, so the mic is already free by the time the frontend opens it. Only the button/hotkey path raced — matching the report. Fix: pauseWakeForVoice now returns an awaitable barrier for the in-flight wake.pause round-trip, and useVoiceConversation awaits a new beforeMicOpen hook (wired to that barrier) right before handle.start(), re-checking enabled/muted/busy/idle after the wait. No behavior change when the wake word isn't running (barrier is null → no wait). tsc + eslint clean, 39 desktop voice/wake tests green. --- .../chat/composer/hooks/use-composer-voice.ts | 30 ++++++++++++++----- .../composer/hooks/use-voice-conversation.ts | 27 ++++++++++++++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 608431d2f7a..e25a6bfc8ed 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -116,6 +116,14 @@ export function useComposerVoice({ await onSubmit(text) } + const wakePausedRef = useRef(false) + // Resolves once the in-flight wake.pause round-trip completes (mic released by + // the wake listener). The conversation awaits this before opening its own mic + // so the two never contend for the device — on Windows especially, opening the + // capture device while the wake listener still holds it makes getUserMedia + // fail and the conversation never starts listening. + const wakePauseBarrierRef = useRef | null>(null) + const conversation = useVoiceConversation({ busy, consumePendingResponse, @@ -128,7 +136,10 @@ export function useComposerVoice({ onStopWord: () => setVoiceConversationActive(false), onSubmit: submitVoiceTurn, onTranscribeAudio, - pendingResponse: pendingTurnResponse + pendingResponse: pendingTurnResponse, + // Before the conversation opens the mic, wait for any in-flight wake.pause + // to finish releasing the capture device (see wakePauseBarrierRef). + beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined }) // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting @@ -158,14 +169,13 @@ export function useComposerVoice({ } }, [disabled, target, voiceConversationActive, voiceStartRequest]) - const wakePausedRef = useRef(false) - const resumeWakeIfPaused = useCallback(() => { if (!wakePausedRef.current) { return } wakePausedRef.current = false + wakePauseBarrierRef.current = null // Reconcile, don't just resume: the wake word is a persistent setting, so // ending a voice chat must re-arm the listener whenever config says // enabled — including when the raw resume loses the mic-release race. @@ -176,10 +186,16 @@ export function useComposerVoice({ // it guards resumeWakeIfPaused from resuming a detector another surface owns. const pauseWakeForVoice = useCallback(() => { wakePausedRef.current = true - void $gateway - .get() - ?.request('wake.pause', {}) - .catch(() => undefined) + const barrier = (async () => { + try { + await $gateway.get()?.request('wake.pause', {}) + } catch { + // No wake listener / older backend — nothing held the mic. + } + })() + wakePauseBarrierRef.current = barrier + + return barrier }, []) useEffect(() => { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 58dea1b7fe2..59ba4ee3c8e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -31,6 +31,9 @@ interface VoiceConversationOptions { onTranscribeAudio?: (audio: Blob) => Promise pendingResponse: () => PendingVoiceResponse | null consumePendingResponse: () => void + /** Awaited right before the mic is opened. Used to let the wake-word listener + * fully release the capture device first, so the two never contend. */ + beforeMicOpen?: () => Promise | void } export function useVoiceConversation({ @@ -41,7 +44,8 @@ export function useVoiceConversation({ onSubmit, onTranscribeAudio, pendingResponse, - consumePendingResponse + consumePendingResponse, + beforeMicOpen }: VoiceConversationOptions) { const { t } = useI18n() const voiceCopy = t.notifications.voice @@ -69,6 +73,13 @@ export function useVoiceConversation({ onStopWordRef.current = onStopWord }, [onStopWord]) + const beforeMicOpenRef = useRef(beforeMicOpen) + + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) + useEffect(() => { + beforeMicOpenRef.current = beforeMicOpen + }, [beforeMicOpen]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { enabledRef.current = enabled @@ -188,6 +199,20 @@ export function useVoiceConversation({ return } + // Let the wake-word listener fully release the capture device before we + // open ours — opening the mic while wake still holds it makes getUserMedia + // fail (the "clicked voice but it never starts listening" bug). + try { + await beforeMicOpenRef.current?.() + } catch { + // A pause failure shouldn't block the user's explicit start. + } + + // enabled/muted/busy or an interleaved turn may have changed while we waited. + if (!enabledRef.current || mutedRef.current || busyRef.current || statusRef.current !== 'idle') { + return + } + try { // VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI. await handle.start({