lean-ctx 3.5.13

Context Runtime for AI Agents with CCP. 57 MCP tools, 10 read modes, 95+ 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;

/// JSON response bodies for dashboard mutation APIs (`/api/*` POST handlers).
pub fn json_ok() -> String {
    r#"{"ok":true}"#.to_string()
}

pub fn json_err(msg: &str) -> String {
    serde_json::json!({ "error": msg }).to_string()
}

pub fn extract_query_param(qs: &str, key: &str) -> Option<String> {
    for pair in qs.split('&') {
        let Some((k, v)) = pair.split_once('=') else {
            continue;
        };
        if k == key {
            return Some(percent_decode_query_component(v));
        }
    }
    None
}

pub fn percent_decode_query_component(s: &str) -> String {
    let mut out: Vec<u8> = Vec::with_capacity(s.len());
    let b = s.as_bytes();
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => {
                out.push(b' ');
                i += 1;
            }
            b'%' if i + 2 < b.len() => {
                let h1 = (b[i + 1] as char).to_digit(16);
                let h2 = (b[i + 2] as char).to_digit(16);
                if let (Some(a), Some(d)) = (h1, h2) {
                    out.push(((a << 4) | d) as u8);
                    i += 3;
                } else {
                    out.push(b'%');
                    i += 1;
                }
            }
            _ => {
                out.push(b[i]);
                i += 1;
            }
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

pub fn normalize_dashboard_demo_path(path: &str) -> String {
    let trimmed = path.trim();
    if trimmed.is_empty() {
        return String::new();
    }

    let candidate = Path::new(trimmed);
    if candidate.is_absolute() || is_windows_absolute_path(trimmed) {
        return trimmed.to_string();
    }

    let mut p = trimmed;
    while p.starts_with("./") || p.starts_with(".\\") {
        p = &p[2..];
    }

    p.trim_start_matches(['\\', '/'])
        .replace('\\', std::path::MAIN_SEPARATOR_STR)
}

pub fn is_windows_absolute_path(path: &str) -> bool {
    let bytes = path.as_bytes();
    if bytes.len() >= 3
        && bytes[0].is_ascii_alphabetic()
        && bytes[1] == b':'
        && matches!(bytes[2], b'\\' | b'/')
    {
        return true;
    }

    path.starts_with("\\\\") || path.starts_with("//")
}

pub fn detect_project_root_for_dashboard() -> String {
    if let Ok(explicit) = std::env::var("LEAN_CTX_DASHBOARD_PROJECT") {
        if !explicit.trim().is_empty() {
            return promote_to_git_root(&explicit);
        }
    }

    if let Some(session) = crate::core::session::SessionState::load_latest() {
        // Try project_root first, but only if it resolves to a real project (has .git or markers).
        // MCP sessions often set project_root to a temp sandbox directory that contains no code.
        if let Some(root) = session.project_root.as_deref() {
            if !root.trim().is_empty() {
                if let Some(git_root) = git_root_for(root) {
                    return git_root;
                }
                if is_real_project(root) {
                    return root.to_string();
                }
            }
        }
        if let Some(cwd) = session.shell_cwd.as_deref() {
            if !cwd.trim().is_empty() {
                let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
                return promote_to_git_root(&r);
            }
        }
        if let Some(last) = session.files_touched.last() {
            if !last.path.trim().is_empty() {
                if let Some(parent) = Path::new(&last.path).parent() {
                    let p = parent.to_string_lossy().to_string();
                    let r = crate::core::protocol::detect_project_root_or_cwd(&p);
                    return promote_to_git_root(&r);
                }
            }
        }
    }

    let cwd = std::env::current_dir()
        .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
    let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
    promote_to_git_root(&r)
}

fn is_real_project(path: &str) -> bool {
    let p = Path::new(path);
    if !p.is_dir() {
        return false;
    }
    const MARKERS: &[&str] = &[
        ".git",
        "Cargo.toml",
        "package.json",
        "go.mod",
        "pyproject.toml",
        "requirements.txt",
        "pom.xml",
        "build.gradle",
        "CMakeLists.txt",
        ".lean-ctx.toml",
    ];
    MARKERS.iter().any(|m| p.join(m).exists())
}

fn promote_to_git_root(path: &str) -> String {
    git_root_for(path).unwrap_or_else(|| path.to_string())
}

fn git_root_for(path: &str) -> Option<String> {
    let mut p = Path::new(path);
    loop {
        let git = p.join(".git");
        if git.exists() {
            return Some(p.to_string_lossy().to_string());
        }
        p = p.parent()?;
    }
}