gioser

tawasuyu/brahman

Rust 534TOML 203Markdown 58JSON 197690 Definiciones

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>

487c457e5b357594bd4ee342419867d2752806ca · por Sergio · (hace 5 meses) · padre 65af98da1 · ver el árbol en este commit

Delta semántico · 3

crates/modules/nouser/core/src/bin/nouser.rs

4 archivos · +173 −1

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 <sha>`.
 
 ## 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<Mutex<MonadDb>>` 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<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();