feat(desktop): end a hands-free voice conversation by saying "stop"

Saying 'stop' in a voice chat did nothing — the transcript was just
submitted to the agent as a normal turn, so the conversation never ended.
The only way out was the mouse/hotkey. That's not how a hands-free voice
assistant should work.

useVoiceConversation now checks each finished utterance against a spoken
stop-command matcher BEFORE submitting: 'stop', 'stop listening', 'never
mind', 'goodbye', 'cancel', 'that's all', etc., optionally addressed
('hey hermes, stop'). A match ends the conversation (flips enabled=false,
which drives the existing end() teardown — mic close, playback stop, wake
re-arm) instead of sending a turn.

Deliberately conservative: only a WHOLE-utterance stop phrase matches, so
substantive requests that merely contain 'stop' ('stop the docker
container', 'how do I stop a process') still go through.

- apps/desktop/src/lib/voice-stop-word.ts: isVoiceStopCommand() matcher
- use-voice-conversation.ts: onStopWord option + intercept before submit
- use-composer-voice.ts: wire onStopWord -> end the conversation
- 6 matcher tests (bare/multi-word/addressed stop; substantive requests
  with 'stop' pass through; bare address words don't match)
- docs: note the spoken-stop behavior
This commit is contained in:
Teknium 2026-07-28 10:03:11 -07:00
parent f106e0ebc2
commit 09c62d5da3
No known key found for this signature in database
5 changed files with 188 additions and 0 deletions

View file

@ -121,6 +121,11 @@ export function useComposerVoice({
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

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 { useMicRecorder } from './use-mic-recorder'
@ -25,6 +26,7 @@ interface VoiceConversationOptions {
busy: boolean
enabled: boolean
onFatalError?: () => void
onStopWord?: () => void
onSubmit: (text: string) => Promise<void> | void
onTranscribeAudio?: (audio: Blob) => Promise<string>
pendingResponse: () => PendingVoiceResponse | null
@ -35,6 +37,7 @@ export function useVoiceConversation({
busy,
enabled,
onFatalError,
onStopWord,
onSubmit,
onTranscribeAudio,
pendingResponse,
@ -59,6 +62,12 @@ 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])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
@ -132,6 +141,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)

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

@ -28,6 +28,12 @@ to the agent.
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 |