From 487c457e5b357594bd4ee342419867d2752806ca Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 01:06:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(nouser):=20notify=20watcher=20=E2=80=94=20?= =?UTF-8?q?el=20sistema=20reacciona=20en=20tiempo=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> 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) --- CHANGELOG.md | 34 +++++ Cargo.lock | 1 + crates/modules/nouser/core/Cargo.toml | 1 + crates/modules/nouser/core/src/bin/nouser.rs | 138 ++++++++++++++++++- 4 files changed, 173 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563d5db..9f1a9ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(nouser): notify watcher — el sistema reacciona en tiempo real +El daemon ahora monta un `notify::recommended_watcher` recursivo +sobre el directorio. Cada `Create`/`Modify` de archivo regular +dispara: embedding del archivo, filtro por `centroid_model`, ranking +contra centroides existentes, log con marker 🧲 / · según supere +el umbral de atracción. + + $ nouser daemon /tmp/x & + # en otra terminal: + $ vim /tmp/x/src/nuevo.rs + # daemon log: + [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>` para sharing con el thread del + 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_monads + diff + publish) queda como work futuro. + +Limitación conocida: `notify` emite múltiples eventos por una sola +edición (Create + Modify, etc.). Sin debounce, el watcher reporta +varias veces. Aceptable para demo; production conviene debounce +~100ms por path. + +Tests: 7 (card) + 24 (core) verdes, 0 errores, 0 warnings. + ### feat(nouser): hidratación del daemon vía sled + path_hint El daemon ya no recomputa ciegamente al arrancar. Si la DB tiene Mónadas previas con `centroid_model` válido, las publica instantáneo diff --git a/Cargo.lock b/Cargo.lock index 1a45878..a2ad1b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6364,6 +6364,7 @@ dependencies = [ "brahman-card", "brahman-handshake", "brahman-sidecar", + "notify", "nouser-card", "nouser-nous", "serde", diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml index 81e4113..b752946 100644 --- a/crates/modules/nouser/core/Cargo.toml +++ b/crates/modules/nouser/core/Cargo.toml @@ -22,6 +22,7 @@ thiserror = { workspace = true } tokio = { workspace = true } ulid = { workspace = true } walkdir = "2" +notify = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs index 8c21658..b58a360 100644 --- a/crates/modules/nouser/core/src/bin/nouser.rs +++ b/crates/modules/nouser/core/src/bin/nouser.rs @@ -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>, +) -> Result> { + use notify::{Event, EventKind, RecursiveMode, Watcher}; + + let (tx, rx) = std::sync::mpsc::channel::>(); + 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>, +) { + 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();