lean-ctx 3.9.19

Context Runtime for AI Agents with CCP. 79 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
use super::{ValueAssessment, cpao};
use std::{
    collections::VecDeque,
    fs::{self, OpenOptions},
    io::{self, Write},
    path::{Path, PathBuf},
    sync::{Arc, Mutex, PoisonError},
};

const MAX_ASSESSMENTS: usize = 100;
const MAX_DISK_ASSESSMENTS: usize = 10_000;
const MAX_LEDGER_BYTES: u64 = 1024 * 1024;
const LEDGER_FILE: &str = "value_assessments.jsonl";

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ValueAggregate {
    pub total: usize,
    pub accepted: usize,
    pub rejected: usize,
    pub avg_cpao: Option<u64>,
    pub total_cost: u64,
}

#[derive(Debug, Clone)]
pub struct ValueGateStore {
    assessments: Arc<Mutex<VecDeque<ValueAssessment>>>,
}

impl Default for ValueGateStore {
    fn default() -> Self {
        let mut assessments: VecDeque<_> = Self::load_from_disk().into_iter().collect();
        while assessments.len() > MAX_ASSESSMENTS {
            assessments.pop_front();
        }
        Self {
            assessments: Arc::new(Mutex::new(assessments)),
        }
    }
}

impl ValueGateStore {
    pub fn record(&self, assessment: &ValueAssessment) {
        let mut entries = self
            .assessments
            .lock()
            .unwrap_or_else(PoisonError::into_inner);
        if entries.len() >= MAX_ASSESSMENTS {
            entries.pop_front();
        }
        entries.push_back(assessment.clone());
        drop(entries);
        let _ = Self::append_to_disk(assessment);
    }

    pub fn recent(&self, n: usize) -> Vec<ValueAssessment> {
        self.assessments
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .iter()
            .rev()
            .take(n)
            .cloned()
            .collect()
    }

    pub fn aggregate(&self) -> ValueAggregate {
        let entries = self
            .assessments
            .lock()
            .unwrap_or_else(PoisonError::into_inner);
        let costs: Vec<u64> = entries.iter().map(|a| a.cost_micros).collect();
        let accepted: Vec<bool> = entries.iter().map(|a| a.outcome_accepted).collect();
        let accepted_count = accepted.iter().filter(|&&ok| ok).count();
        ValueAggregate {
            total: entries.len(),
            accepted: accepted_count,
            rejected: entries.len() - accepted_count,
            avg_cpao: cpao::cost_per_accepted_outcome(&costs, &accepted),
            total_cost: costs.into_iter().fold(0, u64::saturating_add),
        }
    }

    pub fn persist_path() -> PathBuf {
        crate::core::paths::state_dir()
            .unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"))
            .join(LEDGER_FILE)
    }

    pub fn append_to_disk(assessment: &ValueAssessment) -> io::Result<()> {
        Self::append_to_path(&Self::persist_path(), assessment)
    }

    pub fn load_from_disk() -> Vec<ValueAssessment> {
        Self::load_from_path(&Self::persist_path())
    }

    fn append_to_path(path: &Path, assessment: &ValueAssessment) -> io::Result<()> {
        let json = serde_json::to_string(assessment)
            .map_err(|error| io::Error::other(format!("serialize assessment: {error}")))?;
        fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new(".")))?;
        Self::rotate_if_needed(path, json.len() as u64 + 1)?;
        let mut file = OpenOptions::new().create(true).append(true).open(path)?;
        writeln!(file, "{json}")
    }

    fn load_from_path(path: &Path) -> Vec<ValueAssessment> {
        let mut assessments = Self::read_tail(&path.with_extension("jsonl.1"));
        assessments.extend(Self::read_tail(path));
        let keep_from = assessments.len().saturating_sub(MAX_DISK_ASSESSMENTS);
        assessments.drain(..keep_from);
        assessments
    }

    fn read_tail(path: &Path) -> Vec<ValueAssessment> {
        let mut assessments: Vec<_> = fs::read_to_string(path)
            .unwrap_or_default()
            .lines()
            .rev()
            .take(MAX_DISK_ASSESSMENTS)
            .filter_map(|line| serde_json::from_str(line).ok())
            .collect();
        assessments.reverse();
        assessments
    }

    fn rotate_if_needed(path: &Path, incoming_bytes: u64) -> io::Result<()> {
        if fs::metadata(path)
            .is_ok_and(|meta| meta.len().saturating_add(incoming_bytes) > MAX_LEDGER_BYTES)
        {
            let backup = path.with_extension("jsonl.1");
            if backup.exists() {
                fs::remove_file(&backup)?;
            }
            fs::rename(path, backup)?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn assessment(id: usize) -> ValueAssessment {
        ValueAssessment {
            task_id: format!("task-{id}"),
            model: "test".into(),
            total_tokens: id as u64,
            cost_micros: 10,
            outcome_accepted: true,
            cpao_micros: Some(10),
            evidence: vec!["test".into()],
            timestamp: "2026-08-12T00:00:00Z".into(),
        }
    }

    fn test_path(name: &str) -> PathBuf {
        let path =
            std::env::temp_dir().join(format!("lean-ctx-value-gate-{name}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&path);
        path.join(LEDGER_FILE)
    }

    #[test]
    fn test_persist_creates_file() {
        let _iso = crate::core::data_dir::isolated_data_dir();
        let store = ValueGateStore::default();
        let path = ValueGateStore::persist_path();
        store.record(&assessment(1));
        assert!(path.is_file());
    }

    #[test]
    fn test_persist_append() {
        let path = test_path("append");
        (0..3).for_each(|id| ValueGateStore::append_to_path(&path, &assessment(id)).unwrap());
        assert_eq!(fs::read_to_string(path).unwrap().lines().count(), 3);
    }

    #[test]
    fn test_load_roundtrip() {
        let path = test_path("roundtrip");
        let expected = vec![assessment(1), assessment(2)];
        for item in &expected {
            ValueGateStore::append_to_path(&path, item).unwrap();
        }
        assert_eq!(ValueGateStore::load_from_path(&path), expected);
    }

    #[test]
    fn test_load_corrupt_line() {
        let path = test_path("corrupt");
        ValueGateStore::append_to_path(&path, &assessment(1)).unwrap();
        fs::write(
            &path,
            format!(
                "{{not json}}\n{}",
                serde_json::to_string(&assessment(2)).unwrap()
            ),
        )
        .unwrap();
        assert_eq!(ValueGateStore::load_from_path(&path), vec![assessment(2)]);
    }

    #[test]
    fn test_load_empty_file() {
        let path = test_path("empty");
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, "").unwrap();
        assert!(ValueGateStore::load_from_path(&path).is_empty());
    }
}