Big Brain v0.4.0

report.rs

Rust · 420 lines · 19.5 KB
//! Human-facing reports: `brain doctor` (is everything wired up?), `brain stats` (what's in the
//! brain; also what plain `brain` shows), and `brain digest` (what changed recently).

use crate::ai;
use crate::index::Index;
use crate::store::{git_out, home_dir, Brain};
use crate::ui;
use anyhow::Result;
use std::collections::BTreeMap;
use std::fs;

// ------------------------------------------------------------------ doctor

struct Report {
    problems: usize,
    warnings: usize,
}

impl Report {
    fn ok(&self, msg: &str) {
        println!("  {} {msg}", ui::green("✓"));
    }
    fn warn(&mut self, msg: &str) {
        self.warnings += 1;
        println!("  {} {msg}", ui::yellow("⚠"));
    }
    fn bad(&mut self, msg: &str) {
        self.problems += 1;
        println!("  {} {msg}", ui::red("✗"));
    }
    fn section(&self, name: &str) {
        println!("\n{}", ui::heading(name));
    }
}

/// Check a remote provider. Embeddings get a real one-word request (a fraction of a cent) because
/// that is the only way to know the deployment works; the librarian gets a token-free ping.
#[allow(clippy::too_many_arguments)]
fn remote_check(r: &mut Report, what: &str, provider: &str, url: &str, auth: &str, hosts: &[String], model: &str, embed: bool) {
    let kind = if provider == "azure" { ai::Kind::Azure } else { ai::Kind::OpenAi };
    if url.is_empty() {
        r.bad(&format!("{what}: provider `{provider}` but no url set (run `brain setup`)"));
        return;
    }
    let c = ai::Client::new(kind, url, ai::Auth::parse(auth), 20)
        .with_token_cache(crate::store::auth_cache_path())
        .with_token_hosts(hosts)
        .with_privacy(&crate::store::Privacy::default());
    if embed {
        match c.embeddings(model, &["ping".to_string()]) {
            Ok(v) if v.first().is_some_and(|x| !x.is_empty()) => {
                r.ok(&format!("{what}: {provider} `{model}` at {} works ({} dimensions)", c.base, v[0].len()))
            }
            Ok(_) => r.bad(&format!("{what}: {provider} returned no vectors")),
            Err(e) => r.bad(&format!("{what}: {e:#}")),
        }
        return;
    }
    match c.ping() {
        Ok(s) if (200..300).contains(&s) => r.ok(&format!("{what}: {provider} at {} answers (auth: {})", c.base, ai::Auth::parse(auth).label())),
        Ok(s @ (401 | 403)) => r.bad(&format!("{what}: {} refused the credentials (HTTP {s}); check auth and role assignments", c.base)),
        Ok(s) => r.warn(&format!("{what}: {} is reachable but its model list returned HTTP {s}; `brain review` will show whether `{model}` works", c.base)),
        Err(e) => r.bad(&format!("{what}: {e:#}")),
    }
}

/// Settings file content mentions all three brain hooks.
fn has_hooks(path: &std::path::Path) -> Option<bool> {
    let text = fs::read_to_string(path).ok()?;
    Some(["hook session-start", "hook prompt", "hook stop"].iter().all(|h| text.contains(h)))
}

pub fn doctor(brain: &Brain) -> Result<()> {
    let mut r = Report { problems: 0, warnings: 0 };
    let cfg = &brain.config;
    println!("{} {}", ui::wordmark("doctor"), ui::dim(&format!("{} · a remote embeddings check sends one word", env!("CARGO_PKG_VERSION"))));

    r.section("Brain");
    r.ok(&format!("data at {}", brain.home.display()));
    if let Some((new, old)) = crate::store::split_brain_folders() {
        r.warn(&format!(
            "two brain folders hold entries: {} (in use) and {} (from before the rename); merge them or set BRAIN_HOME",
            if brain.home == new { new.display() } else { old.display() },
            if brain.home == new { old.display() } else { new.display() }
        ));
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(md) = fs::metadata(&brain.home)
            && md.permissions().mode() & 0o077 != 0
        {
            r.warn(&format!("other users can open the brain folder: `chmod 700 {}`", brain.home.display()));
        }
    }
    if crate::store::secret_kind(&fs::read_to_string(brain.home.join("skill-local.md")).unwrap_or_default()).is_some() {
        r.bad("skill-local.md looks like it contains a secret; remove it (it is copied into agent instructions)");
    }
    let ids = brain.all_ids()?;
    let mut invalid = 0;
    let mut stale = 0;
    let graph_for_stale = crate::links::Graph::build(brain);
    for id in &ids {
        match brain.load(id).and_then(|e| brain.validate(&e.meta, &e.body).map(|_| e)) {
            Ok(e) => stale += (!graph_for_stale.is_retired(id) && !brain.stale_refs(&e.meta).is_empty()) as usize,
            Err(_) => invalid += 1,
        }
    }
    let n = |k: usize, w: &str| format!("{k} {w}{}", if k == 1 { "y" } else { "ies" });
    r.ok(&format!("{}, {} project{}", n(ids.len(), "entr"), brain.projects.len(), if brain.projects.len() == 1 { "" } else { "s" }));
    if invalid > 0 {
        r.bad(&format!("{} fail validation (see `brain check`)", n(invalid, "entr")));
    }
    if stale > 0 {
        r.warn(&format!("{} stale (see `brain stale --all`)", n(stale, "entr")));
    }
    let dirty = git_out(&brain.home, &["status", "--porcelain"]);
    if dirty.lines().any(|l| !l.ends_with(".lock")) && !dirty.trim().is_empty() {
        r.warn("uncommitted changes in the brain folder (edited by hand?); `git -C <brain> status`");
    }
    let pending = fs::read_dir(brain.home.join("proposals")).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "md")).count()).unwrap_or(0);
    if pending > 0 {
        r.warn(&format!("{pending} runbook proposal(s) waiting for {} (`brain proposals`)", cfg.identity.reviewer));
    }
    for (name, p) in &brain.projects {
        if !crate::store::expand(&p.path).is_dir() {
            r.warn(&format!("project `{name}`: folder {} not found", p.path));
        }
    }
    let graph = crate::links::Graph::build(brain);
    if !graph.broken.is_empty() {
        r.warn(&format!("{} link(s) point at missing entries (see `brain check`)", graph.broken.len()));
    }
    let contra = crate::maintain::open_contradictions(brain);
    if contra > 0 {
        r.warn(&format!("{contra} possible contradiction(s) between entries (see `brain maintain`)"));
    }
    match fs::metadata(brain.home.join("reviews").join("maintain-latest.md")).and_then(|m| m.modified()) {
        Ok(t) => {
            let days = t.elapsed().map(|d| d.as_secs() / 86400).unwrap_or(0);
            if days > 3 {
                r.warn(&format!("last `brain maintain` was {days} days ago (schedule it nightly)"));
            } else {
                r.ok(&format!("`brain maintain` ran {}", if days == 0 { "today".to_string() } else { format!("{days} day(s) ago") }));
            }
        }
        Err(_) => r.warn("`brain maintain` has never run here (schedule it nightly; see the guide)"),
    }
    r.ok(&format!("identity: user `{}`, runbook review {}", cfg.identity.user,
        if cfg.identity.review_runbooks { format!("by {}", cfg.identity.reviewer) } else { "off".into() }));

    r.section("Search");
    match cfg.embeddings.provider.as_str() {
        "none" | "" => r.warn("embeddings off: keyword search only (`brain setup` to add a provider)"),
        "local" if !cfg!(feature = "local-embeddings") => {
            r.bad("embeddings `local`, but this build has no local model (rebuild with --features local-embeddings)")
        }
        "local" => r.ok("embeddings: local model (bge-base-en-v1.5)"),
        p => remote_check(&mut r, "embeddings", p, &cfg.embeddings.url, &cfg.embeddings.auth, &cfg.embeddings.token_hosts, &cfg.embeddings.model, true),
    }
    match Index::open(brain) {
        Ok(ix) => {
            let indexed: i64 = ix.conn.query_row("SELECT count(*) FROM entries", [], |x| x.get(0)).unwrap_or(0);
            let unembedded: i64 = ix.conn.query_row("SELECT count(*) FROM entries WHERE length(vec) = 0", [], |x| x.get(0)).unwrap_or(0);
            if indexed as usize == ids.len() {
                r.ok(&format!("index: {}", n(indexed as usize, "entr")));
            } else {
                r.warn(&format!("index: {indexed} of {} entries (updates on the next search)", ids.len()));
            }
            if ix.semantic() && unembedded > 0 {
                r.warn(&format!("{unembedded} entries have no embedding yet (provider was unreachable; retried on the next search)"));
            }
        }
        Err(e) => r.bad(&format!("index: {e:#}")),
    }

    r.section("Librarian");
    match cfg.librarian.provider.as_str() {
        "none" | "" => r.ok("off (no reviews or --deep search)"),
        "lmstudio" => {
            let lms = crate::store::expand(&cfg.librarian.lms);
            if lms.exists() {
                r.ok(&format!("LM Studio: started on demand with {} (reviews), {} (--deep)", cfg.librarian.model, cfg.librarian.quick_model));
            } else {
                r.bad(&format!("LM Studio CLI not found at {}", lms.display()));
            }
        }
        p => {
            remote_check(&mut r, "librarian", p, &cfg.librarian.url, &cfg.librarian.auth, &cfg.librarian.token_hosts, &cfg.librarian.model, false);
            if cfg.librarian.model.is_empty() {
                r.bad("librarian model/deployment not set");
            }
        }
    }

    r.section("Agent tools");
    let h = home_dir();
    let exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default();
    let mut any = false;
    if h.join(".claude").is_dir() {
        any = true;
        let skill = h.join(".claude/skills/big-brain/SKILL.md").exists();
        match (skill, has_hooks(&h.join(".claude/settings.json"))) {
            (true, Some(true)) => r.ok("Claude Code: skill + 3 hooks"),
            (s, hk) => r.warn(&format!("Claude Code: skill {}, hooks {} (run `brain setup`)",
                if s { "ok" } else { "missing" }, if hk == Some(true) { "ok" } else { "missing" })),
        }
        let settings = fs::read_to_string(h.join(".claude/settings.json")).unwrap_or_default();
        if !exe.is_empty() && settings.contains("hook session-start") && !settings.contains(&exe.replace('\\', "/")) {
            r.warn("Claude Code hooks point at a different brain binary than this one");
        }
    }
    if h.join(".codex").is_dir() {
        any = true;
        let agents = fs::read_to_string(h.join(".codex/AGENTS.md")).unwrap_or_default().contains("big-brain:start");
        match (h.join(".codex/skills/big-brain/SKILL.md").exists(), agents, has_hooks(&h.join(".codex/hooks.json"))) {
            (true, true, Some(true)) => r.ok("Codex: skill + AGENTS.md block + 3 hooks (trust them once in Codex)"),
            _ => r.warn("Codex: not fully set up (run `brain setup`)"),
        }
    }
    if h.join(".grok").is_dir() {
        any = true;
        if h.join(".grok/rules/big-brain.md").exists() {
            r.ok("Grok: always-on rule (hooks run in folders trusted with /hooks-trust)");
        } else {
            r.warn("Grok: no rule (run `brain setup`)");
        }
    }
    if !any {
        r.warn("no Claude Code, Codex or Grok config found; for other tools see `brain setup` (Copilot/other)");
    }

    println!();
    let plural = |n: usize, w: &str| format!("{n} {w}{}", if n == 1 { "" } else { "s" });
    match (r.problems, r.warnings) {
        (0, 0) => println!("{} {}", ui::green("✓"), ui::bold("All good.")),
        (0, w) => println!("{} {}", ui::yellow("⚠"), ui::bold(&format!("Working, {}: see the ⚠ lines above.", plural(w, "warning")))),
        (p, w) => println!(
            "{} {}",
            ui::red("✗"),
            ui::bold(&format!("{}, {}. Fix the ✗ lines first.", plural(p, "problem"), plural(w, "warning")))
        ),
    }
    Ok(())
}

// ------------------------------------------------------------------ stats

fn bar(n: usize, max: usize, width: usize) -> String {
    let full = if max == 0 { 0 } else { (n * width).div_ceil(max) };
    ui::cyan(&"█".repeat(full)) + &" ".repeat(width - full)
}

pub fn stats(brain: &Brain) -> Result<()> {
    let ids = brain.all_ids()?;
    let graph_stats = crate::links::Graph::build(brain);
    let mut by_project: BTreeMap<String, (usize, usize)> = BTreeMap::new();
    let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
    let mut verified = 0;
    let mut newest: Vec<(String, String, String, String)> = vec![];
    for id in &ids {
        let Ok(e) = brain.load(id) else { continue };
        let owner = if e.meta.owner.is_empty() { e.meta.project.clone() } else { e.meta.owner.clone() };
        let st = !graph_stats.is_retired(id) && !brain.stale_refs(&e.meta).is_empty();
        let slot = by_project.entry(owner).or_default();
        slot.0 += 1;
        slot.1 += st as usize;
        *by_kind.entry(e.meta.kind.clone()).or_default() += 1;
        verified += e.meta.verified as usize;
        let when = if e.meta.updated.is_empty() { e.meta.created.clone() } else { e.meta.updated.clone() };
        newest.push((when, e.meta.kind.clone(), e.meta.project.clone(), e.meta.title.clone()));
    }
    let total = ids.len();
    let stale: usize = by_project.values().map(|v| v.1).sum();
    println!(
        "{}  {}",
        ui::wordmark(""),
        ui::dim(&format!("{total} entries · {} projects · {verified} verified · {stale} stale", by_project.len()))
    );
    if total == 0 {
        println!("\nEmpty so far. Agents fill it as they work. To add the first entry by hand:");
        println!("  brain template system > e.md    {}", ui::dim("then fill it in, and"));
        println!("  brain record --file e.md");
        return Ok(());
    }

    let mut projects: Vec<_> = by_project.into_iter().collect();
    projects.sort_by(|a, b| b.1 .0.cmp(&a.1 .0).then(a.0.cmp(&b.0)));
    let max = projects.first().map(|p| p.1 .0).unwrap_or(0);
    let w = projects.iter().map(|p| p.0.len()).max().unwrap_or(0).max(8);
    // Bars shrink on narrow terminals: name, gaps and the count take about w + 16 columns.
    let bw = ui::width().saturating_sub(w + 16).clamp(8, 28);
    println!("\n{}", ui::heading("By project"));
    for (name, (n, st)) in &projects {
        let note = if *st > 0 { ui::yellow(&format!("  {st} stale")) } else { String::new() };
        println!("  {name:<w$}  {} {n:>4}{note}", bar(*n, max, bw));
    }

    let mut kinds: Vec<_> = by_kind.into_iter().collect();
    kinds.sort_by_key(|k| std::cmp::Reverse(k.1));
    let kmax = kinds.first().map(|k| k.1).unwrap_or(0);
    println!("\n{}", ui::heading("By kind"));
    for (k, n) in &kinds {
        let pad = " ".repeat(9usize.saturating_sub(k.len()));
        println!("  {}{pad}  {} {n:>4}", ui::kind(k), bar(*n, kmax, bw));
    }

    // Who has been writing: commits to entries/ in the last 30 days, by author.
    let log = git_out(&brain.home, &["log", "--since=30 days ago", "--format=%an", "--", "entries"]);
    let mut authors: BTreeMap<String, usize> = BTreeMap::new();
    for a in log.lines().filter(|l| !l.is_empty()) {
        *authors.entry(a.to_string()).or_default() += 1;
    }
    if !authors.is_empty() {
        let mut a: Vec<_> = authors.into_iter().collect();
        a.sort_by_key(|x| std::cmp::Reverse(x.1));
        println!("\n{}", ui::heading("Most active (30 days, entry changes)"));
        for (name, n) in a.iter().take(8) {
            println!("  {name:<26} {n:>4}");
        }
    }

    newest.sort_by(|a, b| b.0.cmp(&a.0));
    println!("\n{}", ui::heading("Newest"));
    for (when, kind, project, title) in newest.iter().take(6) {
        let t: String = title.chars().take(70).collect();
        println!("  {}  {}{} {}  {}", ui::dim(when), ui::kind(kind), " ".repeat(9usize.saturating_sub(kind.len())), ui::cyan(project), t);
    }
    println!("\n{}", ui::dim("brain --help for commands · brain digest for what changed today · brain doctor to check setup"));
    Ok(())
}

// ------------------------------------------------------------------ digest

/// `24h`, `3d`, `2w` or a date (YYYY-MM-DD) → a git --since value.
pub(crate) fn since_arg(s: &str) -> String {
    let s = s.trim();
    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
    match (num.parse::<u32>(), unit) {
        (Ok(n), "h") => format!("{n} hours ago"),
        (Ok(n), "d") => format!("{n} days ago"),
        (Ok(n), "w") => format!("{n} weeks ago"),
        _ => s.to_string(),
    }
}

pub fn digest(brain: &Brain, since: &str) -> Result<()> {
    let since_git = since_arg(since);
    let log = git_out(
        &brain.home,
        &["log", "-M", &format!("--since={since_git}"), "--name-status", "--format=@@%an|%ad", "--date=format:%m-%d %H:%M", "--", "entries", "proposals"],
    );
    // id → (status letters seen, last author, last time); git log is newest first.
    let mut changes: BTreeMap<String, (String, String, String)> = BTreeMap::new();
    // Renames (old id → current id), learned newest-first, so older changes count for the current id.
    let mut renamed: BTreeMap<String, String> = BTreeMap::new();
    let id_of = |path: &str| -> Option<String> {
        let file = path.strip_prefix("entries/").or_else(|| path.strip_prefix("proposals/"))?;
        Some(file.trim_end_matches(".md").to_string())
    };
    let (mut author, mut when) = (String::new(), String::new());
    for line in log.lines() {
        if let Some(rest) = line.strip_prefix("@@") {
            let (a, w) = rest.split_once('|').unwrap_or((rest, ""));
            author = a.to_string();
            when = w.to_string();
            continue;
        }
        let cols: Vec<&str> = line.split('\t').collect();
        let (Some(status), Some(path)) = (cols.first(), cols.last()) else { continue };
        let Some(mut id) = id_of(path) else { continue };
        if let Some(cur) = renamed.get(&id) {
            id = cur.clone();
        }
        if status.starts_with('R') {
            if let Some(old) = cols.get(1).and_then(|p| id_of(p)) {
                renamed.insert(old, id.clone());
            }
            changes.entry(id).or_insert_with(|| (String::new(), author.clone(), when.clone())).0.push('M');
            continue;
        }
        let e = changes.entry(id).or_insert_with(|| (String::new(), author.clone(), when.clone()));
        e.0.push_str(&status[..1]);
    }
    println!("{} {}", ui::wordmark("digest"), ui::dim(&format!("· what your agents recorded since {since_git}")));
    if changes.is_empty() {
        println!("\nNothing new in that time. Try a longer window: `brain digest --since 2w`.");
        return Ok(());
    }
    let mut by_project: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let (mut added, mut updated, mut removed) = (0, 0, 0);
    for (id, (status, who, at)) in &changes {
        // Oldest change is last in `status`: an A anywhere means it was created in this window.
        let (label, project, title) = match brain.load(id) {
            Ok(e) => {
                let new = status.contains('A');
                if new { added += 1 } else { updated += 1 }
                (if new { ui::green("new     ") } else { ui::yellow("updated ") }, e.meta.project.clone(), e.meta.title.clone())
            }
            Err(_) => {
                if id.contains("--") {
                    (ui::cyan("proposal"), "proposals".to_string(), id.clone())
                } else {
                    removed += 1;
                    (ui::red("removed "), "(deleted)".to_string(), id.clone())
                }
            }
        };
        let t: String = title.chars().take(72).collect();
        by_project.entry(project).or_default().push(format!("  {label}  {t}  {}", ui::dim(&format!("— {who}, {at}"))));
    }
    for (project, lines) in &by_project {
        println!("\n{}", ui::heading(project));
        for l in lines {
            println!("{l}");
        }
    }
    println!("\n{}", ui::dim(&format!("{added} new · {updated} updated · {removed} removed")));
    Ok(())
}