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>
This commit is contained in:
@@ -39,6 +39,28 @@ const HISTORY: usize = 80;
|
|||||||
/// Líneas de salida visibles por comando (modo launcher liviano).
|
/// Líneas de salida visibles por comando (modo launcher liviano).
|
||||||
const OUTPUT_LINES: usize = 16;
|
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.
|
/// Segundo Unix actual.
|
||||||
fn unix_now() -> u64 {
|
fn unix_now() -> u64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
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.
|
/// La secuencia que el motor predice como continuación, si la hay.
|
||||||
fn predicted_sequence(&self) -> Option<String> {
|
fn predicted_sequence(&self) -> Option<String> {
|
||||||
if self.patterns.is_empty() {
|
if self.patterns.is_empty() {
|
||||||
@@ -410,8 +447,20 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
let records = self.infer_records();
|
let records = self.infer_records();
|
||||||
let tail = &records[records.len().saturating_sub(6)..];
|
let tail = &records[records.len().saturating_sub(6)..];
|
||||||
let next = shuma_infer::predict_next(tail, &self.patterns)?;
|
let (pi, next) = shuma_infer::predict_next(tail, &self.patterns)?;
|
||||||
(!next.is_empty()).then(|| next.join(" && "))
|
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
|
/// Calcula el sufijo fantasma del prompt: el resto de la línea que el
|
||||||
|
|||||||
@@ -168,9 +168,11 @@ fn build_pattern(
|
|||||||
})
|
})
|
||||||
.collect();
|
.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();
|
let mut directories: Vec<String> = Vec::new();
|
||||||
for &s in starts {
|
for &s in starts {
|
||||||
let d = &history[s].cwd;
|
let d = &history[s + len - 1].cwd;
|
||||||
if !directories.contains(d) {
|
if !directories.contains(d) {
|
||||||
directories.push(d.clone());
|
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
|
/// líneas que faltan para completarlo —tomadas de la ocurrencia más
|
||||||
/// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
|
/// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
|
||||||
/// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting".
|
/// 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(
|
pub fn predict_next(
|
||||||
recent: &[CommandRecord],
|
recent: &[CommandRecord],
|
||||||
patterns: &[EmergingPattern],
|
patterns: &[EmergingPattern],
|
||||||
) -> Option<Vec<String>> {
|
) -> Option<(usize, Vec<String>)> {
|
||||||
let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect();
|
let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect();
|
||||||
let mut best: Option<(usize, &EmergingPattern)> = None;
|
// best = (longitud del prefijo coincidente, índice del patrón).
|
||||||
for p in patterns {
|
let mut best: Option<(usize, usize)> = None;
|
||||||
|
for (pi, p) in patterns.iter().enumerate() {
|
||||||
// Tiene que quedar al menos un paso por predecir.
|
// Tiene que quedar al menos un paso por predecir.
|
||||||
let max_k = p.signature.len().saturating_sub(1).min(bins.len());
|
let max_k = p.signature.len().saturating_sub(1).min(bins.len());
|
||||||
for k in (1..=max_k).rev() {
|
for k in (1..=max_k).rev() {
|
||||||
@@ -259,13 +263,13 @@ pub fn predict_next(
|
|||||||
.eq(tail.iter().copied());
|
.eq(tail.iter().copied());
|
||||||
if prefix_matches {
|
if prefix_matches {
|
||||||
if best.map(|(bk, _)| k > bk).unwrap_or(true) {
|
if best.map(|(bk, _)| k > bk).unwrap_or(true) {
|
||||||
best = Some((k, p));
|
best = Some((k, pi));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
best.map(|(k, p)| p.example[k..].to_vec())
|
best.map(|(k, pi)| (pi, patterns[pi].example[k..].to_vec()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -379,7 +383,8 @@ mod tests {
|
|||||||
ok("git pull", "/b"),
|
ok("git pull", "/b"),
|
||||||
];
|
];
|
||||||
let p = &detect_patterns(&history, &InferConfig::default())[0];
|
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]
|
#[test]
|
||||||
@@ -415,7 +420,7 @@ mod tests {
|
|||||||
let patterns = pattern_world();
|
let patterns = pattern_world();
|
||||||
// El usuario acaba de hacer `cd` → se predicen los pasos que faltan.
|
// El usuario acaba de hacer `cd` → se predicen los pasos que faltan.
|
||||||
let recent = vec![ok("cd /nuevo", "/h")];
|
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"]);
|
assert_eq!(next, vec!["git pull", "cargo build"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +428,7 @@ mod tests {
|
|||||||
fn prediction_shrinks_as_the_pattern_advances() {
|
fn prediction_shrinks_as_the_pattern_advances() {
|
||||||
let patterns = pattern_world();
|
let patterns = pattern_world();
|
||||||
let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")];
|
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"]);
|
assert_eq!(next, vec!["cargo build"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user