Skip to main content

sparrow/nl_router/
mod.rs

1//! Natural-language command router — "no commands to learn."
2//!
3//! Maps free-text input ("corrige le bug dans auth.rs", "show me the cockpit",
4//! "what does this function do?") to a Sparrow [`CommandIntent`]. Two tiers:
5//!
6//! 1. [`heuristic_match`] — a zero-cost deterministic pass for the common,
7//!    unambiguous phrasings (FR + EN). No model call.
8//! 2. [`classifier_prompt`] + [`parse_intent`] — when the heuristic is unsure,
9//!    ask the routed model to emit a JSON intent, validated against the catalog
10//!    so it can never invent a command.
11//!
12//! The catalog ([`command_catalog`]) is the single source of truth: each entry
13//! carries a natural-language description, example phrasings, whether it takes a
14//! free-text payload, and a risk level the dispatcher uses to decide
15//! execute-vs-confirm.
16
17use crate::provider::Brain;
18use crate::reasoning::inference_scaling::collect_text;
19
20/// How risky a command is to run unattended — drives execute / confirm / suggest.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CommandRisk {
23    /// Read-only / safe — can run immediately.
24    Safe,
25    /// Mutates the workspace but is checkpointed/reversible — run, but announce.
26    Reversible,
27    /// Outward or hard-to-undo — confirm before running.
28    Confirm,
29}
30
31/// One routable command.
32#[derive(Debug, Clone)]
33pub struct CommandSpec {
34    /// The canonical `sparrow` subcommand (what gets executed).
35    pub command: &'static str,
36    /// One-line natural-language description (fed to the classifier).
37    pub description: &'static str,
38    /// Whether the command takes a free-text payload (the user's words).
39    pub takes_payload: bool,
40    /// Example phrasings that should resolve here.
41    pub examples: &'static [&'static str],
42    /// Lowercased keyword/substring triggers for the heuristic pass.
43    pub triggers: &'static [&'static str],
44    pub risk: CommandRisk,
45}
46
47/// A resolved intent: which command to run, with what free-text payload.
48#[derive(Debug, Clone, PartialEq)]
49pub struct CommandIntent {
50    pub command: String,
51    pub payload: String,
52    /// 0.0–1.0. Heuristic hits are high; classifier supplies its own.
53    pub confidence: f32,
54}
55
56impl CommandIntent {
57    /// Render as the `sparrow` invocation it represents (for display/confirm).
58    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
67/// The routable command catalog. Representative, extensible — the dispatcher
68/// maps `command` back to the real handler.
69pub 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
448/// Strip a leading trigger phrase from `input` to recover the payload.
449fn 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
460/// Deterministic, zero-cost first pass. Returns a high-confidence intent when a
461/// trigger matches unambiguously; `None` when it should defer to the model.
462pub fn heuristic_match(input: &str) -> Option<CommandIntent> {
463    let norm = normalize(input);
464    if norm.is_empty() {
465        return None;
466    }
467    // Longest trigger wins, so "run the tests" beats a bare "run"-like token.
468    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
489/// Build the classifier prompt for the model path.
490pub 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
508/// Parse the classifier's JSON reply into a validated intent. The command must
509/// exist in the catalog (case-insensitive), else `None` — the model can never
510/// invent a command.
511pub fn parse_intent(reply: &str) -> Option<CommandIntent> {
512    // Tolerate prose around the JSON: take the first {...} block.
513    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
543/// Resolve free text to an intent: heuristic first, then the model. Falls back
544/// to a `run` intent with the raw text when the model path yields nothing, so
545/// the user is never stuck — the agent just does the task.
546pub 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    // Last resort: hand the whole thing to the general agent.
569    CommandIntent {
570        command: "run".into(),
571        payload: input.trim().to_string(),
572        confidence: 0.3,
573    }
574}
575
576/// Look up the risk level for a resolved command.
577pub 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        // Non-payload command keeps an empty payload.
626        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        // Tolerates surrounding prose.
644        let messy = parse_intent("Sure! {\"command\": \"plan\", \"payload\": \"x\"} done").unwrap();
645        assert_eq!(messy.command, "plan");
646
647        // Invented command → rejected.
648        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}