feat(nouser): notify watcher — el sistema reacciona en tiempo real
El daemon monta notify::recommended_watcher recursivo sobre el dir escaneado. Cada Create/Modify de archivo regular dispara: embedding → filtro por centroid_model → ranking contra centroides → log con 🧲 / · según supere DEFAULT_ATTRACTION_THRESHOLD. $ nouser daemon /tmp/x & $ vim /tmp/x/src/nuevo.rs [watcher] 🧲 /tmp/x/src/nuevo.rs → x/src (0.7470) $ echo edit >> /tmp/x/docs/n1.md [watcher] 🧲 /tmp/x/docs/n1.md → x/docs (0.8169) Mecánica: - DB pasa a Arc<Mutex<MonadDb>> para sharing con thread watcher. - Watcher en thread dedicado nouser-watcher; reacciona sólo a Create/Modify, ignora Access/Metadata-only. - react_to_change(path, metadata, db) computa embedding, filtra por centroid_model, busca best attraction. - No re-publica al broker ni muta DB — sólo observa y narra. La invalidación selectiva (re-cluster + replace + diff publish) queda para futuro. Limitación conocida: notify emite múltiples eventos por edición (Create + Modify, etc.). Sin debounce el watcher reporta varias veces. Aceptable para demo; producción conviene debounce ~100ms por path. Esto cierra la Fase C del plan post-reporte: el sistema "se siente" vivo. Tocar un archivo en vim y ver inmediatamente la atracción calculada cumple el meta-mensaje "Mónada Viva". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -267,15 +267,151 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
||||
db.replace_monads(monads);
|
||||
|
||||
eprintln!(
|
||||
"nouser daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación). Ctrl-C para terminar.",
|
||||
"nouser daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación)",
|
||||
scanned_count, newly_spawned
|
||||
);
|
||||
|
||||
// Watcher: cada cambio en el árbol dispara un cálculo de
|
||||
// atracción. Esto vuelve "vivo" al sistema — `vim archivo.rs`
|
||||
// produce inmediatamente "→ atraído a brahman-handshake/src 0.91".
|
||||
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
|
||||
let _watcher = match spawn_fs_watcher(dir.clone(), db_shared.clone()) {
|
||||
Ok(w) => {
|
||||
eprintln!(
|
||||
"nouser daemon: watcher activo en {} — Ctrl-C para terminar.",
|
||||
dir.display()
|
||||
);
|
||||
Some(w)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"nouser daemon: watcher deshabilitado ({e}) — Ctrl-C para terminar."
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
std::thread::park();
|
||||
drop(_watcher);
|
||||
drop(pool);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Watcher de filesystem: por cada Create/Modify en el árbol,
|
||||
/// computa el embedding del archivo y reporta a qué Mónada se
|
||||
/// atrae. No re-publica ni muta el broker — sólo observa y narra.
|
||||
/// La invalidación selectiva queda como work futuro.
|
||||
fn spawn_fs_watcher(
|
||||
dir: std::path::PathBuf,
|
||||
db: std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
|
||||
) -> Result<notify::RecommendedWatcher, Box<dyn std::error::Error>> {
|
||||
use notify::{Event, EventKind, RecursiveMode, Watcher};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
|
||||
let mut watcher = notify::recommended_watcher(move |res| {
|
||||
let _ = tx.send(res);
|
||||
})?;
|
||||
watcher.watch(&dir, RecursiveMode::Recursive)?;
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("nouser-watcher".into())
|
||||
.spawn(move || {
|
||||
for res in rx {
|
||||
let event = match res {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("[watcher] error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Sólo reaccionamos a Create/Modify de archivos
|
||||
// regulares; renames / removes los manejará un re-scan.
|
||||
let interesting = matches!(
|
||||
event.kind,
|
||||
EventKind::Create(_) | EventKind::Modify(_)
|
||||
);
|
||||
if !interesting {
|
||||
continue;
|
||||
}
|
||||
for path in &event.paths {
|
||||
let metadata = match std::fs::metadata(path) {
|
||||
Ok(m) if m.is_file() => m,
|
||||
_ => continue,
|
||||
};
|
||||
react_to_change(path, &metadata, &db);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
fn react_to_change(
|
||||
path: &std::path::Path,
|
||||
metadata: &std::fs::Metadata,
|
||||
db: &std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
|
||||
) {
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let target = nouser_card::FileEntry {
|
||||
id: nouser_card::FileId::from(nouser_card::ulid::Ulid::new()),
|
||||
path: path.to_path_buf(),
|
||||
content_hash: None,
|
||||
size: metadata.len(),
|
||||
mtime_ms,
|
||||
extension: path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_lowercase()),
|
||||
};
|
||||
let v = embed::embed(&target);
|
||||
|
||||
let db_lock = match db.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return, // mutex envenenado, salimos silenciosos
|
||||
};
|
||||
|
||||
// Filtramos por modelo coincidente (mismo cuidado que cmd_attract).
|
||||
let best = db_lock
|
||||
.monads()
|
||||
.filter(|m| !m.centroid.is_empty())
|
||||
.filter(|m| {
|
||||
m.centroid_model
|
||||
.as_deref()
|
||||
.map(|id| id == embed::MODEL_ID)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|m| (m, embed::attraction_score(&v, m)))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
match best {
|
||||
Some((m, score)) if score >= embed::DEFAULT_ATTRACTION_THRESHOLD => {
|
||||
eprintln!(
|
||||
"[watcher] 🧲 {} → {} ({:.4})",
|
||||
path.display(),
|
||||
m.label,
|
||||
score
|
||||
);
|
||||
}
|
||||
Some((m, score)) => {
|
||||
eprintln!(
|
||||
"[watcher] · {} → {} (mejor, {:.4} < umbral {:.4})",
|
||||
path.display(),
|
||||
m.label,
|
||||
score,
|
||||
embed::DEFAULT_ATTRACTION_THRESHOLD
|
||||
);
|
||||
}
|
||||
None => {
|
||||
eprintln!("[watcher] {} (ninguna Mónada con centroide compatible)", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_attract(args: &[String]) -> Cmd {
|
||||
let mut remote = false;
|
||||
let mut positional: Vec<&String> = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user