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:
Sergio
2026-05-09 01:06:31 +00:00
parent 65af98da13
commit 487c457e5b
4 changed files with 173 additions and 1 deletions
+137 -1
View File
@@ -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();