lean-ctx 3.9.8

Context Runtime for AI Agents with CCP. 71 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%.
Documentation
use super::model::{Gotcha, GotchaStore};

/// A distilled learning from error-resolution correlation.
pub struct Learning {
    pub category: String,
    pub trigger: String,
    pub resolution: String,
    pub confidence: f32,
    pub occurrences: u32,
    pub sessions: usize,
}

impl std::fmt::Display for Learning {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "[{cat}] {trigger}{res} (confidence: {conf:.0}%, seen {occ}x across {sess} sessions)",
            cat = self.category,
            trigger = self.trigger,
            res = self.resolution,
            conf = self.confidence * 100.0,
            occ = self.occurrences,
            sess = self.sessions,
        )
    }
}

const MIN_CONFIDENCE: f32 = 0.5;
const MIN_OCCURRENCES: u32 = 2;

/// Extract high-confidence learnings from the gotcha store.
pub fn extract_learnings(store: &GotchaStore) -> Vec<Learning> {
    store
        .gotchas
        .iter()
        .filter(|g| g.confidence >= MIN_CONFIDENCE && g.occurrences >= MIN_OCCURRENCES)
        .map(gotcha_to_learning)
        .collect()
}

fn gotcha_to_learning(g: &Gotcha) -> Learning {
    Learning {
        category: g.category.short_label().to_string(),
        trigger: g.trigger.clone(),
        resolution: g.resolution.clone(),
        confidence: g.confidence,
        occurrences: g.occurrences,
        sessions: g.session_ids.len(),
    }
}

const AGENTS_MARKER_START: &str = "<!-- lean-ctx-learn-start -->";
const AGENTS_MARKER_END: &str = "<!-- lean-ctx-learn-end -->";

/// Generate the markdown section to inject into AGENTS.md.
pub fn format_agents_section(learnings: &[Learning]) -> String {
    if learnings.is_empty() {
        return String::new();
    }

    let mut out = String::new();
    out.push_str(AGENTS_MARKER_START);
    out.push('\n');
    out.push_str("## Learned Gotchas (auto-generated by `lean-ctx learn`)\n\n");
    out.push_str("Do NOT edit this section manually — it is overwritten on each `lean-ctx learn --apply`.\n\n");

    for l in learnings {
        out.push_str(&format!(
            "- **[{cat}]** {trigger}\n{res}\n",
            cat = l.category,
            trigger = l.trigger,
            res = l.resolution,
        ));
    }
    out.push_str(AGENTS_MARKER_END);
    out.push('\n');
    out
}

/// Merge the learnings `section` into an existing memory-file body, replacing any
/// previous marker block (or appending after a clean `\n\n` separator). `title`
/// is the H1 used only when the file is created from scratch.
fn merge_marker_section(existing: &str, section: &str, title: &str) -> String {
    if existing.contains(AGENTS_MARKER_START) {
        let before = existing
            .split(AGENTS_MARKER_START)
            .next()
            .unwrap_or(existing);
        let after = existing.split(AGENTS_MARKER_END).nth(1).unwrap_or("");
        format!(
            "{}\n\n{}",
            before.trim_end(),
            section.trim_end().to_owned() + after
        )
    } else if existing.is_empty() {
        format!("# {title}\n\n{section}")
    } else {
        format!("{}\n\n{section}", existing.trim_end())
    }
}

/// Write learnings into one memory file, replacing any existing marker section.
/// The write is atomic (tmp + rename) so a crash mid-write can never truncate the
/// user's AGENTS.md / CLAUDE.local.md. `create_if_missing = false` leaves a
/// non-existent file untouched (returns `Ok(false)`), so optional targets like
/// CLAUDE.local.md are only updated when the project already keeps one.
fn apply_to_memory_file(
    path: &std::path::Path,
    section: &str,
    create_if_missing: bool,
) -> Result<bool, String> {
    let exists = path.exists();
    if !exists && !create_if_missing {
        return Ok(false);
    }
    let existing = if exists {
        std::fs::read_to_string(path)
            .map_err(|e| format!("Failed to read {}: {e}", path.display()))?
    } else {
        String::new()
    };
    let title = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("AGENTS.md");
    let updated = merge_marker_section(&existing, section, title);
    crate::config_io::write_atomic(path, &updated)
        .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
    Ok(true)
}

/// Write learnings into the project's agent-memory files (#980), replacing the
/// auto-generated marker section in each. AGENTS.md (the lean-ctx standard) is
/// created if absent; CLAUDE.local.md (Claude Code's local memory) is updated
/// only when the project already keeps one, so a non-Claude project is never
/// littered with a file it did not ask for. Returns the file names written.
pub fn apply_learnings(project_root: &str, learnings: &[Learning]) -> Result<Vec<String>, String> {
    let section = format_agents_section(learnings);
    if section.is_empty() {
        return Ok(Vec::new());
    }
    let root = std::path::Path::new(project_root);
    let mut written = Vec::new();
    if apply_to_memory_file(&root.join("AGENTS.md"), &section, true)? {
        written.push("AGENTS.md".to_string());
    }
    if apply_to_memory_file(&root.join("CLAUDE.local.md"), &section, false)? {
        written.push("CLAUDE.local.md".to_string());
    }
    Ok(written)
}

/// Back-compat single-target wrapper (AGENTS.md only). Prefer [`apply_learnings`],
/// which also updates CLAUDE.local.md when present.
pub fn apply_to_agents_md(project_root: &str, learnings: &[Learning]) -> Result<String, String> {
    let section = format_agents_section(learnings);
    if section.is_empty() {
        return Ok("No learnings to write (need >=2 occurrences with >=50% confidence).".into());
    }
    let path = std::path::Path::new(project_root).join("AGENTS.md");
    apply_to_memory_file(&path, &section, true)?;
    Ok(format!(
        "Wrote {} learnings to {}",
        learnings.len(),
        path.display()
    ))
}

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

    fn sample() -> Vec<Learning> {
        vec![Learning {
            category: "Build".into(),
            trigger: "cargo E0507".into(),
            resolution: "clone before the move".into(),
            confidence: 0.9,
            occurrences: 3,
            sessions: 2,
        }]
    }

    #[test]
    fn apply_learnings_creates_agents_and_is_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_string_lossy().to_string();

        let written = apply_learnings(&root, &sample()).unwrap();
        assert_eq!(written, vec!["AGENTS.md".to_string()]);

        let agents = dir.path().join("AGENTS.md");
        let body = std::fs::read_to_string(&agents).unwrap();
        assert!(body.contains("cargo E0507"));
        assert!(body.contains(AGENTS_MARKER_START));

        // Re-applying replaces the marker block instead of stacking a second one.
        apply_learnings(&root, &sample()).unwrap();
        let body2 = std::fs::read_to_string(&agents).unwrap();
        assert_eq!(
            body2.matches(AGENTS_MARKER_START).count(),
            1,
            "the marker section must be replaced, never duplicated"
        );
    }

    #[test]
    fn apply_learnings_updates_claude_local_only_when_present() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_string_lossy().to_string();
        let claude = dir.path().join("CLAUDE.local.md");
        std::fs::write(&claude, "# My notes\n\nkeep this line\n").unwrap();

        let written = apply_learnings(&root, &sample()).unwrap();
        assert!(written.contains(&"AGENTS.md".to_string()));
        assert!(written.contains(&"CLAUDE.local.md".to_string()));

        let body = std::fs::read_to_string(&claude).unwrap();
        assert!(body.contains("keep this line"), "user content is preserved");
        assert!(body.contains("cargo E0507"), "learnings are injected");
    }

    #[test]
    fn apply_learnings_skips_absent_claude_local() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_string_lossy().to_string();

        let written = apply_learnings(&root, &sample()).unwrap();
        assert_eq!(
            written,
            vec!["AGENTS.md".to_string()],
            "CLAUDE.local.md is never created unsolicited"
        );
        assert!(!dir.path().join("CLAUDE.local.md").exists());
    }

    #[test]
    fn apply_learnings_empty_writes_nothing() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_string_lossy().to_string();
        let written = apply_learnings(&root, &[]).unwrap();
        assert!(written.is_empty());
        assert!(!dir.path().join("AGENTS.md").exists());
    }
}