Merge pull request #70509 from NousResearch/hermes/hermes-29661bf6

feat(voice): on-device wake words with open-vocabulary phrases and multi-profile voice routing
This commit is contained in:
Teknium 2026-07-28 17:58:33 -07:00 committed by GitHub
commit 7e7f7d3059
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 5886 additions and 68 deletions

View file

@ -208,3 +208,13 @@ test('chatWindowWebPreferences passes the preload path through and keeps the har
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})
test('chatWindowWebPreferences allows autoplay so wake-started voice speaks its first reply', () => {
// Regression: Chromium's default autoplay policy suspends audio until a user
// gesture. A wake-word-started voice conversation has no preceding click, so
// the first reply's playback was rejected and only turn 2+ spoke. A native
// app the user launched should not gate audio on a gesture.
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.autoplayPolicy, 'no-user-gesture-required')
})

View file

@ -21,6 +21,16 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
//
// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice:
// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play()
// and AudioContext) until the user has interacted with the frame. A voice
// conversation started by the "Hey Hermes" wake word has NO preceding click,
// so the FIRST reply's audio playback was rejected (NotAllowedError, silently
// swallowed) and only turn 2+ spoke — the very "first message in a new voice
// session is silent" bug. Manual voice-start worked only because the button
// click counted as the gesture. This is a native app the user deliberately
// launched; there is no drive-by-autoplay concern to protect against.
function chatWindowWebPreferences(preloadPath: string) {
return {
preload: preloadPath,
@ -29,7 +39,8 @@ function chatWindowWebPreferences(preloadPath: string) {
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
backgroundThrottling: false,
autoplayPolicy: 'no-user-gesture-required' as const
}
}

View file

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
import { ComposerControls } from './controls'
@ -77,3 +78,62 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})
describe('wake-word ear visibility', () => {
afterEach(() => {
resetWakeWordState()
})
it('stays mounted during a busy agent turn', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({ busy: true, busyAction: 'stop' })
expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
})
it('stays mounted (enabled in config) even when a start was refused', () => {
applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' })
// Transient refusal marks available false but enabled keeps it mounted.
applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false })
renderControls()
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('stays visible (never hides) even when unavailable and not enabled', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
renderControls()
// The ear ALWAYS shows so the user can click to enable; a failed start
// surfaces its reason in the tooltip rather than hiding the control.
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('surfaces the backend refusal reason in the tooltip, still visible', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false })
renderControls()
const ear = screen.getByLabelText('Wake word: "hey hermes" — off')
expect(ear).toBeTruthy()
})
it('shows a disabled paused ear inside the voice-conversation pill', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({
conversation: {
active: true,
level: 0,
muted: false,
onEnd: vi.fn(),
onStart: vi.fn(),
onStopTurn: vi.fn(),
onToggleMute: vi.fn(),
status: 'listening'
}
})
const ear = screen.getByLabelText('Wake word: "hey hermes" — paused during voice chat')
expect((ear as HTMLButtonElement).disabled).toBe(true)
})
})

View file

@ -1,10 +1,13 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
@ -80,6 +83,7 @@ export function ComposerControls({
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
<WakeWordButton disabled={disabled} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
@ -181,6 +185,9 @@ function ConversationPill({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
{/* Keep the ear visible during voice chat shown paused, since the
conversation holds the mic (the one time wake must not listen). */}
<WakeWordButton disabled={disabled} pausedForVoice />
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Button
aria-label={muted ? c.unmuteMic : c.muteMic}
@ -294,6 +301,53 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
)
}
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
// user must always be able to click it to turn passive listening on; if the
// backend can't start (missing STT/TTS, deps still installing, no mic
// permission, etc.) the click surfaces the reason in the tooltip and the
// toggle stays off. States: listening (accent-highlighted), off (muted
// ear-off), and paused-for-voice (disabled while a voice conversation holds
// the mic — the one time wake genuinely must not listen). Backend refusals
// ({started:false, reason}) keep the toggle off and put the reason/hint in
// the tooltip.
function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) {
const { t } = useI18n()
const c = t.composer
const wake = useStore($wakeWord)
const phrase = wake.phrase || 'hey hermes'
const label = pausedForVoice
? c.wakeWordPausedVoice(phrase)
: wake.listening
? c.wakeWordListening(phrase)
: c.wakeWordOff(phrase)
const tooltip = !pausedForVoice && wake.notice ? `${label}${wake.notice}` : label
return (
<Tip label={tooltip}>
<Button
aria-label={label}
aria-pressed={wake.listening && !pausedForVoice}
className={cn(
GHOST_ICON_BTN,
'p-0',
wake.listening && !pausedForVoice && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
)}
disabled={disabled || pausedForVoice || wake.pending}
onClick={() => {
triggerHaptic(wake.listening ? 'close' : 'open')
void toggleWakeWord()
}}
size="icon"
type="button"
variant="ghost"
>
{wake.listening && !pausedForVoice ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
</Button>
</Tip>
)
}
function DictationButton({
disabled,
state,

View file

@ -1,11 +1,15 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import { resumeWakeAfterVoice } from '@/store/wake-word'
import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
@ -54,6 +58,7 @@ export function useComposerVoice({
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)
const voiceStartRequest = useStore($voiceConversationStartRequest)
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
focusInput,
@ -111,14 +116,30 @@ export function useComposerVoice({
await onSubmit(text)
}
const wakePausedRef = useRef(false)
// Resolves once the in-flight wake.pause round-trip completes (mic released by
// the wake listener). The conversation awaits this before opening its own mic
// so the two never contend for the device — on Windows especially, opening the
// capture device while the wake listener still holds it makes getUserMedia
// fail and the conversation never starts listening.
const wakePauseBarrierRef = useRef<Promise<void> | null>(null)
const conversation = useVoiceConversation({
busy,
consumePendingResponse,
enabled: voiceConversationActive,
onFatalError: () => setVoiceConversationActive(false),
// A spoken stop command ("stop", "never mind", "goodbye", …) ends the
// hands-free conversation. Flipping the flag is the authoritative off
// switch — the enabled=false prop + effect below drive conversation.end()
// teardown (mic close, wake re-arm).
onStopWord: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponse: pendingTurnResponse
pendingResponse: pendingTurnResponse,
// Before the conversation opens the mic, wait for any in-flight wake.pause
// to finish releasing the capture device (see wakePauseBarrierRef).
beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
@ -142,6 +163,51 @@ export function useComposerVoice({
[target, toggleVoiceConversation]
)
useEffect(() => {
if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) {
setVoiceConversationActive(true)
}
}, [disabled, target, voiceConversationActive, voiceStartRequest])
const resumeWakeIfPaused = useCallback(() => {
if (!wakePausedRef.current) {
return
}
wakePausedRef.current = false
wakePauseBarrierRef.current = null
// Reconcile, don't just resume: the wake word is a persistent setting, so
// ending a voice chat must re-arm the listener whenever config says
// enabled — including when the raw resume loses the mic-release race.
void resumeWakeAfterVoice()
}, [])
// The ref is a request token (did WE issue wake.pause?), not an atom mirror —
// it guards resumeWakeIfPaused from resuming a detector another surface owns.
const pauseWakeForVoice = useCallback(() => {
wakePausedRef.current = true
const barrier = (async () => {
try {
await $gateway.get()?.request('wake.pause', {})
} catch {
// No wake listener / older backend — nothing held the mic.
}
})()
wakePauseBarrierRef.current = barrier
return barrier
}, [])
useEffect(() => {
if (voiceConversationActive) {
pauseWakeForVoice()
} else {
resumeWakeIfPaused()
}
}, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive])
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
const startConversation = useCallback(() => setVoiceConversationActive(true), [])

View file

@ -9,6 +9,7 @@ import {
startSpeechStream,
stopVoicePlayback
} from '@/lib/voice-playback'
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
import { notify, notifyError } from '@/store/notifications'
import { $voicePlayback } from '@/store/voice-playback'
@ -26,20 +27,26 @@ interface VoiceConversationOptions {
busy: boolean
enabled: boolean
onFatalError?: () => void
onStopWord?: () => void
onSubmit: (text: string) => Promise<void> | void
onTranscribeAudio?: (audio: Blob) => Promise<string>
pendingResponse: () => PendingVoiceResponse | null
consumePendingResponse: () => void
/** Awaited right before the mic is opened. Used to let the wake-word listener
* fully release the capture device first, so the two never contend. */
beforeMicOpen?: () => Promise<void> | void
}
export function useVoiceConversation({
busy,
enabled,
onFatalError,
onStopWord,
onSubmit,
onTranscribeAudio,
pendingResponse,
consumePendingResponse
consumePendingResponse,
beforeMicOpen
}: VoiceConversationOptions) {
const { t } = useI18n()
const voiceCopy = t.notifications.voice
@ -61,6 +68,19 @@ export function useVoiceConversation({
const busyRef = useRef(busy)
const statusRef = useRef<ConversationStatus>('idle')
const wasEnabledRef = useRef(enabled)
const onStopWordRef = useRef(onStopWord)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
onStopWordRef.current = onStopWord
}, [onStopWord])
const beforeMicOpenRef = useRef(beforeMicOpen)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
beforeMicOpenRef.current = beforeMicOpen
}, [beforeMicOpen])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
@ -134,6 +154,18 @@ export function useVoiceConversation({
return
}
// A spoken "stop" (or "never mind", "goodbye", …) ends the
// conversation instead of being submitted as a turn. Only whole-
// utterance stop commands match, so "stop the container" still goes
// through as a real request.
if (isVoiceStopCommand(transcript)) {
dropSpeechSession()
setStatus('idle')
onStopWordRef.current?.()
return
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
await onSubmit(transcript)
@ -169,6 +201,20 @@ export function useVoiceConversation({
return
}
// Let the wake-word listener fully release the capture device before we
// open ours — opening the mic while wake still holds it makes getUserMedia
// fail (the "clicked voice but it never starts listening" bug).
try {
await beforeMicOpenRef.current?.()
} catch {
// A pause failure shouldn't block the user's explicit start.
}
// enabled/muted/busy or an interleaved turn may have changed while we waited.
if (!enabledRef.current || mutedRef.current || busyRef.current || statusRef.current !== 'idle') {
return
}
try {
// VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI.
await handle.start({

View file

@ -29,11 +29,13 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { playWakeSound } from '@/lib/wake-sound'
import { $billingSettingsRequest } from '@/store/billing-block'
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,
@ -54,6 +56,7 @@ import {
setMessages
} from '@/store/session'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { armWakeWord } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -662,9 +665,41 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const handleGatewayEventWithPlugins = useCallback(
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
emitGatewayEvent(event)
if (event.type === 'wake.detected') {
const payload = event.payload as
| { profile?: null | string; start_new_session?: boolean }
| undefined
// Audible confirmation that the wake registered, before voice capture
// starts. Gated by the shared sound-mute toggle.
playWakeSound()
// 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()
}
requestVoiceConversationStart()
return
}
handleDesktopGatewayEvent(event)
},
[handleDesktopGatewayEvent]
[handleDesktopGatewayEvent, startFreshSessionDraft]
)
useGatewayBoot({
@ -685,6 +720,14 @@ export function ContribWiring({ children }: { children: ReactNode }) {
refreshSessions
})
useEffect(() => {
if (gatewayState === 'open') {
// Status-then-arm, syncing $wakeWord so the composer toggle reflects the
// same listener this auto-arm claims.
void armWakeWord(requestGateway)
}
}, [gatewayState, requestGateway])
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
const activeIsMessaging =

View file

@ -1963,6 +1963,9 @@ export const en: Translations = {
voiceDictation: 'Voice dictation',
speakReplies: 'Read replies aloud',
stopSpeakingReplies: 'Stop reading replies aloud',
wakeWordListening: phrase => `Wake word: "${phrase}" — listening`,
wakeWordOff: phrase => `Wake word: "${phrase}" — off`,
wakeWordPausedVoice: phrase => `Wake word: "${phrase}" — paused during voice chat`,
lookupLoading: 'Looking up…',
lookupNoMatches: 'No matches.',
lookupTry: 'Try',

View file

@ -1820,6 +1820,9 @@ export const ja = defineLocale({
voiceDictation: '音声口述',
speakReplies: '返信を読み上げる',
stopSpeakingReplies: '返信の読み上げを停止',
wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`,
wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`,
wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`,
lookupLoading: '検索中…',
lookupNoMatches: '一致なし。',
lookupTry: '試す',

View file

@ -1647,6 +1647,9 @@ export interface Translations {
voiceDictation: string
speakReplies: string
stopSpeakingReplies: string
wakeWordListening: (phrase: string) => string
wakeWordOff: (phrase: string) => string
wakeWordPausedVoice: (phrase: string) => string
lookupLoading: string
lookupNoMatches: string
lookupTry: string

View file

@ -1763,6 +1763,9 @@ export const zhHant = defineLocale({
voiceDictation: '語音聽寫',
speakReplies: '朗讀回覆',
stopSpeakingReplies: '停止朗讀回覆',
wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`,
wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`,
wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`,
lookupLoading: '查詢中…',
lookupNoMatches: '沒有相符項目。',
lookupTry: '試試',

View file

@ -2156,6 +2156,9 @@ export const zh: Translations = {
voiceDictation: '语音听写',
speakReplies: '朗读回复',
stopSpeakingReplies: '停止朗读回复',
wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`,
wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`,
wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`,
lookupLoading: '查找中…',
lookupNoMatches: '没有匹配项。',
lookupTry: '试试',

View file

@ -37,6 +37,8 @@ import {
IconCpu as Cpu,
IconCreditCard as CreditCard,
IconDownload as Download,
IconEar as Ear,
IconEarOff as EarOff,
IconEgg as Egg,
IconExternalLink as ExternalLink,
IconEye as Eye,
@ -160,6 +162,8 @@ export {
Cpu,
CreditCard,
Download,
Ear,
EarOff,
Egg,
ExternalLink,
Eye,

View file

@ -20,6 +20,36 @@ let currentAudio: HTMLAudioElement | null = null
let currentStop: (() => void) | null = null
let sequence = 0
// A shared, lazily-created AudioContext used only to nudge the browser's
// autoplay state out of "suspended". A wake-word-started voice turn has no
// preceding user gesture, so the first HTMLAudioElement.play() can be rejected
// with NotAllowedError. resume()-ing a context is the documented way to recover
// once the app is allowed to make sound; on Electron chat windows the
// no-user-gesture-required policy means this is already unlocked, so this is a
// cheap no-op fallback for other surfaces.
let unlockCtx: AudioContext | null = null
async function unlockAutoplay(): Promise<void> {
if (typeof window === 'undefined') {
return
}
const Ctor =
window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
return
}
if (!unlockCtx) {
unlockCtx = new Ctor()
}
if (unlockCtx.state === 'suspended') {
await unlockCtx.resume()
}
}
function currentState(
status: VoicePlaybackState['status'],
options?: VoicePlaybackOptions,
@ -243,6 +273,14 @@ function openSpeechStream(wsUrl: string, options: VoicePlaybackOptions): SpeechS
if (frame.type === 'start') {
streamRate = frame.sample_rate || 24_000
context = new AudioContext()
// Autoplay policy can hand back a suspended context when playback wasn't
// started by a user gesture (e.g. a wake-word-started voice turn). Resume
// it so the first reply is audible instead of silently buffering. Electron
// chat windows also set autoplayPolicy: no-user-gesture-required, but the
// dashboard-embedded surface relies on this resume.
if (context.state === 'suspended') {
void context.resume().catch(() => undefined)
}
nextStartAt = 0
} else if (frame.type === 'end') {
finishWhenDrained()
@ -371,7 +409,19 @@ async function playSpeechDataUrl(
audio.addEventListener('error', onError, { once: true })
audio.addEventListener('timeupdate', armStall)
armStall()
void audio.play().catch(onError)
// A wake-word-started turn has no user gesture, so the autoplay policy can
// reject the first play() with NotAllowedError. Electron chat windows set
// autoplayPolicy: no-user-gesture-required to prevent this, but retry once
// after resuming a shared AudioContext as a fallback for other surfaces
// (dashboard-embedded) so the first reply isn't silently dropped.
void audio.play().catch(async () => {
try {
await unlockAutoplay()
await audio.play()
} catch {
onError()
}
})
})
if (!isCurrent()) {

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { isVoiceStopCommand } from './voice-stop-word'
describe('isVoiceStopCommand', () => {
it('matches bare stop commands', () => {
for (const phrase of ['stop', 'Stop', 'STOP', 'stop.', 'stop!', ' stop ', 'stop…']) {
expect(isVoiceStopCommand(phrase)).toBe(true)
}
})
it('matches multi-word stop phrases', () => {
for (const phrase of [
'stop listening',
'stop it',
'please stop',
'stop please',
"that's all",
'that is all',
'never mind',
'nevermind',
'end conversation',
'end the conversation',
'goodbye',
'bye',
'cancel'
]) {
expect(isVoiceStopCommand(phrase)).toBe(true)
}
})
it('matches stop commands addressed to Hermes', () => {
for (const phrase of ['hermes stop', 'hey hermes stop', 'hey hermes, stop', 'ok stop', 'okay stop']) {
expect(isVoiceStopCommand(phrase)).toBe(true)
}
})
it('does NOT match substantive requests that merely contain "stop"', () => {
for (const phrase of [
'stop the docker container',
'how do I stop a running process',
'can you stop the deployment',
'stop the music and play something else',
"don't stop now",
'the bus stop is closed'
]) {
expect(isVoiceStopCommand(phrase)).toBe(false)
}
})
it('does not match bare address words or empty input', () => {
for (const phrase of ['', ' ', 'hermes', 'hey hermes', 'ok', 'okay', 'hey']) {
expect(isVoiceStopCommand(phrase)).toBe(false)
}
})
it('does not match unrelated short utterances', () => {
for (const phrase of ['hello', 'yes', 'what time is it', 'thanks']) {
expect(isVoiceStopCommand(phrase)).toBe(false)
}
})
})

View file

@ -0,0 +1,94 @@
// Spoken stop-word detection for the voice conversation loop.
//
// When someone is in a hands-free "Hey Hermes" voice chat, the natural way to
// end it is to SAY "stop" — not reach for the mouse. Without this, a spoken
// "stop" is just transcribed and sent to the agent as a normal turn, so the
// conversation never ends (the reported bug). This matcher recognises a short
// utterance whose entire content is a stop command and ends the conversation
// instead of submitting it.
//
// Deliberately conservative: it only fires when the WHOLE utterance is a stop
// phrase (optionally addressed to Hermes), so a real turn that merely contains
// the word "stop" — e.g. "stop the docker container" or "how do I stop a
// running process" — is never swallowed.
// Canonical stop commands. Kept short and unambiguous; each must be the entire
// spoken utterance to match.
const STOP_PHRASES: readonly string[] = [
'stop',
'stop listening',
'stop it',
'stop please',
'please stop',
'stop stop',
'that is all',
"that's all",
'never mind',
'nevermind',
'end conversation',
'end the conversation',
'goodbye',
'good bye',
'bye',
'cancel'
]
// Optional address prefixes so "hermes stop" / "ok stop" / "hey hermes, stop"
// still count. Stripped before matching the core phrase.
const ADDRESS_PREFIXES: readonly string[] = ['hey hermes', 'hey hermes,', 'hermes', 'hermes,', 'ok', 'okay', 'hey']
// Normalise: lowercase, strip surrounding punctuation/whitespace, collapse
// internal runs of spaces. Trailing punctuation (".", "!", "…") is common in
// STT output and must not defeat the match.
function normalize(text: string): string {
return text
.toLowerCase()
.replace(/[.,!?;:…]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function stripAddress(text: string): string {
for (const prefix of ADDRESS_PREFIXES) {
if (text === prefix) {
// Bare address ("hermes") is not a stop command on its own.
continue
}
if (text.startsWith(`${prefix} `)) {
return text.slice(prefix.length + 1).trim()
}
}
return text
}
/**
* True when the entire spoken utterance is a stop command (optionally addressed
* to Hermes). Returns false for anything that merely contains "stop" as part of
* a longer, substantive request.
*/
export function isVoiceStopCommand(transcript: string): boolean {
if (!transcript) {
return false
}
const normalized = normalize(transcript)
if (!normalized) {
return false
}
// Match with the address prefix stripped, and also as-is (so a bare "stop"
// with no prefix still matches, and "please stop" — where "please" isn't a
// prefix — matches directly).
const candidates = new Set([normalized, stripAddress(normalized)])
for (const candidate of candidates) {
if (STOP_PHRASES.includes(candidate)) {
return true
}
}
return false
}

View file

@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $hapticsMuted } from '@/store/haptics'
import { playWakeSound } from './wake-sound'
// Minimal WebAudio doubles: enough to record that playWakeSound wired
// oscillators to the destination when it should, and stayed silent when muted.
class FakeParam {
setValueAtTime = vi.fn()
exponentialRampToValueAtTime = vi.fn()
}
class FakeOscillator {
type = 'sine'
frequency = new FakeParam()
connect = vi.fn()
start = vi.fn()
stop = vi.fn()
}
class FakeGain {
gain = new FakeParam()
connect = vi.fn()
}
let oscillators: FakeOscillator[]
class FakeAudioContext {
state = 'running'
currentTime = 0
destination = {}
resume = vi.fn().mockResolvedValue(undefined)
createOscillator() {
const osc = new FakeOscillator()
oscillators.push(osc)
return osc
}
createGain() {
return new FakeGain()
}
}
describe('playWakeSound', () => {
beforeEach(() => {
oscillators = []
$hapticsMuted.set(false)
vi.stubGlobal('AudioContext', FakeAudioContext)
})
afterEach(() => {
vi.unstubAllGlobals()
$hapticsMuted.set(false)
})
it('plays a two-note rising chime when sound is on', () => {
playWakeSound()
// G5 then C6 — two enveloped oscillators, both routed onward.
expect(oscillators).toHaveLength(2)
expect(oscillators[0].frequency.setValueAtTime).toHaveBeenCalledWith(783.99, expect.any(Number))
expect(oscillators[1].frequency.setValueAtTime).toHaveBeenCalledWith(1046.5, expect.any(Number))
for (const osc of oscillators) {
expect(osc.start).toHaveBeenCalled()
expect(osc.stop).toHaveBeenCalled()
}
})
it('stays silent when the shared sound-mute toggle is on', () => {
$hapticsMuted.set(true)
playWakeSound()
expect(oscillators).toHaveLength(0)
})
it('never throws when WebAudio is unavailable', () => {
vi.stubGlobal('AudioContext', undefined)
expect(() => playWakeSound()).not.toThrow()
})
})

View file

@ -0,0 +1,88 @@
// Wake-word activation chime. A short, bright, rising two-note "ding" that
// plays the moment "Hey Hermes" is detected, so it's obvious the wake
// registered before voice capture starts. Deliberately distinct from the
// turn-end completion cue (completion-sound.ts): this one RISES (open/ready),
// the completion cue settles (done). Reuses the same lightweight WebAudio
// synthesis approach — no asset file to ship.
import { $hapticsMuted } from '@/store/haptics'
let ctx: AudioContext | null = null
function getCtx(): AudioContext | null {
if (typeof window === 'undefined') {
return null
}
try {
if (!ctx) {
const Ctor =
window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
return null
}
ctx = new Ctor()
}
// Autoplay policies can leave the context suspended until a gesture; a
// resume() here recovers it once the user has interacted with the window.
if (ctx.state === 'suspended') {
void ctx.resume().catch(() => undefined)
}
return ctx
} catch {
return null
}
}
// One enveloped sine voice → master. Linear-ish attack into an exponential
// decay keeps the tail smooth and avoids the click you get ramping to zero.
function ding(ac: AudioContext, master: GainNode, t0: number, freq: number, dur: number, gain: number) {
const osc = ac.createOscillator()
const env = ac.createGain()
const end = t0 + dur
osc.type = 'sine'
osc.frequency.setValueAtTime(freq, t0)
env.gain.setValueAtTime(0.0001, t0)
env.gain.exponentialRampToValueAtTime(Math.max(gain, 0.0002), t0 + 0.008)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(t0)
osc.stop(end + 0.02)
}
// Play the wake chime. Honours the shared sound-mute toggle ($hapticsMuted),
// the same gate the completion cue uses, so muting turn-end sounds also
// silences this. Best-effort: never throws into the wake-event handler.
export function playWakeSound(): void {
if ($hapticsMuted.get()) {
return
}
const ac = getCtx()
if (!ac) {
return
}
try {
const master = ac.createGain()
master.gain.setValueAtTime(0.5, ac.currentTime)
master.connect(ac.destination)
const t0 = ac.currentTime + 0.01
// Rising perfect-fourth: G5 -> C6. Short and bright — "listening".
ding(ac, master, t0, 783.99, 0.12, 0.06)
ding(ac, master, t0 + 0.1, 1046.5, 0.28, 0.07)
} catch {
// WebAudio can throw if the context died mid-call; a missed chime must
// never break wake handling.
}
}

View file

@ -2,17 +2,33 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
$composerAttachments,
$voiceConversationStartRequest,
addComposerAttachment,
clearSessionDraft,
type ComposerAttachment,
migrateSessionDraft,
removeComposerAttachment,
requestVoiceConversationStart,
SESSION_DRAFTS_STORAGE_KEY,
stashSessionDraft,
takeSessionDraft,
takeVoiceConversationStart,
updateComposerAttachment
} from './composer'
describe('voice conversation start requests', () => {
it('latches each request until the main composer consumes it once', () => {
requestVoiceConversationStart()
const first = $voiceConversationStartRequest.get()
expect(takeVoiceConversationStart(first)).toBe(true)
expect(takeVoiceConversationStart(first)).toBe(false)
requestVoiceConversationStart()
expect(takeVoiceConversationStart($voiceConversationStartRequest.get())).toBe(true)
})
})
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
return { kind: 'file', label: 'doc.pdf', ...overrides }
}

View file

@ -21,6 +21,24 @@ export const $composerDraft = atom('')
export const $composerAttachments = atom<ComposerAttachment[]>([])
export const $composerTerminalSelections = atom<Record<string, string>>({})
// Latched because opening a fresh session may remount the main composer before
// it can start voice. Session-tile composers deliberately never consume this.
export const $voiceConversationStartRequest = atom(0)
let nextVoiceStartRequest = 0
let handledVoiceStartRequest = 0
export const requestVoiceConversationStart = (): void => $voiceConversationStartRequest.set(++nextVoiceStartRequest)
export const takeVoiceConversationStart = (current: number): boolean => {
if (current <= handledVoiceStartRequest) {
return false
}
handledVoiceStartRequest = current
return true
}
// ---------------------------------------------------------------------------
// Composer scopes — one live attachment set PER MOUNTED COMPOSER. The main
// chat's scope wraps the module-level atom above (all existing readers keep

View file

@ -0,0 +1,373 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
$wakeWord,
applyWakeStartResult,
applyWakeStatus,
applyWakeStopResult,
armWakeWord,
resetWakeWordState,
resumeWakeAfterVoice,
toggleWakeWord,
type WakeRequester
} from './wake-word'
const requester = (impl: (method: string, params?: Record<string, unknown>) => unknown) =>
vi.fn(async (method: string, params: Record<string, unknown> = {}) => impl(method, params)) as unknown as WakeRequester
beforeEach(() => {
resetWakeWordState()
})
describe('applyWakeStatus', () => {
it('syncs availability, listening and phrase from wake.status', () => {
applyWakeStatus({
available: true,
hint: '',
listening: true,
owned_by_caller: true,
owner_surface: 'gui',
phrase: 'hey hermes',
provider: 'openwakeword'
})
expect($wakeWord.get()).toMatchObject({
available: true,
listening: true,
notice: '',
phrase: 'hey hermes'
})
})
it('tracks unavailability and carries the hint for the tooltip', () => {
applyWakeStatus({ available: false, hint: 'pip install openwakeword', listening: false, phrase: 'hey hermes' })
const state = $wakeWord.get()
expect(state.available).toBe(false)
expect(state.listening).toBe(false)
expect(state.notice).toBe('pip install openwakeword')
})
it('keeps the dead-mic hint visible while listening (audio_silent)', () => {
applyWakeStatus({
audio_silent: true,
available: true,
hint: 'Microphone delivers only silence — grant mic access',
listening: true,
phrase: 'hey hermes'
})
const state = $wakeWord.get()
expect(state.listening).toBe(true)
expect(state.notice).toBe('Microphone delivers only silence — grant mic access')
})
})
describe('toggleWakeWord', () => {
it('starts via wake.start with surface gui when off, and flips to listening', async () => {
applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' })
const request = requester(method => {
expect(method).toBe('wake.start')
return { owner_surface: 'gui', phrase: 'hey hermes', provider: 'porcupine', started: true }
})
await toggleWakeWord(request)
expect(request).toHaveBeenCalledWith('wake.start', { persist: true, surface: 'gui' })
expect($wakeWord.get()).toMatchObject({ listening: true, notice: '', pending: false })
})
it('stops via wake.stop when listening', async () => {
applyWakeStatus({ available: true, listening: true, phrase: 'hey hermes' })
const request = requester(method => {
expect(method).toBe('wake.stop')
return { reason: null, stopped: true }
})
await toggleWakeWord(request)
expect(request).toHaveBeenCalledWith('wake.stop', { persist: true })
expect($wakeWord.get()).toMatchObject({ listening: false, notice: '', pending: false })
})
it('does NOT flip state on {started:false, reason} and surfaces the reason', async () => {
applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' })
await toggleWakeWord(requester(() => ({ owner_surface: 'tui', reason: 'owned', started: false })))
const state = $wakeWord.get()
expect(state.listening).toBe(false)
expect(state.notice).toBe('another surface owns the listener')
expect(state.available).toBe(true)
})
it('marks the feature unavailable when start refuses with reason unavailable', async () => {
applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' })
await toggleWakeWord(
requester(() => ({ hint: 'Set PORCUPINE_ACCESS_KEY', reason: 'unavailable', started: false }))
)
const state = $wakeWord.get()
expect(state.available).toBe(false)
expect(state.listening).toBe(false)
expect(state.notice).toBe('Set PORCUPINE_ACCESS_KEY')
})
it('stays off and keeps the error as the notice when the RPC throws', async () => {
applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' })
await toggleWakeWord(
requester(() => {
throw new Error('Hermes gateway unavailable')
})
)
expect($wakeWord.get()).toMatchObject({
listening: false,
notice: 'Hermes gateway unavailable',
pending: false
})
})
it('ignores clicks while a toggle is already in flight', async () => {
applyWakeStatus({ available: true, listening: false, phrase: 'hey hermes' })
let resolveStart: (value: unknown) => void = () => undefined
const request = vi.fn(
async () =>
new Promise(resolve => {
resolveStart = resolve
})
) as unknown as WakeRequester
const first = toggleWakeWord(request)
await toggleWakeWord(request)
expect(request).toHaveBeenCalledTimes(1)
resolveStart({ phrase: 'hey hermes', started: true })
await first
expect($wakeWord.get().listening).toBe(true)
})
})
describe('armWakeWord (gateway-ready auto-arm)', () => {
it('queries wake.status then arms and syncs the store', async () => {
const calls: string[] = []
const request = requester(method => {
calls.push(method)
if (method === 'wake.status') {
return { available: true, listening: false, phrase: 'hey hermes', provider: 'porcupine' }
}
return { phrase: 'hey hermes', started: true }
})
await armWakeWord(request)
expect(calls).toEqual(['wake.status', 'wake.start'])
expect($wakeWord.get()).toMatchObject({ available: true, listening: true, phrase: 'hey hermes' })
})
it('does not attempt to arm when the wake word is unavailable', async () => {
const calls: string[] = []
const request = requester(method => {
calls.push(method)
return { available: false, hint: 'no mic', listening: false, phrase: 'hey hermes' }
})
await armWakeWord(request)
expect(calls).toEqual(['wake.status'])
expect($wakeWord.get()).toMatchObject({ available: false, listening: false, notice: 'no mic' })
})
it('skips arming when this surface already listens (status sync only)', async () => {
const calls: string[] = []
const request = requester(method => {
calls.push(method)
return { available: true, listening: true, owned_by_caller: true, phrase: 'hey hermes' }
})
await armWakeWord(request)
expect(calls).toEqual(['wake.status'])
expect($wakeWord.get()).toMatchObject({ available: true, listening: true })
})
it('keeps the default hidden state when the backend lacks wake.* methods', async () => {
await armWakeWord(
requester(() => {
throw new Error('Unknown method: wake.status')
})
)
expect($wakeWord.get()).toMatchObject({ available: false, listening: false })
})
it('keeps the toggle off when auto-arm is refused (e.g. TUI owns the mic)', async () => {
const request = requester(method =>
method === 'wake.status'
? { available: true, listening: false, owner_surface: 'tui', phrase: 'hey hermes' }
: { owner_surface: 'tui', reason: 'owned', started: false }
)
await armWakeWord(request)
const state = $wakeWord.get()
expect(state.available).toBe(true)
expect(state.listening).toBe(false)
expect(state.notice).toBe('another surface owns the listener')
})
})
describe('applyWakeStopResult', () => {
it('lands on off even when the backend says not_owner', () => {
applyWakeStatus({ available: true, listening: true, phrase: 'hey hermes' })
applyWakeStopResult({ reason: 'not_owner', stopped: false })
const state = $wakeWord.get()
expect(state.listening).toBe(false)
expect(state.notice).toBe('another surface owns the listener')
})
})
describe('applyWakeStartResult', () => {
it('adopts the backend phrase when the listener starts', () => {
applyWakeStartResult({ phrase: 'computer', provider: 'porcupine', started: true })
expect($wakeWord.get()).toMatchObject({ available: true, listening: true, phrase: 'computer' })
})
})
describe('resumeWakeAfterVoice (post-voice reconcile)', () => {
it('re-arms when config says enabled but the listener is down', async () => {
const calls: string[] = []
const request = requester(method => {
calls.push(method)
if (method === 'wake.resume') {
return { reason: 'not_owner', resumed: false }
}
if (method === 'wake.status') {
return { available: true, enabled: true, listening: false, phrase: 'hey hermes' }
}
return { phrase: 'hey hermes', started: true }
})
await resumeWakeAfterVoice(request)
expect(calls).toEqual(['wake.resume', 'wake.status', 'wake.start'])
expect($wakeWord.get()).toMatchObject({ listening: true })
})
it('re-arm start never passes persist (passive path must not write config)', async () => {
const startParams: Array<Record<string, unknown> | undefined> = []
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'wake.resume') {
return { resumed: false }
}
if (method === 'wake.status') {
return { available: true, enabled: true, listening: false }
}
startParams.push(params)
return { started: true }
}) as unknown as WakeRequester
await resumeWakeAfterVoice(request)
expect(startParams).toEqual([{ surface: 'gui' }])
})
it('stops after the resume alone brings the listener back', async () => {
const calls: string[] = []
const request = requester(method => {
calls.push(method)
if (method === 'wake.resume') {
return { resumed: true }
}
return { available: true, enabled: true, listening: true, owned_by_caller: true }
})
await resumeWakeAfterVoice(request)
expect(calls).toEqual(['wake.resume', 'wake.status'])
expect($wakeWord.get()).toMatchObject({ listening: true })
})
it('leaves the listener off when config says disabled', async () => {
const calls: string[] = []
const request = requester(method => {
calls.push(method)
if (method === 'wake.resume') {
return { resumed: false }
}
return { available: true, enabled: false, listening: false }
})
await resumeWakeAfterVoice(request)
expect(calls).toEqual(['wake.resume', 'wake.status'])
expect($wakeWord.get().listening).toBe(false)
})
it('yields when another surface owns the mic lease', async () => {
const calls: string[] = []
const request = requester(method => {
calls.push(method)
if (method === 'wake.resume') {
return { resumed: false }
}
if (method === 'wake.status') {
return { available: true, enabled: true, listening: false, owner_surface: 'tui' }
}
return { owner_surface: 'tui', reason: 'owned', started: false }
})
await resumeWakeAfterVoice(request)
expect(calls).toEqual(['wake.resume', 'wake.status', 'wake.start'])
expect($wakeWord.get().listening).toBe(false)
})
it('is a no-op against older backends without wake.* methods', async () => {
const request = requester(() => {
throw new Error('Unknown method: wake.resume')
})
await resumeWakeAfterVoice(request)
expect($wakeWord.get()).toMatchObject({ available: false, listening: false })
})
})

View file

@ -0,0 +1,290 @@
import { atom } from 'nanostores'
import { $gateway } from '@/store/gateway'
// "Hey Hermes" wake-word listener state for the composer toggle. The gateway is
// the single source of truth (the listener lives in the backend and is shared
// with the TUI under a single-owner mic lease); this atom is the renderer's
// cache of that truth, refreshed from every wake.* RPC response we see.
export interface WakeWordState {
/** Wake word can run at all (deps + mic + key). With `enabled` false too, hides the toggle. */
available: boolean
/** Config truth (wake_word.enabled) — keeps the ear mounted through transient refusals. */
enabled: boolean
/** The listener is armed and owned by this surface. */
listening: boolean
/** Last failure reason/hint (start refused, unavailable, …) for the tooltip. */
notice: string
/** A toggle RPC is in flight — guards double-clicks. */
pending: boolean
/** Human-facing wake phrase, e.g. "hey hermes". */
phrase: string
}
const INITIAL_WAKE_WORD_STATE: WakeWordState = {
available: false,
enabled: false,
listening: false,
notice: '',
pending: false,
phrase: ''
}
export const $wakeWord = atom<WakeWordState>(INITIAL_WAKE_WORD_STATE)
export interface WakeStatusResponse {
/** Armed but the mic delivers only silence (macOS backend-permission gap). */
audio_silent?: boolean
available?: boolean
/** Config truth (wake_word.enabled) — drives post-voice re-arm. */
enabled?: boolean
hint?: string
listening?: boolean
owned_by_caller?: boolean
owner_surface?: string | null
phrase?: string
provider?: string
}
export interface WakeStartResponse {
enabled_persisted?: boolean
hint?: string
owner_surface?: string | null
phrase?: string
provider?: string
reason?: string
started?: boolean
}
export interface WakeStopResponse {
disabled_persisted?: boolean
reason?: string | null
stopped?: boolean
}
/** Minimal requester shape satisfied by both `useGatewayRequest`'s
* `requestGateway` and the `$gateway` instance wrapper below. */
export type WakeRequester = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
// First-use wake.start lazy-installs the detection engine (onnxruntime is a
// large wheel) — that legitimately takes minutes. The default 30s WS timeout
// fired mid-install, leaving a dead button that went blue on its own later.
const WAKE_START_TIMEOUT_MS = 180_000
const gatewayRequester: WakeRequester = async <T>(method: string, params: Record<string, unknown> = {}) => {
const gateway = $gateway.get()
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return method === 'wake.start'
? gateway.request<T>(method, params, WAKE_START_TIMEOUT_MS)
: gateway.request<T>(method, params)
}
// 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 {
const current = $wakeWord.get()
const listening = Boolean(status?.listening)
// "Armed but deaf" (macOS backend without mic permission) keeps its hint
// visible in the tooltip even though the toggle shows listening.
const silent = Boolean(status?.audio_silent)
$wakeWord.set({
...current,
available: Boolean(status?.available),
enabled: Boolean(status?.enabled),
listening,
notice: listening && !silent ? '' : noticeFrom(status),
phrase: status?.phrase?.trim() || current.phrase
})
}
/** Sync the atom from a `wake.start` response. A `{started:false, reason}`
* refusal keeps the toggle off and surfaces the reason as the tooltip. */
export function applyWakeStartResult(result: WakeStartResponse | null | undefined): void {
const current = $wakeWord.get()
if (result?.started) {
$wakeWord.set({
...current,
available: true,
enabled: true,
listening: true,
notice: '',
pending: false,
phrase: result.phrase?.trim() || current.phrase
})
return
}
$wakeWord.set({
...current,
// The backend probes requirements on start; an explicit "unavailable"
// refusal means the feature can't run here right now. Keep `enabled`
// (config truth) as-is so the button stays mounted through transient
// refusals instead of vanishing mid-session.
available: result?.reason === 'unavailable' ? false : current.available,
listening: false,
notice: noticeFrom(result),
pending: false
})
}
/** Sync the atom from a `wake.stop` response. `{stopped:false, reason:'not_owner'}`
* still means WE are not listening, so the toggle lands on off either way. */
export function applyWakeStopResult(result: WakeStopResponse | null | undefined): void {
const current = $wakeWord.get()
$wakeWord.set({
...current,
enabled: result?.disabled_persisted ? false : current.enabled,
listening: false,
notice: result?.stopped ? '' : noticeFrom(result),
pending: false
})
}
/**
* Gateway-ready sync + auto-arm (wiring.tsx). Queries `wake.status` first so
* the button knows availability/phrase even when arming is refused, then arms
* the listener for this surface exactly like the historical auto-arm did.
* Best-effort: a gateway without the wake.* methods leaves the atom at its
* hidden default.
*/
export async function armWakeWord(request: WakeRequester = gatewayRequester): Promise<void> {
try {
const status = await request<WakeStatusResponse>('wake.status', {})
applyWakeStatus(status)
if (!status?.available || status.listening) {
return
}
const result = await request<WakeStartResponse>('wake.start', { surface: 'gui' })
applyWakeStartResult(result)
} catch {
// Older backends / transient failures — keep whatever we last knew.
}
}
/** The composer button's click handler: stop when listening, start otherwise. */
export async function toggleWakeWord(request: WakeRequester = gatewayRequester): Promise<void> {
const state = $wakeWord.get()
if (state.pending) {
return
}
$wakeWord.set({
...state,
// First arm may lazy-install the detection engine — say so instead of
// freezing a silent disabled button for the duration.
notice: state.listening ? '' : 'arming — first use may take a minute while the engine installs',
pending: true
})
try {
if (state.listening) {
applyWakeStopResult(await request<WakeStopResponse>('wake.stop', { persist: true }))
} else {
// 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()
$wakeWord.set({
...current,
notice: error instanceof Error ? error.message : String(error),
pending: false
})
}
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
/**
* Post-voice-turn reconcile: the wake word is a persistent setting, so ending a
* voice conversation must land the listener back where config says it belongs.
* `wake.resume` alone isn't enough the mic can still be held by the just-torn
* -down WebRTC capture, and a fire-and-forget resume that loses that race left
* the ear silently off until the user re-toggled. Resume, then verify against
* `wake.status` (config `enabled` is the authority) and re-arm, with a couple
* of spaced retries to ride out mic-release latency. Never passes `persist`
* this is a passive path and must not flip config.
*/
export async function resumeWakeAfterVoice(request: WakeRequester = gatewayRequester): Promise<void> {
try {
await request('wake.resume', {})
} catch {
// Older backend without wake.* — nothing to reconcile.
return
}
for (let attempt = 0; attempt < 3; attempt++) {
try {
const status = await request<WakeStatusResponse>('wake.status', {})
applyWakeStatus(status)
// Config says off (or the feature can't run) — off is the correct rest
// state. A user /wake off during the voice turn stays respected.
if (!status?.enabled || !status.available) {
return
}
if (status.listening) {
return
}
const started = await request<WakeStartResponse>('wake.start', { surface: 'gui' })
applyWakeStartResult(started)
if (started?.started) {
return
}
// Another surface holds the mic lease — theirs to keep.
if (started?.reason === 'owned') {
return
}
} catch {
// Transient (mic still releasing) — fall through to the next attempt.
}
await sleep(1500)
}
}
/** Test-only reset. */
export function resetWakeWordState(): void {
$wakeWord.set(INITIAL_WAKE_WORD_STATE)
}

263
cli.py
View file

@ -984,6 +984,7 @@ def _cleanup_all_browsers(*args, **kwargs):
# Guard to prevent cleanup from running multiple times on exit
_cleanup_done = False
_cli_wake_owner = None
# One-shot CLI finalization runs before process cleanup so plugins can observe
# the session boundary while the agent is still attached. If a signal lands in
# that narrow window, atexit cleanup must not emit that session finalize again.
@ -1177,6 +1178,12 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
# can't skip the reset (#36823). No-op unless the TUI actually ran.
_reset_terminal_input_modes_on_exit()
try:
from tools.wake_word import stop_listening as _stop_wake_word
if _cli_wake_owner is not None:
_stop_wake_word(owner=_cli_wake_owner)
except Exception:
pass
try:
_cleanup_all_terminals()
except Exception:
@ -4016,10 +4023,20 @@ def save_config_value(key_path: str, value: any) -> bool:
Returns:
True if successful, False otherwise
"""
# Use the same precedence as load_cli_config: user config first, then project config
user_config_path = _hermes_home / 'config.yaml'
project_config_path = Path(__file__).parent / 'cli-config.yaml'
config_path = user_config_path if user_config_path.exists() else project_config_path
# Runtime persistence ALWAYS targets the user's HERMES_HOME config.yaml,
# creating it if needed. Resolve HERMES_HOME live (not the import-time
# _hermes_home constant) so profile switches and test isolation land right.
#
# We deliberately do NOT fall back to the repo's project cli-config.yaml:
# that file is a shipped default/template, and most config readers
# (load_config → get_hermes_home()/config.yaml, including
# load_wake_word_config) never read it. Writing a user setting there means
# the reader never sees it. This was the "wake-word ear reverts to disabled
# after restart" bug — the toggle's persist wrote to cli-config.yaml (which
# exists in the checkout) while startup read HERMES_HOME/config.yaml, so the
# setting silently vanished every restart on any install whose
# HERMES_HOME/config.yaml didn't exist yet.
config_path = get_hermes_home() / 'config.yaml'
try:
# Ensure parent directory exists (for ~/.hermes/config.yaml on first use)
@ -9947,6 +9964,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._handle_skin_command(cmd_original)
elif canonical == "voice":
self._handle_voice_command(cmd_original)
elif canonical == "wake":
self._handle_wake_command(cmd_original)
elif canonical == "busy":
self._handle_busy_command(cmd_original)
else:
@ -12262,6 +12281,230 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f"\n{_DIM}Voice mode disabled.{_RST}")
# ── Wake word ("Hey Hermes") ─────────────────────────────────────────
#
# An always-on hotword listener (tools/wake_word.py) that, on detecting
# the wake phrase, starts a fresh session and captures one utterance via
# the existing voice pipeline — the "Hey Siri" pattern, fully on-device.
#
# The detector holds the microphone, so it must be paused while a voice
# turn records (two input streams on one device is unreliable). On wake we
# pause it and mark the system suspended; a lightweight watchdog resumes it
# once the turn finishes and the CLI is idle again — covering every exit
# path (transcript submitted, no speech, or transcription error) without
# threading resume logic through the voice machinery.
def _maybe_start_wake_word(self):
"""Start the wake-word listener at CLI startup if this surface is eligible."""
try:
from tools.wake_word import wake_surface_enabled
if not wake_surface_enabled("cli"):
return
except Exception:
return
self._start_wake_word_listener(announce=True)
def _start_wake_word_listener(self, announce: bool = False) -> bool:
"""Build + start the hotword detector. Returns True on success."""
try:
from tools.wake_word import (
check_wake_word_requirements,
load_wake_word_config,
owns_listener,
start_listening,
)
except Exception as e:
if announce:
_cprint(f"{_DIM}Wake word unavailable: {e}{_RST}")
return False
if getattr(self, "_wake_word_active", False) and owns_listener(self):
if announce:
_cprint(f"{_DIM}Wake word is already listening.{_RST}")
return True
self._wake_word_active = False
cfg = load_wake_word_config()
reqs = check_wake_word_requirements(cfg)
if not reqs["available"]:
if announce:
_cprint(f"\n{_ACCENT}Wake word requirements not met:{_RST}")
if reqs.get("hint"):
_cprint(f" {_DIM}{reqs['hint']}{_RST}")
return False
if announce and not reqs.get("deps_available", True):
# Fresh install: the engine constructor lazy-installs its deps
# (onnxruntime is a large wheel) — tell the user why this is slow.
_cprint(f"{_DIM}Installing wake word engine (first use — this may take a minute)...{_RST}")
self._wake_start_new_session = bool(cfg.get("start_new_session", True))
try:
start_listening(self._on_wake_word, owner=self, config=cfg)
except Exception as e:
if announce:
_cprint(f"\n{_DIM}Failed to start wake word: {e}{_RST}")
return False
self._wake_word_active = True
self._wake_suspended = False
global _cli_wake_owner
_cli_wake_owner = self
self._start_wake_watchdog()
if announce:
_cprint(f"\n{_ACCENT}Wake word listening{_RST} "
f"{_DIM}(say \"{reqs['phrase']}\" — /wake off to stop){_RST}")
return True
def _stop_wake_word_listener(self, announce: bool = False):
"""Stop and tear down the hotword detector."""
global _cli_wake_owner
was_active = getattr(self, "_wake_word_active", False)
self._wake_word_active = False
self._wake_suspended = False
try:
from tools.wake_word import stop_listening
stop_listening(owner=self)
except Exception:
pass
if _cli_wake_owner is self:
_cli_wake_owner = None
if announce:
if was_active:
_cprint(f"{_DIM}Wake word stopped.{_RST}")
else:
_cprint(f"{_DIM}Wake word is not running.{_RST}")
def _on_wake_word(self):
"""Fired after the detector hears the wake phrase."""
if getattr(self, "_should_exit", False):
return
# Ignore wake while a turn is in flight or the mic is already in use.
if self._agent_running or self._voice_recording or getattr(self, "_voice_processing", False):
return
# Release the mic so STT can capture the command utterance.
try:
from tools.wake_word import pause_listening
if not pause_listening(owner=self):
self._wake_word_active = False
return
except Exception as e:
logger.debug("wake word pause failed: %s", e)
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:
self._app.invalidate()
except Exception:
pass
if getattr(self, "_wake_start_new_session", True):
try:
self.new_session(silent=True)
except Exception as e:
logger.debug("wake word new_session failed: %s", e)
# Single-utterance capture (not continuous) via the voice pipeline;
# VAD auto-stop transcribes and queues the transcript for process_loop.
with self._voice_lock:
self._voice_mode = True
self._voice_continuous = False
try:
self._voice_start_recording()
except Exception as e:
_cprint(f"{_DIM}Wake capture failed: {e}{_RST}")
# Leave _wake_suspended set; the watchdog resumes once idle.
def _start_wake_watchdog(self):
"""Resume the paused detector when the CLI returns to a stable idle."""
if getattr(self, "_wake_watchdog_started", False):
return
self._wake_watchdog_started = True
def _loop():
idle_polls = 0
try:
while getattr(self, "_wake_word_active", False) and not getattr(self, "_should_exit", False):
time.sleep(0.25)
if not getattr(self, "_wake_suspended", False):
idle_polls = 0
continue
busy = (
self._agent_running
or self._voice_recording
or getattr(self, "_voice_processing", False)
or not self._pending_input.empty()
)
if busy:
idle_polls = 0
continue
# Require a few consecutive idle polls (~0.75s) so we don't
# resume in the gap between VAD stop and the agent starting.
idle_polls += 1
if idle_polls >= 3:
idle_polls = 0
try:
from tools.wake_word import resume_listening
if resume_listening(owner=self):
self._wake_suspended = False
else:
self._wake_word_active = False
except Exception as e:
logger.debug("wake word resume failed: %s", e)
finally:
self._wake_watchdog_started = False
threading.Thread(target=_loop, daemon=True, name="wake-watchdog").start()
def _show_wake_word_status(self):
"""Show current wake-word listener status."""
from tools.wake_word import (
audio_is_silent,
check_wake_word_requirements,
is_listening,
load_wake_word_config,
owns_listener,
)
cfg = load_wake_word_config()
reqs = check_wake_word_requirements(cfg)
owned = owns_listener(self)
state = "LISTENING" if owned and is_listening() else "PAUSED" if owned else "OFF"
_cprint(f"\n{_BOLD}Wake Word Status{_RST}")
_cprint(f" State: {state}")
_cprint(f" Phrase: \"{reqs['phrase']}\"")
_cprint(f" Provider: {reqs['provider']}")
_cprint(f" Surface: {cfg.get('surface', 'auto')}")
_cprint(f" New session: {'yes' if cfg.get('start_new_session', True) else 'no'}")
if state == "LISTENING" and audio_is_silent():
_cprint(f" {_ACCENT}⚠ Microphone delivers only silence — the listener can't hear anything.{_RST}")
_cprint(f" {_DIM}On macOS: System Settings > Privacy & Security > Microphone — allow your"
f" terminal/Hermes, then /wake off + /wake on.{_RST}")
if not reqs["available"] and reqs.get("hint"):
_cprint(f" {_DIM}{reqs['hint']}{_RST}")
if not owned:
_cprint(f" {_DIM}Enable with /wake on{_RST}")
def _toggle_voice_tts(self):
"""Toggle TTS output for voice mode."""
if not self._voice_mode:
@ -16435,7 +16678,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Start processing thread
process_thread = threading.Thread(target=process_loop, daemon=True)
process_thread.start()
# Wake word ("Hey Hermes") — start the always-on hotword listener if
# enabled. Off-thread so a first-run engine install never blocks the
# prompt; best-effort, so deps/mic/key gaps are surfaced, never fatal.
def _wake_startup():
try:
self._maybe_start_wake_word()
except Exception as e:
logger.debug("wake-word startup skipped: %s", e)
threading.Thread(target=_wake_startup, daemon=True, name="wake-startup").start()
# Register atexit cleanup so resources are freed even on unexpected exit
atexit.register(_run_cleanup)

View file

@ -0,0 +1 @@
omid3098

View file

@ -3189,3 +3189,49 @@ class CLICommandsMixin:
else:
_cprint(f"Unknown voice subcommand: {subcommand}")
_cprint("Usage: /voice [on|off|tts|status]")
def _handle_wake_command(self, command: str):
"""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":
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)
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

@ -181,6 +181,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
subcommands=("kaomoji", "emoji", "unicode", "ascii")),
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
CommandDef("wake", "Toggle the 'Hey Hermes' wake word listener", "Configuration",
cli_only=True, args_hint="[on|off|status]",
subcommands=("on", "off", "status")),
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
cli_only=True, args_hint="[queue|steer|interrupt|status]",
subcommands=("queue", "steer", "interrupt", "status")),

View file

@ -2374,6 +2374,42 @@ DEFAULT_CONFIG = {
# surrounding punctuation ignored. Set [] to disable.
"stop_phrases": ["stop"],
},
# "Hey Hermes" hands-free wake word. Always-on, on-device hotword
# detection that starts a fresh voice session — the "Hey Siri" pattern.
# Off by default; toggle with /wake or `wake_word.enabled: true`.
"wake_word": {
"enabled": False,
"surface": "auto", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui"
"provider": "openwakeword", # "openwakeword" (free, local) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY)
"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.6, # 0.0-1.0 detection threshold, consistent across engines (higher = stricter, fewer false triggers)
"confirmation_frames": 3, # openWakeWord only: consecutive over-threshold frames required to fire (higher = fewer false triggers on ambient speech, slightly more latency; 1 = old single-frame behavior)
"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",
# ...) OR a path to a custom .onnx/.tflite model for another phrase.
# See the wake-word docs for the custom-model training guide.
"model": "hey_hermes",
# "" (auto — tflite on macOS ARM64, onnx elsewhere) | "onnx" | "tflite".
# openWakeWord's onnx backend scores near-zero on macOS ARM64
# (dscripka/openWakeWord#336), so auto avoids a listener that arms
# but never fires. Set explicitly only to override that choice.
"inference_framework": "",
},
"sherpa": {
# Optional path to a sherpa-onnx KWS model directory. Empty =
# auto-download the small English zipformer model on first use.
"model_dir": "",
},
"porcupine": {
# Built-in keyword ("jarvis", "computer", "bumblebee", ...) or a path
# to a custom .ppn from the Picovoice Console.
"keyword": "jarvis",
},
},
"human_delay": {
"mode": "off",
@ -4447,6 +4483,13 @@ OPTIONAL_ENV_VARS = {
"password": True,
"category": "tool",
},
"PORCUPINE_ACCESS_KEY": {
"description": "Picovoice access key for the Porcupine 'Hey Hermes' wake word engine (optional; openWakeWord is the free default)",
"prompt": "Picovoice access key",
"url": "https://console.picovoice.ai/",
"password": True,
"category": "tool",
},
"GITHUB_TOKEN": {
"description": "GitHub token for Skills Hub (higher API rate limits, skill publish)",
"prompt": "GitHub Token",

View file

@ -4454,10 +4454,15 @@ async def transcribe_audio_upload(
pass
if not result.get("success"):
raise HTTPException(
status_code=400,
detail=result.get("error") or "Transcription failed",
)
err = result.get("error") or "Transcription failed"
# An empty transcript means no speech was detected — a normal outcome
# for VAD/continuous voice loops (e.g. a wake-word conversation
# re-listening on silence), not an error. Return an empty transcript so
# the client quietly re-listens instead of surfacing a "transcription
# failed" toast on every silent gap.
if "empty transcript" in err.lower():
return {"ok": True, "transcript": "", "provider": result.get("provider")}
raise HTTPException(status_code=400, detail=err)
return {
"ok": True,

View file

@ -180,6 +180,26 @@ voice = [
"sounddevice==0.5.5",
"numpy==2.4.3",
]
# "Hey Hermes" wake word — on-device hotword detection. All engines are
# optional; openWakeWord (ONNX) is the free default, sherpa-onnx adds
# open-vocabulary phrases (any typed phrase, zero training), Porcupine is
# the premium alternative. Desktop installs ([--include-desktop]) eager-install
# [wake]+[voice] so the ear works instantly; CLI-only installs lazy-install on
# first /wake; mirrored in tools/lazy_deps.py.
wake = [
"openwakeword==0.6.0",
"onnxruntime==1.27.0",
"sherpa-onnx==1.13.4",
"sentencepiece==0.2.2",
"pvporcupine==4.0.3",
"sounddevice==0.5.5",
"numpy==2.4.3",
# openWakeWord's onnx embedding model scores near-zero on macOS ARM64
# (dscripka/openWakeWord#336), so the wake word runs on tflite there.
# Upstream declares tflite-runtime for Linux only; ai-edge-litert is the
# macOS equivalent, bridged in tools/wake_word.py.
"ai-edge-litert==2.1.6; platform_system == 'Darwin'",
]
honcho = ["honcho-ai==2.2.0"]
# Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py
# (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the

View file

@ -2813,6 +2813,34 @@ function Try-RestoreElectronDist {
return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror
}
function Install-DesktopVoiceDeps {
# Desktop ships with working voice out of the box: eagerly install the
# wake-word + local-STT stacks ([wake] + [voice] extras) instead of
# leaving them to lazy first-use install. Policy change (Teknium, July
# 2026, #70509 testing): the first ear-click used to trigger a
# multi-minute onnxruntime pip install that froze the UI and blew RPC
# timeouts. Best-effort -- lazy install remains the fallback for anything
# this step fails to fetch.
if (-not $script:UvCmd) { Resolve-UvCmd }
if (-not $script:UvCmd) {
Write-Warn "uv unavailable -- voice/wake deps will lazy-install at first use instead"
return
}
$env:VIRTUAL_ENV = "$InstallDir\venv"
Write-Info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper -- 1-3min)..."
Push-Location $InstallDir
try {
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e ".[wake,voice]" }
if ($LASTEXITCODE -eq 0) {
Write-Success "Voice + wake-word dependencies installed"
} else {
Write-Warn "Voice/wake dependency install failed (exit $LASTEXITCODE) -- they will lazy-install at first use"
}
} finally {
Pop-Location
}
}
function Install-Desktop {
# Build apps/desktop into a launchable Hermes.exe. Only called from
# Stage-Desktop, which is itself only included in the manifest when
@ -3577,7 +3605,7 @@ function Stage-Repository { Install-Repository }
function Stage-Venv { Resolve-UvCmd; Install-Venv }
function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies }
function Stage-NodeDeps { Install-NodeDeps }
function Stage-Desktop { Install-Desktop }
function Stage-Desktop { Install-DesktopVoiceDeps; Install-Desktop }
function Stage-Path { Set-PathVariable }
function Stage-ConfigTemplates { Copy-ConfigTemplates }
function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks }

View file

@ -2817,6 +2817,37 @@ _restore_electron_dist_with_fallback() {
# (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked
# via the 'desktop' stage / --include-desktop, which the Electron app's own
# first-launch bootstrap never requests (it must not rebuild itself).
install_desktop_voice_deps() {
# Desktop ships with working voice out of the box: eagerly install the
# wake-word + local-STT stacks ([wake] + [voice] extras) instead of
# leaving them to lazy first-use install. Policy change (Teknium, July
# 2026, #70509 testing): the first ear-click used to trigger a
# multi-minute onnxruntime pip install that froze the UI and blew RPC
# timeouts. Lazy install remains the fallback for CLI-only installs and
# for anything this best-effort step fails to fetch.
local _prev_venv="${VIRTUAL_ENV:-}"
if [ "$USE_VENV" = true ]; then
export VIRTUAL_ENV="$INSTALL_DIR/venv"
fi
if [ -z "${UV_CMD:-}" ]; then
install_uv || true
fi
if [ -z "${UV_CMD:-}" ]; then
log_warn "uv unavailable — voice/wake deps will lazy-install at first use instead"
return 0
fi
log_info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper — 1-3min)..."
if (cd "$INSTALL_DIR" && $UV_CMD pip install -e ".[wake,voice]") ; then
log_success "Voice + wake-word dependencies installed"
else
log_warn "Voice/wake dependency install failed — they will lazy-install at first use"
fi
if [ "$USE_VENV" = true ] && [ -z "$_prev_venv" ]; then
unset VIRTUAL_ENV
fi
return 0
}
install_desktop() {
local desktop_dir="$INSTALL_DIR/apps/desktop"
@ -3124,6 +3155,7 @@ run_stage_body() {
# isn't on PATH here. check_node re-adds it (or installs if missing)
# so install_desktop can find npm instead of silently skipping.
check_node
install_desktop_voice_deps
install_desktop
;;
complete)
@ -3210,6 +3242,7 @@ main() {
maybe_start_gateway
if [ "$INCLUDE_DESKTOP" = true ]; then
install_desktop_voice_deps
install_desktop
fi

View file

@ -1,8 +1,10 @@
"""Tests for save_config_value() in cli.py — atomic write behavior."""
import yaml
from pathlib import Path
from unittest.mock import MagicMock
import yaml
import pytest
@ -19,6 +21,10 @@ class TestSaveConfigValueAtomic:
"model": {"default": "test-model", "provider": "openrouter"},
"display": {"skin": "default"},
}))
# save_config_value resolves the target live via get_hermes_home(), so
# point HERMES_HOME at the temp dir (the _hermes_home import-time
# constant is no longer consulted).
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr("cli._hermes_home", hermes_home)
return config_path
@ -144,3 +150,51 @@ class TestSaveConfigValueAtomic:
assert result is False
assert config_env.read_text() == original_content
class TestSaveConfigValueTargetsUserConfig:
"""Regression: persisted runtime settings must land in HERMES_HOME/config.yaml
(which config readers actually read), never the repo's cli-config.yaml.
This was the "wake-word ear reverts to disabled after restart" bug: on an
install whose HERMES_HOME/config.yaml did not exist yet, save_config_value
fell back to the checked-in cli-config.yaml. The toggle reported success, but
startup read HERMES_HOME/config.yaml and never saw the setting."""
def test_creates_user_config_when_absent(self, tmp_path, monkeypatch):
# Fresh HERMES_HOME with NO config.yaml (managed/desktop first launch).
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from cli import save_config_value
assert save_config_value("wake_word.enabled", True) is True
config_path = hermes_home / "config.yaml"
assert config_path.exists(), "user config.yaml must be created, not skipped"
result = yaml.safe_load(config_path.read_text())
assert result["wake_word"]["enabled"] is True
def test_does_not_write_repo_cli_config(self, tmp_path, monkeypatch):
# Even when the repo's cli-config.yaml exists, the write goes to the
# user config, so a runtime setting is never buried in the shipped file.
import cli as cli_module
repo_cli_config = Path(cli_module.__file__).parent / "cli-config.yaml"
before = repo_cli_config.read_text() if repo_cli_config.exists() else None
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from cli import save_config_value
save_config_value("wake_word.enabled", True)
# The repo template is untouched…
after = repo_cli_config.read_text() if repo_cli_config.exists() else None
assert after == before
# …and the value landed in the user config.
result = yaml.safe_load((hermes_home / "config.yaml").read_text())
assert result["wake_word"]["enabled"] is True

View file

@ -1284,6 +1284,7 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
assert captured["silence_duration"] == 3.0
assert captured["auto_restart"] is False
# Round-12 Copilot review regression on #19835: ``bool`` is a subclass
# of ``int``, so the naive ``isinstance(threshold, (int, float))``
# guard would forward ``silence_threshold: true`` as ``1`` instead
@ -1314,6 +1315,264 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
assert captured["auto_restart"] is False
def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatch):
from tools import wake_word
state = {"owner": None, "callback": None, "paused": False}
voice_callbacks = {}
def start_listening(callback, *, owner, config):
if state["owner"] is not None and state["owner"] is not owner:
raise wake_word.WakeWordInUse
state.update(owner=owner, callback=callback, paused=False)
def pause_listening(*, owner):
if state["owner"] is not owner:
return False
state["paused"] = True
return True
def stop_listening(*, owner):
if state["owner"] is not owner:
return False
state.update(owner=None, callback=None, paused=False)
return True
def resume_listening(*, owner):
if state["owner"] is not owner:
return False
state["paused"] = False
return True
def start_continuous(**callbacks):
voice_callbacks.update(callbacks)
return True
monkeypatch.setattr(wake_word, "load_wake_word_config", lambda: {
"enabled": True,
"phrase": "hey hermes",
"surface": "auto",
"start_new_session": True,
})
monkeypatch.setattr(wake_word, "check_wake_word_requirements", lambda _cfg: {
"available": True,
"phrase": "hey hermes",
"provider": "test",
"hint": "",
})
monkeypatch.setattr(wake_word, "start_listening", start_listening)
monkeypatch.setattr(wake_word, "pause_listening", pause_listening)
monkeypatch.setattr(wake_word, "stop_listening", stop_listening)
monkeypatch.setattr(wake_word, "owns_listener", lambda owner: state["owner"] is owner)
monkeypatch.setattr(
wake_word,
"is_listening",
lambda: state["owner"] is not None and not state["paused"],
)
monkeypatch.setattr(
wake_word,
"resume_listening",
resume_listening,
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.voice",
types.SimpleNamespace(
start_continuous=start_continuous,
stop_continuous=lambda **_kwargs: None,
),
)
monkeypatch.setenv("HERMES_VOICE", "1")
first = types.SimpleNamespace(_closed=False)
second = types.SimpleNamespace(_closed=False)
emitted = []
monkeypatch.setattr(
server,
"_emit",
lambda event, sid, payload: emitted.append(
(event, sid, payload, server.current_transport())
),
)
server._wake_owner_transport = None
server._wake_owner_surface = ""
try:
started = server.dispatch({
"id": "wake-1",
"method": "wake.start",
"params": {"surface": "gui", "session_id": "first-session"},
}, transport=first)
denied = server.dispatch({
"id": "wake-2",
"method": "wake.start",
"params": {"surface": "tui", "session_id": "second-session"},
}, transport=second)
denied_stop = server.dispatch({
"id": "wake-stop-2",
"method": "wake.stop",
"params": {},
}, transport=second)
denied_voice_stop = server.dispatch({
"id": "voice-stop-2",
"method": "voice.record",
"params": {"action": "stop"},
}, transport=second)
assert started["result"]["started"] is True
assert denied["result"] == {
"started": False,
"reason": "owned",
"owner_surface": "gui",
}
assert denied_stop["result"] == {
"stopped": False,
"reason": "not_owner",
"disabled_persisted": False,
}
assert denied_voice_stop["result"] == {
"status": "busy",
"reason": "wake_owned",
}
state["callback"]()
assert emitted == [(
"wake.detected",
"first-session",
{"phrase": "hey hermes", "profile": None, "start_new_session": True},
first,
)]
assert state["paused"] is True
voice_started = server.dispatch({
"id": "voice-start-1",
"method": "voice.record",
"params": {"action": "start", "session_id": "first-session"},
}, transport=first)
assert voice_started["result"]["status"] == "recording"
voice_callbacks["on_status"]("idle")
assert state["paused"] is False
stopped = server.dispatch({
"id": "wake-stop-1",
"method": "wake.stop",
"params": {},
}, transport=first)
assert stopped["result"] == {
"stopped": True,
"reason": None,
"disabled_persisted": False,
}
reclaimed = server.dispatch({
"id": "wake-reclaim-2",
"method": "wake.start",
"params": {"surface": "tui", "session_id": "second-session"},
}, transport=second)
assert reclaimed["result"]["started"] is True
assert state["owner"] is second
state["callback"]()
assert emitted[-1] == (
"wake.detected",
"second-session",
{"phrase": "hey hermes", "profile": None, "start_new_session": True},
second,
)
stopped_again = server.dispatch({
"id": "wake-stop-2-after-reclaim",
"method": "wake.stop",
"params": {},
}, transport=second)
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 = ""
def test_voice_record_start_forwards_max_recording_seconds(monkeypatch):
"""voice.max_recording_seconds must reach start_continuous from the TUI.
@ -13802,9 +14061,9 @@ def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch
"agent:\n"
" system_prompt: keepme\n"
)
# save_config_value() resolves the config path from cli._hermes_home, which
# is captured at import time — patch it directly (set_hermes_home_override
# does NOT affect this snapshot).
# save_config_value() resolves the config path from get_hermes_home() (live
# env var), always targeting HERMES_HOME/config.yaml — point it at tmp_path.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(cli, "_hermes_home", tmp_path)
result = types.SimpleNamespace(
@ -13837,6 +14096,7 @@ def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch):
" provider: custom:mylocal\n"
" base_url: http://localhost:1234/v1\n"
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(cli, "_hermes_home", tmp_path)
# Switch to a native provider with no base_url.

View file

@ -152,6 +152,20 @@ def test_ws_connection_registers_then_disconnect_unregisters_live_transport(monk
server._live_transports.clear()
def test_ws_disconnect_releases_wake_word_owner(monkeypatch):
released = []
created = []
monkeypatch.setattr(
server,
"_release_wake_for_transport",
lambda transport: released.append(transport) or True,
)
_run_disconnect(monkeypatch, lambda transport: created.append(transport))
assert released == created
def test_ws_write_loop_stall_does_not_latch_transport(monkeypatch):
"""A write that times out because the event loop is stalled (GIL-heavy
agent turn) must NOT latch the transport closed the frame is already

File diff suppressed because it is too large Load diff

View file

@ -140,6 +140,40 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
# small silk-v3 codec binding; installed on first .silk transcription.
"stt.silk": ("pilk==0.2.4",),
# ─── Wake word ("Hey Hermes") engines ──────────────────────────────────
# Keep in sync with the `wake` extra in pyproject.toml. openWakeWord is the
# free, local default (ONNX runtime); Porcupine is the premium engine.
# openWakeWord's ONNX embedding model returns near-zero scores on macOS
# ARM64 (dscripka/openWakeWord#336), so the wake word runs on the tflite
# backend there. Upstream declares tflite-runtime for Linux only;
# ai-edge-litert is the macOS equivalent, bridged in tools/wake_word.py.
# It lives in its own feature because lazy-dep specs cannot carry PEP 508
# environment markers (_spec_is_safe rejects ";"), so the platform gate is
# applied by the caller instead.
"wake.openwakeword.tflite": (
"ai-edge-litert==2.1.6",
),
"wake.openwakeword": (
"openwakeword==0.6.0",
"onnxruntime==1.27.0",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
# Open-vocabulary keyword spotting: any typed phrase, zero training.
# sentencepiece is required by sherpa_onnx.text2token (runtime phrase
# tokenization) even though sherpa-onnx doesn't declare it.
"wake.sherpa": (
"sherpa-onnx==1.13.4",
"sentencepiece==0.2.2",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
"wake.porcupine": (
"pvporcupine==4.0.3",
"sounddevice==0.5.5",
"numpy==2.4.3",
),
# ─── Image generation backends ─────────────────────────────────────────
"image.fal": ("fal-client==0.13.1",),

1122
tools/wake_word.py Normal file

File diff suppressed because it is too large Load diff

20
tools/wakewords/README.md Normal file
View file

@ -0,0 +1,20 @@
# Bundled wake-word models
`hey_hermes.onnx` / `hey_hermes.tflite` — the on-device "Hey Hermes" hotword
model. This is the default detector for the wake word feature (see
`website/docs/user-guide/features/wake-word.md`); no training or setup is
required to say "hey hermes".
- **Engine:** [openWakeWord](https://github.com/dscripka/openWakeWord) (Apache-2.0).
- **Provenance:** trained with the openWakeWord training pipeline (synthetic
TTS-generated speech), which produces both the `.onnx` and `.tflite` artifacts.
Redistribution is permitted under the openWakeWord license.
- **Label:** the model registers as `hey_hermes` (matches the filename).
- **Runtime:** openWakeWord's shared feature-extraction models (melspectrogram +
embedding) are NOT bundled here — they are fetched once on first use by
`tools/wake_word.py` via `openwakeword.utils.download_models()`.
To use a different phrase, train your own model and point
`wake_word.openwakeword.model` at its path, or set a built-in openWakeWord name
(`hey_jarvis`, `alexa`, `hey_mycroft`, …). See the wake-word docs for the
training guide.

Binary file not shown.

Binary file not shown.

View file

@ -958,6 +958,10 @@ def _close_sessions_for_transport(
def _shutdown_sessions() -> None:
try:
_release_gateway_wake_owner()
except Exception:
pass
with _sessions_lock:
sids = list(_sessions)
for sid in sids:
@ -17628,6 +17632,7 @@ def _(rid, params: dict) -> dict:
_voice_sid_lock = threading.Lock()
_voice_event_sid: str = ""
_voice_wake_owner: "Optional[Transport]" = None
def _voice_emit(event: str, payload: dict | None = None) -> None:
@ -17641,6 +17646,14 @@ def _voice_emit(event: str, payload: dict | None = None) -> None:
_emit(event, sid, payload)
def _resume_voice_wake() -> None:
global _voice_wake_owner
with _voice_sid_lock:
owner, _voice_wake_owner = _voice_wake_owner, None
if owner is not None:
_wake_resume_if_owner(owner)
def _voice_mode_enabled() -> bool:
"""Current voice-mode flag (runtime-only, CLI parity).
@ -17794,6 +17807,340 @@ def _voice_record_key() -> str:
return str(record_key) if isinstance(record_key, str) and record_key else "ctrl+b"
# ── Wake word ("Hey Hermes") ──────────────────────────────────────────────
# The detector is process-global (one mic), like voice. The first eligible
# transport to call wake.start owns it until stop, disconnect, or stream failure.
# On detection we emit wake.detected; the client opens a new session and starts
# its own voice capture. The detector yields the mic to gateway voice.record
# (pause/resume below) and to the desktop's browser mic (wake.pause/resume RPCs).
_wake_lock = threading.Lock()
_wake_owner_transport: "Optional[Transport]" = None
_wake_owner_surface = ""
def _wake_owner_snapshot():
with _wake_lock:
return _wake_owner_transport, _wake_owner_surface
def _release_wake_for_transport(transport: "Transport") -> bool:
"""Release the wake lease iff ``transport`` is the current gateway owner."""
global _wake_owner_transport, _wake_owner_surface
with _wake_lock:
if _wake_owner_transport is not transport:
return False
_wake_owner_transport = None
_wake_owner_surface = ""
try:
from tools.wake_word import stop_listening
stop_listening(owner=transport)
except Exception as e:
logger.debug("wake stop failed: %s", e)
return True
def _release_gateway_wake_owner() -> bool:
owner, _surface = _wake_owner_snapshot()
return owner is not None and _release_wake_for_transport(owner)
_wake_resume_retry_lock = threading.Lock()
_wake_resume_retry_active = False
def _wake_resume_if_owner(owner: "Transport", *, retry_seconds: float = 15.0,
retry_interval: float = 1.0) -> bool:
"""Resume the wake detector for ``owner``; self-heal a busy microphone.
Reopening the mic right after a voice turn can fail while the capture
device is still being released (browser WebRTC tracks release async).
The CLI covers this with its idle watchdog; the gateway had nothing, so
one failed resume left the listener silently dead until the user toggled
it by hand despite ``wake_word.enabled: true``. On an exception (mic
open failure) we retry in a background thread until it sticks, the lease
changes hands, or ``retry_seconds`` elapses. ``False`` from
``resume_listening`` (lease gone / different owner) is final never
retried, so this can't steal another surface's mic.
"""
from tools.wake_word import resume_listening
try:
return resume_listening(owner=owner)
except Exception as e:
logger.debug("wake resume failed (will retry): %s", e)
global _wake_resume_retry_active
with _wake_resume_retry_lock:
if _wake_resume_retry_active:
return False
_wake_resume_retry_active = True
def _retry() -> None:
global _wake_resume_retry_active
deadline = time.monotonic() + retry_seconds
try:
while time.monotonic() < deadline:
time.sleep(retry_interval)
try:
if resume_listening(owner=owner):
logger.info("wake: detector resumed after retry")
return
except Exception:
continue
# False — detector gone or lease moved: stop, don't fight it.
return
logger.warning(
"wake: could not resume detector after voice turn "
"(microphone still busy?) — toggle the wake word to re-arm"
)
finally:
with _wake_resume_retry_lock:
_wake_resume_retry_active = False
threading.Thread(target=_retry, daemon=True, name="wake-resume-retry").start()
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 (
WakeWordInUse,
check_wake_word_requirements,
load_wake_word_config,
owns_listener,
start_listening,
wake_phrase,
wake_surface_enabled,
)
except Exception as e:
return _err(rid, 5026, f"wake module unavailable: {e}")
cfg = load_wake_word_config()
# Requirements first: a gesture on an unarmed-able setup (no STT/TTS, no
# mic, missing key) must refuse WITHOUT flipping wake_word.enabled — else
# config says on while nothing can ever arm, and auto-arm paths churn.
reqs = check_wake_word_requirements(cfg)
if not reqs["available"]:
logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint"))
return _ok(rid, {
"started": False,
"reason": "unavailable",
"hint": reqs.get("hint") or "",
})
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):
# 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})
existing_owner, existing_surface = _wake_owner_snapshot()
if existing_owner is not None and (
_transport_is_dead(existing_owner) or not owns_listener(existing_owner)
):
_release_wake_for_transport(existing_owner)
existing_owner = None
existing_surface = ""
if existing_owner is not None and existing_owner is not transport:
return _ok(rid, {
"started": False,
"reason": "owned",
"owner_surface": existing_surface,
})
sid = str(params.get("session_id") or "")
phrase = wake_phrase(cfg)
new_session = bool(cfg.get("start_new_session", True))
def _on_detect() -> None:
from tools.wake_word import get_last_match, owns_listener, pause_listening
if not pause_listening(owner=transport):
return
if not owns_listener(transport):
return
if _transport_is_dead(transport):
_release_wake_for_transport(transport)
return
# 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": matched_phrase or phrase,
"profile": matched_profile or None,
"start_new_session": new_session,
})
finally:
reset_transport(token)
try:
start_listening(_on_detect, owner=transport, config=cfg)
except WakeWordInUse:
return _ok(rid, {
"started": False,
"reason": "owned",
"owner_surface": existing_surface or None,
})
except Exception as e:
logger.warning("wake.start(%s): failed to start listener: %s", surface, e)
return _err(rid, 5026, str(e))
global _wake_owner_transport, _wake_owner_surface
with _wake_lock:
_wake_owner_transport = transport
_wake_owner_surface = surface
logger.info("wake.start(%s): listening for %r (%s)", surface, reqs["phrase"], reqs["provider"])
return _ok(rid, {
"started": True,
"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,
})
@method("wake.pause")
def _(rid, params: dict) -> dict:
"""Release the mic (e.g. while the desktop's browser captures audio)."""
transport = current_transport() or _stdio_transport
try:
from tools.wake_word import pause_listening
paused = pause_listening(owner=transport)
logger.info("wake.pause: detector paused=%s", paused)
except Exception as e:
logger.debug("wake.pause failed: %s", e)
paused = False
return _ok(rid, {
"paused": paused,
"reason": None if paused else "not_owner",
})
@method("wake.resume")
def _(rid, params: dict) -> dict:
"""Reclaim the mic after a pause; no-op if the listener isn't armed."""
transport = current_transport() or _stdio_transport
resumed = _wake_resume_if_owner(transport)
logger.info("wake.resume: detector resumed=%s", resumed)
return _ok(rid, {
"resumed": resumed,
"reason": None if resumed else "not_owner",
})
@method("wake.status")
def _(rid, params: dict) -> dict:
try:
from tools.wake_word import (
audio_is_silent,
check_wake_word_requirements,
is_listening,
load_wake_word_config,
owns_listener,
)
cfg = load_wake_word_config()
reqs = check_wake_word_requirements(cfg)
transport = current_transport() or _stdio_transport
owner, owner_surface = _wake_owner_snapshot()
owned_by_caller = owns_listener(transport)
listening = owned_by_caller and is_listening()
silent = listening and audio_is_silent()
hint = reqs.get("hint", "")
if silent and not hint:
hint = ("Microphone delivers only silence — on macOS grant the "
"Hermes backend mic access (System Settings > Privacy & "
"Security > Microphone), then toggle the wake word.")
return _ok(rid, {
"listening": listening,
"owned_by_caller": owned_by_caller,
"owner_surface": owner_surface if owner is not None else None,
"phrase": reqs["phrase"],
"provider": reqs["provider"],
"available": reqs["available"],
"hint": hint,
# Config truth: clients use this to re-arm after a voice turn
# ("permanent on") without guessing from runtime listener state.
"enabled": bool(cfg.get("enabled")),
# Armed but deaf (macOS permission failure mode) — see hint.
"audio_silent": silent,
})
except Exception as e:
return _err(rid, 5026, str(e))
@method("voice.toggle")
def _(rid, params: dict) -> dict:
"""CLI parity for the ``/voice`` slash command.
@ -17906,17 +18253,23 @@ def _(rid, params: dict) -> dict:
captures emit ``voice.transcript`` with ``no_speech_limit=True``.
"""
action = params.get("action", "start")
wake_paused = False
if action not in {"start", "stop"}:
return _err(rid, 4019, f"unknown voice action: {action}")
transport = current_transport() or _stdio_transport
wake_owner, _surface = _wake_owner_snapshot()
if wake_owner is not None and wake_owner is not transport:
return _ok(rid, {"status": "busy", "reason": "wake_owned"})
try:
if action == "start":
if not _voice_mode_enabled():
return _err(rid, 4015, "voice mode is off — enable with /voice on")
with _voice_sid_lock:
global _voice_event_sid
global _voice_event_sid, _voice_wake_owner
_voice_event_sid = params.get("session_id") or _voice_event_sid
from hermes_cli.voice import start_continuous
@ -17942,6 +18295,32 @@ def _(rid, params: dict) -> dict:
if isinstance(duration, (int, float)) and not isinstance(duration, bool)
else 3.0
)
# Hand the mic to STT if the wake-word detector holds it; resume
# once a terminal capture event fires (one-shot transcript / silence
# limit), so wake-triggered and manual captures both coexist.
try:
from tools.wake_word import pause_listening
wake_paused = pause_listening(owner=transport)
except Exception:
wake_paused = False
if wake_paused:
with _voice_sid_lock:
_voice_wake_owner = transport
def _on_transcript(t):
_voice_emit("voice.transcript", {"text": t})
_resume_voice_wake()
def _on_silent():
_voice_emit("voice.transcript", {"no_speech_limit": True})
_resume_voice_wake()
def _on_status(state):
_voice_emit("voice.status", {"state": state})
if state == "idle":
_resume_voice_wake()
# voice.max_recording_seconds — hard cap on a single recording's
# length. Same guard as the silence params: non-numeric / bool /
# missing falls back to the documented 120 default, while an
@ -17953,17 +18332,16 @@ def _(rid, params: dict) -> dict:
else 120.0
)
started = start_continuous(
on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
on_silent_limit=lambda: _voice_emit(
"voice.transcript", {"no_speech_limit": True}
),
on_transcript=_on_transcript,
on_status=_on_status,
on_silent_limit=_on_silent,
silence_threshold=safe_threshold,
silence_duration=safe_duration,
auto_restart=False,
max_recording_seconds=safe_max_rec,
)
if started is False:
_resume_voice_wake()
return _ok(rid, {"status": "busy"})
return _ok(rid, {"status": "recording"})
@ -17974,12 +18352,17 @@ def _(rid, params: dict) -> dict:
from hermes_cli.voice import stop_continuous
stop_continuous(force_transcribe=True)
_resume_voice_wake()
return _ok(rid, {"status": "stopped"})
except ImportError:
if wake_paused or action == "stop":
_resume_voice_wake()
return _err(
rid, 5025, "voice module not available — install audio dependencies"
)
except Exception as e:
if wake_paused or action == "stop":
_resume_voice_wake()
return _err(rid, 5025, str(e))

View file

@ -425,6 +425,11 @@ async def handle_ws(ws: Any) -> None:
server.unregister_live_transport(transport)
transport.close()
try:
await asyncio.to_thread(server._release_wake_for_transport, transport)
except Exception:
_log.exception("ws wake-word teardown failed peer=%s", peer)
# Reap sessions this transport owned (close_on_disconnect sidecar
# sessions) or detach the rest to the drop sentinel so later emits
# don't crash into a closed socket or fall through to desktop stdout

View file

@ -851,6 +851,65 @@ describe('createGatewayEventHandler', () => {
expect(polarityBackgroundFromForeground('not-a-color')).toBeUndefined()
})
it('claims wake-word ownership when the gateway becomes ready', () => {
const ctx = buildCtx([])
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' })
})
it('opens a fresh session before starting voice after wake detection', async () => {
const ctx = buildCtx([])
ctx.session.newSession = vi.fn(async () => patchUiState({ sid: 'wake-session' }))
patchUiState({ sid: 'old-session' })
createGatewayEventHandler(ctx)({
payload: { phrase: 'hey hermes', start_new_session: true },
type: 'wake.detected'
} as any)
await vi.waitFor(() =>
expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', {
action: 'start',
session_id: 'wake-session'
})
)
expect(ctx.session.newSession).toHaveBeenCalledOnce()
expect(ctx.voice.setVoiceEnabled).toHaveBeenCalledWith(true)
})
it('keeps the current session when wake detection disables session creation', async () => {
const ctx = buildCtx([])
patchUiState({ sid: 'current-session' })
createGatewayEventHandler(ctx)({
payload: { phrase: 'hey hermes', start_new_session: false },
type: 'wake.detected'
} as any)
await vi.waitFor(() =>
expect(ctx.gateway.rpc).toHaveBeenCalledWith('voice.record', {
action: 'start',
session_id: 'current-session'
})
)
expect(ctx.session.newSession).not.toHaveBeenCalled()
})
it('rearms wake detection when no session is available', async () => {
const ctx = buildCtx([])
patchUiState({ sid: '' })
createGatewayEventHandler(ctx)({
payload: { start_new_session: false },
type: 'wake.detected'
} as any)
await vi.waitFor(() => expect(ctx.gateway.rpc).toHaveBeenCalledWith('wake.resume', {}))
expect(ctx.gateway.rpc).not.toHaveBeenCalledWith('voice.record', expect.anything())
})
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()

View file

@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { wakeCommands } from '../app/slash/commands/wake.js'
import { isWakeUserDisabled, setWakeUserDisabled } from '../app/wakeState.js'
const wakeCommand = wakeCommands.find(cmd => cmd.name === 'wake')!
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method]))
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), sys }
}
const run = async (arg: string) => {
wakeCommand.run(arg, ctx as any, `/wake${arg ? ` ${arg}` : ''}`)
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { ctx, rpc, run, sys }
}
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
describe('/wake slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
setWakeUserDisabled(false)
})
it('registers with usage metadata', () => {
expect(wakeCommand).toBeDefined()
expect(wakeCommand.usage).toBe('/wake [on|off|status]')
})
it('/wake on calls wake.start with surface tui and reports listening', async () => {
const { rpc, run, sys } = buildCtx({
'wake.start': { phrase: 'hey hermes', provider: 'openwakeword', started: true }
})
await run('on')
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')
})
it('/wake on clears the session opt-out flag', async () => {
setWakeUserDisabled(true)
const { run } = buildCtx({ 'wake.start': { started: true } })
await run('on')
expect(isWakeUserDisabled()).toBe(false)
})
it('/wake on prints the reason when the gateway refuses', async () => {
const { run, sys } = buildCtx({
'wake.start': { owner_surface: 'gui', reason: 'owned', started: false }
})
await run('on')
const out = printed(sys)
expect(out).toContain('not started')
expect(out).toContain('another surface owns the listener')
expect(out).toContain('gui')
})
it('/wake on surfaces the hint when unavailable', async () => {
const { run, sys } = buildCtx({
'wake.start': { hint: 'pip install openwakeword', reason: 'unavailable', started: false }
})
await run('on')
const out = printed(sys)
expect(out).toContain('unavailable')
expect(out).toContain('pip install openwakeword')
})
it('/wake off calls wake.stop, remembers the opt-out, and reports', async () => {
const { rpc, run, sys } = buildCtx({ 'wake.stop': { stopped: true } })
await run('off')
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 } })
await run('off')
expect(isWakeUserDisabled()).toBe(true)
expect(printed(sys)).toContain('nothing to stop')
expect(printed(sys)).toContain('doesnt own the listener')
})
it('/wake status prints a listening one-liner', async () => {
const { rpc, run, sys } = buildCtx({
'wake.status': {
available: true,
listening: true,
owned_by_caller: true,
owner_surface: 'tui',
phrase: 'hey hermes',
provider: 'openwakeword'
}
})
await run('status')
expect(rpc).toHaveBeenCalledWith('wake.status', {})
const out = printed(sys)
expect(out).toContain('listening')
expect(out).toContain('hey hermes')
expect(out).toContain('openwakeword')
})
it('bare /wake behaves like /wake status', async () => {
const { rpc, run } = buildCtx({ 'wake.status': { available: true, listening: false } })
await run('')
expect(rpc).toHaveBeenCalledWith('wake.status', {})
})
it('status reports another surface owning the listener', async () => {
const { run, sys } = buildCtx({
'wake.status': {
available: true,
listening: false,
owned_by_caller: false,
owner_surface: 'gui',
phrase: 'hey hermes'
}
})
await run('status')
const out = printed(sys)
expect(out).toContain('off here')
expect(out).toContain('gui')
})
it('status surfaces the hint when the wake word is unavailable', async () => {
const { run, sys } = buildCtx({
'wake.status': { available: false, hint: 'no microphone detected', listening: false }
})
await run('status')
const out = printed(sys)
expect(out).toContain('unavailable')
expect(out).toContain('no microphone detected')
})
it('rejects unknown subcommands with usage text', async () => {
const { rpc, run, sys } = buildCtx({})
await run('banana')
expect(rpc).not.toHaveBeenCalled()
expect(printed(sys)).toContain('usage: /wake [on|off|status]')
})
})

View file

@ -32,6 +32,7 @@ import { flashGoodVibes, flashPet } from './petFlashStore.js'
import { turnController } from './turnController.js'
import { getTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import { isWakeUserDisabled } from './wakeState.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
@ -621,6 +622,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// "too many re-renders" guard in embedded dashboard PTYs.
ensureAgentsNudgeConfig()
// Arm "Hey Hermes" if this surface owns it (server gates on config).
// Fire-and-forget + idempotent server-side, so reconnects are harmless.
// Skipped when the user explicitly ran `/wake off` this session — an
// explicit opt-out must survive gateway reconnects (see wakeState.ts).
if (!isWakeUserDisabled()) {
void rpc('wake.start', { surface: 'tui' }).catch(() => undefined)
}
rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
@ -936,6 +945,47 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
case 'wake.detected': {
// "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()
}
const sid = getUiState().sid
if (!sid) {
await rpc('wake.resume', {}).catch(() => undefined)
return
}
setVoiceEnabled(true)
await rpc('voice.toggle', { action: 'on' })
await rpc('voice.record', { action: 'start', session_id: sid })
})().catch((e: unknown) => {
sys(`wake: ${rpcErrorMessage(e)}`)
void rpc('wake.resume', {}).catch(() => undefined)
})
return
}
case 'gateway.start_timeout': {
const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''

View file

@ -0,0 +1,132 @@
import type { WakeStartResponse, WakeStatusResponse, WakeStopResponse } from '../../../gatewayTypes.js'
import { setWakeUserDisabled } from '../../wakeState.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
const WAKE_SUBCOMMANDS = ['on', 'off', 'status'] as const
type WakeSub = (typeof WAKE_SUBCOMMANDS)[number]
const isWakeSub = (value: string): value is WakeSub => (WAKE_SUBCOMMANDS as readonly string[]).includes(value)
// 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: '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'
}
const startFailureLine = (r: WakeStartResponse): string => {
const reason = r.reason ?? 'unknown'
const base = START_REASON_TEXT[reason] ?? reason
const owner = r.owner_surface ? ` (owned by ${r.owner_surface})` : ''
const hint = r.hint?.trim() ? `${r.hint.trim()}` : ''
return `wake: not started — ${base}${owner}${hint}`
}
const statusLine = (r: WakeStatusResponse): string => {
const phrase = r.phrase ? ` for “${r.phrase}` : ''
const provider = r.provider ? ` · ${r.provider}` : ''
if (r.listening) {
if (r.audio_silent) {
const hint = r.hint?.trim() ? `${r.hint.trim()}` : ''
return `wake: listening${phrase}${provider} · ⚠ mic delivers only silence${hint}`
}
return `wake: listening${phrase}${provider}`
}
if (r.owner_surface && !r.owned_by_caller) {
return `wake: off here · listener owned by ${r.owner_surface}${phrase}${provider}`
}
if (r.available === false) {
const hint = r.hint?.trim() ? `${r.hint.trim()}` : ''
return `wake: unavailable${hint}`
}
return `wake: off${phrase}${provider} · /wake on to arm`
}
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', { persist: true, surface: 'tui' })
.then(
ctx.guarded<WakeStartResponse>(r => {
if (!r.started) {
return ctx.transcript.sys(startFailureLine(r))
}
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}${saved}`)
})
)
.catch(ctx.guardedErr)
}
const runOff = (ctx: SlashRunCtx): void => {
// Remember the explicit opt-out so gateway reconnects don't re-arm the
// listener behind the user's back (see wakeState.ts).
setWakeUserDisabled(true)
ctx.gateway
.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${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}${saved}`)
})
)
.catch(ctx.guardedErr)
}
const runStatus = (ctx: SlashRunCtx): void => {
ctx.gateway
.rpc<WakeStatusResponse>('wake.status', {})
.then(ctx.guarded<WakeStatusResponse>(r => ctx.transcript.sys(statusLine(r))))
.catch(ctx.guardedErr)
}
const WAKE_RUNNERS: Record<WakeSub, (ctx: SlashRunCtx) => void> = {
off: runOff,
on: runOn,
status: runStatus
}
export const wakeCommands: SlashCommand[] = [
{
help: "toggle the 'Hey Hermes' wake word listener [on|off|status]",
name: 'wake',
usage: '/wake [on|off|status]',
run: (arg, ctx) => {
const sub = arg.trim().toLowerCase()
if (sub && !isWakeSub(sub)) {
return ctx.transcript.sys('usage: /wake [on|off|status]')
}
WAKE_RUNNERS[sub && isWakeSub(sub) ? sub : 'status'](ctx)
}
}
]

View file

@ -5,6 +5,7 @@ import { sessionCommands } from './commands/session.js'
import { setupCommands } from './commands/setup.js'
import { subscriptionCommands } from './commands/subscription.js'
import { topupCommands } from './commands/topup.js'
import { wakeCommands } from './commands/wake.js'
import type { SlashCommand } from './types.js'
export const SLASH_COMMANDS: SlashCommand[] = [
@ -13,6 +14,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
...sessionCommands,
...subscriptionCommands,
...opsCommands,
...wakeCommands,
...setupCommands,
...debugCommands
]

View file

@ -0,0 +1,15 @@
// Session-scoped memory of an explicit `/wake off`.
//
// The gateway auto-arms the "Hey Hermes" listener on every `gateway.ready`
// (see createGatewayEventHandler.ts). When the user explicitly disables the
// listener with `/wake off`, a reconnect must NOT silently re-arm it — this
// module-level flag records that intent for the lifetime of the process.
// `/wake on` clears it. Deliberately not persisted: config (`wake_word.*`)
// remains the durable on/off switch; this is only per-session steering.
let wakeUserDisabled = false
export const isWakeUserDisabled = (): boolean => wakeUserDisabled
export const setWakeUserDisabled = (disabled: boolean): void => {
wakeUserDisabled = disabled
}

View file

@ -400,6 +400,38 @@ export interface VoiceRecordResponse {
text?: string
}
// ── Wake word ────────────────────────────────────────────────────────
export interface WakeStartResponse {
enabled_persisted?: boolean
hint?: string
owner_surface?: null | string
phrase?: string
provider?: string
reason?: string
started?: boolean
}
export interface WakeStopResponse {
disabled_persisted?: boolean
reason?: null | string
stopped?: boolean
}
export interface WakeStatusResponse {
/** Armed but the mic delivers only silence (macOS backend-permission gap). */
audio_silent?: boolean
available?: boolean
/** Config truth (wake_word.enabled). */
enabled?: boolean
hint?: string
listening?: boolean
owned_by_caller?: boolean
owner_surface?: null | string
phrase?: string
provider?: string
}
// ── Tools (TS keeps configure since it resets local history) ─────────
export interface ToolsConfigureResponse {
@ -589,6 +621,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; 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' }
| {

366
uv.lock generated
View file

@ -19,6 +19,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" },
]
[[package]]
name = "ai-edge-litert"
version = "2.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-strenum" },
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "protobuf" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/3d/41a85023e1c6cc76d895f3ba6ac7c22b0785db7e08d0827ebeb8a403eefc/ai_edge_litert-2.1.6-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:edf598814004e594b40c888f52cae59e950dbeffd821e83ba45d28db0a0aa3f5", size = 10031164, upload-time = "2026-07-01T21:42:50.986Z" },
{ url = "https://files.pythonhosted.org/packages/52/d5/164aaf69f60f72b7076900ef1cc6153bf50d82cd15202bdf1239c0dbfb1c/ai_edge_litert-2.1.6-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5adf0c9afde6151dc7f2989d039c800f3060d98d40bb5dfc95e426ad4eb3680b", size = 10033170, upload-time = "2026-07-01T21:42:53.208Z" },
{ url = "https://files.pythonhosted.org/packages/ad/92/578b31c4c05afa9081a664cd86afe33c304fecd70de1c2a4d3a3b9ca51c9/ai_edge_litert-2.1.6-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2e8a3f92fa407690189533bea8b64d49bb1b8a9e96f707ba27e1a64a9c3cc8cf", size = 10032950, upload-time = "2026-07-01T21:42:55.102Z" },
]
[[package]]
name = "aiofiles"
version = "24.1.0"
@ -472,6 +490,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "backports-strenum"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" },
]
[[package]]
name = "base58"
version = "2.1.1"
@ -1718,6 +1745,16 @@ voice = [
{ name = "numpy" },
{ name = "sounddevice" },
]
wake = [
{ name = "ai-edge-litert", marker = "sys_platform == 'darwin'" },
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "openwakeword" },
{ name = "pvporcupine" },
{ name = "sentencepiece" },
{ name = "sherpa-onnx" },
{ name = "sounddevice" },
]
web = [
{ name = "fastapi" },
{ name = "python-multipart" },
@ -1734,6 +1771,7 @@ youtube = [
[package.metadata]
requires-dist = [
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" },
{ name = "ai-edge-litert", marker = "sys_platform == 'darwin' and extra == 'wake'", specifier = "==2.1.6" },
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.14.1" },
{ name = "aiohttp", marker = "extra == 'matrix'", specifier = "==3.14.1" },
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.14.1" },
@ -1807,7 +1845,10 @@ requires-dist = [
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
{ name = "nemo-relay", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'ARM64' and sys_platform == 'win32')", specifier = ">=0.6.0,<0.7" },
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
{ name = "numpy", marker = "extra == 'wake'", specifier = "==2.4.3" },
{ name = "onnxruntime", marker = "extra == 'wake'", specifier = "==1.27.0" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "openwakeword", marker = "extra == 'wake'", specifier = "==0.6.0" },
{ name = "packaging", specifier = "==26.0" },
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
{ name = "pathspec", specifier = "==1.1.1" },
@ -1815,6 +1856,7 @@ requires-dist = [
{ name = "prompt-toolkit", specifier = "==3.0.52" },
{ name = "psutil", specifier = "==7.2.2" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
{ name = "pvporcupine", marker = "extra == 'wake'", specifier = "==4.0.3" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
@ -1834,13 +1876,16 @@ requires-dist = [
{ name = "rich", specifier = "==14.3.3" },
{ name = "ruamel-yaml", specifier = "==0.18.17" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
{ name = "sentencepiece", marker = "extra == 'wake'", specifier = "==0.2.2" },
{ name = "setuptools", marker = "extra == 'dev'", specifier = "==81.0.0" },
{ name = "sherpa-onnx", marker = "extra == 'wake'", specifier = "==1.13.4" },
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.29.0" },
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.29.0" },
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" },
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" },
{ name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" },
{ name = "sounddevice", marker = "extra == 'wake'", specifier = "==0.5.5" },
{ name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.3.1" },
{ name = "starlette", marker = "extra == 'dev'", specifier = "==1.3.1" },
{ name = "starlette", marker = "extra == 'mcp'", specifier = "==1.3.1" },
@ -1855,7 +1900,7 @@ requires-dist = [
{ name = "websockets", specifier = "==15.0.1" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "wake", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
[[package]]
name = "hf-xet"
@ -2139,6 +2184,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "joblib"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "jsonpath-python"
version = "1.1.6"
@ -2448,15 +2502,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/aa/f0ffbe6bf679a597e8be692ca3cde47de6156435c2b72cf752fec719bb1f/modal-1.3.4-py3-none-any.whl", hash = "sha256:d66a851969f447936b3512f1c3708435ce1ca81171eeddc3eb0678f594493380", size = 773837, upload-time = "2026-02-23T15:44:03.635Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "msal"
version = "1.36.0"
@ -2599,6 +2644,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "narwhals"
version = "2.24.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" },
]
[[package]]
name = "nemo-relay"
version = "0.6.0"
@ -2739,33 +2793,32 @@ wheels = [
[[package]]
name = "onnxruntime"
version = "1.24.4"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/69/6c40720201012c6af9aa7d4ecdd620e521bd806dc6269d636fdd5c5aeebe/onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2", size = 17332131, upload-time = "2026-03-17T22:05:49.005Z" },
{ url = "https://files.pythonhosted.org/packages/38/e9/8c901c150ce0c368da38638f44152fb411059c0c7364b497c9e5c957321a/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7", size = 15152472, upload-time = "2026-03-17T22:03:26.176Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993, upload-time = "2026-03-17T22:04:34.485Z" },
{ url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863, upload-time = "2026-03-17T22:05:38.749Z" },
{ url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895, upload-time = "2026-03-17T22:05:28.812Z" },
{ url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" },
{ url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" },
{ url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" },
{ url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" },
{ url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" },
{ url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" },
{ url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1f/a2117aa3f144fce88774efa37440d0ca72d0c9144854dfc0961f2b04c6fc/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb083321af8a236a84c7c140a7f4cecbfa2a987a18c07c78db471c20cd390ef", size = 16419330, upload-time = "2026-06-15T22:42:37.58Z" },
{ url = "https://files.pythonhosted.org/packages/e0/cd/74bb804170ceb622fda9111df31a07b3024f7491472256d3a90b5391a4d2/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4f7b0e90d2d212e2c2deaa6c8291616183ab815d3ec558ea12d3ac8b26d36f4", size = 18636930, upload-time = "2026-06-15T22:43:01.584Z" },
{ url = "https://files.pythonhosted.org/packages/fe/8f/5b8e2b85e81735696887175dbaf6409f215683f5ca9d4928fbb038211d32/onnxruntime-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff050e4f6bf7f12918fa14dcb047c0b02e295f35e86d42532552be4b3d54e977", size = 13356110, upload-time = "2026-06-15T22:43:32.172Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3a/4f568de678126b6a371a93862f015a82138359decd97fcac61fc84b5b774/onnxruntime-1.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:75fbc1e1fb43a39a856c8209c544cca7817b5de7ac16b15b1bdf55d1cc67b9df", size = 13098635, upload-time = "2026-06-15T22:43:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" },
{ url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" },
{ url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" },
{ url = "https://files.pythonhosted.org/packages/4f/88/8ec9db1a4d126bb8b758992beb40d1249df171917d75f44a327eb5f20dda/onnxruntime-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:20c321cf187ba496e648acf6b4cf90b4d398b0d17c2a77fdaeba365b908cc1c1", size = 13358769, upload-time = "2026-06-15T22:43:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/ae/9f/fdad359dfcba7e7cd8815569b304a596531d4efa77a75d77f8b4981891a2/onnxruntime-1.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:d0d1f68868e2ef30ef70998ba9bbbc5c305e9b17041e3936751c1b8aa6aade06", size = 13104440, upload-time = "2026-06-15T22:43:22.893Z" },
{ url = "https://files.pythonhosted.org/packages/fb/2b/54208fd03ad410480bc17edf4869376362da8bbf46fe186ddf4cb5cc20fe/onnxruntime-1.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b3e5b58b8c89c2b20e086e890aa9527377e5c240dc3ecc1640d18e07705eeb1c", size = 18432958, upload-time = "2026-06-15T22:42:53.105Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/24fc51fcbb126da6d032372314e47b55c3faad58f2aa78c0e199ccd20b9c/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b3d87eb560ff6a772240506f3c78d6d27c63cafedd5c775672e1194f968cfd", size = 16438180, upload-time = "2026-06-15T22:42:43.093Z" },
{ url = "https://files.pythonhosted.org/packages/cb/19/14929c3c2fe0b79b41cce24463062bf3afa4cdd3c19dccf00319caa92bff/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6872443f236a554921cda6f318c900e2d0c226792cf3534d00e5057c6926e5d2", size = 18658445, upload-time = "2026-06-15T22:43:08.053Z" },
{ url = "https://files.pythonhosted.org/packages/7f/76/59ed932b0244acd7bbbd6449480053a6d958ea66357f022f932872e19287/onnxruntime-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:760021bca514d64a811837820d351a08a41741f16f8b4c26450da708fecf14e6", size = 13357856, upload-time = "2026-06-15T22:43:37.315Z" },
{ url = "https://files.pythonhosted.org/packages/79/51/d1ec60ec7b1e2ae2d7340ba52b8a13529140039cd4407ba8dddbbc046582/onnxruntime-1.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:2fdfa9df40a0ded0028ce6f9cd863264237f3970559dea2b81456e9ac4622b94", size = 13104412, upload-time = "2026-06-15T22:43:27.457Z" },
{ url = "https://files.pythonhosted.org/packages/5e/7d/e6bb1c6445c94f708c38cd8fbb7bf0264108c33498b9445c93e60fe6d329/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54c0c4e9202c36c4ecdb1f3443f5dfbfd5ee3b54d1362c4b4c6134110e74fb32", size = 16443331, upload-time = "2026-06-15T22:42:45.649Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/b18b31e806eabc41077810199fbbb36fbc2d5f19912416e5ccfbf73053d1/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b215aa662c8f983f7d6dedafe65a9be72c26e5338e0fe98b3e0422c32c85428", size = 18670967, upload-time = "2026-06-15T22:43:10.621Z" },
]
[[package]]
@ -2909,6 +2962,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" },
]
[[package]]
name = "openwakeword"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "onnxruntime" },
{ name = "requests" },
{ name = "scikit-learn" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "tflite-runtime", marker = "sys_platform == 'linux'" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/73b7d98b07f4e1f525ad39703e0c5f30ff61c3fa16c8bfe4d99eadc0567a/openwakeword-0.6.0.tar.gz", hash = "sha256:36858d90f1183e307485597a912a4e3c3384b14ea9923f83feaffae7c1565565", size = 70830, upload-time = "2024-02-11T20:56:17.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/33/dafd6822bebe463a9098951d06a0d88fb4f8c946ce087025bc4fa132e533/openwakeword-0.6.0-py3-none-any.whl", hash = "sha256:6f423a4e3ae9dd0e3cd12b50ff8abf69679f687b4ab349d7c82c021c0e2abc9d", size = 60690, upload-time = "2024-02-11T20:56:16.179Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -3181,6 +3252,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pvporcupine"
version = "4.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/37/db209e19c4e1d931d1752bdf05c763f119271bb79661d482bdf5f564f662/pvporcupine-4.0.3.tar.gz", hash = "sha256:87d0e4d743a13c3a15b1fb34a9ced66e14bb1125ae079f2e2c09423364a68386", size = 3643620, upload-time = "2026-06-25T21:58:11.366Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/ef/1c4b8e47d8248fe1b615772028265ca65d9ff3ea98022d84cd973d46db87/pvporcupine-4.0.3-py3-none-any.whl", hash = "sha256:92796dbd3cf80a56db1ce20702cbceb151aee34f19ef730591f815eb16f2ebfb", size = 3659883, upload-time = "2026-06-25T21:58:08.805Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
@ -3844,6 +3927,165 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
]
[[package]]
name = "scikit-learn"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "narwhals" },
{ name = "numpy" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" },
{ url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" },
{ url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" },
{ url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" },
{ url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" },
{ url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" },
{ url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" },
{ url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" },
{ url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" },
{ url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" },
{ url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" },
{ url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" },
]
[[package]]
name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12'",
]
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
]
[[package]]
name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version == '3.12.*'",
]
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" },
{ url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" },
{ url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" },
{ url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" },
{ url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" },
{ url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" },
{ url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" },
{ url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" },
{ url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" },
{ url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" },
{ url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" },
{ url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" },
{ url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" },
]
[[package]]
name = "sentencepiece"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/33/ea3cb3839607eb175da835244a798f797f478c5ddf0e8ecdf57ea85a4c70/sentencepiece-0.2.2.tar.gz", hash = "sha256:3d2b5e824b5622038dc7b490897efe05ebbbb9e7350fc142f3ecc8789ef9bdf6", size = 8218435, upload-time = "2026-07-12T08:39:34.701Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/31/f23a2efaa0210b883574001b88fa64e499f798f0848a0b610fb9b384d162/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:69e9dc8078e128286ed3b975e37c837ba96e215a50c3ef9f3f8b7ab9e5a832a0", size = 2184255, upload-time = "2026-07-12T08:38:14.855Z" },
{ url = "https://files.pythonhosted.org/packages/96/f2/1ee0ccb772d71e822f625d6cb5f0ea825835e877f28a9ef299a1291df19e/sentencepiece-0.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6dd76f3e5c8b2eb8a3a3efee787bbf5b9a66e52a048fe09cab85eca33fec6790", size = 1438545, upload-time = "2026-07-12T08:38:16.674Z" },
{ url = "https://files.pythonhosted.org/packages/2a/92/3a6ea4a2c6dd9e7062698a5a33534ca0e20844883338ae9c6b9c122c1a9f/sentencepiece-0.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:443ac618c7a2a1377cf5c82581fbb849591d14e656d5e5a3e4682d4e36a34e4e", size = 1346997, upload-time = "2026-07-12T08:38:18.499Z" },
{ url = "https://files.pythonhosted.org/packages/f3/3a/7839048997c7bc0c34c57526f539f835e20c7a57dc2a99f99579b11cdbef/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e2aae42960392d6dcb9a72d8e1e65a97294c965071b43c7b3429a42f350250e", size = 1324282, upload-time = "2026-07-12T08:38:20.342Z" },
{ url = "https://files.pythonhosted.org/packages/06/5f/9117bf854aef817ad0d0ee9310eed0308a7e529e7eaf2e80ad9cd281ef82/sentencepiece-0.2.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1416b92f2f010333786fe6306ed2631121d5ea492219b0841e967b6765e64107", size = 1394242, upload-time = "2026-07-12T08:38:22.976Z" },
{ url = "https://files.pythonhosted.org/packages/ab/62/9e2569867e3dcff7ad6d89642a9615b9801b5cd698abe7df3b490361f66e/sentencepiece-0.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:70d4ca6f4d06df7f0ccab6fe4f49c8a712c8c8b6847b4f0af9a0e1dbb0e0337e", size = 1246268, upload-time = "2026-07-12T08:38:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/96/c9/5d781d4ef1124564a45c98b9ff25d531c10cdf568ec6314a2d1946f9251c/sentencepiece-0.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:252908153eeec06c3ca3a32077e64a49d572e3d89881475b4e0f02d99d9fcc7c", size = 1190702, upload-time = "2026-07-12T08:38:26.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/13/7a562289c8d5b49ebdf3f9c1e8ab67cf14a8743b1d90c8f406bfdec36b72/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1edb10e520e4bddf74d85b0f5ae74cc2d60c2b448885080bfb618bc2b3a49f6b", size = 2188384, upload-time = "2026-07-12T08:38:28.486Z" },
{ url = "https://files.pythonhosted.org/packages/85/d1/912f14fd5eae168aba726ffb6a9a2dc1c71fe7676c53da6f5c442b886d4a/sentencepiece-0.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7c06c751c19d923435a54bff4f7e66e728fad160e8da28254f133abc9725820", size = 1441553, upload-time = "2026-07-12T08:38:30.552Z" },
{ url = "https://files.pythonhosted.org/packages/bd/44/caa9cab5f261a019e2808bc5046152775dc57352ba9cbae7525e9e7a1ed4/sentencepiece-0.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38111ed1f79268f399c505028023d5eaaf0ab4e5eafceb709468b0d3323e7838", size = 1347176, upload-time = "2026-07-12T08:38:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/19/90/cd798935668cff71d309d8ff10385844ecf216b1fe454f1993ed8bf2cb91/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbce24284f51f71d10a42b7b9c964dcb9048b28f1c8e5db40bcbcb6f428cba6a", size = 1325200, upload-time = "2026-07-12T08:38:33.689Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2d/37e3da037318a70066ded0d51bc2a7f35491ae6338dd993d5eb1503fc3b5/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da", size = 1397736, upload-time = "2026-07-12T08:38:35.771Z" },
{ url = "https://files.pythonhosted.org/packages/8d/11/753fca2e6b109be3ab7867abf357dfe48677fe726ae5a5363d0b54ca9450/sentencepiece-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383", size = 1248030, upload-time = "2026-07-12T08:38:37.604Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0a/70efbe861ca182d7d4b6e1a20f58e043400848fa9f2915229f082e221648/sentencepiece-0.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d", size = 1187325, upload-time = "2026-07-12T08:38:39.348Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a3/b3b05095c174d6e80d37d5ddc2f57c2c56237333e7bbd6079cf3243c2a8a/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77c3ce990b23441e5ecfa5bce181fd6f408b564aeb6d7e1d1e7de9c5612501c8", size = 2188346, upload-time = "2026-07-12T08:38:41.089Z" },
{ url = "https://files.pythonhosted.org/packages/ca/f3/72ebc4acb10a06bcf7503fbc6091c8f5db68300f6aac4356c09e6c76e0e1/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd523c4992041faa5c2b3cde62253d11a96c30d73a34afe48a486e8e2254cd1c", size = 1441434, upload-time = "2026-07-12T08:38:42.56Z" },
{ url = "https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a", size = 1347267, upload-time = "2026-07-12T08:38:44.389Z" },
{ url = "https://files.pythonhosted.org/packages/32/4f/31c1073314ad94466bca37d29581761d70110237ee3d46b0efece59a8c1e/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eed98514bffe5ecac37f493f91869c351fbb05629328bfdbc08502c6c094dc0", size = 1324980, upload-time = "2026-07-12T08:38:46.304Z" },
{ url = "https://files.pythonhosted.org/packages/59/b4/a0356fa04d6a14337a6e0e443556785a0422c53ec58baae6b9568120eb0f/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64b656f025355cf8c51abe9fbe3848540756c6d7ca5e6791b1afa664bc24c7cb", size = 1397593, upload-time = "2026-07-12T08:38:48.302Z" },
{ url = "https://files.pythonhosted.org/packages/09/fa/d2d6369257fd2f0de616b1c7110b73fab409ef61b14f1b9e0010ed325914/sentencepiece-0.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:74f0ee601047c0c12a783088b51be4e6214a62ecd9e02278c477433cd16e0ed9", size = 1247987, upload-time = "2026-07-12T08:38:50.15Z" },
{ url = "https://files.pythonhosted.org/packages/17/ee/2bb594da6fd95e32f29057f1aa7fa996701b8980090923c2d8711fdc0a24/sentencepiece-0.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:b23fe17779834d3c27aaf2edac9486d04cca1a7deb8f5facda35150ac6263a91", size = 1187250, upload-time = "2026-07-12T08:38:52.246Z" },
{ url = "https://files.pythonhosted.org/packages/58/9c/dfc82846460e7a712310f5613f23d8b553cabb4e2e648663c11d8382af56/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:72b7825b331b1b7e7c45be2e674b3e3c65af608fa376bad2d851b20aaf0cdc78", size = 2223080, upload-time = "2026-07-12T08:38:54.391Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4e/3ff12cebe6d31662d9ceeabfb282de20bd0d6098fa282b4a3b8305abc7e8/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d795c4ac689a57f9d4ba2288126ec7901d389ad5827d2f8b8533c883974fe563", size = 1458511, upload-time = "2026-07-12T08:38:56.811Z" },
{ url = "https://files.pythonhosted.org/packages/59/5a/16d51d05360be4cee3ebfe4837c184054c4eed16cabaeb3b039524e9a000/sentencepiece-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ab3f1ae98970b5590e2209341522718900ba19bcc2c207ffaa6bd417ad960c5", size = 1361138, upload-time = "2026-07-12T08:38:58.808Z" },
{ url = "https://files.pythonhosted.org/packages/0f/af/c30ee2a9f99d51db9844acaa8fa0b611a97c2fa7116646fa43db3300b187/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec27c152a1f1b24bc9168b55a5880f3c16e2334e697da6f55a1046a22405a3d", size = 1328625, upload-time = "2026-07-12T08:39:00.849Z" },
{ url = "https://files.pythonhosted.org/packages/3e/1a/4c6b39d03f5ba8439509adbd5a23c9538088a3cb679e7a47b911e8442bc6/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59d6588712101ccfcae9b03692be3aaae1514c2078666d7b05f15ba3a702e41b", size = 1398595, upload-time = "2026-07-12T08:39:02.86Z" },
{ url = "https://files.pythonhosted.org/packages/0f/bc/9eedddcec1fd57bc70200fa3ebf792d18fa63527a5369581cd416c81f97f/sentencepiece-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:89625fb43765cccaa1443b9adb61f283e5fe4cb1536728205d06bada730caa53", size = 1259346, upload-time = "2026-07-12T08:39:04.559Z" },
{ url = "https://files.pythonhosted.org/packages/41/15/7e74c8533848866ff560b29f7d8719921b76c4ec7149592d6d28e0deee75/sentencepiece-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f0603267cd15b92b68c2c0e852a441507614b70dc7773659baa6b8c214a91fd", size = 1196596, upload-time = "2026-07-12T08:39:06.454Z" },
]
[[package]]
name = "setuptools"
version = "81.0.0"
@ -3862,6 +4104,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sherpa-onnx"
version = "1.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/f8/735244770b4bc63f85fabdad0e46d6ec1f4cc24e64f6e082c2e0fea92b8c/sherpa_onnx-1.13.4.tar.gz", hash = "sha256:29547692418513ad88034c2b5f98985e33042b2351e4ab375469f19a8de18c5f", size = 982750, upload-time = "2026-07-07T13:04:55.145Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/4f/6ab541c5b2a4b2a6e9970edc4b558f270f7b6624c861eccb85126ccd279c/sherpa_onnx-1.13.4-cp311-cp311-linux_armv7l.whl", hash = "sha256:8e1cdbd53b432630a81ea479ce5bad6aa8192eb4a458d8c9432c54052cb9cc7d", size = 11930819, upload-time = "2026-07-07T14:22:29.536Z" },
{ url = "https://files.pythonhosted.org/packages/1d/41/b750ec336f882e75c5e23c9ad5d52b0902be2337cc50d13ce68cde9e4459/sherpa_onnx-1.13.4-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:5d35aeb5ad13b54cea0d6fed681660f6308acb841de981745e33d457255b9134", size = 4349088, upload-time = "2026-07-07T13:14:19.408Z" },
{ url = "https://files.pythonhosted.org/packages/41/c8/a2ff828ce9a2702b607c25f6303b21d7d41b6ad1520f660b95a0113f4051/sherpa_onnx-1.13.4-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c3e9f96a07570faefca8e3aabcfb78690e680d188d5d44d06fd8711185fe37d6", size = 2288314, upload-time = "2026-07-07T12:08:36.643Z" },
{ url = "https://files.pythonhosted.org/packages/e8/93/4385fcdb1f197521fe13fa887893cc200d27569d3cbcccf0d7b92d6a9e62/sherpa_onnx-1.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2d4337b80b54dd68f566cab941a2ad47ab6cfabb68e88002ee0c920493c16d3", size = 2100029, upload-time = "2026-07-07T12:23:26.936Z" },
{ url = "https://files.pythonhosted.org/packages/ea/cb/c80832f800719c72fc5805a94fe489e805fc67156e102927609917ad8f67/sherpa_onnx-1.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7c4a1c178eb801af92f70120128c1f956b5388b9d684f0af2a08614e05dc3047", size = 4132838, upload-time = "2026-07-07T11:55:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ac/88b4e1ce614ddebe2484e95cad4b19d7db24f3b489d04f9877667cb48ccb/sherpa_onnx-1.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbfc385b98d730080e1b12dc94c604be3867e4fb7bd6b15b830eb33cbf390111", size = 4356406, upload-time = "2026-07-07T12:42:29.823Z" },
{ url = "https://files.pythonhosted.org/packages/b5/73/2fff5d28669e91851e981d223c50aa21f90d712a4551dcb51c231b3a27fe/sherpa_onnx-1.13.4-cp311-cp311-win32.whl", hash = "sha256:1d746b8c6ed1ce9eb94868d71b5ea9c22274b8cb166420bd1772f7df470b753a", size = 1927745, upload-time = "2026-07-07T12:33:50.17Z" },
{ url = "https://files.pythonhosted.org/packages/7e/b1/ae1c113ac9c67dcabbed559f50950c7220a62e49e6b5acb4c2219ab22409/sherpa_onnx-1.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:04a82d79c13a4ce2bd9ccf51de93e83cbfc7bc50520c53e5e967565100d0724d", size = 2239901, upload-time = "2026-07-07T12:22:27.112Z" },
{ url = "https://files.pythonhosted.org/packages/8e/57/179e3a6c1fec33aa6535051feddd5da36e5622d35630b12a67a2805b76b3/sherpa_onnx-1.13.4-cp312-cp312-linux_armv7l.whl", hash = "sha256:bcf64f2d853a1afe236e9e220df62f2f53ef6ad792ca7e406d6173ec003319b8", size = 11933213, upload-time = "2026-07-07T13:50:35.989Z" },
{ url = "https://files.pythonhosted.org/packages/e8/34/b6d3483b08ec8a4a141e978c4b92530fd0a61dd571a575c1fe24bee300d7/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:2257545ea170f58b7977309793979d6d078761b7fdb0528561285f8ead4169db", size = 4422157, upload-time = "2026-07-07T12:29:40.234Z" },
{ url = "https://files.pythonhosted.org/packages/82/37/07f03e97f157b206f6e62d722ec7c5ff41c7e9dc6aa2dc7de69b57e39b5a/sherpa_onnx-1.13.4-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:02b57dc2c829976eb842e6aee6a0e4ac3b9991aeb5afa89fd44eb71d848a4ecd", size = 2345135, upload-time = "2026-07-07T12:10:19.957Z" },
{ url = "https://files.pythonhosted.org/packages/fc/79/ee999f0c3b7789077d0939716a38234573d139f851a31409aa028fe2c610/sherpa_onnx-1.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:84e58b5a074b97c5307c9b6221d1d20fbf412a1a5dff4960ca9c32bb5184219f", size = 2105219, upload-time = "2026-07-07T11:48:22.518Z" },
{ url = "https://files.pythonhosted.org/packages/f4/90/9b67ed3e7adc79daf0ba49c4936a691521488125b04fe469b64a8b5398ff/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f709e6dd02ebf7d37dcb02d5eadc5fb66c9922dd5809df770c1ef5d625ae7a44", size = 4135963, upload-time = "2026-07-07T11:58:30.98Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b1/8dfe5d1d72c92ea1c95db999a95b61bfbb9769f1c569f06e572eda095c52/sherpa_onnx-1.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f0158f3513d3adab1ebba0c26f0c815e53ba13b96846d92ef095ae25d648860", size = 4358555, upload-time = "2026-07-07T12:58:59.654Z" },
{ url = "https://files.pythonhosted.org/packages/d4/04/cfd543933ae24430d124e533847c73a84a5f0efd60f07bbc6403032f9624/sherpa_onnx-1.13.4-cp312-cp312-win32.whl", hash = "sha256:d49928a3455bae1dd4e93f6b013cfbd2c3ccb5cde74aabae3710b656e7d79b6b", size = 1930647, upload-time = "2026-07-07T12:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/bb/bb/1e723ab703a1e354f390de19981ec0c347576f87be01915d826dc6fc9f41/sherpa_onnx-1.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:b8436ffe2763b3fd522fbac8fe53f47d611721c84819c241acfb65d122403d7d", size = 2244142, upload-time = "2026-07-07T13:01:42.293Z" },
{ url = "https://files.pythonhosted.org/packages/4b/36/45b17335f041f1383f6fd142ab57c2d8a337ba2386b7547b125ec9d780af/sherpa_onnx-1.13.4-cp313-cp313-linux_armv7l.whl", hash = "sha256:9e98dc5e0559ad953f227fc884958c71b10c65a93667331405e7d4441ed5f76d", size = 11932654, upload-time = "2026-07-07T14:18:59.791Z" },
{ url = "https://files.pythonhosted.org/packages/68/d7/1e9a7dedab2da8af1a8417b4f4d5f496bd7700a71b59c5e085de5e10761b/sherpa_onnx-1.13.4-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:083747c2d0362ead0501cc773a618be19862a800b3f8f259d3bd3486f1494af4", size = 4372494, upload-time = "2026-07-07T13:02:05.512Z" },
{ url = "https://files.pythonhosted.org/packages/62/28/09aa9461e8bdf894ba8466e047e40fb5da8aaa6d68c19cf1e2aabe01e706/sherpa_onnx-1.13.4-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b4f54363b264b16148a724b4442f00cda97fcd4e9beeda3d75637753910e8557", size = 2307539, upload-time = "2026-07-07T12:28:22.056Z" },
{ url = "https://files.pythonhosted.org/packages/21/ed/d07787dd4be4119e6587c840f6b417c2d57c14d694d334af609d68cb5a41/sherpa_onnx-1.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ec5394b4ea73bf01e6883cf078348f87350f4eb3567d51d92cae77ea2582403", size = 2115586, upload-time = "2026-07-07T12:47:31.367Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c2/281d84dc9e448ea99d7fb77708cbe1cc7cfd8c7d669727dc94385a9e4ca5/sherpa_onnx-1.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a39352ceb2ec6671a1f252fb768fff75bb2f0bc849cca5f66f490e89910a860d", size = 4136268, upload-time = "2026-07-07T12:10:09.762Z" },
{ url = "https://files.pythonhosted.org/packages/db/47/da3ea14ab647a4f6580227853fe29353e1173ff77064d42c0bb31d01b453/sherpa_onnx-1.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88af596be24eac32982dd64fcac30af99d9130ca498bfa1a0064189c8498195b", size = 4358385, upload-time = "2026-07-07T13:10:10.265Z" },
{ url = "https://files.pythonhosted.org/packages/44/90/84205ff383ba9335c3821c2cd6d514350f52199cb640ec094517f1f911a0/sherpa_onnx-1.13.4-cp313-cp313-win32.whl", hash = "sha256:0cabb508a15be22138f9fb7695d7ec5f3893ecd088ee419b9df559fed7e8f649", size = 1929707, upload-time = "2026-07-07T12:51:53.196Z" },
{ url = "https://files.pythonhosted.org/packages/82/40/ee8a0a8c83fc6d7f5245a5a031e471d3b115e20cce867e7abb2f9d4185c9/sherpa_onnx-1.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:17050fdfb48d37ae996364f697c554a1399740d18e5a56b143c011d00cfed3e0", size = 2244504, upload-time = "2026-07-07T12:32:59.882Z" },
]
[[package]]
name = "simple-term-menu"
version = "1.6.6"
@ -4012,18 +4286,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/be/caf3b7d4b21851c7d8ddec7661f22089d95bb55bc7b4bdd79dea1001604e/supermemory-3.50.0-py3-none-any.whl", hash = "sha256:f6e2dd142934ec213d561414aeb0164ee408a34d339b84ed93440f03f4ca2290", size = 155533, upload-time = "2026-06-24T09:29:12.012Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "synchronicity"
version = "0.11.1"
@ -4063,6 +4325,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
]
[[package]]
name = "tflite-runtime"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/a6/02d68cb62cd221589a0ff055073251d883936237c9c990e34a1d7cecd06f/tflite_runtime-2.14.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:195ab752e7e57329a68e54dd3dd5439fad888b9bff1be0f0dc042a3237a90e4d", size = 2414486, upload-time = "2023-10-03T21:15:44.331Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e9/5fc0435129c23c17551fcfadc82bd0d5482276213dfbc641f07b4420cb6d/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ce9fa5d770a9725c746dcbf6f59f3178233b3759f09982e8b2db8d2234c333b0", size = 2325913, upload-time = "2023-10-03T21:15:46.348Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/e246c39d92929655bac8878d76406d6fb0293c678237e55621e7ece4a269/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_armv7l.whl", hash = "sha256:c4e66a74165b18089c86788400af19fa551768ac782d231a9beae2f6434f7949", size = 1820588, upload-time = "2023-10-03T21:15:48.399Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
]
[[package]]
name = "tokenizers"
version = "0.22.2"

View file

@ -154,6 +154,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `KREA_API_KEY` | Krea API key for Krea 2 image generation ([krea.ai](https://krea.ai/)) |
| `GROQ_API_KEY` | Groq Whisper STT API key ([groq.com](https://groq.com/)) |
| `ELEVENLABS_API_KEY` | ElevenLabs premium TTS voices ([elevenlabs.io](https://elevenlabs.io/)) |
| `PORCUPINE_ACCESS_KEY` | Picovoice Porcupine wake-word engine ([console.picovoice.ai](https://console.picovoice.ai/)) — only for `wake_word.provider: porcupine`; the default openWakeWord and sherpa engines need no key |
| `STT_GROQ_MODEL` | Override the Groq STT model (default: `whisper-large-v3-turbo`) |
| `GROQ_BASE_URL` | Override the Groq OpenAI-compatible STT endpoint |
| `STT_OPENAI_MODEL` | Override the OpenAI STT model (default: `whisper-1`) |

View file

@ -32,6 +32,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch
## Media & Web
- **[Voice Mode](voice-mode.md)** — Full voice interaction across CLI and messaging platforms. Talk to the agent using your microphone, hear spoken replies, and have live voice conversations in Discord voice channels.
- **[Wake Word](wake-word.md)** — Hands-free "Hey Hermes" trigger for the CLI, TUI, and desktop app. An on-device hotword listener starts a voice session when you speak the wake phrase.
- **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome/Brave/Chromium/Edge via CDP, or local Chromium. Navigate websites, fill forms, and extract information.
- **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model.
- **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Eleven models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo, Krea V2 Medium/Large); pick one via `hermes tools`.

View file

@ -10,6 +10,8 @@ Hermes Agent supports full voice interaction across CLI and messaging platforms.
If you want a practical setup walkthrough with recommended configurations and real usage patterns, see [Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes).
For hands-free session start — saying "hey hermes" (or any phrase) to open a fresh voice session on the CLI, TUI, or desktop app — see [Wake Word](/user-guide/features/wake-word).
## Prerequisites
Before using voice features, make sure you have:

View file

@ -0,0 +1,275 @@
---
sidebar_position: 11
title: "Wake Word"
description: "Hands-free 'Hey Hermes' wake word — start a voice session by speaking, the 'Hey Siri' way"
---
# Wake Word ("Hey Hermes")
The wake word turns Hermes into a hands-free assistant across the CLI, TUI, and
desktop app: with one setting on, Hermes listens in the background for a spoken
trigger phrase. Say it, and Hermes starts a fresh session, opens the microphone,
captures your command via the normal [voice pipeline](/user-guide/features/voice-mode),
and answers — exactly like "Hey Siri" or "Alexa". Use `surface` to pick which
one listens.
Detection runs **entirely on-device**. The always-on listener only watches for
the wake phrase; no audio leaves your machine until you actually speak a command
to the agent.
## How it works
1. With `wake_word.enabled: true` (or after `/wake on`), a lightweight hotword
detector listens on your default microphone.
2. When it hears the wake phrase it pauses itself (freeing the mic), starts a new
session, and records one utterance with voice mode's silence detection.
3. Your speech is transcribed and sent to the agent. After it replies, the
listener resumes automatically and waits for the next wake word.
It is **off by default** — nothing listens until you turn it on.
On the desktop app, a hands-free voice conversation can be ended by simply
**saying "stop"** (or "never mind", "goodbye", "cancel", "that's all") — the
spoken command ends the conversation instead of being sent to the agent. Only a
whole-utterance stop command matches, so a real request like "stop the docker
container" still goes through normally.
## Engines
| Engine | Cost | API key | Notes |
|--------|------|---------|-------|
| **openWakeWord** (default) | Free | None | Local ONNX models. Ships a bundled **"hey hermes"** model (default); also supports `hey_jarvis`, `alexa`, `hey_mycroft`, … and custom models |
| **sherpa** | Free | None | **Open vocabulary** — detects ANY typed phrase with zero training. Small English model auto-downloads on first use (~13 MB) |
| **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files |
By default the phrase is **"hey hermes"** — a model for it ships with Hermes, so
it works out of the box with no training. (On first use, openWakeWord downloads
its shared feature-extraction models — a small one-time fetch.)
Both are lazy-installed the first time you enable the wake word (desktop
installs made with `--include-desktop` pre-install them, so the ear works
instantly). To install ahead of time:
```bash
cd ~/.hermes/hermes-agent && uv pip install -e ".[wake]"
```
## Quick start
```bash
# In an interactive `hermes` session:
/wake on # start listening (installs the engine on first use)
/wake status # show phrase, provider, and state
/wake off # stop listening
```
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:
enabled: true
```
## Configuration
```yaml
wake_word:
enabled: false
surface: auto # eligible surface: "auto" | "cli" | "tui" | "gui"
provider: openwakeword # "openwakeword" (free, local) | "porcupine"
phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below
sensitivity: 0.6 # 0.0-1.0 — higher = stricter (fewer false triggers), consistent across all engines
confirmation_frames: 3 # openWakeWord only — consecutive over-threshold frames required to fire
start_new_session: true # start a fresh session on wake vs. continue the current one
openwakeword:
model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .onnx/.tflite
inference_framework: "" # "" (auto) | "onnx" | "tflite"
porcupine:
keyword: jarvis # built-in keyword OR path to a custom .ppn
```
`sensitivity`, `phrase`, and `start_new_session` apply to both engines. The
`openwakeword` and `porcupine` blocks select the actual detection model.
### Reducing false triggers on ambient speech
openWakeWord scores one short (~80ms) audio frame at a time, so a stray phoneme
in background conversation can occasionally spike a single frame over the
threshold and fire the wake word unintentionally. Two knobs control this:
- **`confirmation_frames`** (default `3`, openWakeWord only) — how many
*consecutive* over-threshold frames are required before the wake fires. A real
"hey hermes" holds a high score across several frames; an ambient blip spikes
just one. Raise it (e.g. `4``5`) if you still get false triggers in a noisy
room; the cost is a few tens of milliseconds of extra latency. `1` restores
the old fire-on-first-frame behavior.
- **`sensitivity`** (default `0.6`) — the detection threshold, `0.0``1.0`.
Higher is stricter (fewer false triggers). This direction is consistent across
**all** engines — for openWakeWord it's the raw per-frame score threshold, for
sherpa it maps onto the keyword threshold, and for Porcupine it's inverted
internally so "higher = stricter" holds there too. The `0.6` default sits
above openWakeWord's permissive `0.5` baseline, which let near-misses like
"hey hor" through; raise toward `0.8` if you still get false fires, lower it
if real "hey hermes" utterances are missed.
The `sherpa` and `porcupine` engines decode the whole phrase internally, so they
don't have the single-frame-spike problem and ignore `confirmation_frames`
(but they still honor `sensitivity`).
`inference_framework` picks the openWakeWord backend. Leave it empty (the
default) to let Hermes choose per platform: **tflite on Apple Silicon**, onnx
everywhere else. openWakeWord's onnx backend returns near-zero scores on macOS
ARM64 ([openWakeWord#336](https://github.com/dscripka/openWakeWord/issues/336)),
so a listener pinned to `onnx` there will arm, show as listening, and never
fire. The tflite backend needs `ai-edge-litert` on macOS, which Hermes installs
on demand alongside the other wake-word deps.
### Surfaces (CLI, TUI, GUI)
The wake word works in all three Hermes surfaces, and `surface` picks which one
owns the listener and opens the new session when it fires:
| `surface` | Behavior |
|-----------|----------|
| `auto` (default) | All local surfaces are eligible; the first one to arm owns the listener. |
| `cli` | Only the classic `hermes` CLI. |
| `tui` | Only `hermes --tui`. |
| `gui` | Only the desktop app. |
The detector is on-device and single-mic, so only one surface listens at a time,
including when Hermes surfaces run in separate processes. Ownership is sticky:
the first eligible claimant keeps the listener until it stops, disconnects, or
its process exits. Hermes does not silently fail over to another open surface.
Set `surface` when you want to pin ownership instead of using first-claim wins.
The TUI and desktop GUI share the same Python backend (`tui_gateway`), which
runs the detector server-side and yields the mic to voice capture while a
command records.
## Using a different phrase
"Hey Hermes" works out of the box — the bundled openWakeWord model
(`model: hey_hermes`) is the default. To wake on something else, the easiest
path is the open-vocabulary engine:
### Option A — sherpa (any phrase, zero training)
Type the phrase you want; it's tokenized at runtime — "hey coder",
"computer", "wake up neo", anything:
```yaml
wake_word:
enabled: true
provider: sherpa
phrase: "hey coder" # detection key — just type your phrase
```
The small English KWS model (~13 MB) downloads once on first use. Each
profile can set its own phrase — "hey \<profile\>" 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 <profile name>` 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 <profile>`) 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
custom model (≈7590 min on a free/Colab GPU) for maximum robustness, drop
the `.onnx` file somewhere, and reference it:
```yaml
wake_word:
enabled: true
provider: openwakeword
phrase: "computer"
openwakeword:
model: ~/.hermes/wakewords/computer.onnx # or a built-in name like hey_jarvis
```
Training references:
- [openWakeWord](https://github.com/dscripka/openWakeWord)
- [2026 training Colab](https://github.com/alfiedennen/openwakeword-colab-2026)
:::tip Pick a distinctive phrase
Wake phrases that don't collide with everyday speech generalize best. Two
syllables with an uncommon word ("hermes" qualifies) beat common words like
"hello" or "stop".
:::
### Option C — Porcupine (custom keyword in seconds)
Create a "Hey Hermes" keyword in the [Picovoice Console](https://console.picovoice.ai/),
download the `.ppn`, and:
```yaml
wake_word:
enabled: true
provider: porcupine
phrase: "hey hermes"
porcupine:
keyword: ~/.hermes/wakewords/hey_hermes.ppn
```
Set your access key in `~/.hermes/.env`:
```bash
PORCUPINE_ACCESS_KEY=your-key-here
```
## Requirements
- A working microphone and the `sounddevice` + `numpy` audio stack (shared with
voice mode).
- An STT provider for transcribing the spoken command — local `faster-whisper`
works out of the box; see [Voice Mode](/user-guide/features/voice-mode) for the
full provider list.
- A TTS provider for speaking the reply (the default `edge-tts` works with no
key). The wake flow is fully hands-free, so the toggle refuses to arm until
both STT and TTS are ready — `hermes tools` (Voice section) sets them up.
- The wake engine deps (auto-installed, or `hermes-agent[wake]`).
`/wake status` reports exactly what's missing if the listener won't start.
### "Listening" but never wakes (macOS)
macOS grants microphone access per **process**. STT working in the desktop app
proves the *renderer* has mic access — the wake listener runs in the Python
*backend*, which needs its own grant. Without it, CoreAudio hands the backend a
"working" stream that only ever delivers silence, so the ear shows listening
but the phrase never fires. Hermes detects this (`/wake status` shows
"mic delivers only silence"; the desktop ear tooltip carries the same hint).
Fix: System Settings → Privacy & Security → Microphone → enable the Hermes
backend (it may appear as your terminal, `python`, or Hermes), then toggle the
wake word off and on.
## Notes & limits
- **Local surfaces only.** The wake word runs in the CLI, TUI, and desktop GUI —
wherever a local microphone is available. It does not run in the messaging
gateway (Telegram, Discord, …), which has no mic.
- **One mic at a time.** The detector releases the microphone while a command is
recording and reclaims it once the turn ends, so it won't fight voice capture.
- **Privacy.** Hotword detection is local. Set `sensitivity` higher if you get
false triggers, lower if it misses you.

View file

@ -109,6 +109,7 @@ const sidebars: SidebarsConfig = {
label: 'Media & Web',
items: [
'user-guide/features/voice-mode',
'user-guide/features/wake-word',
'user-guide/features/web-search',
'user-guide/features/x-search',
'user-guide/features/browser',