lean-ctx 3.3.7

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::path::Path;

use crate::core::cache::SessionCache;
use crate::core::signatures;
use crate::core::tokens::count_tokens;
use crate::tools::CrpMode;

struct FileCandidate {
    path: String,
    score: f64,
    tokens_full: usize,
    tokens_map: usize,
    tokens_sig: usize,
}

pub fn handle(
    cache: &mut SessionCache,
    paths: &[String],
    budget: usize,
    crp_mode: CrpMode,
    task: Option<&str>,
) -> String {
    if paths.is_empty() {
        return "No files specified.".to_string();
    }

    let mut candidates: Vec<FileCandidate> = Vec::new();

    for path in paths {
        let content = match std::fs::read_to_string(path) {
            Ok(c) => c,
            Err(_) => continue,
        };

        let ext = Path::new(path)
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");
        let tokens_full = count_tokens(&content);
        let sigs = signatures::extract_signatures(&content, ext);
        let sig_text: String = sigs
            .iter()
            .map(|s| s.to_compact())
            .collect::<Vec<_>>()
            .join("\n");
        let tokens_sig = count_tokens(&sig_text);

        let map_text = format_map(&content, ext, &sigs);
        let tokens_map = count_tokens(&map_text);

        let score = compute_relevance_score(path, &content);

        candidates.push(FileCandidate {
            path: path.clone(),
            score,
            tokens_full,
            tokens_map,
            tokens_sig,
        });
    }

    candidates.sort_by(|a, b| {
        b.score
            .partial_cmp(&a.score)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    let mut pop_lines: Vec<String> = Vec::new();
    if let Some(t) = task {
        if let Some(root) = paths
            .first()
            .and_then(|p| crate::core::protocol::detect_project_root(p))
        {
            let rs: Vec<crate::core::task_relevance::RelevanceScore> = candidates
                .iter()
                .map(|c| crate::core::task_relevance::RelevanceScore {
                    path: c.path.clone(),
                    score: c.score,
                    recommended_mode: "signatures",
                })
                .collect();
            let refs: Vec<&crate::core::task_relevance::RelevanceScore> = rs.iter().collect();
            let pop = crate::core::pop_pruning::decide_for_candidates(t, &root, &refs);
            if !pop.excluded_modules.is_empty() {
                let excluded: std::collections::BTreeSet<&str> = pop
                    .excluded_modules
                    .iter()
                    .map(|e| e.module.as_str())
                    .collect();
                candidates.retain(|c| {
                    let m = crate::core::pop_pruning::module_for_path(&c.path, &root);
                    !excluded.contains(m.as_str())
                });
                pop_lines.push("POP:".to_string());
                for ex in &pop.excluded_modules {
                    pop_lines.push(format!(
                        "  - exclude {}/ ({} candidates) — {}",
                        ex.module, ex.candidate_files, ex.reason
                    ));
                }
            }
        }
    }

    let mut used_tokens = 0usize;
    let mut selections: Vec<(String, String)> = Vec::new();

    for candidate in &candidates {
        if used_tokens >= budget {
            break;
        }

        let remaining = budget - used_tokens;
        let (mode, cost) = select_best_fit(candidate, remaining);

        if cost > remaining {
            let sig_cost = candidate.tokens_sig;
            if sig_cost <= remaining {
                selections.push((candidate.path.clone(), "signatures".to_string()));
                used_tokens += sig_cost;
            }
            continue;
        }

        selections.push((candidate.path.clone(), mode));
        used_tokens += cost;
    }

    let mut output_parts = Vec::new();
    output_parts.push(format!(
        "ctx_fill: {budget} token budget, {} files analyzed, {} selected",
        candidates.len(),
        selections.len()
    ));
    if !pop_lines.is_empty() {
        output_parts.push(pop_lines.join("\n"));
    }
    output_parts.push(String::new());

    for (path, mode) in &selections {
        let result = crate::tools::ctx_read::handle(cache, path, mode, crp_mode);
        output_parts.push(result);
        output_parts.push("---".to_string());
    }

    let skipped = candidates.len() - selections.len();
    if skipped > 0 {
        output_parts.push(format!("{skipped} files skipped (budget exhausted)"));
    }
    output_parts.push(format!("\nUsed: {used_tokens}/{budget} tokens"));

    output_parts.join("\n")
}

fn select_best_fit(candidate: &FileCandidate, remaining: usize) -> (String, usize) {
    if candidate.tokens_full <= remaining {
        return ("full".to_string(), candidate.tokens_full);
    }
    if candidate.tokens_map <= remaining {
        return ("map".to_string(), candidate.tokens_map);
    }
    if candidate.tokens_sig <= remaining {
        return ("signatures".to_string(), candidate.tokens_sig);
    }
    ("signatures".to_string(), candidate.tokens_sig)
}

fn compute_relevance_score(path: &str, content: &str) -> f64 {
    let mut score = 1.0;

    let name = Path::new(path)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("");
    if name.contains("test") || name.contains("spec") {
        score *= 0.5;
    }
    if name.contains("config") || name.contains("types") || name.contains("schema") {
        score *= 1.3;
    }
    if name == "mod.rs" || name == "index.ts" || name == "index.js" || name == "__init__.py" {
        score *= 1.5;
    }

    let ext = Path::new(path)
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("");
    if matches!(ext, "rs" | "ts" | "py" | "go" | "java") {
        score *= 1.2;
    }

    let lines = content.lines().count();
    if lines > 500 {
        score *= 0.8;
    }
    if lines < 50 {
        score *= 1.1;
    }

    let export_count = content
        .lines()
        .filter(|l| l.contains("pub ") || l.contains("export ") || l.contains("def "))
        .count();
    score *= 1.0 + (export_count as f64 * 0.02).min(0.5);

    score
}

fn format_map(content: &str, ext: &str, sigs: &[crate::core::signatures::Signature]) -> String {
    let deps = crate::core::deps::extract_deps(content, ext);
    let mut parts = Vec::new();
    if !deps.imports.is_empty() {
        parts.push(format!("deps: {}", deps.imports.join(", ")));
    }
    if !deps.exports.is_empty() {
        parts.push(format!("exports: {}", deps.exports.join(", ")));
    }
    let key_sigs: Vec<_> = sigs
        .iter()
        .filter(|s| s.is_exported || s.indent == 0)
        .collect();
    for sig in &key_sigs {
        parts.push(sig.to_compact());
    }
    parts.join("\n")
}