From 37ea535cb7ea2ea6b15f11684c48f55462d87c24 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 19:12:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(shuma):=20shuma-infer=20=E2=80=94=20motor?= =?UTF-8?q?=20de=20inferencia=20de=20intenciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detecta patrones de comandos repetidos en el historial: ventana deslizante sobre las firmas de binarios (sólo ventanas 100% exitosas), abstracción de argumentos variables (cd /a vs cd /b → cd <…>), patrones maximales, puntaje por largo × frecuencia. 10 tests, agnóstico y determinista. El shell lo corre tras cada comando terminado y promueve el patrón más fuerte a un grupo «✨ ...» en el panel [RUN] — la rehidratación que convierte la repetición orgánica en una receta de un clic. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 8 + Cargo.toml | 1 + crates/apps/shuma-shell/Cargo.toml | 1 + crates/apps/shuma-shell/src/main.rs | 31 ++ crates/modules/shuma/SDD.md | 3 +- crates/modules/shuma/shuma-infer/Cargo.toml | 11 + crates/modules/shuma/shuma-infer/src/lib.rs | 382 ++++++++++++++++++++ 7 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 crates/modules/shuma/shuma-infer/Cargo.toml create mode 100644 crates/modules/shuma/shuma-infer/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f1fac80..114751b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11396,6 +11396,13 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "shuma-infer" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "shuma-intent" version = "0.1.0" @@ -11439,6 +11446,7 @@ dependencies = [ "nahual-launcher", "nahual-theme", "shuma-exec", + "shuma-infer", "shuma-line", "shuma-session", "shuma-sysmon", diff --git a/Cargo.toml b/Cargo.toml index 61ff42c..62c0b69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,6 +184,7 @@ members = [ "crates/modules/shuma/shuma-sysmon", "crates/modules/shuma/shuma-session", "crates/modules/shuma/shuma-exec", + "crates/modules/shuma/shuma-infer", "crates/modules/shuma/shuma-shell-render", # ============================================================ diff --git a/crates/apps/shuma-shell/Cargo.toml b/crates/apps/shuma-shell/Cargo.toml index ad87cb1..90d2e9d 100644 --- a/crates/apps/shuma-shell/Cargo.toml +++ b/crates/apps/shuma-shell/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" shuma-line = { path = "../../modules/shuma/shuma-line" } shuma-session = { path = "../../modules/shuma/shuma-session" } shuma-exec = { path = "../../modules/shuma/shuma-exec" } +shuma-infer = { path = "../../modules/shuma/shuma-infer" } shuma-sysmon = { path = "../../modules/shuma/shuma-sysmon" } nahual-theme = { path = "../../modules/nahual/libs/theme" } nahual-launcher = { path = "../../modules/nahual/libs/launcher" } diff --git a/crates/apps/shuma-shell/src/main.rs b/crates/apps/shuma-shell/src/main.rs index 6f66bd1..fea64b7 100644 --- a/crates/apps/shuma-shell/src/main.rs +++ b/crates/apps/shuma-shell/src/main.rs @@ -356,13 +356,44 @@ impl Shell { } } } + let finished = self.active.iter().any(|(_, h)| h.is_finished()); self.active.retain(|(_, h)| !h.is_finished()); + if finished { + // Al cerrarse un comando, el motor de inferencia revisa si + // emergió un patrón repetido y lo promueve a un grupo. + self.infer_patterns(); + } if changed { self.scroll.scroll_to_bottom(); } changed } + /// Corre el motor de inferencia sobre el historial y promueve el + /// patrón más fuerte a un grupo reutilizable (rehidratación). + fn infer_patterns(&mut self) { + let records: Vec = self + .session + .history() + .iter() + .map(|r| { + shuma_infer::CommandRecord::parse( + &r.line, + &r.cwd, + r.status == RunStatus::Ok, + ) + }) + .collect(); + let patterns = + shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default()); + if let Some(top) = patterns.first() { + let name = format!("✨ {}", top.suggested_name()); + if self.session.group(&name).is_none() { + self.session.save_group(name, top.example.clone()); + } + } + } + /// Resuelve el destino de un `cd` contra el cwd de la sesión. fn resolve_cd(&self, arg: &str) -> Result { let home = std::env::var("HOME").unwrap_or_else(|_| "/".into()); diff --git a/crates/modules/shuma/SDD.md b/crates/modules/shuma/SDD.md index 9d81280..e639e2c 100644 --- a/crates/modules/shuma/SDD.md +++ b/crates/modules/shuma/SDD.md @@ -16,7 +16,8 @@ hablan postcard sobre Unix socket. | `shuma-intent` | lib | Intenciones, grafo de sesión y libro de macros | | `shuma-line` | lib | Análisis de la línea de comandos: lexer bash, clasificación, pipeline, autocompletado, `LineState` editable — agnóstico (GUI/TUI) | | `shuma-session` | lib | `WorkSession`: cwd (= identificador de aislamiento), historial de comandos y grupos reutilizables | -| `shuma-exec` | lib | Ejecución de comandos con salida en streaming (stdout/stderr línea a línea por canal) | +| `shuma-exec` | lib | Ejecución de comandos con salida en streaming (stdout/stderr línea a línea por canal) + `kill` | +| `shuma-infer` | lib | Motor de inferencia: detecta patrones de comandos repetidos en el historial y abstrae sus argumentos variables | | `shuma-sysmon` | lib | Muestreo de CPU/memoria con historial para los monitores | | `shuma-shell-render` | lib | Layout del lienzo de intenciones (legado del grafo) | diff --git a/crates/modules/shuma/shuma-infer/Cargo.toml b/crates/modules/shuma/shuma-infer/Cargo.toml new file mode 100644 index 0000000..2e0939c --- /dev/null +++ b/crates/modules/shuma/shuma-infer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shuma-infer" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — motor de inferencia de intenciones secuenciales: detecta patrones de comandos repetidos en el historial y abstrae sus argumentos variables." + +[dependencies] +serde = { workspace = true } diff --git a/crates/modules/shuma/shuma-infer/src/lib.rs b/crates/modules/shuma/shuma-infer/src/lib.rs new file mode 100644 index 0000000..8589906 --- /dev/null +++ b/crates/modules/shuma/shuma-infer/src/lib.rs @@ -0,0 +1,382 @@ +//! `shuma-infer` — el motor de inferencia de intenciones secuenciales. +//! +//! El shell observa cómo trabajas. Cuando una *coreografía* de comandos +//! se repite —`cd` a un proyecto, `git pull`, `cargo build`— este motor +//! la detecta, la abstrae (los argumentos que cambian se vuelven +//! variables) y la ofrece como un patrón reutilizable. Automatización +//! que nace de la repetición orgánica, no de escribir scripts. +//! +//! Es agnóstico y determinista: recibe el historial reducido a +//! [`CommandRecord`]s y devuelve [`EmergingPattern`]s. No toca disco, ni +//! la red, ni ningún frontend — el shell se encarga de eso. +//! +//! ```text +//! historial ──► detect_patterns ──► [EmergingPattern] +//! · firma de binarios (ventana deslizante) +//! · sólo ventanas 100% exitosas +//! · abstracción: args que varían → Varies +//! · se quedan los patrones maximales +//! ``` + +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Un comando ejecutado, reducido a lo que importa para inferir. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandRecord { + /// El binario invocado — la primera palabra de la línea. + pub binary: String, + /// Los argumentos, en orden. + pub args: Vec, + /// Directorio en que se ejecutó. + pub cwd: String, + /// Si terminó con éxito (código 0). + pub success: bool, +} + +impl CommandRecord { + /// Reduce una línea de comando a un registro. La división es simple + /// (`split_whitespace`) — suficiente para comparar firmas. + pub fn parse(line: &str, cwd: impl Into, success: bool) -> Self { + let mut words = line.split_whitespace().map(str::to_string); + let binary = words.next().unwrap_or_default(); + Self { binary, args: words.collect(), cwd: cwd.into(), success } + } +} + +/// Ajustes del detector. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct InferConfig { + /// Largo mínimo de una secuencia para considerarla patrón. + pub min_len: usize, + /// Largo máximo de ventana a buscar. + pub max_len: usize, + /// Cuántas veces debe repetirse una firma para emerger. + pub min_occurrences: usize, +} + +impl Default for InferConfig { + fn default() -> Self { + Self { min_len: 2, max_len: 5, min_occurrences: 2 } + } +} + +/// Los argumentos de un paso del patrón, tras la abstracción. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum StepArgs { + /// Los argumentos son idénticos en todas las ocurrencias. + Fixed(Vec), + /// Los argumentos cambian entre ocurrencias — son una variable. + Varies, +} + +/// Un paso abstracto del patrón: el binario + sus argumentos. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PatternStep { + pub binary: String, + pub args: StepArgs, +} + +impl PatternStep { + /// Renderiza el paso para mostrarlo — `"git pull"`, `"cd <…>"`. + pub fn render(&self) -> String { + match &self.args { + StepArgs::Fixed(a) if a.is_empty() => self.binary.clone(), + StepArgs::Fixed(a) => format!("{} {}", self.binary, a.join(" ")), + StepArgs::Varies => format!("{} <…>", self.binary), + } + } +} + +/// Un patrón de comandos que emergió del historial. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EmergingPattern { + /// Firma: la secuencia de binarios. + pub signature: Vec, + /// Pasos abstractos — para mostrar al usuario. + pub steps: Vec, + /// Las líneas reales de la ocurrencia más reciente — ejecutables. + pub example: Vec, + /// Cuántas veces apareció el patrón. + pub occurrences: usize, + /// Directorios donde arrancó el patrón, sin repetir. + pub directories: Vec, +} + +impl EmergingPattern { + /// Puntaje de interés: más largo y más frecuente, más arriba. + pub fn score(&self) -> usize { + self.occurrences * self.signature.len() + } + + /// Nombre sugerido para el patrón — los binarios significativos + /// (sin el `cd` inicial) unidos por `+`. + pub fn suggested_name(&self) -> String { + let significant: Vec<&str> = self + .signature + .iter() + .filter(|b| b.as_str() != "cd") + .map(String::as_str) + .collect(); + if significant.is_empty() { + self.signature.join("+") + } else { + significant.join("+") + } + } +} + +/// `true` si `needle` aparece como sub-secuencia contigua de `haystack`. +fn contains_subslice(haystack: &[String], needle: &[String]) -> bool { + needle.len() <= haystack.len() && haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Construye el patrón abstracto a partir de su firma y las posiciones +/// donde ocurrió. +fn build_pattern( + history: &[CommandRecord], + signature: &[String], + starts: &[usize], +) -> EmergingPattern { + let len = signature.len(); + let mut steps = Vec::with_capacity(len); + for i in 0..len { + // Argumentos de este paso a lo largo de todas las ocurrencias. + let first = &history[starts[0] + i].args; + let all_same = starts.iter().all(|&s| &history[s + i].args == first); + let args = if all_same { + StepArgs::Fixed(first.clone()) + } else { + StepArgs::Varies + }; + steps.push(PatternStep { binary: signature[i].clone(), args }); + } + + // La ocurrencia más reciente da las líneas reales, ejecutables. + let last = *starts.iter().max().expect("hay ocurrencias"); + let example: Vec = (0..len) + .map(|i| { + let c = &history[last + i]; + if c.args.is_empty() { + c.binary.clone() + } else { + format!("{} {}", c.binary, c.args.join(" ")) + } + }) + .collect(); + + let mut directories: Vec = Vec::new(); + for &s in starts { + let d = &history[s].cwd; + if !directories.contains(d) { + directories.push(d.clone()); + } + } + + EmergingPattern { + signature: signature.to_vec(), + steps, + example, + occurrences: starts.len(), + directories, + } +} + +/// Detecta los patrones de comandos repetidos en `history`. +/// +/// Sólo cuentan las ventanas cuyos comandos terminaron todos con éxito. +/// Se devuelven los patrones *maximales* (uno contenido en otro más +/// largo no se reporta), ordenados por puntaje descendente. +pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec { + // firma → posiciones de inicio de las ventanas que la producen. + let mut windows: BTreeMap, Vec> = BTreeMap::new(); + for len in cfg.min_len..=cfg.max_len { + if history.len() < len { + break; + } + for start in 0..=history.len() - len { + let win = &history[start..start + len]; + if !win.iter().all(|c| c.success) { + continue; // una ventana con un fallo no es un patrón + } + let signature: Vec = win.iter().map(|c| c.binary.clone()).collect(); + windows.entry(signature).or_default().push(start); + } + } + + // Firmas que se repiten lo suficiente. + let qualifying: Vec<(Vec, Vec)> = windows + .into_iter() + .filter(|(_, starts)| starts.len() >= cfg.min_occurrences) + .collect(); + + // Sólo las maximales: una firma contenida en otra más larga que + // también califica se descarta (la larga la subsume). + let mut patterns: Vec = qualifying + .iter() + .filter(|(sig, _)| { + !qualifying + .iter() + .any(|(other, _)| other.len() > sig.len() && contains_subslice(other, sig)) + }) + .map(|(sig, starts)| build_pattern(history, sig, starts)) + .collect(); + + patterns.sort_by(|a, b| { + b.score() + .cmp(&a.score()) + .then(a.signature.cmp(&b.signature)) + }); + patterns +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Atajo: un `CommandRecord` exitoso. + fn ok(line: &str, cwd: &str) -> CommandRecord { + CommandRecord::parse(line, cwd, true) + } + + #[test] + fn parse_splits_binary_and_args() { + let r = CommandRecord::parse("git commit -m mensaje", "/p", true); + assert_eq!(r.binary, "git"); + assert_eq!(r.args, vec!["commit", "-m", "mensaje"]); + } + + #[test] + fn detects_a_repeated_sequence() { + // cd → git pull → cargo build, dos veces, en dos directorios. + let history = vec![ + ok("cd /proj/a", "/home"), + ok("git pull", "/proj/a"), + ok("cargo build", "/proj/a"), + ok("cd /proj/b", "/home"), + ok("git pull", "/proj/b"), + ok("cargo build", "/proj/b"), + ]; + let patterns = detect_patterns(&history, &InferConfig::default()); + assert_eq!(patterns.len(), 1); + let p = &patterns[0]; + assert_eq!(p.signature, vec!["cd", "git", "cargo"]); + assert_eq!(p.occurrences, 2); + } + + #[test] + fn abstracts_varying_arguments() { + let history = vec![ + ok("cd /proj/a", "/home"), + ok("git pull", "/proj/a"), + ok("cd /proj/b", "/home"), + ok("git pull", "/proj/b"), + ]; + let patterns = detect_patterns(&history, &InferConfig::default()); + let p = &patterns[0]; + // El `cd` cambia de argumento → Varies; `git pull` es constante. + assert_eq!(p.steps[0].args, StepArgs::Varies); + assert_eq!(p.steps[1].args, StepArgs::Fixed(vec!["pull".into()])); + assert_eq!(p.steps[0].render(), "cd <…>"); + assert_eq!(p.steps[1].render(), "git pull"); + } + + #[test] + fn example_is_the_most_recent_occurrence() { + let history = vec![ + ok("cd /proj/a", "/home"), + ok("git pull", "/proj/a"), + ok("cd /proj/b", "/home"), + ok("git pull", "/proj/b"), + ]; + let p = &detect_patterns(&history, &InferConfig::default())[0]; + // Las líneas reales y ejecutables de la última ocurrencia. + assert_eq!(p.example, vec!["cd /proj/b", "git pull"]); + } + + #[test] + fn a_failed_command_breaks_the_pattern() { + let history = vec![ + ok("cd /proj/a", "/home"), + ok("git pull", "/proj/a"), + ok("cd /proj/b", "/home"), + CommandRecord::parse("git pull", "/proj/b", false), // falló + ]; + // Sólo una ventana [cd, git] exitosa → no se repite → sin patrón. + assert!(detect_patterns(&history, &InferConfig::default()).is_empty()); + } + + #[test] + fn no_repetition_yields_no_patterns() { + let history = vec![ + ok("ls", "/a"), + ok("pwd", "/a"), + ok("date", "/a"), + ]; + assert!(detect_patterns(&history, &InferConfig::default()).is_empty()); + } + + #[test] + fn longer_pattern_subsumes_its_subsequences() { + // [cd, git, cargo] repetido → no se reporta también [cd, git]. + let history = vec![ + ok("cd /a", "/h"), + ok("git pull", "/a"), + ok("cargo build", "/a"), + ok("cd /b", "/h"), + ok("git pull", "/b"), + ok("cargo build", "/b"), + ]; + let patterns = detect_patterns(&history, &InferConfig::default()); + assert_eq!(patterns.len(), 1); + assert_eq!(patterns[0].signature.len(), 3); + } + + #[test] + fn directories_are_collected() { + let history = vec![ + ok("cd /a", "/home"), + ok("git pull", "/a"), + ok("cd /b", "/work"), + ok("git pull", "/b"), + ]; + let p = &detect_patterns(&history, &InferConfig::default())[0]; + assert_eq!(p.directories, vec!["/home", "/work"]); + } + + #[test] + fn suggested_name_drops_the_cd() { + let history = vec![ + ok("cd /a", "/h"), + ok("git pull", "/a"), + ok("cargo build", "/a"), + ok("cd /b", "/h"), + ok("git pull", "/b"), + ok("cargo build", "/b"), + ]; + let p = &detect_patterns(&history, &InferConfig::default())[0]; + assert_eq!(p.suggested_name(), "git+cargo"); + } + + #[test] + fn score_ranks_longer_and_more_frequent_higher() { + let short = EmergingPattern { + signature: vec!["a".into(), "b".into()], + steps: vec![], + example: vec![], + occurrences: 2, + directories: vec![], + }; + let long = EmergingPattern { + signature: vec!["a".into(), "b".into(), "c".into()], + steps: vec![], + example: vec![], + occurrences: 3, + directories: vec![], + }; + assert!(long.score() > short.score()); + } +}