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:
@@ -6,6 +6,40 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 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
|
### feat(nouser): hidratación del daemon vía sled + path_hint
|
||||||
El daemon ya no recomputa ciegamente al arrancar. Si la DB tiene
|
El daemon ya no recomputa ciegamente al arrancar. Si la DB tiene
|
||||||
Mónadas previas con `centroid_model` válido, las publica instantáneo
|
Mónadas previas con `centroid_model` válido, las publica instantáneo
|
||||||
|
|||||||
Generated
+1
@@ -6364,6 +6364,7 @@ dependencies = [
|
|||||||
"brahman-card",
|
"brahman-card",
|
||||||
"brahman-handshake",
|
"brahman-handshake",
|
||||||
"brahman-sidecar",
|
"brahman-sidecar",
|
||||||
|
"notify",
|
||||||
"nouser-card",
|
"nouser-card",
|
||||||
"nouser-nous",
|
"nouser-nous",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ thiserror = { workspace = true }
|
|||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
ulid = { workspace = true }
|
ulid = { workspace = true }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
|
notify = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -267,15 +267,151 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
|||||||
db.replace_monads(monads);
|
db.replace_monads(monads);
|
||||||
|
|
||||||
eprintln!(
|
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
|
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();
|
std::thread::park();
|
||||||
|
drop(_watcher);
|
||||||
drop(pool);
|
drop(pool);
|
||||||
Ok(())
|
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 {
|
fn cmd_attract(args: &[String]) -> Cmd {
|
||||||
let mut remote = false;
|
let mut remote = false;
|
||||||
let mut positional: Vec<&String> = Vec::new();
|
let mut positional: Vec<&String> = Vec::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user