#![allow(dead_code)]
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Instant;
use crate::config::BrainConfig;
use crate::rules::{self, RuleAction, RuleMatch};
use crate::session::{ClaudeSession, SessionStatus};
use super::client::BrainSuggestion;
use super::context;
pub struct BrainResult {
pub pid: u32,
pub suggestion: Result<BrainSuggestion, String>,
}
pub struct BrainEngine {
config: BrainConfig,
tx: Sender<BrainResult>,
rx: Receiver<BrainResult>,
inflight: HashSet<u32>,
cooldown: HashMap<u32, Instant>,
pub pending: HashMap<u32, BrainSuggestion>,
}
const COOLDOWN_SECS: u64 = 10;
impl BrainEngine {
pub fn new(config: BrainConfig) -> Self {
let (tx, rx) = mpsc::channel();
Self {
config,
tx,
rx,
inflight: HashSet::new(),
cooldown: HashMap::new(),
pending: HashMap::new(),
}
}
pub fn tick(
&mut self,
sessions: &[ClaudeSession],
deny_rules: &[crate::rules::AutoRule],
) -> Vec<(u32, String)> {
let mut actions = Vec::new();
while let Ok(result) = self.rx.try_recv() {
self.inflight.remove(&result.pid);
self.cooldown.insert(result.pid, Instant::now());
match result.suggestion {
Ok(suggestion) => {
let session = sessions.iter().find(|s| s.pid == result.pid);
if let Some(session) = session {
let deny_match = rules::evaluate(deny_rules, session);
if let Some(dm) = &deny_match {
if dm.action == RuleAction::Deny {
actions.push((
result.pid,
format!(
"Brain suggested {}, but deny rule '{}' overrides",
suggestion.action.label(),
dm.rule_name,
),
));
continue;
}
}
}
if self.config.auto_mode {
if let Some(session) = session {
let rule_match = suggestion_to_rule_match(&suggestion);
match rules::execute(&rule_match, session) {
Ok(msg) => actions.push((result.pid, msg)),
Err(e) => actions.push((result.pid, format!("Brain error: {e}"))),
}
}
} else {
self.pending.insert(result.pid, suggestion);
}
}
Err(e) => {
crate::logger::log(
"BRAIN",
&format!("Inference failed for PID {}: {e}", result.pid),
);
}
}
}
for session in sessions {
if !matches!(
session.status,
SessionStatus::NeedsInput | SessionStatus::WaitingInput
) {
continue;
}
if self.inflight.contains(&session.pid) {
continue;
}
if let Some(last) = self.cooldown.get(&session.pid) {
if last.elapsed().as_secs() < COOLDOWN_SECS {
continue;
}
}
if self.pending.contains_key(&session.pid) {
continue;
}
self.spawn_inference(session);
}
actions
}
fn spawn_inference(&mut self, session: &ClaudeSession) {
let pid = session.pid;
let config = self.config.clone();
let tx = self.tx.clone();
let mut brain_ctx = context::build_context(session, config.max_context_tokens);
if config.few_shot_count > 0 {
let similar = super::decisions::retrieve_similar(
session.pending_tool_name.as_deref(),
session.display_name(),
config.few_shot_count,
);
brain_ctx.few_shot_examples = super::decisions::format_few_shot_examples(&similar);
}
let prompt = context::format_brain_prompt(&brain_ctx);
self.inflight.insert(pid);
std::thread::spawn(move || {
let suggestion = super::client::infer(&config, &prompt);
let _ = tx.send(BrainResult { pid, suggestion });
});
}
pub fn accept(&mut self, pid: u32, session: &ClaudeSession) -> Option<String> {
let suggestion = self.pending.remove(&pid)?;
let rule_match = suggestion_to_rule_match(&suggestion);
match rules::execute(&rule_match, session) {
Ok(msg) => Some(msg),
Err(e) => Some(format!("Brain execute error: {e}")),
}
}
pub fn reject(&mut self, pid: u32) -> Option<BrainSuggestion> {
self.pending.remove(&pid)
}
pub fn cleanup(&mut self, sessions: &[ClaudeSession]) {
let active_pids: HashSet<u32> = sessions.iter().map(|s| s.pid).collect();
self.pending.retain(|pid, _| {
active_pids.contains(pid)
&& sessions.iter().any(|s| {
s.pid == *pid
&& matches!(
s.status,
SessionStatus::NeedsInput | SessionStatus::WaitingInput
)
})
});
self.inflight.retain(|pid| active_pids.contains(pid));
}
}
fn suggestion_to_rule_match(suggestion: &BrainSuggestion) -> RuleMatch {
RuleMatch {
rule_name: format!(
"brain ({}% confidence)",
(suggestion.confidence * 100.0) as u32
),
action: suggestion.action.clone(),
message: suggestion.message.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{RawSession, TelemetryStatus};
fn make_config() -> BrainConfig {
BrainConfig {
enabled: true,
endpoint: "http://localhost:11434/api/generate".into(),
model: "test".into(),
auto_mode: false,
timeout_ms: 1000,
max_context_tokens: 1000,
few_shot_count: 5,
}
}
fn make_session(pid: u32, status: SessionStatus) -> ClaudeSession {
let raw = RawSession {
pid,
session_id: "test".into(),
cwd: "/tmp/test".into(),
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
s.status = status;
s.telemetry_status = TelemetryStatus::Available;
s.pending_tool_name = Some("Bash".into());
s
}
#[test]
fn engine_creates_without_panic() {
let _engine = BrainEngine::new(make_config());
}
#[test]
fn suggestion_to_rule_match_format() {
let suggestion = BrainSuggestion {
action: RuleAction::Approve,
message: None,
reasoning: "safe".into(),
confidence: 0.95,
};
let rm = suggestion_to_rule_match(&suggestion);
assert_eq!(rm.action, RuleAction::Approve);
assert!(rm.rule_name.contains("95%"));
}
#[test]
fn cleanup_removes_stale_pending() {
let mut engine = BrainEngine::new(make_config());
engine.pending.insert(
999,
BrainSuggestion {
action: RuleAction::Approve,
message: None,
reasoning: "test".into(),
confidence: 0.9,
},
);
engine.cleanup(&[]);
assert!(engine.pending.is_empty());
}
#[test]
fn cleanup_keeps_active_pending() {
let mut engine = BrainEngine::new(make_config());
let session = make_session(100, SessionStatus::NeedsInput);
engine.pending.insert(
100,
BrainSuggestion {
action: RuleAction::Approve,
message: None,
reasoning: "test".into(),
confidence: 0.9,
},
);
engine.cleanup(&[session]);
assert!(engine.pending.contains_key(&100));
}
#[test]
fn reject_removes_and_returns_suggestion() {
let mut engine = BrainEngine::new(make_config());
engine.pending.insert(
100,
BrainSuggestion {
action: RuleAction::Approve,
message: None,
reasoning: "test".into(),
confidence: 0.9,
},
);
let rejected = engine.reject(100);
assert!(rejected.is_some());
assert!(engine.pending.is_empty());
}
}