use crate::provider::Brain;
use crate::reasoning::inference_scaling::collect_text;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandRisk {
Safe,
Reversible,
Confirm,
}
#[derive(Debug, Clone)]
pub struct CommandSpec {
pub command: &'static str,
pub description: &'static str,
pub takes_payload: bool,
pub examples: &'static [&'static str],
pub triggers: &'static [&'static str],
pub risk: CommandRisk,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommandIntent {
pub command: String,
pub payload: String,
pub confidence: f32,
}
impl CommandIntent {
pub fn as_invocation(&self) -> String {
if self.payload.is_empty() {
format!("sparrow {}", self.command)
} else {
format!("sparrow {} \"{}\"", self.command, self.payload)
}
}
}
pub fn command_catalog() -> &'static [CommandSpec] {
&[
CommandSpec {
command: "fix",
description: "Diagnose and fix a problem, bug, crash, or failing build the user describes.",
takes_payload: true,
examples: &[
"corrige le bug dans auth.rs",
"my site crashes",
"répare le build",
"fix the failing test",
],
triggers: &[
"corrige",
"répare",
"repare",
"fix",
"plante",
"crash",
"bug",
"erreur",
"ça marche pas",
"casse",
"broken",
"failing",
],
risk: CommandRisk::Reversible,
},
CommandSpec {
command: "explique",
description: "Explain a file, error, concept, or piece of code in plain language.",
takes_payload: true,
examples: &[
"explique src/app.js",
"c'est quoi un borrow checker",
"what does this function do",
"comprends cette erreur",
],
triggers: &[
"explique",
"explain",
"c'est quoi",
"qu'est-ce que",
"comprends",
"what is",
"what does",
"how does",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "plan",
description: "Propose an approach or plan for a task, read-only, without making changes.",
takes_payload: true,
examples: &[
"propose une approche pour ajouter l'auth",
"how should I structure this",
"plan the refactor",
],
triggers: &[
"propose",
"plan",
"approche",
"comment faire",
"how should",
"strategy",
"stratégie",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "run",
description: "Carry out a general coding/agent task: implement, refactor, write, edit, run something.",
takes_payload: true,
examples: &[
"ajoute la pagination à l'API",
"write a TODO.md",
"refactor this module",
"lance les tests",
],
triggers: &[
"ajoute",
"implémente",
"implemente",
"écris",
"ecris",
"refactor",
"crée",
"cree",
"add",
"implement",
"write",
"build me",
"lance les tests",
"run the tests",
],
risk: CommandRisk::Reversible,
},
CommandSpec {
command: "annule",
description: "Undo the last change, roll back to the previous checkpoint.",
takes_payload: false,
examples: &["annule", "undo", "reviens en arrière", "rollback that"],
triggers: &[
"annule",
"undo",
"reviens en arrière",
"rollback",
"roll back",
"défais",
"defais",
],
risk: CommandRisk::Confirm,
},
CommandSpec {
command: "console",
description: "Open the WebView cockpit / graphical interface.",
takes_payload: false,
examples: &["montre la console", "open the cockpit", "ouvre l'interface"],
triggers: &[
"console",
"cockpit",
"montre",
"ouvre l'interface",
"open the cockpit",
"webview",
"interface graphique",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "reason",
description: "Answer a hard reasoning question with maximum rigor (inference-time scaling).",
takes_payload: true,
examples: &[
"prouve que sqrt(2) est irrationnel",
"reason carefully about this trade-off",
"think hard about",
],
triggers: &[
"prouve",
"raisonne",
"reason carefully",
"think hard",
"démontre",
"demontre",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "security audit",
description: "Run a defensive security audit of the project.",
takes_payload: false,
examples: &[
"audit de sécurité",
"scan for vulnerabilities",
"vérifie la sécurité",
],
triggers: &[
"sécurité",
"securite",
"security",
"audit",
"vulnérab",
"vulnerab",
"scan",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "model --list",
description: "List the available providers and models.",
takes_payload: false,
examples: &[
"quels modèles sont dispo",
"list the models",
"montre les providers",
],
triggers: &["modèles", "modeles", "models", "providers", "provider"],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "memory list",
description: "Show what Sparrow remembers (stored facts/preferences).",
takes_payload: false,
examples: &[
"qu'est-ce que tu retiens",
"what do you remember",
"montre la mémoire",
],
triggers: &[
"mémoire",
"memoire",
"memory",
"tu retiens",
"remember",
"souviens",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "doctor",
description: "Diagnose Sparrow's own setup/health.",
takes_payload: false,
examples: &[
"est-ce que tout va bien",
"check sparrow health",
"diagnostic",
],
triggers: &[
"doctor",
"diagnostic",
"santé",
"sante",
"health",
"tout va bien",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "chat",
description: "Open an interactive multi-turn conversation.",
takes_payload: false,
examples: &["discutons", "let's chat", "parle avec moi"],
triggers: &["discut", "chat", "parle avec", "conversation"],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "setup",
description: "Configure providers, API keys, and first-run settings.",
takes_payload: false,
examples: &[
"configure les providers",
"set up my api keys",
"configuration",
],
triggers: &[
"configure",
"setup",
"configuration",
"réglages",
"reglages",
"settings",
],
risk: CommandRisk::Reversible,
},
CommandSpec {
command: "import auto",
description: "Import configuration from Claude Code / Codex / OpenCode.",
takes_payload: false,
examples: &[
"importe ma config claude code",
"migrate from codex",
"import my settings",
],
triggers: &[
"importe",
"import",
"migrate",
"migre",
"depuis claude",
"from codex",
],
risk: CommandRisk::Reversible,
},
CommandSpec {
command: "replay",
description: "Replay a past run / show a previous session's transcript.",
takes_payload: true,
examples: &[
"rejoue le dernier run",
"replay that session",
"montre l'historique",
],
triggers: &[
"rejoue",
"replay",
"historique",
"transcript",
"session passée",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "checkpoint list",
description: "List the saved checkpoints you can roll back to.",
takes_payload: false,
examples: &[
"liste les points de sauvegarde",
"show checkpoints",
"où puis-je revenir",
],
triggers: &[
"checkpoint",
"point de sauvegarde",
"points de sauvegarde",
"sauvegardes",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "rewind",
description: "Restore the workspace to a specific checkpoint.",
takes_payload: true,
examples: &[
"restaure le checkpoint abc",
"rewind to that point",
"reviens à la sauvegarde",
],
triggers: &["rewind", "restaure", "rembobine", "reviens à"],
risk: CommandRisk::Confirm,
},
CommandSpec {
command: "skills",
description: "List or manage the available skills.",
takes_payload: false,
examples: &["quelles compétences", "list skills", "montre les skills"],
triggers: &["compétences", "competences", "skills", "skill"],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "gateway start",
description: "Start the messaging gateway (Telegram/Discord/Slack/WS).",
takes_payload: false,
examples: &[
"démarre la passerelle",
"start the gateway",
"lance telegram",
],
triggers: &["passerelle", "gateway", "telegram", "discord", "slack"],
risk: CommandRisk::Confirm,
},
CommandSpec {
command: "intel scan",
description: "Scan public releases / changelogs for relevant updates.",
takes_payload: false,
examples: &["scanne les nouveautés", "scan releases", "quoi de neuf"],
triggers: &[
"nouveautés",
"nouveautes",
"intel",
"releases",
"quoi de neuf",
],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "tools",
description: "List the available tools.",
takes_payload: false,
examples: &["quels outils", "list tools", "montre les outils"],
triggers: &["outils", "tools"],
risk: CommandRisk::Safe,
},
CommandSpec {
command: "idees",
description: "Browse a gallery of things you can do with Sparrow, by persona.",
takes_payload: true,
examples: &[
"donne-moi des idées",
"what can I do",
"idées pour enseignant",
],
triggers: &[
"idées",
"idees",
"ideas",
"que puis-je faire",
"what can i do",
],
risk: CommandRisk::Safe,
},
]
}
fn normalize(s: &str) -> String {
s.trim().to_lowercase()
}
fn payload_after_trigger(input: &str, trigger: &str) -> String {
let lower = input.to_lowercase();
match lower.find(trigger) {
Some(pos) => input[pos + trigger.len()..]
.trim_start_matches([':', ' ', '\t'])
.trim()
.to_string(),
None => input.trim().to_string(),
}
}
pub fn heuristic_match(input: &str) -> Option<CommandIntent> {
let norm = normalize(input);
if norm.is_empty() {
return None;
}
let mut best: Option<(&CommandSpec, &str)> = None;
for spec in command_catalog() {
for trig in spec.triggers {
if norm.contains(trig) && best.map(|(_, t)| trig.len() > t.len()).unwrap_or(true) {
best = Some((spec, trig));
}
}
}
let (spec, trig) = best?;
let payload = if spec.takes_payload {
payload_after_trigger(input, trig)
} else {
String::new()
};
Some(CommandIntent {
command: spec.command.to_string(),
payload,
confidence: 0.85,
})
}
pub fn classifier_prompt(input: &str) -> String {
let mut catalog = String::new();
for spec in command_catalog() {
catalog.push_str(&format!(
"- {} — {} (payload: {})\n",
spec.command,
spec.description,
if spec.takes_payload { "yes" } else { "no" }
));
}
format!(
"You map a user's natural-language request to ONE Sparrow command from the catalog.\n\
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\
Use the user's own words for payload. If nothing fits, use command \"run\" with the full text as payload.\n\n\
CATALOG:\n{catalog}\nUSER REQUEST:\n{input}\n\nJSON:"
)
}
pub fn parse_intent(reply: &str) -> Option<CommandIntent> {
let start = reply.find('{')?;
let end = reply.rfind('}')?;
let json = &reply[start..=end];
let value: serde_json::Value = serde_json::from_str(json).ok()?;
let command = value.get("command")?.as_str()?.trim().to_string();
let valid = command_catalog()
.iter()
.any(|s| s.command.eq_ignore_ascii_case(&command));
if !valid {
return None;
}
let payload = value
.get("payload")
.and_then(|p| p.as_str())
.unwrap_or("")
.trim()
.to_string();
let confidence = value
.get("confidence")
.and_then(|c| c.as_f64())
.map(|c| c as f32)
.unwrap_or(0.6)
.clamp(0.0, 1.0);
Some(CommandIntent {
command,
payload,
confidence,
})
}
pub async fn route(brain: &dyn Brain, input: &str) -> CommandIntent {
if let Some(hit) = heuristic_match(input) {
return hit;
}
let prompt = classifier_prompt(input);
let req = crate::provider::BrainRequest {
system: Some("You are a precise command router. Output only JSON.".into()),
messages: vec![crate::provider::Msg {
role: "user".into(),
content: vec![crate::provider::ContentBlock::Text { text: prompt }],
}],
tools: vec![],
max_tokens: 200,
temperature: 0.0,
stop: vec![],
cache: crate::provider::PromptCacheConfig::disabled(),
};
if let Some(reply) = collect_text(brain, req).await {
if let Some(intent) = parse_intent(&reply) {
return intent;
}
}
CommandIntent {
command: "run".into(),
payload: input.trim().to_string(),
confidence: 0.3,
}
}
pub fn risk_of(command: &str) -> CommandRisk {
command_catalog()
.iter()
.find(|s| s.command.eq_ignore_ascii_case(command))
.map(|s| s.risk)
.unwrap_or(CommandRisk::Reversible)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heuristic_routes_common_french_and_english() {
assert_eq!(
heuristic_match("corrige le bug dans auth.rs")
.unwrap()
.command,
"fix"
);
assert_eq!(heuristic_match("my site crashes").unwrap().command, "fix");
assert_eq!(
heuristic_match("explique src/app.js").unwrap().command,
"explique"
);
assert_eq!(heuristic_match("annule").unwrap().command, "annule");
assert_eq!(
heuristic_match("ouvre l'interface").unwrap().command,
"console"
);
assert_eq!(
heuristic_match("quels modèles sont dispo").unwrap().command,
"model --list"
);
assert_eq!(
heuristic_match("fais un audit de sécurité")
.unwrap()
.command,
"security audit"
);
}
#[test]
fn heuristic_extracts_payload_after_trigger() {
let intent = heuristic_match("explique le borrow checker").unwrap();
assert_eq!(intent.command, "explique");
assert_eq!(intent.payload, "le borrow checker");
let undo = heuristic_match("undo").unwrap();
assert!(undo.payload.is_empty());
}
#[test]
fn heuristic_returns_none_for_unmatched() {
assert!(heuristic_match("xyzzy quux").is_none());
assert!(heuristic_match("").is_none());
}
#[test]
fn parse_intent_validates_against_catalog() {
let ok =
parse_intent(r#"{"command":"fix","payload":"the build","confidence":0.9}"#).unwrap();
assert_eq!(ok.command, "fix");
assert_eq!(ok.payload, "the build");
let messy = parse_intent("Sure! {\"command\": \"plan\", \"payload\": \"x\"} done").unwrap();
assert_eq!(messy.command, "plan");
assert!(parse_intent(r#"{"command":"selfdestruct","payload":""}"#).is_none());
assert!(parse_intent("not json").is_none());
}
#[test]
fn invocation_rendering() {
let i = CommandIntent {
command: "fix".into(),
payload: "le build".into(),
confidence: 0.9,
};
assert_eq!(i.as_invocation(), "sparrow fix \"le build\"");
let j = CommandIntent {
command: "doctor".into(),
payload: String::new(),
confidence: 0.9,
};
assert_eq!(j.as_invocation(), "sparrow doctor");
}
#[test]
fn risk_levels_resolve() {
assert_eq!(risk_of("explique"), CommandRisk::Safe);
assert_eq!(risk_of("annule"), CommandRisk::Confirm);
assert_eq!(risk_of("fix"), CommandRisk::Reversible);
}
}