Skip to main content

lean_ctx/tools/
ctx_prefetch.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::path::Path;
3
4use crate::core::cache::SessionCache;
5use crate::core::graph_index::ProjectIndex;
6use crate::core::protocol;
7use crate::core::task_relevance::{compute_relevance, parse_task_hints};
8use crate::tools::CrpMode;
9
10const DEFAULT_MAX_FILES: usize = 10;
11
12pub fn handle(
13    cache: &mut SessionCache,
14    root: &str,
15    task: Option<&str>,
16    changed_files: Option<&[String]>,
17    budget_tokens: usize,
18    max_files: Option<usize>,
19    crp_mode: CrpMode,
20) -> String {
21    let project_root = if root.trim().is_empty() { "." } else { root };
22    let index = crate::core::graph_index::load_or_build(project_root);
23
24    let mut candidates: BTreeMap<String, f64> = BTreeMap::new(); // path -> score
25
26    if let Some(t) = task {
27        let (task_files, task_keywords) = parse_task_hints(t);
28        let relevance = compute_relevance(&index, &task_files, &task_keywords);
29        for r in relevance.iter().take(50) {
30            if r.score < 0.1 {
31                break;
32            }
33            candidates.insert(r.path.clone(), r.score);
34        }
35    }
36
37    if let Some(changed) = changed_files {
38        for p in changed {
39            let rel = normalize_rel_path(p, project_root);
40            for (path, dist) in blast_radius(&index, &rel, 2) {
41                let boost = 1.0 / (dist.max(1) as f64);
42                candidates
43                    .entry(path)
44                    .and_modify(|s| *s = (*s + boost).min(1.0))
45                    .or_insert(boost.min(1.0));
46            }
47        }
48    }
49
50    if candidates.is_empty() {
51        return "ctx_prefetch: no candidates (provide task or changed_files)".to_string();
52    }
53
54    let mut scored: Vec<(String, f64)> = candidates.into_iter().collect();
55    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
56
57    let max_files = max_files.unwrap_or(DEFAULT_MAX_FILES).max(1);
58    let mut picked: Vec<String> = Vec::new();
59    for (p, _s) in scored {
60        picked.push(p);
61        if picked.len() >= max_files {
62            break;
63        }
64    }
65
66    let mut total = 0usize;
67    let mut prefetched: Vec<(String, String)> = Vec::new(); // (path, mode)
68    let jail_root = Path::new(project_root);
69    for p in &picked {
70        let full = to_fs_path(project_root, p);
71        let Ok((jailed, warning)) = crate::core::io_boundary::jail_and_check_path(
72            "ctx_prefetch",
73            Path::new(&full),
74            jail_root,
75        ) else {
76            continue;
77        };
78        if warning.is_some() {
79            continue;
80        }
81        let jailed_s = jailed.to_string_lossy().to_string();
82
83        let Ok(content) = std::fs::read_to_string(&jailed) else {
84            continue;
85        };
86        let tokens = crate::core::tokens::count_tokens(&content);
87        total = total.saturating_add(tokens);
88
89        let mode = if crate::tools::ctx_read::is_instruction_file(&jailed_s) {
90            "full"
91        } else if budget_tokens > 0 {
92            let ratio = budget_tokens as f64 / total.max(1) as f64;
93            if ratio >= 0.8 {
94                "full"
95            } else if ratio >= 0.4 {
96                "map"
97            } else {
98                "signatures"
99            }
100        } else {
101            "signatures"
102        };
103
104        let _ = crate::tools::ctx_read::handle_with_task_resolved(
105            cache, &jailed_s, mode, crp_mode, task,
106        );
107        prefetched.push((jailed_s, mode.to_string()));
108    }
109
110    let mut lines = vec![
111        format!(
112            "ctx_prefetch: prefetched {} file(s) (max_files={})",
113            prefetched.len(),
114            max_files
115        ),
116        format!("  root: {}", project_root),
117    ];
118    for (p, mode) in prefetched.iter().take(20) {
119        let r = cache.get_file_ref(p);
120        let short = protocol::shorten_path(p);
121        lines.push(format!("  - [{r}] {short} mode={mode}"));
122    }
123    lines.join("\n")
124}
125
126fn blast_radius(index: &ProjectIndex, start_rel: &str, max_depth: usize) -> Vec<(String, usize)> {
127    let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
128    for e in &index.edges {
129        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
130        adj.entry(e.to.as_str()).or_default().push(e.from.as_str());
131    }
132
133    let mut out = Vec::new();
134    let mut q: VecDeque<(String, usize)> = VecDeque::new();
135    let mut seen: BTreeSet<String> = BTreeSet::new();
136
137    q.push_back((start_rel.to_string(), 0));
138    seen.insert(start_rel.to_string());
139
140    while let Some((node, depth)) = q.pop_front() {
141        out.push((node.clone(), depth));
142        if depth >= max_depth {
143            continue;
144        }
145        if let Some(nbrs) = adj.get(node.as_str()) {
146            for &n in nbrs {
147                let ns = n.to_string();
148                if seen.insert(ns.clone()) {
149                    q.push_back((ns, depth + 1));
150                }
151            }
152        }
153    }
154    out
155}
156
157fn normalize_rel_path(path: &str, project_root: &str) -> String {
158    let p = Path::new(path);
159    if p.is_absolute() {
160        if let Ok(stripped) = p.strip_prefix(project_root) {
161            return stripped
162                .to_string_lossy()
163                .trim_start_matches('/')
164                .to_string();
165        }
166    }
167    path.trim_start_matches('/').to_string()
168}
169
170fn to_fs_path(project_root: &str, rel_or_abs: &str) -> String {
171    let p = Path::new(rel_or_abs);
172    if p.is_absolute() {
173        return rel_or_abs.to_string();
174    }
175    Path::new(project_root)
176        .join(rel_or_abs)
177        .to_string_lossy()
178        .to_string()
179}