lean-ctx 3.2.1

Context Runtime for AI Agents with CCP. 46 MCP tools, 10 read modes, 90+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing + diaries, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24 AI tools. Reduces LLM token consumption by up to 99%.
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::core::knowledge::ProjectKnowledge;
use crate::core::session::SessionState;
use crate::core::workflow::WorkflowRun;
use crate::tools::ToolCallRecord;

const SCHEMA_VERSION: u32 = 1;
const MAX_KNOWLEDGE_FACTS: usize = 50;
const MAX_CURATED_REFS: usize = 20;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffLedgerV1 {
    pub schema_version: u32,
    pub created_at: String,
    pub content_md5: String,
    pub manifest_md5: String,
    pub project_root: Option<String>,
    pub agent_id: Option<String>,
    pub client_name: Option<String>,
    pub workflow: Option<WorkflowRun>,
    pub session_snapshot: String,
    pub session: SessionExcerpt,
    pub tool_calls: ToolCallsSummary,
    pub evidence_keys: Vec<String>,
    pub knowledge: KnowledgeExcerpt,
    pub curated_refs: Vec<CuratedRef>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionExcerpt {
    pub id: String,
    pub task: Option<String>,
    pub decisions: Vec<String>,
    pub findings: Vec<String>,
    pub next_steps: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallsSummary {
    pub total: usize,
    pub by_tool: BTreeMap<String, u64>,
    pub by_ctx_read_mode: BTreeMap<String, u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KnowledgeExcerpt {
    pub project_hash: Option<String>,
    pub facts: Vec<KnowledgeFactMini>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeFactMini {
    pub category: String,
    pub key: String,
    pub value: String,
    pub confidence: f32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CuratedRef {
    pub path: String,
    pub mode: String,
    pub content_md5: String,
    pub content: String,
}

#[derive(Debug, Clone)]
pub struct CreateLedgerInput {
    pub agent_id: Option<String>,
    pub client_name: Option<String>,
    pub project_root: Option<String>,
    pub session: SessionState,
    pub tool_calls: Vec<ToolCallRecord>,
    pub workflow: Option<WorkflowRun>,
    pub curated_refs: Vec<(String, String)>, // (abs_path, signatures_text)
}

pub fn create_ledger(input: CreateLedgerInput) -> Result<(HandoffLedgerV1, PathBuf), String> {
    let manifest_md5 = manifest_md5();

    let mut evidence_keys: BTreeSet<String> = BTreeSet::new();
    for ev in &input.session.evidence {
        evidence_keys.insert(ev.key.clone());
    }

    let mut by_tool: BTreeMap<String, u64> = BTreeMap::new();
    let mut by_mode: BTreeMap<String, u64> = BTreeMap::new();
    for call in &input.tool_calls {
        *by_tool.entry(call.tool.clone()).or_insert(0) += 1;
        if call.tool == "ctx_read" {
            if let Some(m) = call.mode.as_deref() {
                *by_mode.entry(m.to_string()).or_insert(0) += 1;
            }
        }
    }

    let session_excerpt = SessionExcerpt {
        id: input.session.id.clone(),
        task: input.session.task.as_ref().map(|t| t.description.clone()),
        decisions: input
            .session
            .decisions
            .iter()
            .rev()
            .take(10)
            .map(|d| d.summary.clone())
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect(),
        findings: input
            .session
            .findings
            .iter()
            .rev()
            .take(20)
            .map(|f| f.summary.clone())
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect(),
        next_steps: input.session.next_steps.iter().take(20).cloned().collect(),
    };

    let knowledge_excerpt = build_knowledge_excerpt(input.project_root.as_deref());

    let mut curated = Vec::new();
    for (p, text) in input.curated_refs.into_iter().take(MAX_CURATED_REFS) {
        let md5 = md5_hex(text.as_bytes());
        curated.push(CuratedRef {
            path: p,
            mode: "signatures".to_string(),
            content_md5: md5,
            content: text,
        });
    }

    let mut ledger = HandoffLedgerV1 {
        schema_version: SCHEMA_VERSION,
        created_at: chrono::Local::now().to_rfc3339(),
        content_md5: String::new(),
        manifest_md5,
        project_root: input.project_root,
        agent_id: input.agent_id,
        client_name: input.client_name,
        workflow: input.workflow,
        session_snapshot: input.session.build_compaction_snapshot(),
        session: session_excerpt,
        tool_calls: ToolCallsSummary {
            total: input.tool_calls.len(),
            by_tool,
            by_ctx_read_mode: by_mode,
        },
        evidence_keys: evidence_keys.into_iter().collect(),
        knowledge: knowledge_excerpt,
        curated_refs: curated,
    };

    let md5 = ledger_content_md5(&ledger);
    ledger.content_md5 = md5.clone();

    let path = ledger_path(&ledger.created_at, &md5)?;
    let json = serde_json::to_string_pretty(&ledger).map_err(|e| format!("serialize: {e}"))?;
    crate::config_io::write_atomic_with_backup(&path, &(json + "\n"))
        .map_err(|e| format!("write {}: {e}", path.display()))?;

    Ok((ledger, path))
}

pub fn list_ledgers() -> Vec<PathBuf> {
    let dir = handoffs_dir().ok();
    let Some(dir) = dir else {
        return Vec::new();
    };
    let Ok(rd) = std::fs::read_dir(&dir) else {
        return Vec::new();
    };
    let mut items: Vec<PathBuf> = rd
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
        .collect();
    items.sort();
    items.reverse();
    items
}

pub fn load_ledger(path: &Path) -> Result<HandoffLedgerV1, String> {
    let s = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
    serde_json::from_str(&s).map_err(|e| format!("parse {}: {e}", path.display()))
}

pub fn clear_ledgers() -> Result<u32, String> {
    let dir = handoffs_dir()?;
    let mut removed = 0u32;
    if let Ok(rd) = std::fs::read_dir(&dir) {
        for e in rd.flatten() {
            let p = e.path();
            if p.extension().and_then(|e| e.to_str()) != Some("json") {
                continue;
            }
            if std::fs::remove_file(&p).is_ok() {
                removed += 1;
            }
        }
    }
    Ok(removed)
}

fn build_knowledge_excerpt(project_root: Option<&str>) -> KnowledgeExcerpt {
    let Some(root) = project_root else {
        return KnowledgeExcerpt::default();
    };
    let Some(knowledge) = ProjectKnowledge::load(root) else {
        return KnowledgeExcerpt::default();
    };

    let mut facts = Vec::new();
    for f in knowledge.facts.iter().filter(|f| f.is_current()) {
        facts.push(KnowledgeFactMini {
            category: f.category.clone(),
            key: f.key.clone(),
            value: f.value.clone(),
            confidence: f.confidence,
        });
        if facts.len() >= MAX_KNOWLEDGE_FACTS {
            break;
        }
    }

    KnowledgeExcerpt {
        project_hash: Some(knowledge.project_hash.clone()),
        facts,
    }
}

fn ledger_path(created_at: &str, md5: &str) -> Result<PathBuf, String> {
    let dir = handoffs_dir()?;
    std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
    let ts = created_at
        .chars()
        .filter(|c| c.is_ascii_digit())
        .take(14)
        .collect::<String>();
    let name = format!("{ts}-{md5}.json");
    Ok(dir.join(name))
}

fn handoffs_dir() -> Result<PathBuf, String> {
    let dir = crate::core::data_dir::lean_ctx_data_dir()
        .map_err(|e| e.to_string())?
        .join("handoffs");
    Ok(dir)
}

fn manifest_md5() -> String {
    let v = crate::core::mcp_manifest::manifest_value();
    let canon = canonicalize_json(&v);
    md5_hex(canon.to_string().as_bytes())
}

fn ledger_content_md5(ledger: &HandoffLedgerV1) -> String {
    let mut tmp = ledger.clone();
    tmp.content_md5.clear();
    let v = serde_json::to_value(&tmp).unwrap_or(Value::Null);
    let canon = canonicalize_json(&v);
    md5_hex(canon.to_string().as_bytes())
}

fn md5_hex(bytes: &[u8]) -> String {
    use md5::{Digest, Md5};
    let mut hasher = Md5::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

fn canonicalize_json(v: &Value) -> Value {
    match v {
        Value::Object(map) => {
            let mut keys: Vec<&String> = map.keys().collect();
            keys.sort();
            let mut out = serde_json::Map::new();
            for k in keys {
                if let Some(val) = map.get(k) {
                    out.insert(k.clone(), canonicalize_json(val));
                }
            }
            Value::Object(out)
        }
        Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()),
        other => other.clone(),
    }
}