use anyhow::Result;
use chrono::{DateTime, Utc};
use neurogrim_core::agent_output::{build_agent_output, AgentOutput, CorrelationFired};
use neurogrim_core::correlation::{
evaluate_condition, evaluate_incident_patterns, extract_domain_variables, IncidentLedgerEntry,
};
use neurogrim_core::governance::build_domain_recommendations;
use neurogrim_core::learning::{compute_all_effectiveness, ProposalLedgerEntry};
use neurogrim_core::registry::{BrainRegistry, ExportedVariable};
use neurogrim_core::calibration_ledger::auto_trigger_calibration_writes;
use neurogrim_core::scoring::{build_scorecard, CmdbData};
use neurogrim_core::trajectory::compute_trajectory;
use neurogrim_core::types::ScoreSnapshot;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub struct BrainContext {
#[allow(dead_code)]
pub registry: BrainRegistry,
#[allow(dead_code)]
pub project_root: PathBuf,
pub agent_output: AgentOutput,
}
impl BrainContext {
pub async fn load(
registry_path: &str,
hat: Option<String>,
human_persona: Option<String>,
) -> Result<Self> {
let span = tracing::info_span!(
"score.pipeline.run",
domains_count = tracing::field::Empty,
score = tracing::field::Empty,
confidence = tracing::field::Empty,
);
let _entered = span.enter();
let json = tokio::fs::read_to_string(registry_path).await?;
let registry = BrainRegistry::from_json(&json)?;
registry.validate()?;
let registry_dir = Path::new(registry_path).parent().unwrap_or(Path::new("."));
let project_root = registry_dir
.parent()
.unwrap_or(Path::new("."))
.to_path_buf();
let now = Utc::now();
let cmdb_data = load_cmdb_data(®istry, &project_root).await;
let scorecard = build_scorecard(®istry, &cmdb_data, now);
let _ = auto_trigger_calibration_writes(
®istry,
&scorecard,
&HashMap::new(),
&project_root,
);
let history = load_json_file::<Vec<ScoreSnapshot>>(
&project_root.join(".claude/brain/score-history.json"),
)
.await;
let incident_ledger = load_json_file::<Vec<IncidentLedgerEntry>>(
&project_root.join(".claude/brain/incident-ledger.json"),
)
.await;
let proposal_ledger = load_json_file::<Vec<ProposalLedgerEntry>>(
&project_root.join(".claude/brain/proposal-ledger.json"),
)
.await;
let raw_cmdbs = load_raw_cmdbs(®istry, &project_root).await;
let exported: HashMap<String, HashMap<String, ExportedVariable>> = registry
.config
.domain_definitions
.iter()
.filter(|(_, d)| !d.exported_variables.is_empty())
.map(|(k, d)| (k.clone(), d.exported_variables.clone()))
.collect();
let domain_variables = extract_domain_variables(&raw_cmdbs, &exported);
let unified_traj = compute_trajectory(
&history,
®istry.config.trajectory,
None,
®istry.config.domain_weights,
);
let mut dom_trajs = HashMap::new();
for dk in registry.config.domain_weights.keys() {
dom_trajs.insert(
dk.clone(),
compute_trajectory(
&history,
®istry.config.trajectory,
Some(dk),
®istry.config.domain_weights,
),
);
}
let corrs: Vec<CorrelationFired> = registry
.config
.correlations
.iter()
.filter_map(|c| {
let name = c.get("name")?.as_str()?;
let desc = c.get("description").and_then(|v| v.as_str()).unwrap_or("");
if let Some(ct) = c.get("condition_tree") {
if !evaluate_condition(ct, &domain_variables, &history) {
return None;
}
}
Some(CorrelationFired {
id: name.to_string(),
description: desc.to_string(),
skill: None,
})
})
.collect();
let (incidents, skipped) = evaluate_incident_patterns(
®istry.config.incident_patterns,
&domain_variables,
&history,
&incident_ledger,
®istry.config.severity_thresholds,
);
let effectiveness = compute_all_effectiveness(&proposal_ledger, 3);
let recommendations = build_domain_recommendations(
&scorecard,
&dom_trajs,
®istry.config,
&effectiveness,
5,
);
let agent_output = build_agent_output(
&scorecard,
&domain_variables,
vec![],
vec![],
recommendations,
corrs,
incidents,
skipped,
Some(unified_traj),
dom_trajs,
Some(effectiveness),
hat,
human_persona,
);
span.record("domains_count", agent_output.domains.len() as i64);
span.record("score", agent_output.score as i64);
span.record("confidence", agent_output.unified_confidence as i64);
Ok(BrainContext {
registry,
project_root,
agent_output,
})
}
}
async fn load_cmdb_data(
registry: &BrainRegistry,
project_root: &Path,
) -> HashMap<String, CmdbData> {
use crate::scoring_source_registry::Dispatcher;
let mut data = HashMap::new();
for (dk, def) in ®istry.config.domain_definitions {
if let Some(ref src) = def.scoring_source {
let Some(dispatcher) = Dispatcher::for_source_type(&src.source_type) else {
tracing::warn!(
"domain {dk}: unknown scoring_source.type {:?}; ignoring",
src.source_type
);
continue;
};
if let Some(cmdb) = dispatcher.load(dk, src, project_root).await {
data.insert(dk.clone(), cmdb);
} else if src.source_type == "a2a" {
tracing::debug!(
"a2a scoring source for domain {dk} unresolved; \
scoring pipeline will fall back to no_file_score"
);
}
}
}
data
}
async fn load_raw_cmdbs(
registry: &BrainRegistry,
project_root: &Path,
) -> HashMap<String, serde_json::Value> {
let mut cmdbs = HashMap::new();
for (dk, def) in ®istry.config.domain_definitions {
if let Some(ref src) = def.scoring_source {
if let Some(ref p) = src.path {
if let Ok(s) = tokio::fs::read_to_string(project_root.join(p)).await {
let s = s.trim_start_matches('\u{FEFF}');
if let Ok(v) = serde_json::from_str(s) {
cmdbs.insert(dk.clone(), v);
}
}
}
}
}
cmdbs
}
async fn load_json_file<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
tokio::fs::read_to_string(path)
.await
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub async fn append_proposal_ledger(project_root: &Path, agent_output: &AgentOutput) {
use neurogrim_core::learning::{Proposal, ProposalLedgerEntry};
let brain_dir = project_root.join(".claude").join("brain");
if let Err(e) = tokio::fs::create_dir_all(&brain_dir).await {
tracing::warn!("cannot create {:?}: {e}; skipping ledger write", brain_dir);
return;
}
let ledger_path = brain_dir.join("proposal-ledger.json");
let mut ledger: Vec<ProposalLedgerEntry> = match tokio::fs::read_to_string(&ledger_path).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => Vec::new(),
};
let pre_score = ledger.last().and_then(|e| e.post_score).or(Some(0));
let proposals: Vec<Proposal> = agent_output
.top_recommendations
.iter()
.map(|r| Proposal {
id: Some(format!("{}:{}", r.domain, r.gate)),
command: Some(r.command.clone()),
domain: Some(r.domain.clone()),
action_type: Some(if r.gate.is_empty() {
r.domain.clone()
} else {
r.gate.clone()
}),
})
.collect();
let commit = tokio::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(project_root)
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let entry = ProposalLedgerEntry {
timestamp: Utc::now().to_rfc3339(),
proposals,
pre_score,
post_score: Some(agent_output.score as i64),
commit,
hat: agent_output.current_hat.clone(),
};
ledger.push(entry);
match serde_json::to_string_pretty(&ledger) {
Ok(json) => {
if let Err(e) = tokio::fs::write(&ledger_path, json).await {
tracing::warn!("cannot write {:?}: {e}; ledger append dropped", ledger_path);
}
}
Err(e) => {
tracing::warn!("cannot serialize proposal ledger: {e}; ledger append dropped");
}
}
}
pub async fn append_score_history(
project_root: &Path,
agent_output: &AgentOutput,
retention_days: u32,
) {
use neurogrim_core::queue::{QueueMessage, SCORE_SNAPSHOTS_TOPIC};
use neurogrim_core::queue_backend::{QueueBackend, SqliteBackend};
use neurogrim_core::types::{ScoreSnapshot, SnapshotDomain};
let scored_at = agent_output
.scored_at
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now());
let domains = agent_output
.domains
.iter()
.map(|(k, d)| {
(
k.clone(),
SnapshotDomain {
score: d.score,
confidence: d.confidence,
},
)
})
.collect();
let snapshot = ScoreSnapshot {
scored_at,
score: agent_output.score,
domains,
hat: agent_output.current_hat.clone(),
};
let brain_dir = project_root.join(".claude").join("brain");
if let Err(e) = tokio::fs::create_dir_all(&brain_dir).await {
tracing::warn!("cannot create {:?}: {e}; skipping history write", brain_dir);
return;
}
let sqlite_path = brain_dir
.join("queues")
.join("_neurogrim")
.join("score-snapshots.sqlite");
if let Some(parent) = sqlite_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!("cannot create {:?}: {e}; skipping history write", parent);
return;
}
}
let needs_migration = !sqlite_path.exists();
let mut backend = match SqliteBackend::open(&sqlite_path) {
Ok(b) => b,
Err(e) => {
tracing::warn!("score-snapshots backend open failed: {e}; history append dropped");
return;
}
};
if needs_migration {
let json_path = brain_dir.join("score-history.json");
migrate_score_history_json(&mut backend, &json_path, retention_days);
}
let payload = match serde_json::to_value(&snapshot) {
Ok(v) => v,
Err(e) => {
tracing::warn!("cannot serialize score snapshot: {e}; history append dropped");
return;
}
};
let expires = Utc::now() + chrono::Duration::days(retention_days as i64);
let msg = QueueMessage::new(SCORE_SNAPSHOTS_TOPIC, payload).with_expires_at(expires);
if let Err(e) = backend.append(&msg) {
tracing::warn!("score snapshot append to bus failed: {e}");
}
}
fn migrate_score_history_json(
backend: &mut neurogrim_core::queue_backend::SqliteBackend,
json_path: &std::path::Path,
retention_days: u32,
) {
use neurogrim_core::queue::{QueueMessage, SCORE_SNAPSHOTS_TOPIC};
use neurogrim_core::queue_backend::QueueBackend;
let text = match std::fs::read_to_string(json_path) {
Ok(t) => t,
Err(_) => return, };
let entries: Vec<serde_json::Value> = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => return,
};
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
let mut migrated = 0usize;
for entry in entries {
let scored_at = entry
.get("scored_at")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<chrono::DateTime<chrono::Utc>>().ok());
if let Some(ts) = scored_at {
if ts < cutoff {
continue;
}
}
let msg = QueueMessage::new(SCORE_SNAPSHOTS_TOPIC, entry);
if let Err(e) = backend.append(&msg) {
tracing::warn!("score history migration: append failed for entry {migrated}: {e}");
break;
}
migrated += 1;
}
if migrated > 0 {
tracing::info!(
"score history migrated {migrated} entries from {} to SQLite bus topic",
json_path.display()
);
}
}