//! `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()
}
}