/** * 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' }