Skip to main content

clawft_plugin/voice/
commands.rs

1//! Voice command shortcuts.
2//!
3//! Maps spoken trigger phrases to direct tool invocations, bypassing
4//! the full LLM pipeline for common commands. The
5//! [`VoiceCommandRegistry`] performs exact prefix matching and
6//! Levenshtein-based fuzzy matching.
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// A voice command shortcut that maps a spoken phrase to a tool invocation.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct VoiceCommand {
14    /// Trigger phrases (any of these activates the command).
15    /// Matched after STT, case-insensitive, with fuzzy tolerance.
16    pub triggers: Vec<String>,
17    /// Tool name to invoke.
18    pub tool: String,
19    /// Static parameters to pass to the tool.
20    #[serde(default)]
21    pub params: serde_json::Value,
22    /// Whether this command requires voice confirmation before executing.
23    #[serde(default)]
24    pub confirm: bool,
25    /// Human-readable description (for help listing).
26    pub description: String,
27}
28
29/// Registry of voice command shortcuts.
30///
31/// Supports exact prefix matching and Levenshtein fuzzy matching
32/// (edit distance <= 2) on the trigger phrase portion of transcriptions.
33pub struct VoiceCommandRegistry {
34    commands: Vec<VoiceCommand>,
35    /// Precomputed lowercase triggers for fast matching.
36    trigger_index: HashMap<String, usize>,
37}
38
39impl VoiceCommandRegistry {
40    /// Build a registry from a list of voice commands.
41    pub fn new(commands: Vec<VoiceCommand>) -> Self {
42        let mut trigger_index = HashMap::new();
43        for (idx, cmd) in commands.iter().enumerate() {
44            for trigger in &cmd.triggers {
45                trigger_index.insert(trigger.to_lowercase(), idx);
46            }
47        }
48        Self {
49            commands,
50            trigger_index,
51        }
52    }
53
54    /// Match a transcribed phrase against registered commands.
55    ///
56    /// Returns the matched command if the transcription starts with
57    /// or closely matches a registered trigger phrase (Levenshtein
58    /// distance <= 2).
59    pub fn match_command(&self, transcription: &str) -> Option<&VoiceCommand> {
60        let lower = transcription.to_lowercase();
61        let lower = lower.trim();
62
63        // Pass 1: exact prefix match
64        for (trigger, idx) in &self.trigger_index {
65            if lower.starts_with(trigger.as_str()) {
66                return Some(&self.commands[*idx]);
67            }
68        }
69
70        // Pass 2: fuzzy match on the first N words (Levenshtein <= 2)
71        let words: Vec<&str> = lower.split_whitespace().collect();
72        for (trigger, idx) in &self.trigger_index {
73            let trigger_words: Vec<&str> = trigger.split_whitespace().collect();
74            if words.len() >= trigger_words.len() {
75                let spoken = words[..trigger_words.len()].join(" ");
76                if levenshtein_distance(&spoken, trigger) <= 2 {
77                    return Some(&self.commands[*idx]);
78                }
79            }
80        }
81
82        None
83    }
84
85    /// List all registered commands.
86    pub fn list(&self) -> &[VoiceCommand] {
87        &self.commands
88    }
89
90    /// Build a registry with the default built-in commands.
91    pub fn with_defaults() -> Self {
92        let commands = vec![
93            VoiceCommand {
94                triggers: vec!["stop listening".into(), "stop voice".into()],
95                tool: "voice_stop".into(),
96                params: serde_json::json!({}),
97                confirm: false,
98                description: "Stop the voice listening session.".into(),
99            },
100            VoiceCommand {
101                triggers: vec!["what time is it".into(), "current time".into()],
102                tool: "system_info".into(),
103                params: serde_json::json!({"query": "time"}),
104                confirm: false,
105                description: "Show the current time.".into(),
106            },
107            VoiceCommand {
108                triggers: vec!["list files".into(), "show files".into()],
109                tool: "list_directory".into(),
110                params: serde_json::json!({"path": "."}),
111                confirm: false,
112                description: "List files in the current directory.".into(),
113            },
114        ];
115        Self::new(commands)
116    }
117}
118
119/// Simple Levenshtein distance (edit distance) between two strings.
120///
121/// Standard dynamic programming implementation. Used for fuzzy matching
122/// of voice commands where minor transcription errors are expected.
123pub fn levenshtein_distance(a: &str, b: &str) -> usize {
124    let a_chars: Vec<char> = a.chars().collect();
125    let b_chars: Vec<char> = b.chars().collect();
126    let m = a_chars.len();
127    let n = b_chars.len();
128
129    let mut dp = vec![vec![0usize; n + 1]; m + 1];
130    for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
131        row[0] = i;
132    }
133    for j in 0..=n {
134        dp[0][j] = j;
135    }
136
137    for i in 1..=m {
138        for j in 1..=n {
139            let cost = if a_chars[i - 1] == b_chars[j - 1] {
140                0
141            } else {
142                1
143            };
144            dp[i][j] = (dp[i - 1][j] + 1)
145                .min(dp[i][j - 1] + 1)
146                .min(dp[i - 1][j - 1] + cost);
147        }
148    }
149
150    dp[m][n]
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn test_registry() -> VoiceCommandRegistry {
158        VoiceCommandRegistry::with_defaults()
159    }
160
161    #[test]
162    fn exact_match_stop_listening() {
163        let registry = test_registry();
164        let cmd = registry.match_command("stop listening").unwrap();
165        assert_eq!(cmd.tool, "voice_stop");
166    }
167
168    #[test]
169    fn exact_match_what_time() {
170        let registry = test_registry();
171        let cmd = registry.match_command("what time is it").unwrap();
172        assert_eq!(cmd.tool, "system_info");
173    }
174
175    #[test]
176    fn exact_match_list_files_with_suffix() {
177        let registry = test_registry();
178        let cmd = registry.match_command("list files in the current directory").unwrap();
179        assert_eq!(cmd.tool, "list_directory");
180    }
181
182    #[test]
183    fn exact_match_case_insensitive() {
184        let registry = test_registry();
185        let cmd = registry.match_command("Stop Listening").unwrap();
186        assert_eq!(cmd.tool, "voice_stop");
187    }
188
189    #[test]
190    fn fuzzy_match_within_distance_2() {
191        let registry = test_registry();
192        // "stopp listening" has distance 1 from "stop listening"
193        let cmd = registry.match_command("stopp listening");
194        assert!(cmd.is_some());
195        assert_eq!(cmd.unwrap().tool, "voice_stop");
196    }
197
198    #[test]
199    fn no_match_for_unrelated_phrase() {
200        let registry = test_registry();
201        let cmd = registry.match_command("tell me a joke");
202        assert!(cmd.is_none());
203    }
204
205    #[test]
206    fn levenshtein_identical_strings() {
207        assert_eq!(levenshtein_distance("hello", "hello"), 0);
208    }
209
210    #[test]
211    fn levenshtein_one_insertion() {
212        assert_eq!(levenshtein_distance("hell", "hello"), 1);
213    }
214
215    #[test]
216    fn levenshtein_one_deletion() {
217        assert_eq!(levenshtein_distance("hello", "helo"), 1);
218    }
219
220    #[test]
221    fn levenshtein_one_substitution() {
222        assert_eq!(levenshtein_distance("hello", "hallo"), 1);
223    }
224
225    #[test]
226    fn levenshtein_completely_different() {
227        assert_eq!(levenshtein_distance("abc", "xyz"), 3);
228    }
229
230    #[test]
231    fn levenshtein_empty_strings() {
232        assert_eq!(levenshtein_distance("", ""), 0);
233        assert_eq!(levenshtein_distance("abc", ""), 3);
234        assert_eq!(levenshtein_distance("", "abc"), 3);
235    }
236
237    #[test]
238    fn voice_command_serde_roundtrip() {
239        let cmd = VoiceCommand {
240            triggers: vec!["test".into()],
241            tool: "test_tool".into(),
242            params: serde_json::json!({"key": "value"}),
243            confirm: true,
244            description: "A test command".into(),
245        };
246        let json = serde_json::to_string(&cmd).unwrap();
247        let restored: VoiceCommand = serde_json::from_str(&json).unwrap();
248        assert_eq!(restored.triggers, vec!["test"]);
249        assert_eq!(restored.tool, "test_tool");
250        assert!(restored.confirm);
251    }
252
253    #[test]
254    fn registry_list_returns_all_commands() {
255        let registry = test_registry();
256        assert_eq!(registry.list().len(), 3);
257    }
258
259    #[test]
260    fn custom_commands_registry() {
261        let commands = vec![VoiceCommand {
262            triggers: vec!["deploy now".into(), "ship it".into()],
263            tool: "deploy".into(),
264            params: serde_json::json!({"env": "production"}),
265            confirm: true,
266            description: "Deploy to production.".into(),
267        }];
268        let registry = VoiceCommandRegistry::new(commands);
269        let cmd = registry.match_command("ship it").unwrap();
270        assert_eq!(cmd.tool, "deploy");
271        assert!(cmd.confirm);
272    }
273}