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:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user