⎇ d7b4886164df5ef048a7c8c0d735fb83555c7990
crates/modules/nouser/core/src/bin/nouser.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(sidecar): Phase B-3 — SidecarPool consolida sidecars en un runtime
1//! `nouser` CLI — explorador de Mónadas.
2//!
3//! Subcomandos:
4//!
5//! - `scan <dir>` recorre `dir` y muestra las Mónadas detectadas.
6//! - `show <dir> <id?>` scan + detalles de la Mónada con prefijo de ID.
7//! - `json <dir>` scan + dump JSON con los manifests.
8//!
9//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
10//! sesión termina y todo se descarta. Phase B agrega persistencia y
11//! presencia ante el Init.
12
13use std::path::PathBuf;
14use std::process::ExitCode;
15
16use nouser_core::{
17 cluster, db, embed,
18 scanner::{self, ScanConfig},
19};
20
21fn main() -> ExitCode {
22 let args: Vec<String> = std::env::args().collect();
23 let prog = args.first().cloned().unwrap_or_else(|| "nouser".into());
24 let sub = match args.get(1).map(String::as_str) {
25 Some(s) => s,
26 None => {
27 print_usage(&prog);
28 return ExitCode::from(2);
29 }
30 };
31 let rest = &args[2..];
32
33 let result = match sub {
34 "scan" => cmd_scan(rest),
35 "show" => cmd_show(rest),
36 "json" => cmd_json(rest),
37 "daemon" => cmd_daemon(rest),
38 "attract" => cmd_attract(rest),
39 "--help" | "-h" | "help" => {
40 print_usage(&prog);
41 return ExitCode::SUCCESS;
42 }
43 other => {
44 eprintln!("nouser: comando desconocido '{other}'");
45 print_usage(&prog);
46 return ExitCode::from(2);
47 }
48 };
49
50 match result {
51 Ok(()) => ExitCode::SUCCESS,
52 Err(e) => {
53 eprintln!("nouser: {e}");
54 ExitCode::from(1)
55 }
56 }
57}
58
59fn print_usage(prog: &str) {
60 eprintln!("uso: {prog} <comando> [args]");
61 eprintln!();
62 eprintln!("comandos:");
63 eprintln!(" scan <dir> recorre un directorio y lista las Mónadas detectadas");
64 eprintln!(" show <dir> <prefix> scan + detalle de la Mónada cuyo ID empieza con <prefix>");
65 eprintln!(" json <dir> scan + dump JSON de todos los manifests");
66 eprintln!(" daemon <dir> scan + sidecarea cada Mónada al Init brahman");
67 eprintln!(" attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
68 eprintln!();
69 eprintln!("env:");
70 eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
71 eprintln!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)");
72}
73
74type Cmd = Result<(), Box<dyn std::error::Error>>;
75
76fn min_files() -> usize {
77 std::env::var("NOUSER_MIN_FILES")
78 .ok()
79 .and_then(|s| s.parse().ok())
80 .unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
81}
82
83fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
84 let dir = args.first().ok_or("falta argumento <dir>")?;
85 Ok(PathBuf::from(dir))
86}
87
88fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
89 let files = scanner::scan_directory(dir, &ScanConfig::default())?;
90 let n_files = files.len();
91 let monads = cluster::by_directory(&files, min_files());
92 let mut db = db::MonadDb::new();
93 db.ingest_files(files);
94 db.replace_monads(monads);
95 Ok((db, n_files))
96}
97
98fn cmd_scan(args: &[String]) -> Cmd {
99 let dir = require_dir(args)?;
100 let (db, n_files) = run_scan(&dir)?;
101
102 println!(
103 "scan: {} archivos en {}, {} mónadas (min_files={})",
104 n_files,
105 dir.display(),
106 db.monad_count(),
107 min_files()
108 );
109 if db.monad_count() == 0 {
110 println!(" (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
111 return Ok(());
112 }
113 println!();
114 for m in db.monads() {
115 let id_short = format!("{}", m.id);
116 let id_short = &id_short[..8];
117 println!(
118 " [{}] {:30} card={} ent={:.2} lens={:?}",
119 id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
120 );
121 if !m.keywords.is_empty() {
122 println!(" keywords: {}", m.keywords.join(", "));
123 }
124 }
125 Ok(())
126}
127
128fn cmd_show(args: &[String]) -> Cmd {
129 let dir = require_dir(args)?;
130 let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
131 let (db, _) = run_scan(&dir)?;
132
133 let m = db
134 .monads()
135 .find(|m| m.id.to_string().starts_with(prefix))
136 .ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
137
138 println!("Monad {}", m.id);
139 println!(" label: {}", m.label);
140 println!(" summary: {}", m.summary);
141 println!(" cardinality: {}", m.cardinality);
142 println!(" entropy: {:.4}", m.entropy);
143 println!(" lens: {:?}", m.dominant_lens);
144 println!(" keywords: {}", m.keywords.join(", "));
145 println!(" members ({}):", m.members.len());
146 for f in db.resolve_members(m.id) {
147 println!(
148 " {:>10} bytes {}",
149 f.size,
150 f.path.display()
151 );
152 }
153 Ok(())
154}
155
156fn cmd_json(args: &[String]) -> Cmd {
157 let dir = require_dir(args)?;
158 let (db, _) = run_scan(&dir)?;
159 let manifests: Vec<_> = db.monads().cloned().collect();
160 println!("{}", serde_json::to_string_pretty(&manifests)?);
161 Ok(())
162}
163
164fn cmd_daemon(args: &[String]) -> Cmd {
165 let dir = require_dir(args)?;
166
167 // Pool consolidado: 1 thread + 1 tokio runtime para TODAS las
168 // sesiones (engine + N mónadas). Antes era 1 thread por sesión.
169 let pool = brahman_sidecar::SidecarPool::new()
170 .map_err(|e| format!("crear pool: {e}"))?;
171
172 // 1. El propio engine se presenta como Ente.
173 let engine_card = build_engine_card();
174 let engine_id = engine_card.id;
175 let engine_label = engine_card.label.clone();
176 eprintln!(
177 "nouser daemon: publicando engine '{}' (kind=Ente, id={})",
178 engine_label, engine_id
179 );
180 pool.spawn(engine_card);
181
182 // 2. Scan y cluster.
183 let (db, n_files) = run_scan(&dir)?;
184 eprintln!(
185 "nouser daemon: {} archivos en {}, {} mónadas detectadas",
186 n_files,
187 dir.display(),
188 db.monad_count()
189 );
190
191 // 3. Cada Mónada se presenta como Card de tipo Data, declarando
192 // su relación OwnedBy con el engine. Todas comparten el runtime
193 // del pool — sin overhead de N threads.
194 let mut count = 0usize;
195 for monad in db.monads() {
196 let mut card = monad.to_brahman_card();
197 card.references.push(brahman_card::CardReference {
198 kind: brahman_card::RelationshipKind::OwnedBy,
199 target_id: engine_id,
200 target_label: engine_label.clone(),
201 });
202 pool.spawn(card);
203 count += 1;
204 }
205 eprintln!(
206 "nouser daemon: 1 ente + {} mónadas en pool consolidado. Ctrl-C para terminar.",
207 count
208 );
209
210 // 4. Park: el thread del pool sigue vivo mientras `pool` exista.
211 std::thread::park();
212 drop(pool);
213 Ok(())
214}
215
216fn cmd_attract(args: &[String]) -> Cmd {
217 let mut remote = false;
218 let mut positional: Vec<&String> = Vec::new();
219 for a in args {
220 if a == "--remote" {
221 remote = true;
222 } else {
223 positional.push(a);
224 }
225 }
226 let dir = positional
227 .first()
228 .map(|s| std::path::PathBuf::from(s.as_str()))
229 .ok_or("falta argumento <dir>")?;
230 let file_path = positional.get(1).ok_or("falta argumento <file>")?;
231 let file_path = std::path::PathBuf::from(file_path.as_str());
232 if !file_path.exists() {
233 return Err(format!("archivo no existe: {}", file_path.display()).into());
234 }
235
236 let (db, _) = run_scan(&dir)?;
237
238 // Construimos un FileEntry para el archivo objetivo.
239 let metadata = std::fs::metadata(&file_path)?;
240 let mtime_ms = metadata
241 .modified()
242 .ok()
243 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
244 .map(|d| d.as_millis() as u64)
245 .unwrap_or(0);
246 let target = nouser_card::FileEntry {
247 id: nouser_card::FileId::from(nouser_card::ulid::Ulid::new()),
248 path: file_path.clone(),
249 content_hash: None,
250 size: metadata.len(),
251 mtime_ms,
252 extension: file_path
253 .extension()
254 .and_then(|s| s.to_str())
255 .map(|s| s.to_lowercase()),
256 };
257
258 // Embedding: --remote consulta al socket de nouser-nous; sin flag,
259 // se computa localmente. El resultado debe ser idéntico mientras
260 // el proveedor sea el mock determinista.
261 let (target_vec, source) = if remote {
262 let v = remote_embed(&target)?;
263 (v, "remote")
264 } else {
265 (embed::embed(&target).to_vec(), "local")
266 };
267
268 // Ranking completo, no sólo el ganador — útil para entender qué
269 // Mónadas son secundarias.
270 let mut ranked: Vec<(&nouser_card::MonadManifest, f32)> = db
271 .monads()
272 .filter(|m| !m.centroid.is_empty())
273 .map(|m| (m, embed::attraction_score(&target_vec, m)))
274 .collect();
275 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
276
277 if ranked.is_empty() {
278 println!("ninguna Mónada con centroide en {}", dir.display());
279 return Ok(());
280 }
281
282 println!("archivo: {}", file_path.display());
283 println!("scan dir: {}", dir.display());
284 println!("embed: {}", source);
285 println!("ranking de atracción (cosine similarity):");
286 println!();
287 for (i, (m, score)) in ranked.iter().take(5).enumerate() {
288 let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
289 "🧲"
290 } else if i == 0 {
291 "·"
292 } else {
293 " "
294 };
295 let id_short = format!("{}", m.id);
296 let id_short = &id_short[..8];
297 println!(
298 " {} {:.4} [{}] {:30} ({})",
299 marker, score, id_short, m.label, m.summary
300 );
301 }
302 if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
303 println!();
304 println!(
305 " (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
306 ranked[0].1,
307 embed::DEFAULT_ATTRACTION_THRESHOLD
308 );
309 }
310 Ok(())
311}
312
313/// Cliente blocking del socket nouser-nous. Conecta, envía un
314/// `EmbedRequest`, lee la response, devuelve el vector. Single-shot.
315fn remote_embed(file: &nouser_card::FileEntry) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
316 use std::io::{BufRead, BufReader, Write};
317 use std::os::unix::net::UnixStream;
318
319 let sock_path = nouser_nous::transport::default_socket_path();
320 if !sock_path.exists() {
321 return Err(format!(
322 "socket nouser-nous no existe en {} — corrió nouser-nous-mock?",
323 sock_path.display()
324 )
325 .into());
326 }
327
328 let mut stream = UnixStream::connect(&sock_path)?;
329 let req = nouser_nous::EmbedRequest {
330 kind: nouser_nous::RequestKind::EmbedFile,
331 payload: serde_json::to_value(nouser_nous::EmbedFilePayload {
332 path: file.path.display().to_string(),
333 extension: file.extension.clone(),
334 size: file.size,
335 mtime_ms: file.mtime_ms,
336 })?,
337 };
338 let line = serde_json::to_string(&req)?;
339 stream.write_all(line.as_bytes())?;
340 stream.write_all(b"\n")?;
341 stream.flush()?;
342
343 let mut reader = BufReader::new(stream);
344 let mut response = String::new();
345 reader.read_line(&mut response)?;
346 if response.is_empty() {
347 return Err("nouser-nous cerró sin respuesta".into());
348 }
349
350 // Intentamos primero como response normal; si falla, como error.
351 if let Ok(resp) = serde_json::from_str::<nouser_nous::EmbedResponse>(&response) {
352 return Ok(resp.embedding);
353 }
354 let err: nouser_nous::ErrorResponse = serde_json::from_str(&response)?;
355 Err(format!("nouser-nous: {}", err.error).into())
356}
357
358/// Card del propio engine (kind=Ente). Es el "ser" que produce y
359/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
360fn build_engine_card() -> brahman_card::Card {
361 use brahman_card::{ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision};
362 Card {
363 schema_version: brahman_card::CARD_SCHEMA_VERSION,
364 id: Ulid::new(),
365 label: "brahman.nouser_engine".into(),
366 payload: Payload::Virtual,
367 supervision: Supervision::Delegate,
368 lifecycle: Lifecycle::Daemon,
369 priority: Priority::Normal,
370 kind: CardKind::Ente,
371 ..Default::default()
372 }
373}