feat(voice): "Hey Hermes" wake word to start a hands-free session - #58539
Closed
omid3098 wants to merge 13 commits into
Closed
feat(voice): "Hey Hermes" wake word to start a hands-free session#58539omid3098 wants to merge 13 commits into
omid3098 wants to merge 13 commits into
Conversation
teknium1
reviewed
Jul 15, 2026
teknium1
left a comment
Contributor
There was a problem hiding this comment.
Thanks for the substantial cross-surface implementation. The feature is not present on current main, but the default ownership path needs correction before it is safe to salvage.
Problems
tools/wake_word.py:106lets every surface passsurface: auto; both clients then callwake.start(ui-tui/src/app/createGatewayEventHandler.ts:322,apps/desktop/src/app/desktop-controller.tsx:1035).tui_gateway/server.py:12518-12524retains only one event transport/callback target, so the later caller takes wake-event ownership. The process-local singleton also cannot prevent separate CLI/TUI processes from competing for the same microphone.tests/tools/test_wake_word.py:30-35encodes that permissive behavior and does not exercise gateway ownership or event routing.
Suggested changes
- Make
autoacquire one deterministic owner and reject or explicitly hand off later callers; release it on disconnect/stop. Use a machine-scoped lock if multiple processes are supported. - Add TUI/desktop competing-client and reconnect ownership tests.
Automated hermes-sweeper review.
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.
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.
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.
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.
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.
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.
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.
…vent 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.
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.
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.
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 <https://...> as JSX.
This was referenced Jul 24, 2026
13 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replacement for #53378, rebased onto current
mainwhile preserving Brooklyn Nicholson's contributor commits and authorship.What this adds
Review feedback addressed
surface: autonow makes surfaces eligible but grants a sticky lease to the first claimant.ContribWiring/composer architecture; the obsolete controller is not restored.Verification
uv lock --check349 passed: wake-word, TUI gateway, and websocket lifecycle tests9 passed; affected ESLint checks pass83 passed; TUI typecheck and affected ESLint checks passgit diff --check origin/main...HEADThe full desktop typecheck is currently blocked locally by unrelated installed
@assistant-ui/react/react-streamdownexport mismatches in existing files; the affected desktop files pass their targeted tests and lint checks.