diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts index 21a8f74b539c..0236f150e7ad 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 97f0c8b3c89d..69175d19a6df 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 f2312296078b..f8c592522a33 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 ca2139a7c137..5ff4032772bc 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 a361c34e5499..8c978967da6e 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 185c35d00347..32f86b1a67ed 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 d73dea4fa945..1984678f9db2 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 6a86e13b9c28..a47b904e3ab0 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 794ce0246e63..86dbe9678019 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: