diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 3ba86e4741fe..895826794a19 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 472e20df902f..4635b9c8adf0 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 5c443f4e8182..8d43bc75131c 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 caf22da054ed..ca2139a7c137 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 c21e41def0f3..f70498a516e0 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 c0a10f6fc079..b8a3e5f95e9a 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 e29fcae05c4e..a361c34e5499 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 16971e18fbf2..338ea7ea498a 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 42d6e18f21f4..6a86e13b9c28 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 582ef1e97781..794ce0246e63 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