use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use brain::BrainConfig;
use chrono::{DateTime, Utc};
pub fn capabilities(_config: &BrainConfig) -> Vec<intent::ToolDescriptor> {
use crate::capabilities::{backend, native, read_only, usage};
vec![native(
"logs",
"analyze",
backend("logs"),
read_only(),
usage(
"The user asks what's been going wrong, to scan recent logs, or to surface recurring errors/warnings.",
"For reading a specific file's contents — use fs.read.",
&["The daemon has written logs (or the OS log is readable)."],
"free / local log read (offline; optional LLM narration)",
"\"Any recurring errors in the logs lately?\"",
),
)]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogSource {
Brain,
System,
}
pub async fn analyze(
config: &BrainConfig,
source: LogSource,
since: &str,
lines: usize,
) -> Result<String> {
let window = parse_since(since)?;
let lines = lines.max(1);
let (raw, source_label, cutoff) = match source {
LogSource::Brain => {
let (raw, label) = read_brain_log(config, lines)?;
(raw, label, Some(Utc::now() - window))
}
LogSource::System => {
let (raw, label) = read_system_log(since, lines).await?;
(raw, label, None)
}
};
let window_label = match source {
LogSource::Brain => format!("last {since}, \u{2264}{lines} lines"),
LogSource::System => format!("last {since}"),
};
let digest = analyze_lines(&raw, cutoff, &source_label, &window_label);
Ok(digest.render())
}
const MAX_LOG_FILES: usize = 8;
fn read_brain_log(config: &BrainConfig, lines: usize) -> Result<(Vec<String>, String)> {
let dir = config.data_dir().join("logs");
let files = resolve_log_files(&dir);
if files.is_empty() {
anyhow::bail!(
"no daemon logs in {} — the daemon may never have run (start it with `brain start`)",
dir.display()
);
}
let mut all: Vec<String> = Vec::new();
for path in &files {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading daemon log at {}", path.display()))?;
all.extend(content.lines().map(str::to_string));
}
let label = if files.len() == 1 {
files[0].display().to_string()
} else {
format!("{} ({} rotated files)", dir.display(), files.len())
};
Ok((tail_n(all, lines), label))
}
fn resolve_log_files(dir: &PathBuf) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "brain.log" || n.starts_with("brain.log."))
})
.collect();
files.sort();
if files.len() > MAX_LOG_FILES {
files.drain(0..files.len() - MAX_LOG_FILES);
}
files
}
async fn read_system_log(since: &str, lines: usize) -> Result<(Vec<String>, String)> {
let (binary, args, label) = system_log_command(since)?;
let stdout = run_system_log(&binary, &args).await?;
let all: Vec<String> = stdout.lines().map(str::to_string).collect();
Ok((tail_n(all, lines), label))
}
#[cfg(target_os = "macos")]
async fn run_system_log(binary: &str, args: &[String]) -> Result<String> {
let output = tokio::time::timeout(
Duration::from_secs(30),
tokio::process::Command::new(binary).args(args).output(),
)
.await
.map_err(|_| anyhow::anyhow!("`{binary}` timed out after 30s"))?
.with_context(|| format!("running `{binary}`"))?;
if !output.status.success() {
let detail = String::from_utf8_lossy(&output.stderr);
let detail = detail.trim();
anyhow::bail!(
"`{binary}` failed{}",
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(not(target_os = "macos"))]
async fn run_system_log(binary: &str, args: &[String]) -> Result<String> {
use sandbox::SandboxExecutor;
let timeout = Duration::from_secs(30);
let command =
sandbox::SandboxCommand::new(binary.to_string(), args.to_vec()).with_timeout(timeout);
let executor = sandbox::IsolatedSandbox::new(vec![binary.to_string()], timeout);
let outcome = executor
.run(command)
.await
.with_context(|| format!("running `{binary}` via the exec sandbox"))?;
if outcome.exit_code != 0 {
let detail = outcome.stderr.trim();
anyhow::bail!(
"`{binary}` exited {}{}",
outcome.exit_code,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
);
}
Ok(outcome.stdout)
}
#[cfg(target_os = "macos")]
fn system_log_command(since: &str) -> Result<(String, Vec<String>, String)> {
let _ = parse_since(since)?;
Ok((
"log".to_string(),
vec![
"show".to_string(),
"--style".to_string(),
"syslog".to_string(),
"--last".to_string(),
since.to_string(),
],
format!("system log (`log show --last {since}`)"),
))
}
#[cfg(target_os = "linux")]
fn system_log_command(since: &str) -> Result<(String, Vec<String>, String)> {
let ago = linux_since(since)?;
Ok((
"journalctl".to_string(),
vec!["--no-pager".to_string(), "--since".to_string(), ago.clone()],
format!("system journal (`journalctl --since \"{ago}\"`)"),
))
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn system_log_command(_since: &str) -> Result<(String, Vec<String>, String)> {
anyhow::bail!("the system log source is only supported on macOS and Linux")
}
#[cfg(any(target_os = "linux", test))]
fn linux_since(since: &str) -> Result<String> {
let (amount, unit) = parse_since_parts(since)?;
let noun = match unit {
'm' => "minute",
'h' => "hour",
_ => "day",
};
let plural = if amount == 1 { "" } else { "s" };
Ok(format!("{amount} {noun}{plural} ago"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Level {
Error,
Warn,
Info,
Debug,
Trace,
Other,
}
impl Level {
fn label(self) -> &'static str {
match self {
Level::Error => "ERROR",
Level::Warn => "WARN",
Level::Info => "INFO",
Level::Debug => "DEBUG",
Level::Trace => "TRACE",
Level::Other => "other",
}
}
fn from_keyword(word: &str) -> Option<Level> {
match word.to_ascii_uppercase().as_str() {
"ERROR" | "ERR" | "FAULT" | "CRITICAL" | "FATAL" => Some(Level::Error),
"WARN" | "WARNING" => Some(Level::Warn),
"INFO" | "NOTICE" => Some(Level::Info),
"DEBUG" => Some(Level::Debug),
"TRACE" => Some(Level::Trace),
_ => None,
}
}
}
struct ParsedLine {
ts: Option<DateTime<Utc>>,
level: Level,
signature: String,
}
fn parse_line(line: &str) -> ParsedLine {
let trimmed = line.trim_start();
if trimmed.starts_with('{') {
if let Some(parsed) = parse_json_line(trimmed) {
return parsed;
}
}
parse_text_line(line)
}
fn parse_json_line(line: &str) -> Option<ParsedLine> {
let value: serde_json::Value = serde_json::from_str(line).ok()?;
let obj = value.as_object()?;
let ts = obj
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&Utc));
let level = obj
.get("level")
.and_then(|v| v.as_str())
.and_then(Level::from_keyword)
.unwrap_or(Level::Other);
let message = obj
.get("fields")
.and_then(|f| f.get("message"))
.or_else(|| obj.get("message"))
.and_then(|v| v.as_str())
.unwrap_or(line);
let target = obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
let basis = if target.is_empty() {
message.to_string()
} else {
format!("{target}: {message}")
};
Some(ParsedLine {
ts,
level,
signature: normalize_signature(&basis),
})
}
fn parse_text_line(line: &str) -> ParsedLine {
let mut rest = line.trim_start();
let mut ts = None;
if let Some((first, tail)) = split_first_token(rest) {
if let Ok(t) = DateTime::parse_from_rfc3339(first) {
ts = Some(t.with_timezone(&Utc));
rest = tail;
}
}
let level = match split_first_token(rest) {
Some((first, tail)) if Level::from_keyword(first).is_some() => {
rest = tail;
Level::from_keyword(first).unwrap()
}
_ => classify_text_level(line),
};
ParsedLine {
ts,
level,
signature: normalize_signature(rest),
}
}
fn split_first_token(s: &str) -> Option<(&str, &str)> {
let s = s.trim_start();
if s.is_empty() {
return None;
}
match s.find(char::is_whitespace) {
Some(i) => Some((&s[..i], s[i..].trim_start())),
None => Some((s, "")),
}
}
fn classify_text_level(line: &str) -> Level {
for token in line.split(|c: char| !c.is_ascii_alphabetic()) {
if let Some(level) = Level::from_keyword(token) {
return level;
}
}
Level::Other
}
const MAX_SIGNATURE_LEN: usize = 160;
fn normalize_signature(line: &str) -> String {
let mut out = String::with_capacity(line.len());
for (i, token) in line.split_whitespace().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(&normalize_token(token));
}
if out.chars().count() > MAX_SIGNATURE_LEN {
out = out.chars().take(MAX_SIGNATURE_LEN).collect();
out.push('\u{2026}');
}
out
}
fn normalize_token(token: &str) -> String {
if looks_like_uuid(token) {
return "<uuid>".to_string();
}
if let Some(rest) = token.strip_prefix("0x") {
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_hexdigit()) {
return "<hex>".to_string();
}
}
if token.starts_with('/') && token.len() > 1 {
return "<path>".to_string();
}
if is_timestamp(token) {
return "<ts>".to_string();
}
collapse_digit_runs(token)
}
fn looks_like_uuid(token: &str) -> bool {
let t = token.trim_end_matches([',', ';', ')', ']', '"', '\'']);
let parts: Vec<&str> = t.split('-').collect();
if parts.len() != 5 {
return false;
}
let lens = [8, 4, 4, 4, 12];
parts
.iter()
.zip(lens)
.all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
}
fn is_timestamp(token: &str) -> bool {
let has_digit = token.chars().any(|c| c.is_ascii_digit());
let has_sep = token.contains(':') || (token.contains('-') && token.contains('T'));
has_digit && has_sep
}
fn collapse_digit_runs(token: &str) -> String {
let mut out = String::with_capacity(token.len());
let mut in_run = false;
for c in token.chars() {
if c.is_ascii_digit() {
if !in_run {
out.push_str("<n>");
in_run = true;
}
} else {
out.push(c);
in_run = false;
}
}
out
}
fn tail_n(mut v: Vec<String>, n: usize) -> Vec<String> {
if v.len() > n {
v.drain(0..v.len() - n);
}
v
}
struct PatternHit {
level: Level,
signature: String,
count: usize,
}
struct Digest {
source_label: String,
window_label: String,
total_lines: usize,
level_counts: BTreeMap<Level, usize>,
patterns: Vec<PatternHit>,
}
const TOP_PATTERNS: usize = 10;
fn analyze_lines(
lines: &[String],
cutoff: Option<DateTime<Utc>>,
source_label: &str,
window_label: &str,
) -> Digest {
let mut level_counts: BTreeMap<Level, usize> = BTreeMap::new();
let mut groups: BTreeMap<String, (Level, usize)> = BTreeMap::new();
let mut total = 0usize;
for line in lines {
if line.trim().is_empty() {
continue;
}
let parsed = parse_line(line);
if let (Some(cut), Some(ts)) = (cutoff, parsed.ts) {
if ts < cut {
continue;
}
}
total += 1;
*level_counts.entry(parsed.level).or_insert(0) += 1;
let entry = groups.entry(parsed.signature).or_insert((parsed.level, 0));
entry.1 += 1;
}
let mut patterns: Vec<PatternHit> = groups
.into_iter()
.filter(|(_, (_, count))| *count >= 2)
.map(|(signature, (level, count))| PatternHit {
level,
signature,
count,
})
.collect();
patterns.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then(a.level.cmp(&b.level))
.then(a.signature.cmp(&b.signature))
});
patterns.truncate(TOP_PATTERNS);
Digest {
source_label: source_label.to_string(),
window_label: window_label.to_string(),
total_lines: total,
level_counts,
patterns,
}
}
impl Digest {
fn render(&self) -> String {
let mut s = String::new();
s.push_str(&format!(
"Analyzed {} line{} from {} ({})\n",
self.total_lines,
if self.total_lines == 1 { "" } else { "s" },
self.source_label,
self.window_label,
));
let parts: Vec<String> = [
Level::Error,
Level::Warn,
Level::Info,
Level::Debug,
Level::Trace,
Level::Other,
]
.into_iter()
.filter_map(|lvl| {
self.level_counts
.get(&lvl)
.map(|c| format!("{}: {c}", lvl.label()))
})
.collect();
if parts.is_empty() {
s.push_str(" (no log lines in this window)");
return s;
}
s.push_str(&format!(" {}\n", parts.join(" ")));
if self.patterns.is_empty() {
s.push_str("\nNo recurring patterns — every line was distinct after normalization.");
} else {
s.push_str("\nTop recurring patterns:\n");
for p in &self.patterns {
s.push_str(&format!(
" \u{00d7}{:<4} [{}] {}\n",
p.count,
p.level.label(),
p.signature
));
}
s.pop();
}
s
}
}
pub async fn narrate(config: &BrainConfig, digest: &str) -> Result<String> {
let provider = cortex::llm::select_provider(&config.llm)
.await
.map_err(|e| anyhow::anyhow!("no LLM provider reachable: {e}"))?;
let system = "You are summarizing a log-analysis digest for an operator. \
Describe ONLY what the digest below contains — the level counts and the \
recurring patterns with their occurrence counts. Do NOT invent log lines, \
error messages, root causes, or fixes that are not present in the digest. \
If the digest shows no errors or warnings, say the logs look healthy. \
Keep it to 2–4 short sentences, plain and factual.";
let user = format!("Log-analysis digest:\n\n{digest}");
let messages = [
cortex::llm::Message::system(system),
cortex::llm::Message::user(user),
];
let response = provider
.generate(&messages)
.await
.map_err(|e| anyhow::anyhow!("provider call failed: {e}"))?;
let text = response.content.trim().to_string();
if text.is_empty() {
anyhow::bail!("provider returned an empty summary");
}
Ok(text)
}
fn parse_since_parts(since: &str) -> Result<(u64, char)> {
let since = since.trim();
let unit = since
.chars()
.last()
.filter(|c| matches!(c, 'm' | 'h' | 'd'))
.ok_or_else(|| {
anyhow::anyhow!("invalid since '{since}': expected <n>{{m|h|d}}, e.g. 30m, 1h, 2d")
})?;
let amount: u64 = since[..since.len() - 1].parse().map_err(|_| {
anyhow::anyhow!(
"invalid since '{since}': '{}' is not a number",
&since[..since.len() - 1]
)
})?;
if amount == 0 {
anyhow::bail!("invalid since '{since}': the amount must be greater than zero");
}
Ok((amount, unit))
}
fn parse_since(since: &str) -> Result<chrono::Duration> {
let (amount, unit) = parse_since_parts(since)?;
let amount = amount as i64;
Ok(match unit {
'm' => chrono::Duration::minutes(amount),
'h' => chrono::Duration::hours(amount),
_ => chrono::Duration::days(amount),
})
}
pub struct LogAnalysis {
config: BrainConfig,
}
impl LogAnalysis {
pub fn new(config: BrainConfig) -> Self {
Self { config }
}
}
#[async_trait::async_trait]
impl cortex::actions::LogAnalysisBackend for LogAnalysis {
async fn analyze(
&self,
system: bool,
since: &str,
lines: usize,
) -> Result<String, cortex::actions::ActionError> {
let source = if system {
LogSource::System
} else {
LogSource::Brain
};
analyze(&self.config, source, since, lines)
.await
.map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_collapses_volatile_tokens() {
assert_eq!(normalize_token("0xdeadbeef"), "<hex>");
assert_eq!(normalize_token("/Users/x/.brain/db.sqlite"), "<path>");
assert_eq!(
normalize_token("550e8400-e29b-41d4-a716-446655440000"),
"<uuid>"
);
assert_eq!(normalize_token("2026-06-09T10:11:12.345Z"), "<ts>");
assert_eq!(normalize_token("10:11:12"), "<ts>");
assert_eq!(normalize_token("tool-42"), "tool-<n>");
assert_eq!(normalize_token("count=1500"), "count=<n>");
assert_eq!(normalize_token("provider"), "provider");
}
#[test]
fn uuid_detection_tolerates_trailing_punctuation() {
assert!(looks_like_uuid("550e8400-e29b-41d4-a716-446655440000"));
assert!(looks_like_uuid("550e8400-e29b-41d4-a716-446655440000,"));
assert!(!looks_like_uuid("not-a-uuid"));
assert!(!looks_like_uuid("550e8400-e29b-41d4-a716")); }
#[test]
fn parses_real_pretty_line() {
let line = "2026-06-09T08:39:16.314126Z INFO brainos_storage::sqlite: \
SQLite database opened at /Users/x/.brain/db/brain.db (pool size 8)";
let p = parse_line(line);
assert_eq!(p.level, Level::Info);
assert!(p.ts.is_some(), "leading RFC3339 token should parse");
assert!(p.signature.starts_with("brainos_storage::sqlite:"));
assert!(!p.signature.contains("<ts>"));
assert!(!p.signature.contains("INFO"));
assert!(p.signature.contains("<path>"));
assert!(p.signature.contains("pool size <n>"));
}
#[test]
fn parses_json_line() {
let line = r#"{"timestamp":"2026-06-09T08:39:16.314126Z","level":"WARN","fields":{"message":"breaker open for tool abc-1"},"target":"brainos_resilience"}"#;
let p = parse_line(line);
assert_eq!(p.level, Level::Warn);
assert!(p.ts.is_some());
assert!(p.signature.contains("brainos_resilience"));
assert!(p.signature.contains("breaker open for tool"));
assert!(p.signature.contains("<n>"), "the id should be normalized");
assert!(!p.signature.contains("timestamp"));
}
#[test]
fn json_lines_with_volatile_ids_group_together() {
let lines: Vec<String> = (1..=3)
.map(|i| {
format!(
r#"{{"timestamp":"2026-06-09T10:00:0{i}Z","level":"ERROR","fields":{{"message":"breaker open for tool t-{i}"}},"target":"res"}}"#
)
})
.collect();
let digest = analyze_lines(&lines, None, "test", "all");
assert_eq!(digest.level_counts.get(&Level::Error), Some(&3));
assert_eq!(digest.patterns.len(), 1);
assert_eq!(digest.patterns[0].count, 3);
}
#[test]
fn analyze_groups_structurally_identical_lines() {
let lines: Vec<String> = vec![
"2026-06-09T10:00:00Z ERROR circuit breaker open for tool abc-1".into(),
"2026-06-09T10:00:01Z ERROR circuit breaker open for tool abc-2".into(),
"2026-06-09T10:00:02Z ERROR circuit breaker open for tool abc-3".into(),
"2026-06-09T10:00:03Z INFO signal received from user".into(),
"2026-06-09T10:00:04Z INFO consolidation complete".into(),
];
let digest = analyze_lines(&lines, None, "test", "all");
assert_eq!(digest.total_lines, 5);
assert_eq!(digest.level_counts.get(&Level::Error), Some(&3));
assert_eq!(digest.level_counts.get(&Level::Info), Some(&2));
assert_eq!(digest.patterns.len(), 1);
let top = &digest.patterns[0];
assert_eq!(top.count, 3);
assert_eq!(top.level, Level::Error);
assert!(top.signature.contains("circuit breaker open for tool"));
assert!(
top.signature.contains("<n>"),
"tool id should be normalized"
);
}
#[test]
fn analyze_applies_the_since_cutoff() {
let cutoff = Utc::now() - chrono::Duration::hours(1);
let fresh = format!("{} ERROR fresh boom", Utc::now().to_rfc3339());
let lines: Vec<String> = vec![
"2000-01-01T00:00:00Z ERROR ancient boom".into(),
fresh,
"no timestamp but kept".into(),
];
let digest = analyze_lines(&lines, Some(cutoff), "test", "1h");
assert_eq!(digest.total_lines, 2);
}
#[test]
fn analyze_reports_no_recurrence_when_all_distinct() {
let lines: Vec<String> = vec![
"alpha event one".into(),
"beta event two".into(),
"gamma event three".into(),
];
let digest = analyze_lines(&lines, None, "test", "all");
assert!(digest.patterns.is_empty());
assert!(digest.render().contains("No recurring patterns"));
}
#[test]
fn resolve_log_files_picks_rotated_files_newest_last() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
for name in [
"brain.log.2026-06-07",
"brain.log.2026-06-09",
"brain.log.2026-06-08",
"brain.stderr.log", "unrelated.txt", ] {
std::fs::write(p.join(name), "x").unwrap();
}
let files = resolve_log_files(&p.to_path_buf());
let names: Vec<String> = files
.iter()
.map(|f| f.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(
names,
vec![
"brain.log.2026-06-07",
"brain.log.2026-06-08",
"brain.log.2026-06-09",
],
"rotated files only, sorted chronologically (newest last)"
);
}
#[test]
fn resolve_log_files_handles_unrotated_single_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("brain.log"), "x").unwrap();
let files = resolve_log_files(&dir.path().to_path_buf());
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("brain.log"));
}
#[test]
fn tail_n_keeps_the_most_recent() {
let v: Vec<String> = (0..5).map(|i| i.to_string()).collect();
assert_eq!(tail_n(v.clone(), 2), vec!["3".to_string(), "4".to_string()]);
assert_eq!(tail_n(v.clone(), 10), v);
}
#[test]
fn parse_since_accepts_units_and_rejects_garbage() {
assert_eq!(parse_since("30m").unwrap(), chrono::Duration::minutes(30));
assert_eq!(parse_since("2h").unwrap(), chrono::Duration::hours(2));
assert_eq!(parse_since("1d").unwrap(), chrono::Duration::days(1));
assert!(parse_since("5").is_err()); assert!(parse_since("h").is_err()); assert!(parse_since("0h").is_err()); assert!(parse_since("3w").is_err()); }
#[test]
fn linux_since_phrases_are_systemd_shaped() {
assert_eq!(linux_since("1h").unwrap(), "1 hour ago");
assert_eq!(linux_since("30m").unwrap(), "30 minutes ago");
assert_eq!(linux_since("2d").unwrap(), "2 days ago");
}
}