gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Sutichaykuna

feat(shuma): shuma-infer — motor de inferencia de intenciones

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 <noreply@anthropic.com>

37ea535cb7ea2ea6b15f11684c48f55462d87c24 · ruwaq sergio · (5 killa ñawpaq) · tayta 3bfb42f1c · kay commitpi sach'ata qhaway

Semántico sayk'uy · 27

crates/apps/shuma-shell/src/main.rs

crates/modules/shuma/shuma-infer/src/lib.rs

7 qillqakuna · +436 −1

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<shuma_infer::CommandRecord> = 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<String, String> {
         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<String>,
+    /// 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<String>, 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<String>),
+    /// 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<String>,
+    /// Pasos abstractos — para mostrar al usuario.
+    pub steps: Vec<PatternStep>,
+    /// Las líneas reales de la ocurrencia más reciente — ejecutables.
+    pub example: Vec<String>,
+    /// Cuántas veces apareció el patrón.
+    pub occurrences: usize,
+    /// Directorios donde arrancó el patrón, sin repetir.
+    pub directories: Vec<String>,
+}
+
+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<String> = (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<String> = 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<EmergingPattern> {
+    // firma → posiciones de inicio de las ventanas que la producen.
+    let mut windows: BTreeMap<Vec<String>, Vec<usize>> = 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<String> = 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<String>, Vec<usize>)> = 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<EmergingPattern> = 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());
+    }
+}