gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Sutichaykuna

feat(shuma): disparo de patrones por estructura del directorio

shuma-infer: el directorio de una ocurrencia es ahora el cwd de su
último comando (el dir donde realmente operó el patrón, ya hechos los
cd). predict_next devuelve también el índice del patrón.

shuma-shell: la predicción se filtra por marcadores de proyecto
(.git, Cargo.toml, package.json, go.mod…). El shell deriva la
condición de disparo de un patrón —los marcadores comunes a sus
directorios— y sólo lo anticipa en un cwd que comparta esa estructura.
Así el ghost no sugiere `cargo build` en un directorio sin Cargo.toml.

Cierra la visión: del scripting a la intención, con precisión.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

9d8f45a9f8721506156a2810f49bb80ab35c659d · ruwaq sergio · (5 killa ñawpaq) · tayta 05ccab64f · kay commitpi sach'ata qhaway

Semántico sayk'uy · 9

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

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

2 qillqakuna · +65 −11

diff --git a/crates/apps/shuma-shell/src/main.rs b/crates/apps/shuma-shell/src/main.rs
index 60a2330..4c18fc0 100644
--- a/crates/apps/shuma-shell/src/main.rs
+++ b/crates/apps/shuma-shell/src/main.rs
@@ -39,6 +39,28 @@ const HISTORY: usize = 80;
 /// Líneas de salida visibles por comando (modo launcher liviano).
 const OUTPUT_LINES: usize = 16;
 
+/// Archivos/directorios que delatan la estructura de un proyecto.
+const PROJECT_MARKERS: &[&str] = &[
+    ".git",
+    "Cargo.toml",
+    "package.json",
+    "go.mod",
+    "Makefile",
+    "pyproject.toml",
+    "pom.xml",
+    "build.gradle",
+];
+
+/// Marcadores de proyecto presentes en `dir`.
+fn markers_in(dir: &str) -> Vec<String> {
+    let base = std::path::Path::new(dir);
+    PROJECT_MARKERS
+        .iter()
+        .filter(|m| base.join(m).exists())
+        .map(|m| m.to_string())
+        .collect()
+}
+
 /// Segundo Unix actual.
 fn unix_now() -> u64 {
     SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
@@ -403,6 +425,21 @@ impl Shell {
         }
     }
 
+    /// Condición de disparo de un patrón: los marcadores de proyecto
+    /// comunes a todos los directorios donde corrió.
+    fn pattern_trigger(&self, p: &shuma_infer::EmergingPattern) -> Vec<String> {
+        let mut dirs = p.directories.iter();
+        let Some(first) = dirs.next() else {
+            return Vec::new();
+        };
+        let mut common = markers_in(first);
+        for d in dirs {
+            let here = markers_in(d);
+            common.retain(|m| here.contains(m));
+        }
+        common
+    }
+
     /// La secuencia que el motor predice como continuación, si la hay.
     fn predicted_sequence(&self) -> Option<String> {
         if self.patterns.is_empty() {
@@ -410,8 +447,20 @@ impl Shell {
         }
         let records = self.infer_records();
         let tail = &records[records.len().saturating_sub(6)..];
-        let next = shuma_infer::predict_next(tail, &self.patterns)?;
-        (!next.is_empty()).then(|| next.join(" && "))
+        let (pi, next) = shuma_infer::predict_next(tail, &self.patterns)?;
+        if next.is_empty() {
+            return None;
+        }
+        // Disparo por estructura: no anticipar un patrón en un directorio
+        // que no comparte su forma (no sugerir `cargo` sin `Cargo.toml`).
+        let trigger = self.pattern_trigger(&self.patterns[pi]);
+        if !trigger.is_empty() {
+            let here = markers_in(self.session.cwd());
+            if !trigger.iter().all(|m| here.contains(m)) {
+                return None;
+            }
+        }
+        Some(next.join(" && "))
     }
 
     /// Calcula el sufijo fantasma del prompt: el resto de la línea que el
diff --git a/crates/modules/shuma/shuma-infer/src/lib.rs b/crates/modules/shuma/shuma-infer/src/lib.rs
index 405bb39..076e08b 100644
--- a/crates/modules/shuma/shuma-infer/src/lib.rs
+++ b/crates/modules/shuma/shuma-infer/src/lib.rs
@@ -168,9 +168,11 @@ fn build_pattern(
         })
         .collect();
 
+    // El directorio "de trabajo" de una ocurrencia: el cwd de su último
+    // comando — para entonces todos los `cd` ya se hicieron.
     let mut directories: Vec<String> = Vec::new();
     for &s in starts {
-        let d = &history[s].cwd;
+        let d = &history[s + len - 1].cwd;
         if !directories.contains(d) {
             directories.push(d.clone());
         }
@@ -240,13 +242,15 @@ pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec<Emer
 /// líneas que faltan para completarlo —tomadas de la ocurrencia más
 /// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
 /// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting".
+/// Devuelve `(índice del patrón en `patterns`, líneas de continuación)`.
 pub fn predict_next(
     recent: &[CommandRecord],
     patterns: &[EmergingPattern],
-) -> Option<Vec<String>> {
+) -> Option<(usize, Vec<String>)> {
     let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect();
-    let mut best: Option<(usize, &EmergingPattern)> = None;
-    for p in patterns {
+    // best = (longitud del prefijo coincidente, índice del patrón).
+    let mut best: Option<(usize, usize)> = None;
+    for (pi, p) in patterns.iter().enumerate() {
         // Tiene que quedar al menos un paso por predecir.
         let max_k = p.signature.len().saturating_sub(1).min(bins.len());
         for k in (1..=max_k).rev() {
@@ -259,13 +263,13 @@ pub fn predict_next(
                 .eq(tail.iter().copied());
             if prefix_matches {
                 if best.map(|(bk, _)| k > bk).unwrap_or(true) {
-                    best = Some((k, p));
+                    best = Some((k, pi));
                 }
                 break;
             }
         }
     }
-    best.map(|(k, p)| p.example[k..].to_vec())
+    best.map(|(k, pi)| (pi, patterns[pi].example[k..].to_vec()))
 }
 
 #[cfg(test)]
@@ -379,7 +383,8 @@ mod tests {
             ok("git pull", "/b"),
         ];
         let p = &detect_patterns(&history, &InferConfig::default())[0];
-        assert_eq!(p.directories, vec!["/home", "/work"]);
+        // El directorio de cada ocurrencia es el de su último comando.
+        assert_eq!(p.directories, vec!["/a", "/b"]);
     }
 
     #[test]
@@ -415,7 +420,7 @@ mod tests {
         let patterns = pattern_world();
         // El usuario acaba de hacer `cd` → se predicen los pasos que faltan.
         let recent = vec![ok("cd /nuevo", "/h")];
-        let next = predict_next(&recent, &patterns).unwrap();
+        let (_, next) = predict_next(&recent, &patterns).unwrap();
         assert_eq!(next, vec!["git pull", "cargo build"]);
     }
 
@@ -423,7 +428,7 @@ mod tests {
     fn prediction_shrinks_as_the_pattern_advances() {
         let patterns = pattern_world();
         let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")];
-        let next = predict_next(&recent, &patterns).unwrap();
+        let (_, next) = predict_next(&recent, &patterns).unwrap();
         assert_eq!(next, vec!["cargo build"]);
     }