feat(wake): the toggle IS the config — explicit on/off persists wake_word.enabled

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.
This commit is contained in:
teknium1 2026-07-27 14:47:16 -07:00 committed by Teknium
parent 7a87c6ffd6
commit e8f9d471c6
No known key found for this signature in database
9 changed files with 249 additions and 28 deletions

View file

@ -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')
})
})

View file

@ -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 <T>(method: string, params: Record
return gateway.request<T>(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<string, string> = {
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<WakeStopResponse>('wake.stop', {}))
applyWakeStopResult(await request<WakeStopResponse>('wake.stop', { persist: true }))
} else {
applyWakeStartResult(await request<WakeStartResponse>('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<WakeStartResponse>('wake.start', { persist: true, surface: 'gui' }))
}
} catch (error) {
const current = $wakeWord.get()

View file

@ -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}")

View file

@ -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 = ""

View file

@ -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,
})

View file

@ -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 } })

View file

@ -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<string, string> = {
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<WakeStartResponse>('wake.start', { surface: 'tui' })
.rpc<WakeStartResponse>('wake.start', { persist: true, surface: 'tui' })
.then(
ctx.guarded<WakeStartResponse>(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<WakeStopResponse>('wake.stop', {})
.rpc<WakeStopResponse>('wake.stop', { persist: true })
.then(
ctx.guarded<WakeStopResponse>(r => {
const saved = r.disabled_persisted ? ' · disabled in config' : ''
if (r.stopped) {
return ctx.transcript.sys('wake: listener off (wont re-arm this session)')
return ctx.transcript.sys(`wake: listener off${saved}`)
}
const reason = r.reason === 'not_owner' ? 'this surface doesnt 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)

View file

@ -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
}

View file

@ -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: