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.
This commit is contained in:
91
public/voice-commands.ts
Normal file
91
public/voice-commands.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
Reference in New Issue
Block a user