1use crate::provider::Brain;
18use crate::reasoning::inference_scaling::collect_text;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CommandRisk {
23 Safe,
25 Reversible,
27 Confirm,
29}
30
31#[derive(Debug, Clone)]
33pub struct CommandSpec {
34 pub command: &'static str,
36 pub description: &'static str,
38 pub takes_payload: bool,
40 pub examples: &'static [&'static str],
42 pub triggers: &'static [&'static str],
44 pub risk: CommandRisk,
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub struct CommandIntent {
50 pub command: String,
51 pub payload: String,
52 pub confidence: f32,
54}
55
56impl CommandIntent {
57 pub fn as_invocation(&self) -> String {
59 if self.payload.is_empty() {
60 format!("sparrow {}", self.command)
61 } else {
62 format!("sparrow {} \"{}\"", self.command, self.payload)
63 }
64 }
65}
66
67pub fn command_catalog() -> &'static [CommandSpec] {
70 &[
71 CommandSpec {
72 command: "fix",
73 description: "Diagnose and fix a problem, bug, crash, or failing build the user describes.",
74 takes_payload: true,
75 examples: &[
76 "corrige le bug dans auth.rs",
77 "my site crashes",
78 "répare le build",
79 "fix the failing test",
80 ],
81 triggers: &[
82 "corrige",
83 "répare",
84 "repare",
85 "fix",
86 "plante",
87 "crash",
88 "bug",
89 "erreur",
90 "ça marche pas",
91 "casse",
92 "broken",
93 "failing",
94 ],
95 risk: CommandRisk::Reversible,
96 },
97 CommandSpec {
98 command: "explique",
99 description: "Explain a file, error, concept, or piece of code in plain language.",
100 takes_payload: true,
101 examples: &[
102 "explique src/app.js",
103 "c'est quoi un borrow checker",
104 "what does this function do",
105 "comprends cette erreur",
106 ],
107 triggers: &[
108 "explique",
109 "explain",
110 "c'est quoi",
111 "qu'est-ce que",
112 "comprends",
113 "what is",
114 "what does",
115 "how does",
116 ],
117 risk: CommandRisk::Safe,
118 },
119 CommandSpec {
120 command: "plan",
121 description: "Propose an approach or plan for a task, read-only, without making changes.",
122 takes_payload: true,
123 examples: &[
124 "propose une approche pour ajouter l'auth",
125 "how should I structure this",
126 "plan the refactor",
127 ],
128 triggers: &[
129 "propose",
130 "plan",
131 "approche",
132 "comment faire",
133 "how should",
134 "strategy",
135 "stratégie",
136 ],
137 risk: CommandRisk::Safe,
138 },
139 CommandSpec {
140 command: "run",
141 description: "Carry out a general coding/agent task: implement, refactor, write, edit, run something.",
142 takes_payload: true,
143 examples: &[
144 "ajoute la pagination à l'API",
145 "write a TODO.md",
146 "refactor this module",
147 "lance les tests",
148 ],
149 triggers: &[
150 "ajoute",
151 "implémente",
152 "implemente",
153 "écris",
154 "ecris",
155 "refactor",
156 "crée",
157 "cree",
158 "add",
159 "implement",
160 "write",
161 "build me",
162 "lance les tests",
163 "run the tests",
164 ],
165 risk: CommandRisk::Reversible,
166 },
167 CommandSpec {
168 command: "annule",
169 description: "Undo the last change, roll back to the previous checkpoint.",
170 takes_payload: false,
171 examples: &["annule", "undo", "reviens en arrière", "rollback that"],
172 triggers: &[
173 "annule",
174 "undo",
175 "reviens en arrière",
176 "rollback",
177 "roll back",
178 "défais",
179 "defais",
180 ],
181 risk: CommandRisk::Confirm,
182 },
183 CommandSpec {
184 command: "console",
185 description: "Open the WebView cockpit / graphical interface.",
186 takes_payload: false,
187 examples: &["montre la console", "open the cockpit", "ouvre l'interface"],
188 triggers: &[
189 "console",
190 "cockpit",
191 "montre",
192 "ouvre l'interface",
193 "open the cockpit",
194 "webview",
195 "interface graphique",
196 ],
197 risk: CommandRisk::Safe,
198 },
199 CommandSpec {
200 command: "reason",
201 description: "Answer a hard reasoning question with maximum rigor (inference-time scaling).",
202 takes_payload: true,
203 examples: &[
204 "prouve que sqrt(2) est irrationnel",
205 "reason carefully about this trade-off",
206 "think hard about",
207 ],
208 triggers: &[
209 "prouve",
210 "raisonne",
211 "reason carefully",
212 "think hard",
213 "démontre",
214 "demontre",
215 ],
216 risk: CommandRisk::Safe,
217 },
218 CommandSpec {
219 command: "security audit",
220 description: "Run a defensive security audit of the project.",
221 takes_payload: false,
222 examples: &[
223 "audit de sécurité",
224 "scan for vulnerabilities",
225 "vérifie la sécurité",
226 ],
227 triggers: &[
228 "sécurité",
229 "securite",
230 "security",
231 "audit",
232 "vulnérab",
233 "vulnerab",
234 "scan",
235 ],
236 risk: CommandRisk::Safe,
237 },
238 CommandSpec {
239 command: "model --list",
240 description: "List the available providers and models.",
241 takes_payload: false,
242 examples: &[
243 "quels modèles sont dispo",
244 "list the models",
245 "montre les providers",
246 ],
247 triggers: &["modèles", "modeles", "models", "providers", "provider"],
248 risk: CommandRisk::Safe,
249 },
250 CommandSpec {
251 command: "memory list",
252 description: "Show what Sparrow remembers (stored facts/preferences).",
253 takes_payload: false,
254 examples: &[
255 "qu'est-ce que tu retiens",
256 "what do you remember",
257 "montre la mémoire",
258 ],
259 triggers: &[
260 "mémoire",
261 "memoire",
262 "memory",
263 "tu retiens",
264 "remember",
265 "souviens",
266 ],
267 risk: CommandRisk::Safe,
268 },
269 CommandSpec {
270 command: "doctor",
271 description: "Diagnose Sparrow's own setup/health.",
272 takes_payload: false,
273 examples: &[
274 "est-ce que tout va bien",
275 "check sparrow health",
276 "diagnostic",
277 ],
278 triggers: &[
279 "doctor",
280 "diagnostic",
281 "santé",
282 "sante",
283 "health",
284 "tout va bien",
285 ],
286 risk: CommandRisk::Safe,
287 },
288 CommandSpec {
289 command: "chat",
290 description: "Open an interactive multi-turn conversation.",
291 takes_payload: false,
292 examples: &["discutons", "let's chat", "parle avec moi"],
293 triggers: &["discut", "chat", "parle avec", "conversation"],
294 risk: CommandRisk::Safe,
295 },
296 CommandSpec {
297 command: "setup",
298 description: "Configure providers, API keys, and first-run settings.",
299 takes_payload: false,
300 examples: &[
301 "configure les providers",
302 "set up my api keys",
303 "configuration",
304 ],
305 triggers: &[
306 "configure",
307 "setup",
308 "configuration",
309 "réglages",
310 "reglages",
311 "settings",
312 ],
313 risk: CommandRisk::Reversible,
314 },
315 CommandSpec {
316 command: "import auto",
317 description: "Import configuration from Claude Code / Codex / OpenCode.",
318 takes_payload: false,
319 examples: &[
320 "importe ma config claude code",
321 "migrate from codex",
322 "import my settings",
323 ],
324 triggers: &[
325 "importe",
326 "import",
327 "migrate",
328 "migre",
329 "depuis claude",
330 "from codex",
331 ],
332 risk: CommandRisk::Reversible,
333 },
334 CommandSpec {
335 command: "replay",
336 description: "Replay a past run / show a previous session's transcript.",
337 takes_payload: true,
338 examples: &[
339 "rejoue le dernier run",
340 "replay that session",
341 "montre l'historique",
342 ],
343 triggers: &[
344 "rejoue",
345 "replay",
346 "historique",
347 "transcript",
348 "session passée",
349 ],
350 risk: CommandRisk::Safe,
351 },
352 CommandSpec {
353 command: "checkpoint list",
354 description: "List the saved checkpoints you can roll back to.",
355 takes_payload: false,
356 examples: &[
357 "liste les points de sauvegarde",
358 "show checkpoints",
359 "où puis-je revenir",
360 ],
361 triggers: &[
362 "checkpoint",
363 "point de sauvegarde",
364 "points de sauvegarde",
365 "sauvegardes",
366 ],
367 risk: CommandRisk::Safe,
368 },
369 CommandSpec {
370 command: "rewind",
371 description: "Restore the workspace to a specific checkpoint.",
372 takes_payload: true,
373 examples: &[
374 "restaure le checkpoint abc",
375 "rewind to that point",
376 "reviens à la sauvegarde",
377 ],
378 triggers: &["rewind", "restaure", "rembobine", "reviens à"],
379 risk: CommandRisk::Confirm,
380 },
381 CommandSpec {
382 command: "skills",
383 description: "List or manage the available skills.",
384 takes_payload: false,
385 examples: &["quelles compétences", "list skills", "montre les skills"],
386 triggers: &["compétences", "competences", "skills", "skill"],
387 risk: CommandRisk::Safe,
388 },
389 CommandSpec {
390 command: "gateway start",
391 description: "Start the messaging gateway (Telegram/Discord/Slack/WS).",
392 takes_payload: false,
393 examples: &[
394 "démarre la passerelle",
395 "start the gateway",
396 "lance telegram",
397 ],
398 triggers: &["passerelle", "gateway", "telegram", "discord", "slack"],
399 risk: CommandRisk::Confirm,
400 },
401 CommandSpec {
402 command: "intel scan",
403 description: "Scan public releases / changelogs for relevant updates.",
404 takes_payload: false,
405 examples: &["scanne les nouveautés", "scan releases", "quoi de neuf"],
406 triggers: &[
407 "nouveautés",
408 "nouveautes",
409 "intel",
410 "releases",
411 "quoi de neuf",
412 ],
413 risk: CommandRisk::Safe,
414 },
415 CommandSpec {
416 command: "tools",
417 description: "List the available tools.",
418 takes_payload: false,
419 examples: &["quels outils", "list tools", "montre les outils"],
420 triggers: &["outils", "tools"],
421 risk: CommandRisk::Safe,
422 },
423 CommandSpec {
424 command: "idees",
425 description: "Browse a gallery of things you can do with Sparrow, by persona.",
426 takes_payload: true,
427 examples: &[
428 "donne-moi des idées",
429 "what can I do",
430 "idées pour enseignant",
431 ],
432 triggers: &[
433 "idées",
434 "idees",
435 "ideas",
436 "que puis-je faire",
437 "what can i do",
438 ],
439 risk: CommandRisk::Safe,
440 },
441 ]
442}
443
444fn normalize(s: &str) -> String {
445 s.trim().to_lowercase()
446}
447
448fn payload_after_trigger(input: &str, trigger: &str) -> String {
450 let lower = input.to_lowercase();
451 match lower.find(trigger) {
452 Some(pos) => input[pos + trigger.len()..]
453 .trim_start_matches([':', ' ', '\t'])
454 .trim()
455 .to_string(),
456 None => input.trim().to_string(),
457 }
458}
459
460pub fn heuristic_match(input: &str) -> Option<CommandIntent> {
463 let norm = normalize(input);
464 if norm.is_empty() {
465 return None;
466 }
467 let mut best: Option<(&CommandSpec, &str)> = None;
469 for spec in command_catalog() {
470 for trig in spec.triggers {
471 if norm.contains(trig) && best.map(|(_, t)| trig.len() > t.len()).unwrap_or(true) {
472 best = Some((spec, trig));
473 }
474 }
475 }
476 let (spec, trig) = best?;
477 let payload = if spec.takes_payload {
478 payload_after_trigger(input, trig)
479 } else {
480 String::new()
481 };
482 Some(CommandIntent {
483 command: spec.command.to_string(),
484 payload,
485 confidence: 0.85,
486 })
487}
488
489pub fn classifier_prompt(input: &str) -> String {
491 let mut catalog = String::new();
492 for spec in command_catalog() {
493 catalog.push_str(&format!(
494 "- {} — {} (payload: {})\n",
495 spec.command,
496 spec.description,
497 if spec.takes_payload { "yes" } else { "no" }
498 ));
499 }
500 format!(
501 "You map a user's natural-language request to ONE Sparrow command from the catalog.\n\
502 Reply with ONLY a JSON object: {{\"command\": \"<exact command from the catalog>\", \"payload\": \"<the user's task text, or empty>\", \"confidence\": <0.0-1.0>}}.\n\
503 Use the user's own words for payload. If nothing fits, use command \"run\" with the full text as payload.\n\n\
504 CATALOG:\n{catalog}\nUSER REQUEST:\n{input}\n\nJSON:"
505 )
506}
507
508pub fn parse_intent(reply: &str) -> Option<CommandIntent> {
512 let start = reply.find('{')?;
514 let end = reply.rfind('}')?;
515 let json = &reply[start..=end];
516 let value: serde_json::Value = serde_json::from_str(json).ok()?;
517 let command = value.get("command")?.as_str()?.trim().to_string();
518 let valid = command_catalog()
519 .iter()
520 .any(|s| s.command.eq_ignore_ascii_case(&command));
521 if !valid {
522 return None;
523 }
524 let payload = value
525 .get("payload")
526 .and_then(|p| p.as_str())
527 .unwrap_or("")
528 .trim()
529 .to_string();
530 let confidence = value
531 .get("confidence")
532 .and_then(|c| c.as_f64())
533 .map(|c| c as f32)
534 .unwrap_or(0.6)
535 .clamp(0.0, 1.0);
536 Some(CommandIntent {
537 command,
538 payload,
539 confidence,
540 })
541}
542
543pub async fn route(brain: &dyn Brain, input: &str) -> CommandIntent {
547 if let Some(hit) = heuristic_match(input) {
548 return hit;
549 }
550 let prompt = classifier_prompt(input);
551 let req = crate::provider::BrainRequest {
552 system: Some("You are a precise command router. Output only JSON.".into()),
553 messages: vec![crate::provider::Msg {
554 role: "user".into(),
555 content: vec![crate::provider::ContentBlock::Text { text: prompt }],
556 }],
557 tools: vec![],
558 max_tokens: 200,
559 temperature: 0.0,
560 stop: vec![],
561 cache: crate::provider::PromptCacheConfig::disabled(),
562 };
563 if let Some(reply) = collect_text(brain, req).await {
564 if let Some(intent) = parse_intent(&reply) {
565 return intent;
566 }
567 }
568 CommandIntent {
570 command: "run".into(),
571 payload: input.trim().to_string(),
572 confidence: 0.3,
573 }
574}
575
576pub fn risk_of(command: &str) -> CommandRisk {
578 command_catalog()
579 .iter()
580 .find(|s| s.command.eq_ignore_ascii_case(command))
581 .map(|s| s.risk)
582 .unwrap_or(CommandRisk::Reversible)
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn heuristic_routes_common_french_and_english() {
591 assert_eq!(
592 heuristic_match("corrige le bug dans auth.rs")
593 .unwrap()
594 .command,
595 "fix"
596 );
597 assert_eq!(heuristic_match("my site crashes").unwrap().command, "fix");
598 assert_eq!(
599 heuristic_match("explique src/app.js").unwrap().command,
600 "explique"
601 );
602 assert_eq!(heuristic_match("annule").unwrap().command, "annule");
603 assert_eq!(
604 heuristic_match("ouvre l'interface").unwrap().command,
605 "console"
606 );
607 assert_eq!(
608 heuristic_match("quels modèles sont dispo").unwrap().command,
609 "model --list"
610 );
611 assert_eq!(
612 heuristic_match("fais un audit de sécurité")
613 .unwrap()
614 .command,
615 "security audit"
616 );
617 }
618
619 #[test]
620 fn heuristic_extracts_payload_after_trigger() {
621 let intent = heuristic_match("explique le borrow checker").unwrap();
622 assert_eq!(intent.command, "explique");
623 assert_eq!(intent.payload, "le borrow checker");
624
625 let undo = heuristic_match("undo").unwrap();
627 assert!(undo.payload.is_empty());
628 }
629
630 #[test]
631 fn heuristic_returns_none_for_unmatched() {
632 assert!(heuristic_match("xyzzy quux").is_none());
633 assert!(heuristic_match("").is_none());
634 }
635
636 #[test]
637 fn parse_intent_validates_against_catalog() {
638 let ok =
639 parse_intent(r#"{"command":"fix","payload":"the build","confidence":0.9}"#).unwrap();
640 assert_eq!(ok.command, "fix");
641 assert_eq!(ok.payload, "the build");
642
643 let messy = parse_intent("Sure! {\"command\": \"plan\", \"payload\": \"x\"} done").unwrap();
645 assert_eq!(messy.command, "plan");
646
647 assert!(parse_intent(r#"{"command":"selfdestruct","payload":""}"#).is_none());
649 assert!(parse_intent("not json").is_none());
650 }
651
652 #[test]
653 fn invocation_rendering() {
654 let i = CommandIntent {
655 command: "fix".into(),
656 payload: "le build".into(),
657 confidence: 0.9,
658 };
659 assert_eq!(i.as_invocation(), "sparrow fix \"le build\"");
660 let j = CommandIntent {
661 command: "doctor".into(),
662 payload: String::new(),
663 confidence: 0.9,
664 };
665 assert_eq!(j.as_invocation(), "sparrow doctor");
666 }
667
668 #[test]
669 fn risk_levels_resolve() {
670 assert_eq!(risk_of("explique"), CommandRisk::Safe);
671 assert_eq!(risk_of("annule"), CommandRisk::Confirm);
672 assert_eq!(risk_of("fix"), CommandRisk::Reversible);
673 }
674}