gioser

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Sutichaykuna

⎇ d7b4886164df5ef048a7c8c0d735fb83555c7990 crates/modules/nouser/core/src/bin/nouser.rs Rust · 373 siq'ikuna · 12.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(sidecar): Phase B-3 — SidecarPool consolida sidecars en un runtime d7b488616 · Sergio ·

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}