feat(voice): route wake phrases to their profile — "hey <profile>" wakes that profile

One sherpa listener now enrolls every wake-enabled profile's phrase
(defaulting to "hey <profile name>") and reports WHICH phrase fired.
wake.detected gains a profile field; the desktop live-switches to the
matching profile (same path as the profile rail), opens a fresh session
there, and starts hands-free voice. The single-profile CLI/TUI print the
hermes -p switch command for foreign-profile phrases instead of
answering as the wrong profile. Opt out per listener with
wake_word.profile_routing: false.
This commit is contained in:
Hermes Agent 2026-07-24 09:52:00 -07:00 committed by Teknium
parent 567f47f01f
commit 2a35c8f0b8
No known key found for this signature in database
10 changed files with 274 additions and 16 deletions

View file

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