mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
7a87c6ffd6
commit
e8f9d471c6
9 changed files with 249 additions and 28 deletions
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue