Skip to main content

brainos_backends/
logs.rs

1//! Log pattern analysis — the executor behind `brain logs analyze` and the
2//! `logs.analyze` native capability.
3//!
4//! Two-stage, truthful-by-construction:
5//!
6//! 1. **Deterministic pattern pass** (always runs, works offline). Recent log
7//!    lines are read from one of two sources, each line is classified by level
8//!    and *normalised* into a signature — volatile tokens (timestamps, UUIDs,
9//!    hex, paths, numbers) are collapsed to placeholders — and identical
10//!    signatures are counted. The result is a digest: per-level totals plus the
11//!    top recurring patterns with their counts. This is the core; it never
12//!    invents anything, it only counts what is there. [`analyze`] returns this
13//!    digest, and it is the entire `logs.analyze` capability — narration is the
14//!    reasoner's job, not the capability's.
15//!
16//! 2. **Optional LLM narration** ([`narrate`], used by the CLI). The
17//!    deterministic digest — not the raw log — is handed to the LLM, which
18//!    renders a short plain-language summary. Because it only ever sees the
19//!    digest, it cannot fabricate log lines or causes.
20//!
21//! Sources: [`LogSource::Brain`] (default) reads the daemon's own rotated logs in
22//! `~/.brain/logs/`; [`LogSource::System`] reads OS logs (`log show` on macOS,
23//! sandboxed `journalctl` on Linux).
24//!
25//! Both the daemon's pretty format (`2026-… INFO target: msg`) and its JSON
26//! format (`logging.format: json`) are parsed: a JSON line's `timestamp`,
27//! `level`, and `fields.message` are read directly; otherwise the leading
28//! RFC3339 token + level keyword are used.
29
30use std::collections::BTreeMap;
31use std::path::PathBuf;
32use std::time::Duration;
33
34use anyhow::{Context, Result};
35use brain::BrainConfig;
36use chrono::{DateTime, Utc};
37
38/// The capability this backend declares: read-only log pattern analysis. Always
39/// wired — it reads the daemon's own (or the OS) log offline.
40pub fn capabilities(_config: &BrainConfig) -> Vec<intent::ToolDescriptor> {
41    use crate::capabilities::{backend, native, read_only, usage};
42    vec![native(
43        "logs",
44        "analyze",
45        backend("logs"),
46        read_only(),
47        usage(
48            "The user asks what's been going wrong, to scan recent logs, or to surface recurring errors/warnings.",
49            "For reading a specific file's contents — use fs.read.",
50            &["The daemon has written logs (or the OS log is readable)."],
51            "free / local log read (offline; optional LLM narration)",
52            "\"Any recurring errors in the logs lately?\"",
53        ),
54    )]
55}
56
57/// Where to read logs from. A plain enum (no `clap` derive) so the backends
58/// crate stays free of the CLI's arg-parsing dependency; the CLI maps its own
59/// `--source` value onto this.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum LogSource {
62    /// The daemon's own rotated logs in `~/.brain/logs/`.
63    Brain,
64    /// OS logs: `log show` (macOS) / sandboxed `journalctl` (Linux).
65    System,
66}
67
68/// Analyse recent logs into the deterministic digest. Reads from `source`,
69/// applies the `since` window, caps at `lines`, groups by normalised signature,
70/// and renders the per-level totals + top recurring patterns. No LLM, no
71/// network — this is the whole `logs.analyze` capability.
72pub async fn analyze(
73    config: &BrainConfig,
74    source: LogSource,
75    since: &str,
76    lines: usize,
77) -> Result<String> {
78    // Validate the window up front so a bad `since` fails before we read.
79    let window = parse_since(since)?;
80    let lines = lines.max(1);
81
82    // The brain source filters by timestamp here (its lines carry one); the
83    // system source is already filtered by the OS tool, so we don't re-cut it.
84    let (raw, source_label, cutoff) = match source {
85        LogSource::Brain => {
86            let (raw, label) = read_brain_log(config, lines)?;
87            (raw, label, Some(Utc::now() - window))
88        }
89        LogSource::System => {
90            let (raw, label) = read_system_log(since, lines).await?;
91            (raw, label, None)
92        }
93    };
94
95    let window_label = match source {
96        LogSource::Brain => format!("last {since}, \u{2264}{lines} lines"),
97        LogSource::System => format!("last {since}"),
98    };
99
100    let digest = analyze_lines(&raw, cutoff, &source_label, &window_label);
101    Ok(digest.render())
102}
103
104// ─── Sources ────────────────────────────────────────────────────────────────
105
106/// Maximum number of rotated log files to read. The newest files are kept; the
107/// per-line `since` filter then cuts precisely, so this is just a memory bound
108/// for an unexpectedly long history.
109const MAX_LOG_FILES: usize = 8;
110
111/// Read the daemon's own logs. `tracing_appender` rotates daily/hourly into
112/// `brain.log.<date>[-<hh>]` (or a single `brain.log` when rotation is off), so
113/// we gather every such file, read the newest [`MAX_LOG_FILES`], concatenate
114/// oldest→newest, and tail to `lines`.
115fn read_brain_log(config: &BrainConfig, lines: usize) -> Result<(Vec<String>, String)> {
116    let dir = config.data_dir().join("logs");
117    let files = resolve_log_files(&dir);
118    if files.is_empty() {
119        anyhow::bail!(
120            "no daemon logs in {} — the daemon may never have run (start it with `brain start`)",
121            dir.display()
122        );
123    }
124
125    let mut all: Vec<String> = Vec::new();
126    for path in &files {
127        let content = std::fs::read_to_string(path)
128            .with_context(|| format!("reading daemon log at {}", path.display()))?;
129        all.extend(content.lines().map(str::to_string));
130    }
131
132    let label = if files.len() == 1 {
133        files[0].display().to_string()
134    } else {
135        format!("{} ({} rotated files)", dir.display(), files.len())
136    };
137    Ok((tail_n(all, lines), label))
138}
139
140/// Enumerate the daemon's log files in a directory, newest last (so a final
141/// concat is chronological). Matches `brain.log` and `brain.log.<suffix>`,
142/// excluding the `brain.stderr.log` capture file. The date/datetime suffix
143/// sorts chronologically, so a lexicographic sort by filename is correct.
144fn resolve_log_files(dir: &PathBuf) -> Vec<PathBuf> {
145    let Ok(entries) = std::fs::read_dir(dir) else {
146        return Vec::new();
147    };
148    let mut files: Vec<PathBuf> = entries
149        .flatten()
150        .map(|e| e.path())
151        .filter(|p| p.is_file())
152        .filter(|p| {
153            p.file_name()
154                .and_then(|n| n.to_str())
155                .is_some_and(|n| n == "brain.log" || n.starts_with("brain.log."))
156        })
157        .collect();
158    files.sort();
159    // Keep the newest MAX_LOG_FILES (sort is ascending, so that's the tail).
160    if files.len() > MAX_LOG_FILES {
161        files.drain(0..files.len() - MAX_LOG_FILES);
162    }
163    files
164}
165
166/// Read the platform's system log and return its most recent lines.
167///
168/// On Linux, `journalctl` runs through the exec sandbox (bounded, isolated).
169/// On macOS, Apple's `log` tool *refuses to run inside `sandbox-exec`* ("Cannot
170/// run while sandboxed"), so the fixed, read-only `log show` command runs via a
171/// timeout-bounded child process instead — the only way to make the system
172/// source work there.
173async fn read_system_log(since: &str, lines: usize) -> Result<(Vec<String>, String)> {
174    let (binary, args, label) = system_log_command(since)?;
175    let stdout = run_system_log(&binary, &args).await?;
176    let all: Vec<String> = stdout.lines().map(str::to_string).collect();
177    Ok((tail_n(all, lines), label))
178}
179
180/// Run a system-log command and return its stdout. macOS bypasses the sandbox
181/// (see [`read_system_log`]); Linux routes through `IsolatedSandbox`.
182#[cfg(target_os = "macos")]
183async fn run_system_log(binary: &str, args: &[String]) -> Result<String> {
184    let output = tokio::time::timeout(
185        Duration::from_secs(30),
186        tokio::process::Command::new(binary).args(args).output(),
187    )
188    .await
189    .map_err(|_| anyhow::anyhow!("`{binary}` timed out after 30s"))?
190    .with_context(|| format!("running `{binary}`"))?;
191
192    if !output.status.success() {
193        let detail = String::from_utf8_lossy(&output.stderr);
194        let detail = detail.trim();
195        anyhow::bail!(
196            "`{binary}` failed{}",
197            if detail.is_empty() {
198                String::new()
199            } else {
200                format!(": {detail}")
201            }
202        );
203    }
204    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
205}
206
207#[cfg(not(target_os = "macos"))]
208async fn run_system_log(binary: &str, args: &[String]) -> Result<String> {
209    use sandbox::SandboxExecutor;
210
211    let timeout = Duration::from_secs(30);
212    let command =
213        sandbox::SandboxCommand::new(binary.to_string(), args.to_vec()).with_timeout(timeout);
214    let executor = sandbox::IsolatedSandbox::new(vec![binary.to_string()], timeout);
215
216    let outcome = executor
217        .run(command)
218        .await
219        .with_context(|| format!("running `{binary}` via the exec sandbox"))?;
220
221    if outcome.exit_code != 0 {
222        let detail = outcome.stderr.trim();
223        anyhow::bail!(
224            "`{binary}` exited {}{}",
225            outcome.exit_code,
226            if detail.is_empty() {
227                String::new()
228            } else {
229                format!(": {detail}")
230            }
231        );
232    }
233    Ok(outcome.stdout)
234}
235
236#[cfg(target_os = "macos")]
237fn system_log_command(since: &str) -> Result<(String, Vec<String>, String)> {
238    // `log show --last` accepts the same `30m`/`1h`/`2d` shape we validate.
239    let _ = parse_since(since)?;
240    Ok((
241        "log".to_string(),
242        vec![
243            "show".to_string(),
244            "--style".to_string(),
245            "syslog".to_string(),
246            "--last".to_string(),
247            since.to_string(),
248        ],
249        format!("system log (`log show --last {since}`)"),
250    ))
251}
252
253#[cfg(target_os = "linux")]
254fn system_log_command(since: &str) -> Result<(String, Vec<String>, String)> {
255    let ago = linux_since(since)?;
256    Ok((
257        "journalctl".to_string(),
258        vec!["--no-pager".to_string(), "--since".to_string(), ago.clone()],
259        format!("system journal (`journalctl --since \"{ago}\"`)"),
260    ))
261}
262
263#[cfg(not(any(target_os = "macos", target_os = "linux")))]
264fn system_log_command(_since: &str) -> Result<(String, Vec<String>, String)> {
265    anyhow::bail!("the system log source is only supported on macOS and Linux")
266}
267
268/// Translate `<n>{m|h|d}` into a systemd `--since` phrase, e.g. `1h` →
269/// `"1 hour ago"`.
270#[cfg(any(target_os = "linux", test))]
271fn linux_since(since: &str) -> Result<String> {
272    let (amount, unit) = parse_since_parts(since)?;
273    let noun = match unit {
274        'm' => "minute",
275        'h' => "hour",
276        _ => "day",
277    };
278    let plural = if amount == 1 { "" } else { "s" };
279    Ok(format!("{amount} {noun}{plural} ago"))
280}
281
282// ─── Line parsing (pretty + JSON) ─────────────────────────────────────────────
283
284/// Log severity, ordered most-severe-first so the derived `Ord` sorts the way a
285/// reader expects.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
287enum Level {
288    Error,
289    Warn,
290    Info,
291    Debug,
292    Trace,
293    Other,
294}
295
296impl Level {
297    fn label(self) -> &'static str {
298        match self {
299            Level::Error => "ERROR",
300            Level::Warn => "WARN",
301            Level::Info => "INFO",
302            Level::Debug => "DEBUG",
303            Level::Trace => "TRACE",
304            Level::Other => "other",
305        }
306    }
307
308    /// Map a level keyword (any case) to a [`Level`], or `None` if unrecognised.
309    fn from_keyword(word: &str) -> Option<Level> {
310        match word.to_ascii_uppercase().as_str() {
311            "ERROR" | "ERR" | "FAULT" | "CRITICAL" | "FATAL" => Some(Level::Error),
312            "WARN" | "WARNING" => Some(Level::Warn),
313            "INFO" | "NOTICE" => Some(Level::Info),
314            "DEBUG" => Some(Level::Debug),
315            "TRACE" => Some(Level::Trace),
316            _ => None,
317        }
318    }
319}
320
321/// One parsed log line: an optional timestamp (for `since` filtering), a level,
322/// and a normalised signature (for recurrence grouping).
323struct ParsedLine {
324    ts: Option<DateTime<Utc>>,
325    level: Level,
326    signature: String,
327}
328
329/// Parse one raw line, handling both the daemon's JSON format and its
330/// pretty/syslog text format.
331fn parse_line(line: &str) -> ParsedLine {
332    let trimmed = line.trim_start();
333    if trimmed.starts_with('{') {
334        if let Some(parsed) = parse_json_line(trimmed) {
335            return parsed;
336        }
337    }
338    parse_text_line(line)
339}
340
341/// Parse a structured JSON log line (`logging.format: json`). Reads `timestamp`,
342/// `level`, and the `fields.message` (falling back to a top-level `message`).
343fn parse_json_line(line: &str) -> Option<ParsedLine> {
344    let value: serde_json::Value = serde_json::from_str(line).ok()?;
345    let obj = value.as_object()?;
346
347    let ts = obj
348        .get("timestamp")
349        .and_then(|v| v.as_str())
350        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
351        .map(|t| t.with_timezone(&Utc));
352
353    let level = obj
354        .get("level")
355        .and_then(|v| v.as_str())
356        .and_then(Level::from_keyword)
357        .unwrap_or(Level::Other);
358
359    let message = obj
360        .get("fields")
361        .and_then(|f| f.get("message"))
362        .or_else(|| obj.get("message"))
363        .and_then(|v| v.as_str())
364        .unwrap_or(line);
365
366    // Group by target + message so the same event from the same module clusters.
367    let target = obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
368    let basis = if target.is_empty() {
369        message.to_string()
370    } else {
371        format!("{target}: {message}")
372    };
373
374    Some(ParsedLine {
375        ts,
376        level,
377        signature: normalize_signature(&basis),
378    })
379}
380
381/// Parse a pretty/syslog text line. A leading RFC3339 token is taken as the
382/// timestamp and a following level keyword as the level; both are stripped from
383/// the signature so it reads as `target: message` (matching the JSON path) and
384/// doesn't repeat the level already shown beside the count. When the leading
385/// tokens aren't in that shape (e.g. `log show` syslog style), the level falls
386/// back to a keyword scan and the whole line normalises into the signature.
387fn parse_text_line(line: &str) -> ParsedLine {
388    let mut rest = line.trim_start();
389
390    let mut ts = None;
391    if let Some((first, tail)) = split_first_token(rest) {
392        if let Ok(t) = DateTime::parse_from_rfc3339(first) {
393            ts = Some(t.with_timezone(&Utc));
394            rest = tail;
395        }
396    }
397
398    let level = match split_first_token(rest) {
399        Some((first, tail)) if Level::from_keyword(first).is_some() => {
400            rest = tail;
401            Level::from_keyword(first).unwrap()
402        }
403        // Level wasn't where we expected it; scan the whole line instead.
404        _ => classify_text_level(line),
405    };
406
407    ParsedLine {
408        ts,
409        level,
410        signature: normalize_signature(rest),
411    }
412}
413
414/// Split off the first whitespace-delimited token, returning `(token, rest)`
415/// with `rest` left-trimmed. `None` when the input is all whitespace.
416fn split_first_token(s: &str) -> Option<(&str, &str)> {
417    let s = s.trim_start();
418    if s.is_empty() {
419        return None;
420    }
421    match s.find(char::is_whitespace) {
422        Some(i) => Some((&s[..i], s[i..].trim_start())),
423        None => Some((s, "")),
424    }
425}
426
427/// Heuristic level classification for text lines: the first level keyword found
428/// as a standalone alphabetic token.
429fn classify_text_level(line: &str) -> Level {
430    for token in line.split(|c: char| !c.is_ascii_alphabetic()) {
431        if let Some(level) = Level::from_keyword(token) {
432            return level;
433        }
434    }
435    Level::Other
436}
437
438// ─── Normalisation ────────────────────────────────────────────────────────────
439
440/// Signatures longer than this are truncated so one giant line can't dominate.
441const MAX_SIGNATURE_LEN: usize = 160;
442
443/// Collapse a line into a stable signature by replacing volatile tokens with
444/// placeholders, so structurally-identical messages group together.
445fn normalize_signature(line: &str) -> String {
446    let mut out = String::with_capacity(line.len());
447    for (i, token) in line.split_whitespace().enumerate() {
448        if i > 0 {
449            out.push(' ');
450        }
451        out.push_str(&normalize_token(token));
452    }
453    if out.chars().count() > MAX_SIGNATURE_LEN {
454        out = out.chars().take(MAX_SIGNATURE_LEN).collect();
455        out.push('\u{2026}');
456    }
457    out
458}
459
460fn normalize_token(token: &str) -> String {
461    if looks_like_uuid(token) {
462        return "<uuid>".to_string();
463    }
464    if let Some(rest) = token.strip_prefix("0x") {
465        if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_hexdigit()) {
466            return "<hex>".to_string();
467        }
468    }
469    if token.starts_with('/') && token.len() > 1 {
470        return "<path>".to_string();
471    }
472    if is_timestamp(token) {
473        return "<ts>".to_string();
474    }
475    collapse_digit_runs(token)
476}
477
478fn looks_like_uuid(token: &str) -> bool {
479    // 8-4-4-4-12 hex with dashes. Strip common trailing punctuation first.
480    let t = token.trim_end_matches([',', ';', ')', ']', '"', '\'']);
481    let parts: Vec<&str> = t.split('-').collect();
482    if parts.len() != 5 {
483        return false;
484    }
485    let lens = [8, 4, 4, 4, 12];
486    parts
487        .iter()
488        .zip(lens)
489        .all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
490}
491
492/// A token carrying a date/time: has a digit and a date/time separator. Catches
493/// `2026-06-09T10:11:12.345Z`, `10:11:12`, and `2026-06-09` alike.
494fn is_timestamp(token: &str) -> bool {
495    let has_digit = token.chars().any(|c| c.is_ascii_digit());
496    let has_sep = token.contains(':') || (token.contains('-') && token.contains('T'));
497    has_digit && has_sep
498}
499
500/// Replace each run of ASCII digits with `<n>`, so `tool-42` → `tool-<n>` and
501/// `count=1500` → `count=<n>`.
502fn collapse_digit_runs(token: &str) -> String {
503    let mut out = String::with_capacity(token.len());
504    let mut in_run = false;
505    for c in token.chars() {
506        if c.is_ascii_digit() {
507            if !in_run {
508                out.push_str("<n>");
509                in_run = true;
510            }
511        } else {
512            out.push(c);
513            in_run = false;
514        }
515    }
516    out
517}
518
519/// Keep only the last `n` elements of `v` (the most recent log lines).
520fn tail_n(mut v: Vec<String>, n: usize) -> Vec<String> {
521    if v.len() > n {
522        v.drain(0..v.len() - n);
523    }
524    v
525}
526
527// ─── Deterministic digest ─────────────────────────────────────────────────────
528
529/// One recurring signature with its level and occurrence count.
530struct PatternHit {
531    level: Level,
532    signature: String,
533    count: usize,
534}
535
536/// The deterministic result of analysing a batch of log lines.
537struct Digest {
538    source_label: String,
539    window_label: String,
540    total_lines: usize,
541    level_counts: BTreeMap<Level, usize>,
542    /// Recurring signatures (count ≥ 2), most frequent first.
543    patterns: Vec<PatternHit>,
544}
545
546/// The maximum number of recurring patterns to surface.
547const TOP_PATTERNS: usize = 10;
548
549/// Analyse a batch of raw log lines into a [`Digest`]. When `cutoff` is set,
550/// lines whose parsed timestamp is older are dropped (lines without a timestamp
551/// are kept — we never drop what we can't date).
552fn analyze_lines(
553    lines: &[String],
554    cutoff: Option<DateTime<Utc>>,
555    source_label: &str,
556    window_label: &str,
557) -> Digest {
558    let mut level_counts: BTreeMap<Level, usize> = BTreeMap::new();
559    // Group by signature, remembering the level of the first occurrence.
560    let mut groups: BTreeMap<String, (Level, usize)> = BTreeMap::new();
561
562    let mut total = 0usize;
563    for line in lines {
564        if line.trim().is_empty() {
565            continue;
566        }
567        let parsed = parse_line(line);
568        if let (Some(cut), Some(ts)) = (cutoff, parsed.ts) {
569            if ts < cut {
570                continue;
571            }
572        }
573        total += 1;
574        *level_counts.entry(parsed.level).or_insert(0) += 1;
575        let entry = groups.entry(parsed.signature).or_insert((parsed.level, 0));
576        entry.1 += 1;
577    }
578
579    let mut patterns: Vec<PatternHit> = groups
580        .into_iter()
581        .filter(|(_, (_, count))| *count >= 2)
582        .map(|(signature, (level, count))| PatternHit {
583            level,
584            signature,
585            count,
586        })
587        .collect();
588    // Most frequent first; ties broken by severity, then signature for stability.
589    patterns.sort_by(|a, b| {
590        b.count
591            .cmp(&a.count)
592            .then(a.level.cmp(&b.level))
593            .then(a.signature.cmp(&b.signature))
594    });
595    patterns.truncate(TOP_PATTERNS);
596
597    Digest {
598        source_label: source_label.to_string(),
599        window_label: window_label.to_string(),
600        total_lines: total,
601        level_counts,
602        patterns,
603    }
604}
605
606impl Digest {
607    fn render(&self) -> String {
608        let mut s = String::new();
609        s.push_str(&format!(
610            "Analyzed {} line{} from {} ({})\n",
611            self.total_lines,
612            if self.total_lines == 1 { "" } else { "s" },
613            self.source_label,
614            self.window_label,
615        ));
616
617        // Per-level totals, severe first, only the levels actually seen.
618        let parts: Vec<String> = [
619            Level::Error,
620            Level::Warn,
621            Level::Info,
622            Level::Debug,
623            Level::Trace,
624            Level::Other,
625        ]
626        .into_iter()
627        .filter_map(|lvl| {
628            self.level_counts
629                .get(&lvl)
630                .map(|c| format!("{}: {c}", lvl.label()))
631        })
632        .collect();
633        if parts.is_empty() {
634            s.push_str("  (no log lines in this window)");
635            return s;
636        }
637        s.push_str(&format!("  {}\n", parts.join("   ")));
638
639        if self.patterns.is_empty() {
640            s.push_str("\nNo recurring patterns — every line was distinct after normalization.");
641        } else {
642            s.push_str("\nTop recurring patterns:\n");
643            for p in &self.patterns {
644                s.push_str(&format!(
645                    "  \u{00d7}{:<4} [{}] {}\n",
646                    p.count,
647                    p.level.label(),
648                    p.signature
649                ));
650            }
651            // Trim the trailing newline for a clean join with the summary.
652            s.pop();
653        }
654        s
655    }
656}
657
658// ─── Optional LLM narration (CLI only) ─────────────────────────────────────────
659
660/// Narrate a rendered digest in plain language. The prompt is strictly
661/// grounded: the model is told to summarise only what the digest contains and
662/// never to invent log lines or causes. Returns an error (not a panic) when no
663/// provider is reachable, so the caller can degrade to the digest alone.
664///
665/// This is the optional CLI layer on top of [`analyze`]; the `logs.analyze`
666/// capability returns the digest only and lets the reasoner narrate.
667pub async fn narrate(config: &BrainConfig, digest: &str) -> Result<String> {
668    let provider = cortex::llm::select_provider(&config.llm)
669        .await
670        .map_err(|e| anyhow::anyhow!("no LLM provider reachable: {e}"))?;
671
672    let system = "You are summarizing a log-analysis digest for an operator. \
673        Describe ONLY what the digest below contains — the level counts and the \
674        recurring patterns with their occurrence counts. Do NOT invent log lines, \
675        error messages, root causes, or fixes that are not present in the digest. \
676        If the digest shows no errors or warnings, say the logs look healthy. \
677        Keep it to 2–4 short sentences, plain and factual.";
678
679    let user = format!("Log-analysis digest:\n\n{digest}");
680
681    let messages = [
682        cortex::llm::Message::system(system),
683        cortex::llm::Message::user(user),
684    ];
685    let response = provider
686        .generate(&messages)
687        .await
688        .map_err(|e| anyhow::anyhow!("provider call failed: {e}"))?;
689
690    let text = response.content.trim().to_string();
691    if text.is_empty() {
692        anyhow::bail!("provider returned an empty summary");
693    }
694    Ok(text)
695}
696
697// ─── Since parsing ────────────────────────────────────────────────────────────
698
699/// Parse a `<n>{m|h|d}` window into `(amount, unit)`.
700fn parse_since_parts(since: &str) -> Result<(u64, char)> {
701    let since = since.trim();
702    let unit = since
703        .chars()
704        .last()
705        .filter(|c| matches!(c, 'm' | 'h' | 'd'))
706        .ok_or_else(|| {
707            anyhow::anyhow!("invalid since '{since}': expected <n>{{m|h|d}}, e.g. 30m, 1h, 2d")
708        })?;
709    let amount: u64 = since[..since.len() - 1].parse().map_err(|_| {
710        anyhow::anyhow!(
711            "invalid since '{since}': '{}' is not a number",
712            &since[..since.len() - 1]
713        )
714    })?;
715    if amount == 0 {
716        anyhow::bail!("invalid since '{since}': the amount must be greater than zero");
717    }
718    Ok((amount, unit))
719}
720
721/// Parse a `<n>{m|h|d}` window into a `chrono::Duration`.
722fn parse_since(since: &str) -> Result<chrono::Duration> {
723    let (amount, unit) = parse_since_parts(since)?;
724    let amount = amount as i64;
725    Ok(match unit {
726        'm' => chrono::Duration::minutes(amount),
727        'h' => chrono::Duration::hours(amount),
728        _ => chrono::Duration::days(amount),
729    })
730}
731
732// ─── cortex backend wiring ──────────────────────────────────────────────────
733
734/// The [`cortex::actions::LogAnalysisBackend`] implementation. Holds a snapshot
735/// of the config taken at boot and runs the deterministic [`analyze`] on each
736/// dispatch, returning the digest. The chat tool-loop dispatches `logs.analyze`
737/// through this; the CLI calls [`analyze`]/[`narrate`] directly.
738pub struct LogAnalysis {
739    config: BrainConfig,
740}
741
742impl LogAnalysis {
743    pub fn new(config: BrainConfig) -> Self {
744        Self { config }
745    }
746}
747
748#[async_trait::async_trait]
749impl cortex::actions::LogAnalysisBackend for LogAnalysis {
750    async fn analyze(
751        &self,
752        system: bool,
753        since: &str,
754        lines: usize,
755    ) -> Result<String, cortex::actions::ActionError> {
756        let source = if system {
757            LogSource::System
758        } else {
759            LogSource::Brain
760        };
761        analyze(&self.config, source, since, lines)
762            .await
763            .map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn normalize_collapses_volatile_tokens() {
773        assert_eq!(normalize_token("0xdeadbeef"), "<hex>");
774        assert_eq!(normalize_token("/Users/x/.brain/db.sqlite"), "<path>");
775        assert_eq!(
776            normalize_token("550e8400-e29b-41d4-a716-446655440000"),
777            "<uuid>"
778        );
779        assert_eq!(normalize_token("2026-06-09T10:11:12.345Z"), "<ts>");
780        assert_eq!(normalize_token("10:11:12"), "<ts>");
781        assert_eq!(normalize_token("tool-42"), "tool-<n>");
782        assert_eq!(normalize_token("count=1500"), "count=<n>");
783        assert_eq!(normalize_token("provider"), "provider");
784    }
785
786    #[test]
787    fn uuid_detection_tolerates_trailing_punctuation() {
788        assert!(looks_like_uuid("550e8400-e29b-41d4-a716-446655440000"));
789        assert!(looks_like_uuid("550e8400-e29b-41d4-a716-446655440000,"));
790        assert!(!looks_like_uuid("not-a-uuid"));
791        assert!(!looks_like_uuid("550e8400-e29b-41d4-a716")); // only 4 groups
792    }
793
794    #[test]
795    fn parses_real_pretty_line() {
796        // The exact shape the daemon writes (verified against ~/.brain/logs).
797        let line = "2026-06-09T08:39:16.314126Z  INFO brainos_storage::sqlite: \
798                    SQLite database opened at /Users/x/.brain/db/brain.db (pool size 8)";
799        let p = parse_line(line);
800        assert_eq!(p.level, Level::Info);
801        assert!(p.ts.is_some(), "leading RFC3339 token should parse");
802        // The ts + level tokens are stripped; the signature reads as target: msg.
803        assert!(p.signature.starts_with("brainos_storage::sqlite:"));
804        assert!(!p.signature.contains("<ts>"));
805        assert!(!p.signature.contains("INFO"));
806        assert!(p.signature.contains("<path>"));
807        assert!(p.signature.contains("pool size <n>"));
808    }
809
810    #[test]
811    fn parses_json_line() {
812        let line = r#"{"timestamp":"2026-06-09T08:39:16.314126Z","level":"WARN","fields":{"message":"breaker open for tool abc-1"},"target":"brainos_resilience"}"#;
813        let p = parse_line(line);
814        assert_eq!(p.level, Level::Warn);
815        assert!(p.ts.is_some());
816        // Signature is built from target + message, not the JSON scaffolding.
817        assert!(p.signature.contains("brainos_resilience"));
818        assert!(p.signature.contains("breaker open for tool"));
819        assert!(p.signature.contains("<n>"), "the id should be normalized");
820        assert!(!p.signature.contains("timestamp"));
821    }
822
823    #[test]
824    fn json_lines_with_volatile_ids_group_together() {
825        let lines: Vec<String> = (1..=3)
826            .map(|i| {
827                format!(
828                    r#"{{"timestamp":"2026-06-09T10:00:0{i}Z","level":"ERROR","fields":{{"message":"breaker open for tool t-{i}"}},"target":"res"}}"#
829                )
830            })
831            .collect();
832        let digest = analyze_lines(&lines, None, "test", "all");
833        assert_eq!(digest.level_counts.get(&Level::Error), Some(&3));
834        assert_eq!(digest.patterns.len(), 1);
835        assert_eq!(digest.patterns[0].count, 3);
836    }
837
838    #[test]
839    fn analyze_groups_structurally_identical_lines() {
840        let lines: Vec<String> = vec![
841            "2026-06-09T10:00:00Z ERROR circuit breaker open for tool abc-1".into(),
842            "2026-06-09T10:00:01Z ERROR circuit breaker open for tool abc-2".into(),
843            "2026-06-09T10:00:02Z ERROR circuit breaker open for tool abc-3".into(),
844            "2026-06-09T10:00:03Z INFO  signal received from user".into(),
845            "2026-06-09T10:00:04Z INFO  consolidation complete".into(),
846        ];
847        let digest = analyze_lines(&lines, None, "test", "all");
848
849        assert_eq!(digest.total_lines, 5);
850        assert_eq!(digest.level_counts.get(&Level::Error), Some(&3));
851        assert_eq!(digest.level_counts.get(&Level::Info), Some(&2));
852
853        assert_eq!(digest.patterns.len(), 1);
854        let top = &digest.patterns[0];
855        assert_eq!(top.count, 3);
856        assert_eq!(top.level, Level::Error);
857        assert!(top.signature.contains("circuit breaker open for tool"));
858        assert!(
859            top.signature.contains("<n>"),
860            "tool id should be normalized"
861        );
862    }
863
864    #[test]
865    fn analyze_applies_the_since_cutoff() {
866        let cutoff = Utc::now() - chrono::Duration::hours(1);
867        let fresh = format!("{} ERROR fresh boom", Utc::now().to_rfc3339());
868        let lines: Vec<String> = vec![
869            "2000-01-01T00:00:00Z ERROR ancient boom".into(),
870            fresh,
871            "no timestamp but kept".into(),
872        ];
873        let digest = analyze_lines(&lines, Some(cutoff), "test", "1h");
874        // Ancient line dropped; fresh + undatable kept.
875        assert_eq!(digest.total_lines, 2);
876    }
877
878    #[test]
879    fn analyze_reports_no_recurrence_when_all_distinct() {
880        let lines: Vec<String> = vec![
881            "alpha event one".into(),
882            "beta event two".into(),
883            "gamma event three".into(),
884        ];
885        let digest = analyze_lines(&lines, None, "test", "all");
886        assert!(digest.patterns.is_empty());
887        assert!(digest.render().contains("No recurring patterns"));
888    }
889
890    #[test]
891    fn resolve_log_files_picks_rotated_files_newest_last() {
892        let dir = tempfile::tempdir().unwrap();
893        let p = dir.path();
894        for name in [
895            "brain.log.2026-06-07",
896            "brain.log.2026-06-09",
897            "brain.log.2026-06-08",
898            "brain.stderr.log", // must be excluded
899            "unrelated.txt",    // must be excluded
900        ] {
901            std::fs::write(p.join(name), "x").unwrap();
902        }
903        let files = resolve_log_files(&p.to_path_buf());
904        let names: Vec<String> = files
905            .iter()
906            .map(|f| f.file_name().unwrap().to_str().unwrap().to_string())
907            .collect();
908        assert_eq!(
909            names,
910            vec![
911                "brain.log.2026-06-07",
912                "brain.log.2026-06-08",
913                "brain.log.2026-06-09",
914            ],
915            "rotated files only, sorted chronologically (newest last)"
916        );
917    }
918
919    #[test]
920    fn resolve_log_files_handles_unrotated_single_file() {
921        let dir = tempfile::tempdir().unwrap();
922        std::fs::write(dir.path().join("brain.log"), "x").unwrap();
923        let files = resolve_log_files(&dir.path().to_path_buf());
924        assert_eq!(files.len(), 1);
925        assert!(files[0].ends_with("brain.log"));
926    }
927
928    #[test]
929    fn tail_n_keeps_the_most_recent() {
930        let v: Vec<String> = (0..5).map(|i| i.to_string()).collect();
931        assert_eq!(tail_n(v.clone(), 2), vec!["3".to_string(), "4".to_string()]);
932        assert_eq!(tail_n(v.clone(), 10), v);
933    }
934
935    #[test]
936    fn parse_since_accepts_units_and_rejects_garbage() {
937        assert_eq!(parse_since("30m").unwrap(), chrono::Duration::minutes(30));
938        assert_eq!(parse_since("2h").unwrap(), chrono::Duration::hours(2));
939        assert_eq!(parse_since("1d").unwrap(), chrono::Duration::days(1));
940        assert!(parse_since("5").is_err()); // no unit
941        assert!(parse_since("h").is_err()); // no amount
942        assert!(parse_since("0h").is_err()); // zero
943        assert!(parse_since("3w").is_err()); // unsupported unit
944    }
945
946    #[test]
947    fn linux_since_phrases_are_systemd_shaped() {
948        assert_eq!(linux_since("1h").unwrap(), "1 hour ago");
949        assert_eq!(linux_since("30m").unwrap(), "30 minutes ago");
950        assert_eq!(linux_since("2d").unwrap(), "2 days ago");
951    }
952}