refactor(naming): A1 — ente→arje, vista→revista, pluma→fana

Rename batch de la Fase A del PLAN_MACRO:
- 25 crates ente-* → arje-* (protocol/init/runtime/compat). El linaje
  arje (init Linux) queda con prefijo coherente.
- vista → revista (revista-core + revista-web).
- pluma → fana (fana-md + fana-md-reader-web). fana absorbe el linaje
  markdown de pluma; será el writer DAG editor (prioridad alta).

Cambios:
- git mv de 29 crate dirs + 2 SDDs
- package/lib/bin names + path refs + imports .rs reescritos
- workspace Cargo.toml + comentarios de sección
- SDDs de init/runtime/compat/protocol actualizados a arje-
- SDD de revista + SDD de fana (reescrito: writer DAG editor)
- docs/STATUS.md, ROADMAP.md, PLAN_MACRO.md, arje-boot.md,
  arje-replace-systemd.md actualizados
- docs/changelog/akasha.md → chasqui.md

scripts/rename-fase-a.py idempotente (--dry-run soportado).
cargo check --workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 00:10:14 +00:00
parent 3fc6dcfa72
commit b83d40a833
159 changed files with 2384 additions and 1111 deletions
@@ -0,0 +1,795 @@
//! `chasqui` CLI — explorador de Mónadas.
//!
//! Subcomandos:
//!
//! - `scan <dir>` recorre `dir` y muestra las Mónadas detectadas.
//! - `show <dir> <id?>` scan + detalles de la Mónada con prefijo de ID.
//! - `json <dir>` scan + dump JSON con los manifests.
//!
//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
//! sesión termina y todo se descarta. Phase B agrega persistencia y
//! presencia ante el Init.
use std::path::PathBuf;
use std::process::ExitCode;
use chasqui_core::{
cluster, db, embed,
scanner::{self, ScanConfig},
};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let prog = args.first().cloned().unwrap_or_else(|| "chasqui".into());
let sub = match args.get(1).map(String::as_str) {
Some(s) => s,
None => {
print_usage(&prog);
return ExitCode::from(2);
}
};
let rest = &args[2..];
let result = match sub {
"scan" => cmd_scan(rest),
"show" => cmd_show(rest),
"json" => cmd_json(rest),
"daemon" => cmd_daemon(rest),
"attract" => cmd_attract(rest),
"--help" | "-h" | "help" => {
print_usage(&prog);
return ExitCode::SUCCESS;
}
other => {
eprintln!("chasqui: comando desconocido '{other}'");
print_usage(&prog);
return ExitCode::from(2);
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("chasqui: {e}");
ExitCode::from(1)
}
}
}
fn print_usage(prog: &str) {
eprintln!("uso: {prog} <comando> [args]");
eprintln!();
eprintln!("comandos:");
eprintln!(" scan <dir> recorre un directorio y lista las Mónadas detectadas");
eprintln!(" show <dir> <prefix> scan + detalle de la Mónada cuyo ID empieza con <prefix>");
eprintln!(" json <dir> scan + dump JSON de todos los manifests");
eprintln!(" daemon <dir> scan + sidecarea cada Mónada al Init brahman");
eprintln!(" attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
eprintln!();
eprintln!("env:");
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
eprintln!(" NOUSER_DB_PATH si está set, abre sled en esa ruta (persistencia)");
eprintln!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)");
}
type Cmd = Result<(), Box<dyn std::error::Error>>;
fn min_files() -> usize {
std::env::var("NOUSER_MIN_FILES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
}
fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = args.first().ok_or("falta argumento <dir>")?;
Ok(PathBuf::from(dir))
}
fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
let files = scanner::scan_directory(dir, &ScanConfig::default())?;
let n_files = files.len();
let monads = cluster::by_directory(&files, min_files());
let mut db = open_db()?;
db.ingest_files(files);
db.replace_monads(monads);
Ok((db, n_files))
}
/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
/// si no, store en memoria.
fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
if let Ok(path) = std::env::var("NOUSER_DB_PATH") {
Ok(db::MonadDb::open(&path)?)
} else {
Ok(db::MonadDb::new())
}
}
fn cmd_scan(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let (db, n_files) = run_scan(&dir)?;
println!(
"scan: {} archivos en {}, {} mónadas (min_files={})",
n_files,
dir.display(),
db.monad_count(),
min_files()
);
if db.monad_count() == 0 {
println!(" (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
return Ok(());
}
println!();
for m in db.monads() {
let id_short = format!("{}", m.id);
let id_short = &id_short[..8];
println!(
" [{}] {:30} card={} ent={:.2} lens={:?}",
id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
);
if !m.keywords.is_empty() {
println!(" keywords: {}", m.keywords.join(", "));
}
}
Ok(())
}
fn cmd_show(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
let (db, _) = run_scan(&dir)?;
let m = db
.monads()
.find(|m| m.id.to_string().starts_with(prefix))
.ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
println!("Monad {}", m.id);
println!(" label: {}", m.label);
println!(" summary: {}", m.summary);
println!(" cardinality: {}", m.cardinality);
println!(" entropy: {:.4}", m.entropy);
println!(" lens: {:?}", m.dominant_lens);
println!(" keywords: {}", m.keywords.join(", "));
println!(" members ({}):", m.members.len());
for f in db.resolve_members(m.id) {
println!(
" {:>10} bytes {}",
f.size,
f.path.display()
);
}
Ok(())
}
fn cmd_json(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let (db, _) = run_scan(&dir)?;
let manifests: Vec<_> = db.monads().cloned().collect();
println!("{}", serde_json::to_string_pretty(&manifests)?);
Ok(())
}
fn cmd_daemon(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let pool = std::sync::Arc::new(
brahman_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
);
// 1. Decidir el path del query socket ANTES de armar el engine
// Card (porque viaja como service_socket en la Card).
let query_socket = chasqui_card::query::transport::default_socket_path();
// 2. Engine como Ente. Declara service_socket + flow.output para
// que el broker pueda emitir MatchEvent::Available a consumers
// interesados en `flow.input = monad-list:json`.
let engine_card = build_engine_card(query_socket.clone());
let engine_id = engine_card.id;
let engine_label = engine_card.label.clone();
eprintln!(
"chasqui daemon: publicando engine '{}' (kind=Ente, id={}, socket={})",
engine_label,
engine_id,
query_socket.display()
);
pool.spawn(engine_card);
// 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado,
// publicar lo que ya tenemos ANTES del re-scan. brahman-status
// ve mónadas reales en milisegundos, no en segundos.
let mut db = open_db()?;
let prior_count = db.monad_count();
if prior_count > 0 {
let mut hydrated = 0usize;
let mut skipped_model = 0usize;
for monad in db.monads() {
// Sólo publicamos centroides del modelo actual; los demás
// son data muerta hasta que el re-scan los reemplace.
let valid = monad
.centroid_model
.as_deref()
.map(|id| id == embed::MODEL_ID)
.unwrap_or(false);
if !valid {
skipped_model += 1;
continue;
}
let mut card = monad.to_brahman_card();
card.references.push(brahman_card::CardReference {
kind: brahman_card::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.clone(),
});
pool.spawn(card);
hydrated += 1;
}
eprintln!(
"chasqui daemon: hidratadas {} mónadas previas{} en O(1)",
hydrated,
if skipped_model > 0 {
format!(" ({} dropeadas por centroid_model distinto)", skipped_model)
} else {
String::new()
}
);
}
// 3. Re-scan con hidratación: las Mónadas con mismo path_hint
// reusan id, así que NO generamos sesiones duplicadas para los
// mismos directorios — el sidecar previo ya tiene esa identidad.
let files = scanner::scan_directory(&dir, &scanner::ScanConfig::default())?;
let n_files = files.len();
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(&db));
let scanned_count = monads.len();
eprintln!(
"chasqui daemon: re-scan {} archivos en {}{} mónadas",
n_files,
dir.display(),
scanned_count
);
// Publicamos sólo las Mónadas NUEVAS (las que no estaban en la
// hidratación inicial). El criterio: si el id estaba en la DB
// previa, el sidecar de la hidratación ya cubre esa identidad.
let prior_ids: std::collections::BTreeSet<_> = db.monads().map(|m| m.id).collect();
let mut newly_spawned = 0usize;
for monad in &monads {
if prior_ids.contains(&monad.id) {
continue; // ya publicada en hidratación
}
let mut card = monad.to_brahman_card();
card.references.push(brahman_card::CardReference {
kind: brahman_card::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.clone(),
});
pool.spawn(card);
newly_spawned += 1;
}
// Reescribimos la DB con el set actual (idempotente para los
// hidratados; reemplazo para los nuevos).
db.ingest_files(files);
db.replace_monads(monads);
eprintln!(
"chasqui daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación)",
scanned_count, newly_spawned
);
// Engine query socket: bind antes del watcher para que cualquier
// consumer descubierto vía broker pueda consultarnos enseguida.
// Si el bind falla, seguimos sin él — la UI degrada a "no
// alcanzable" pero el daemon sigue procesando cambios.
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
let _query_listener = match chasqui_core::engine_socket::spawn_listener(
chasqui_core::engine_socket::ListenerConfig {
socket_path: query_socket.clone(),
engine_id,
engine_label: engine_label.clone(),
watching: Some(dir.clone()),
},
db_shared.clone(),
) {
Ok(h) => {
eprintln!(
"chasqui daemon: query socket activo en {} (proto: chasqui_card::query)",
query_socket.display()
);
Some(h)
}
Err(e) => {
eprintln!(
"chasqui daemon: query socket NO disponible ({e}) — explorer no podrá consultar"
);
None
}
};
// Watcher: cada cambio en el árbol — coalescido con debounce de
// 150ms — dispara un re-scan + re-cluster del directorio y
// re-publica al broker las Mónadas afectadas (drop + spawn por id,
// gracias al replace en `SidecarPool::spawn`).
let _watcher = match spawn_fs_watcher(
dir.clone(),
db_shared.clone(),
pool.clone(),
engine_id,
engine_label.clone(),
) {
Ok(w) => {
eprintln!(
"chasqui daemon: watcher activo en {} (debounce 150ms, re-publish on) — Ctrl-C para terminar.",
dir.display()
);
Some(w)
}
Err(e) => {
eprintln!(
"chasqui daemon: watcher deshabilitado ({e}) — Ctrl-C para terminar."
);
None
}
};
std::thread::park();
drop(_watcher);
drop(_query_listener);
let _ = std::fs::remove_file(&query_socket); // best-effort cleanup
drop(pool);
Ok(())
}
/// Ventana de debounce: notify dispara Create+Modify(+) por cada
/// edición; sin coalescer veríamos N reacciones por un solo `:w`.
/// 150ms es generoso para editores típicos (vim/code) y mantiene el
/// feedback "vivo" para el usuario.
const WATCHER_DEBOUNCE_MS: u64 = 150;
/// Watcher de filesystem con debounce + re-publish al broker.
///
/// Pipeline:
///
/// 1. **notify** dispara eventos crudos a un canal interno.
/// 2. **dispatcher**: filtra a Create/Modify/Remove de paths bajo
/// `dir`, descarta el resto, reenvía al canal de debounce.
/// 3. **coordinator**: mantiene un `HashMap<PathBuf, Instant>`.
/// Cada vez que el canal queda en silencio durante
/// `WATCHER_DEBOUNCE_MS`, agrupa los paths cuya última actividad
/// superó la ventana y los procesa en **un solo batch**.
/// 4. **process_change_batch**: re-scan + re-cluster hidratado +
/// diff vs DB + `pool.drop_session` para Mónadas desaparecidas
/// + `pool.spawn` para Mónadas nuevas o con composición distinta.
/// `pool.spawn` reemplaza la sesión previa con el mismo `Card.id`,
/// así que el broker ve el manifest fresco sin sesiones huérfanas.
fn spawn_fs_watcher(
dir: std::path::PathBuf,
db: std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
pool: std::sync::Arc<brahman_sidecar::SidecarPool>,
engine_id: brahman_card::ulid::Ulid,
engine_label: String,
) -> Result<notify::RecommendedWatcher, Box<dyn std::error::Error>> {
use notify::{Event, EventKind, RecursiveMode, Watcher};
let (notify_tx, notify_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = notify_tx.send(res);
})?;
watcher.watch(&dir, RecursiveMode::Recursive)?;
let (path_tx, path_rx) = std::sync::mpsc::channel::<std::path::PathBuf>();
// Dispatcher: notify → filtro → canal de paths.
let dispatch_dir = dir.clone();
std::thread::Builder::new()
.name("chasqui-watcher-dispatch".into())
.spawn(move || {
for res in notify_rx {
let event = match res {
Ok(e) => e,
Err(e) => {
eprintln!("[watcher] error: {e}");
continue;
}
};
// Create/Modify viven; Remove también nos importa
// (puede colapsar Mónadas).
let interesting = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
);
if !interesting {
continue;
}
for path in event.paths {
if !path.starts_with(&dispatch_dir) {
continue;
}
let _ = path_tx.send(path);
}
}
})?;
// Coordinator: debounce + batch dispatch.
let coord_dir = dir;
std::thread::Builder::new()
.name("chasqui-watcher-coord".into())
.spawn(move || {
let debounce = std::time::Duration::from_millis(WATCHER_DEBOUNCE_MS);
let mut pending: std::collections::HashMap<
std::path::PathBuf,
std::time::Instant,
> = std::collections::HashMap::new();
loop {
match path_rx.recv_timeout(debounce) {
Ok(path) => {
pending.insert(path, std::time::Instant::now());
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
let now = std::time::Instant::now();
let due: Vec<std::path::PathBuf> = pending
.iter()
.filter(|(_, t)| now.duration_since(**t) >= debounce)
.map(|(p, _)| p.clone())
.collect();
if due.is_empty() {
continue;
}
for p in &due {
pending.remove(p);
}
process_change_batch(
&due,
&coord_dir,
&db,
&pool,
engine_id,
&engine_label,
);
}
})?;
Ok(watcher)
}
/// Procesa un batch de paths cambiados: re-scanea el árbol, re-clusteriza
/// con hidratación, y propaga el delta de Mónadas al broker.
///
/// El re-scan global es deliberado: el clustering por directorio es global
/// por diseño, así que un cambio en `src/foo.rs` puede mover Mónadas en
/// `src/` sin tocar `tests/`. Coste O(N archivos), aceptable para
/// directorios típicos (<10k archivos). Optimizar a re-cluster parcial
/// cuando duela.
fn process_change_batch(
paths: &[std::path::PathBuf],
dir: &std::path::Path,
db: &std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
pool: &std::sync::Arc<brahman_sidecar::SidecarPool>,
engine_id: brahman_card::ulid::Ulid,
engine_label: &str,
) {
eprintln!(
"[watcher] ⚙ batch: {} path(s) coalescidos → re-scan",
paths.len()
);
let files = match scanner::scan_directory(dir, &scanner::ScanConfig::default()) {
Ok(f) => f,
Err(e) => {
eprintln!("[watcher] re-scan falló: {e}");
return;
}
};
let mut db_lock = match db.lock() {
Ok(g) => g,
Err(_) => {
eprintln!("[watcher] mutex envenenado — abortando batch");
return;
}
};
let prior_monads: Vec<chasqui_card::MonadManifest> = db_lock.monads().cloned().collect();
let prior_ref: &db::MonadDb = &db_lock;
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(prior_ref));
let prior_ids: std::collections::BTreeSet<_> =
prior_monads.iter().map(|m| m.id).collect();
let new_ids: std::collections::BTreeSet<_> = monads.iter().map(|m| m.id).collect();
// Mónadas que ya no existen (directorio quedó por debajo de
// min_files o fue removido): cerramos su sesión en el broker.
let mut removed = 0usize;
for id in prior_ids.difference(&new_ids) {
pool.drop_session(*id);
removed += 1;
if let Some(prev) = prior_monads.iter().find(|m| &m.id == id) {
eprintln!(
"[watcher] ✖ {} ({}) desapareció — sesión cerrada",
&id.to_string()[..8],
prev.label
);
}
}
// Mónadas nuevas o cuya composición cambió (members/centroid):
// (re)spawn — el pool reemplaza la sesión previa con el mismo id.
let mut respawned = 0usize;
let mut fresh = 0usize;
for monad in &monads {
let prev = prior_monads.iter().find(|m| m.id == monad.id);
let is_new = prev.is_none();
let changed = match prev {
Some(p) => p.members != monad.members || p.centroid != monad.centroid,
None => true,
};
if !changed {
continue;
}
let mut card = monad.to_brahman_card();
card.references.push(brahman_card::CardReference {
kind: brahman_card::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.to_string(),
});
pool.spawn(card);
if is_new {
fresh += 1;
eprintln!(
"[watcher] ✦ {} nace ({} miembros, lens={:?})",
monad.label, monad.cardinality, monad.dominant_lens
);
} else {
respawned += 1;
let prev = prev.unwrap();
let delta_members = monad.members.len() as i64 - prev.members.len() as i64;
eprintln!(
"[watcher] ↻ {} refresh ({} miembros, Δ={:+})",
monad.label, monad.cardinality, delta_members
);
}
}
if removed == 0 && fresh == 0 && respawned == 0 {
eprintln!("[watcher] (sin cambios estructurales tras re-cluster)");
} else {
eprintln!(
"[watcher] ⌃ delta: {} nuevas, {} refrescadas, {} cerradas — {} sesiones vivas",
fresh,
respawned,
removed,
pool.live_sessions()
);
}
db_lock.ingest_files(files);
db_lock.replace_monads(monads);
}
fn cmd_attract(args: &[String]) -> Cmd {
let mut remote = false;
let mut positional: Vec<&String> = Vec::new();
for a in args {
if a == "--remote" {
remote = true;
} else {
positional.push(a);
}
}
let dir = positional
.first()
.map(|s| std::path::PathBuf::from(s.as_str()))
.ok_or("falta argumento <dir>")?;
let file_path = positional.get(1).ok_or("falta argumento <file>")?;
let file_path = std::path::PathBuf::from(file_path.as_str());
if !file_path.exists() {
return Err(format!("archivo no existe: {}", file_path.display()).into());
}
let (db, _) = run_scan(&dir)?;
// Construimos un FileEntry para el archivo objetivo.
let metadata = std::fs::metadata(&file_path)?;
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 = chasqui_card::FileEntry {
id: chasqui_card::FileId::from(chasqui_card::ulid::Ulid::new()),
path: file_path.clone(),
content_hash: None,
size: metadata.len(),
mtime_ms,
extension: file_path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase()),
};
// Embedding del target + identificación del modelo que lo produjo.
// Local: pseudo-32d. Remote: lo que devuelva el provider electo
// (mock=pseudo-32d, real=fastembed-384d).
let (target_vec, target_model, source) = if remote {
let (v, model) = remote_embed(&target)?;
(v, model, "remote")
} else {
(
embed::embed(&target).to_vec(),
embed::MODEL_ID.to_string(),
"local",
)
};
// Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar
// 32-d con 384-d daría scores sin sentido (diferente semántica
// y cosine no compara cross-modelo).
let mut ranked: Vec<(&chasqui_card::MonadManifest, f32)> = db
.monads()
.filter(|m| !m.centroid.is_empty())
.filter(|m| match &m.centroid_model {
Some(id) => id == &target_model,
None => true, // legacy sin tag — comparamos best-effort
})
.map(|m| (m, embed::attraction_score(&target_vec, m)))
.collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let total_monads = db.monads().filter(|m| !m.centroid.is_empty()).count();
let skipped = total_monads - ranked.len();
if ranked.is_empty() {
println!("ninguna Mónada con centroide en {}", dir.display());
return Ok(());
}
println!("archivo: {}", file_path.display());
println!("scan dir: {}", dir.display());
println!("embed: {} ({})", source, target_model);
if skipped > 0 {
println!(
"skipped: {} mónada(s) con centroid_model distinto (no comparables)",
skipped
);
}
println!("ranking de atracción (cosine similarity):");
println!();
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
"🧲"
} else if i == 0 {
"·"
} else {
" "
};
let id_short = format!("{}", m.id);
let id_short = &id_short[..8];
println!(
" {} {:.4} [{}] {:30} ({})",
marker, score, id_short, m.label, m.summary
);
}
if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
println!();
println!(
" (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
ranked[0].1,
embed::DEFAULT_ATTRACTION_THRESHOLD
);
}
Ok(())
}
/// Pipeline completo del modo `--remote`:
/// 1. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (override
/// explícito, atajo para tests).
/// 2. Si no, delega en `brahman_sidecar::await_provider_blocking` —
/// el sidecar se conecta al broker, registra un consumer Card con
/// `flow.input = embed-result:json`, espera el primer
/// `MatchEvent::Available` y devuelve el socket. Esto activa la
/// lógica de `priority_contexts`: bajo `BRAHMAN_BROKER_CONTEXT=test/prod`,
/// el proveedor electo cambia sin que este código toque nada.
/// 3. Con el socket resuelto, dispara la RPC `EmbedFile`.
///
/// Devuelve `(embedding, model_id)` — el caller necesita ambos para
/// comparar contra centroides taggeados con su mismo `centroid_model`.
fn remote_embed(
file: &chasqui_card::FileEntry,
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") {
let sock = std::path::PathBuf::from(explicit);
return embed_via(&sock, file);
}
let consumer = brahman_sidecar::build_consumer_card(
"chasqui.attract-cli",
chasqui_nous::FLOW_EMBED_RESULT,
chasqui_nous::FLOW_TYPE_NAME,
);
let producer_sock = brahman_sidecar::await_provider_blocking(
consumer,
std::time::Duration::from_secs(3),
)?;
embed_via(&producer_sock, file)
}
/// RPC blocking contra un socket chasqui-nous concreto. Devuelve
/// `(embedding, model_id)` — el `model_id` viaja en la response y
/// permite al caller saber qué centroides son comparables.
fn embed_via(
sock_path: &std::path::Path,
file: &chasqui_card::FileEntry,
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
if !sock_path.exists() {
return Err(format!("socket no existe: {}", sock_path.display()).into());
}
let mut stream = UnixStream::connect(sock_path)?;
let req = chasqui_nous::EmbedRequest {
kind: chasqui_nous::RequestKind::EmbedFile,
payload: serde_json::to_value(chasqui_nous::EmbedFilePayload {
path: file.path.display().to_string(),
extension: file.extension.clone(),
size: file.size,
mtime_ms: file.mtime_ms,
})?,
};
let line = serde_json::to_string(&req)?;
stream.write_all(line.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response)?;
if response.is_empty() {
return Err("chasqui-nous cerró sin respuesta".into());
}
if let Ok(resp) = serde_json::from_str::<chasqui_nous::EmbedResponse>(&response) {
return Ok((resp.embedding, resp.model));
}
let err: chasqui_nous::ErrorResponse = serde_json::from_str(&response)?;
Err(format!("chasqui-nous: {}", err.error).into())
}
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
///
/// Declara `service_socket` y `flow.output = monad-list:json` para
/// que un consumer (UI, CLI) pueda descubrir al daemon vía broker
/// MatchEvent y consultarle por sus Mónadas sin pasar por
/// brahman-admin.
fn build_engine_card(service_socket: std::path::PathBuf) -> brahman_card::Card {
use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef};
use chasqui_card::query::{FLOW_MONAD_LIST, FLOW_TYPE_NAME};
Card {
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
service_socket: Some(service_socket),
flow: Flows {
input: vec![],
output: vec![Flow {
name: FLOW_MONAD_LIST.into(),
ty: TypeRef::Primitive {
name: FLOW_TYPE_NAME.into(),
},
pin_to: None,
}],
},
..Card::new("brahman.nouser_engine")
}
}