Files
web-terminal/public/voice-commands.ts
Yaojia Wang e4c327e25e feat: voice command mapping — context-gated approve/reject over voice
While a tool-permission gate is held on the active tab, spoken confirm
phrases resolve it via the existing approve/reject WS channel — no server
change (byte-shuttle intact). Everything else stays ordinary dictation.

Safety: whole-utterance-exact matching + leading-negation guard + reject
precedence + confidence gate (approve only) + a cancellable 1.5s confirm
window whose commit re-validates gate identity/epoch/connectivity (TOCTOU
guard) + stale-gate epoch bound at PTT-start.

New pure modules public/voice-commands.ts and public/voice-confirm.ts at
100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass,
typecheck clean, build:web OK. Independent code + security reviews flagged a
confirm-window TOCTOU and an unwired cancel path — both fixed and covered.

Plan: docs/PLAN_VOICE_COMMANDS.md.
2026-07-01 10:38:32 +02:00

92 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* public/voice-commands.ts — pure, DOM-free/WS-free voice command matcher.
*
* Context-gated confirm-class command mapping: while the active tab has a
* HELD tool-gate permission (`pendingApproval===true`, `gate==='tool'`),
* spoken confirm/deny phrases resolve to `approve`/`reject`; every other
* transcript (or context) falls through to ordinary dictation (`text`),
* byte-for-byte unchanged. See docs/PLAN_VOICE_COMMANDS.md §3/§7 for the
* locked decisions and security rationale — this module is intentionally
* whole-utterance-exact (no substring/token matching) so incidental
* dictation can never auto-approve a shell-granting gate.
*/
export type VoiceAction = 'approve' | 'reject' | 'text'
export interface VoiceMatchContext {
readonly pendingApproval: boolean // active session held-permission flag
readonly gate: 'tool' | 'plan' | null // v1 acts ONLY on 'tool'
readonly confidence?: number // optional ASR confidence (decision D)
}
/** Approve requires ASR confidence at or above this threshold; reject is unaffected. */
export const MIN_APPROVE_CONFIDENCE = 0.6
/** Normalized whole-utterance affirmative phrases (see normalizeTranscript). */
export const APPROVE_PHRASES: readonly string[] = [
'确认', '批准', '同意', '通过', '允许', '可以', '好', '好的', '好吧', '好啊',
'是', '是的', '对', '行', '继续', '回车',
'yes', 'yeah', 'yep', 'ok', 'okay', 'confirm', 'approve', 'accept', 'proceed', 'go ahead',
]
/** Normalized whole-utterance negative phrases (see normalizeTranscript). */
export const REJECT_PHRASES: readonly string[] = [
'拒绝', '取消', '不行', '不要', '不用', '不同意', '不批准', '不可以', '不允许', '不通过',
'中断', '停止', '算了', '别', '不',
'no', 'nope', 'reject', 'deny', 'cancel', 'abort', 'decline', 'stop',
]
/** Leading tokens that negate an utterance; a match can never approve. */
export const NEGATION_PREFIXES: readonly string[] = [
'不', '别', '没', '未', '勿',
'no', 'not', 'dont', 'cannot', 'wont', 'never',
]
/** ASCII + CJK punctuation and quote marks, plus straight/curly apostrophes. */
const PUNCTUATION_RE =
/['.,!?;:,。!?;:、「」『』“”‘’()()[\]{}【】〈〉《》…—\-_/\\|"`~@#$%^&*+=<>]/g
/**
* `trim → toLowerCase → strip punctuation (so don't→dont) → collapse ASCII
* whitespace → trim`. Keeps CJK + ASCII letters/digits.
*/
export function normalizeTranscript(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(PUNCTUATION_RE, '')
.replace(/\s+/g, ' ')
.trim()
}
/** True if a normalized transcript begins with a negation token (word-boundary for ASCII). */
export function startsWithNegation(norm: string): boolean {
return NEGATION_PREFIXES.some((prefix) => {
if (!norm.startsWith(prefix)) return false
// CJK negation particles (不/别/没/未/勿) have no word boundary, so any
// following character still counts as negation. ASCII negators require a
// word boundary after them, so "now" is NOT negated by the "no" prefix.
if (!/^[a-z0-9]/.test(prefix)) return true
const rest = norm.slice(prefix.length)
return rest === '' || !/^[a-z0-9]/.test(rest)
})
}
/**
* Whole-utterance-exact, reject-first, negation-guarded command match.
* Falls through to `'text'` (ordinary dictation) for anything not an exact,
* gate-eligible, confidence-sufficient command phrase.
*/
export function matchCommand(transcript: string, ctx: VoiceMatchContext): VoiceAction {
const norm = normalizeTranscript(transcript)
if (!ctx.pendingApproval || norm === '') return 'text'
if (ctx.gate !== 'tool') return 'text'
if (startsWithNegation(norm)) return REJECT_PHRASES.includes(norm) ? 'reject' : 'text'
if (REJECT_PHRASES.includes(norm)) return 'reject'
if (APPROVE_PHRASES.includes(norm)) {
if (ctx.confidence !== undefined && ctx.confidence < MIN_APPROVE_CONFIDENCE) return 'text'
return 'approve'
}
return 'text'
}