//! `nouser` CLI — explorador de Mónadas. //! //! Subcomandos: //! //! - `scan ` recorre `dir` y muestra las Mónadas detectadas. //! - `show ` scan + detalles de la Mónada con prefijo de ID. //! - `json ` 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 nouser_core::{ cluster, db, embed, scanner::{self, ScanConfig}, }; fn main() -> ExitCode { let args: Vec = std::env::args().collect(); let prog = args.first().cloned().unwrap_or_else(|| "nouser".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!("nouser: comando desconocido '{other}'"); print_usage(&prog); return ExitCode::from(2); } }; match result { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("nouser: {e}"); ExitCode::from(1) } } } fn print_usage(prog: &str) { eprintln!("uso: {prog} [args]"); eprintln!(); eprintln!("comandos:"); eprintln!(" scan recorre un directorio y lista las Mónadas detectadas"); eprintln!(" show scan + detalle de la Mónada cuyo ID empieza con "); eprintln!(" json scan + dump JSON de todos los manifests"); eprintln!(" daemon scan + sidecarea cada Mónada al Init brahman"); eprintln!(" attract 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!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)"); } type Cmd = Result<(), Box>; 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> { let dir = args.first().ok_or("falta argumento ")?; Ok(PathBuf::from(dir)) } fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box> { let files = scanner::scan_directory(dir, &ScanConfig::default())?; let n_files = files.len(); let monads = cluster::by_directory(&files, min_files()); let mut db = db::MonadDb::new(); db.ingest_files(files); db.replace_monads(monads); Ok((db, n_files)) } 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 ")?; 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)?; // Pool consolidado: 1 thread + 1 tokio runtime para TODAS las // sesiones (engine + N mónadas). Antes era 1 thread por sesión. let pool = brahman_sidecar::SidecarPool::new() .map_err(|e| format!("crear pool: {e}"))?; // 1. El propio engine se presenta como Ente. let engine_card = build_engine_card(); let engine_id = engine_card.id; let engine_label = engine_card.label.clone(); eprintln!( "nouser daemon: publicando engine '{}' (kind=Ente, id={})", engine_label, engine_id ); pool.spawn(engine_card); // 2. Scan y cluster. let (db, n_files) = run_scan(&dir)?; eprintln!( "nouser daemon: {} archivos en {}, {} mónadas detectadas", n_files, dir.display(), db.monad_count() ); // 3. Cada Mónada se presenta como Card de tipo Data, declarando // su relación OwnedBy con el engine. Todas comparten el runtime // del pool — sin overhead de N threads. let mut count = 0usize; for monad in db.monads() { 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); count += 1; } eprintln!( "nouser daemon: 1 ente + {} mónadas en pool consolidado. Ctrl-C para terminar.", count ); // 4. Park: el thread del pool sigue vivo mientras `pool` exista. std::thread::park(); drop(pool); Ok(()) } 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 ")?; let file_path = positional.get(1).ok_or("falta argumento ")?; 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 = nouser_card::FileEntry { id: nouser_card::FileId::from(nouser_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: --remote consulta al socket de nouser-nous; sin flag, // se computa localmente. El resultado debe ser idéntico mientras // el proveedor sea el mock determinista. let (target_vec, source) = if remote { let v = remote_embed(&target)?; (v, "remote") } else { (embed::embed(&target).to_vec(), "local") }; // Ranking completo, no sólo el ganador — útil para entender qué // Mónadas son secundarias. let mut ranked: Vec<(&nouser_card::MonadManifest, f32)> = db .monads() .filter(|m| !m.centroid.is_empty()) .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)); 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); 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(()) } /// Cliente blocking del socket nouser-nous. Conecta, envía un /// `EmbedRequest`, lee la response, devuelve el vector. Single-shot. fn remote_embed(file: &nouser_card::FileEntry) -> Result, Box> { use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; let sock_path = nouser_nous::transport::default_socket_path(); if !sock_path.exists() { return Err(format!( "socket nouser-nous no existe en {} — corrió nouser-nous-mock?", sock_path.display() ) .into()); } let mut stream = UnixStream::connect(&sock_path)?; let req = nouser_nous::EmbedRequest { kind: nouser_nous::RequestKind::EmbedFile, payload: serde_json::to_value(nouser_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("nouser-nous cerró sin respuesta".into()); } // Intentamos primero como response normal; si falla, como error. if let Ok(resp) = serde_json::from_str::(&response) { return Ok(resp.embedding); } let err: nouser_nous::ErrorResponse = serde_json::from_str(&response)?; Err(format!("nouser-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. fn build_engine_card() -> brahman_card::Card { use brahman_card::{ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision}; Card { schema_version: brahman_card::CARD_SCHEMA_VERSION, id: Ulid::new(), label: "brahman.nouser_engine".into(), payload: Payload::Virtual, supervision: Supervision::Delegate, lifecycle: Lifecycle::Daemon, priority: Priority::Normal, kind: CardKind::Ente, ..Default::default() } }