feat(nouser): Phase A — mecanismo determinista de Mónadas

Primer trozo de Nouser/Kairos: explorador de Mónadas como agrupaciones
semánticas sobre el filesystem, sin tocar IA todavía. Cubre el 90% de
los casos con heurísticas puras.

Crates nuevos:

crates/modules/nouser/card:
- MonadManifest: la Tarjeta de Presentación de una Mónada. Espejo
  conceptual de brahman::Card pero para datos: id (Ulid), label,
  summary, centroid (vacío en Phase A), keywords, cardinality, entropy
  [0,1], dominant_lens (Grid|Code|Gallery|Database|Markdown|Tree),
  pins, members, timestamps, extensions (forward-compat).
- Diferencia explícita en docs: brahman::Card describe entidades
  runtime con payload/soma/supervision; MonadManifest describe una
  agrupación de datos sin proceso atrás.
- Validación: schema_version, label no vacío, entropy en rango,
  cardinality consistente con members.len().
- 6 tests (validación + JSON roundtrip).

crates/modules/nouser/core:
- scanner::scan_directory: walkdir → Vec<FileEntry> con metadatos.
  Skipea hidden por default; configurable max_depth y follow_links.
- cluster::by_directory: agrupa archivos por parent dir, mínimo 3
  para promover a Mónada (configurable). Computa keywords (top-N
  extensiones por freq + alfabético), elige Lens dominante por
  extensión más frecuente, entropía de Shannon normalizada.
- db::MonadDb: store en memoria con índices BTreeMap.
  resolve_members filtra IDs huérfanos.
- bin nouser con subcomandos scan, show, json. Env var
  NOUSER_MIN_FILES para el threshold.
- 13 tests (4 scanner + 6 cluster + 3 db).

Demo end-to-end:

  $ nouser scan crates
  scan: 255 archivos en crates, 19 mónadas (min_files=3)
    [01KR4C13] src       card=12  ent=0.00  lens=Code  keywords: rs
    [01KR4C13] tests     card=14  ent=0.00  lens=Code  keywords: rs
    [01KR4C13] fixtures  card=5   ent=0.00  lens=Grid  keywords: rhai

Pendientes (anotados en CHANGELOG, no urgentes):
- Phase B: bin nouser daemon que sidecarea a brahman-init.
- Phase C: pseudo-embeddings de metadatos + atracción por centroide.
- Phase D: módulo nouser-nous para el LLM real, swappable por
  priority_contexts (mock-nous en test, real-nous en prod).
- Polish: labels con 2-3 componentes del path.

cargo check --workspace: 0 errores, 0 warnings.
Tests acumulados: 58.

CHANGELOG.md actualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 18:03:49 +00:00
parent bbb9a9d2f5
commit 7bdc26e61a
11 changed files with 1226 additions and 0 deletions
@@ -0,0 +1,157 @@
//! `nouser` 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 nouser_core::{
cluster, db,
scanner::{self, ScanConfig},
};
fn main() -> ExitCode {
let args: Vec<String> = 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),
"--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} <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!();
eprintln!("env:");
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
}
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 = 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 <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(())
}