Skip to main content

lean_ctx_server/tools/
ctx_prefetch.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::{BTreeMap, BTreeSet, VecDeque};
3use std::path::Path;
4
5use crate::tools::CrpMode;
6use lean_ctx::core::cache::SessionCache;
7use lean_ctx::core::graph_provider::{self, GraphProvider};
8use lean_ctx::core::protocol;
9use lean_ctx::core::task_relevance::{compute_relevance, parse_task_hints};
10
11const DEFAULT_MAX_FILES: usize = 10;
12
13pub fn handle(
14    cache: &mut SessionCache,
15    root: &str,
16    task: Option<&str>,
17    changed_files: Option<&[String]>,
18    budget_tokens: usize,
19    max_files: Option<usize>,
20    crp_mode: CrpMode,
21) -> String {
22    let project_root = if root.trim().is_empty() { "." } else { root };
23    let open = graph_provider::open_or_build(project_root);
24    let gp = open.as_ref().map(|o| &o.provider);
25
26    let mut candidates: BTreeMap<String, f64> = BTreeMap::new();
27
28    if let Some(t) = task
29        && let Some(gp) = gp
30    {
31        let (task_files, task_keywords) = parse_task_hints(t);
32        let mut relevance = compute_relevance(gp, &task_files, &task_keywords);
33        lean_ctx::core::git_signals::apply_boost(&mut relevance, project_root);
34        lean_ctx::core::diagnostics_store::apply_boost(&mut relevance);
35        lean_ctx::core::editor_signal::apply_boost(&mut relevance);
36        for r in relevance.iter().take(50) {
37            if r.score < 0.1 {
38                break;
39            }
40            candidates.insert(r.path.clone(), r.score);
41        }
42    }
43
44    if let Some(changed) = changed_files {
45        for p in changed {
46            let rel = normalize_rel_path(p, project_root);
47            if let Some(gp) = gp {
48                for (path, dist) in blast_radius(gp, &rel, 2) {
49                    let boost = 1.0 / (dist.max(1) as f64);
50                    candidates
51                        .entry(path)
52                        .and_modify(|s| *s = (*s + boost).min(1.0))
53                        .or_insert(boost.min(1.0));
54                }
55            } else {
56                candidates.entry(rel).or_insert(1.0);
57            }
58        }
59    }
60
61    if candidates.is_empty() {
62        return "ctx_prefetch: no candidates (provide task or changed_files)".to_string();
63    }
64
65    let mut scored: Vec<(String, f64)> = candidates.into_iter().collect();
66    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
67
68    let max_files = max_files.unwrap_or(DEFAULT_MAX_FILES).max(1);
69    let mut picked: Vec<String> = Vec::new();
70    for (p, _s) in scored {
71        picked.push(p);
72        if picked.len() >= max_files {
73            break;
74        }
75    }
76
77    let mut total = 0usize;
78    let mut prefetched: Vec<(String, String)> = Vec::new(); // (path, mode)
79    let mut withheld: Vec<String> = Vec::new();
80    let mut remaining = lean_ctx::core::limits::max_read_bytes().saturating_mul(max_files);
81    let jail_root = Path::new(project_root);
82    for p in &picked {
83        let full = to_fs_path(project_root, p);
84        let Ok((jailed, warning)) = lean_ctx::core::io_boundary::jail_and_check_path(
85            "ctx_prefetch",
86            Path::new(&full),
87            jail_root,
88        ) else {
89            continue;
90        };
91        if warning.is_some() {
92            continue;
93        }
94        let jailed_s = jailed.to_string_lossy().to_string();
95
96        if lean_ctx::core::binary_detect::is_binary_file(&jailed_s) {
97            continue;
98        }
99        let cap = lean_ctx::core::limits::max_read_bytes() as u64;
100        if let Ok(meta) = std::fs::metadata(&jailed)
101            && meta.len() > cap
102        {
103            continue;
104        }
105
106        // A prefetch exists to fill the session cache, a derived store: the
107        // candidate is acquired through the common rooted admitted reader
108        // (G01) and only a source whose ORIGINAL may be kept is prefetched.
109        // A withheld source is named; a restricted one is left to an explicit
110        // read, which delivers it without retaining it.
111        let acquired = crate::tools::admitted_sources::read(
112            &jailed_s,
113            project_root,
114            "ctx_prefetch",
115            &mut remaining,
116        );
117        if let crate::tools::admitted_sources::SourceRead::Withheld(reason) = &acquired {
118            withheld.push(crate::tools::admitted_sources::withheld_line(
119                &protocol::shorten_path(&jailed_s),
120                reason,
121            ));
122            let _ = cache.invalidate(&jailed_s);
123            continue;
124        }
125        let Some(content) = acquired.storable() else {
126            let _ = cache.invalidate(&jailed_s);
127            continue;
128        };
129        let tokens = lean_ctx::core::tokens::count_tokens(content);
130        total = total.saturating_add(tokens);
131
132        let mode = if crate::tools::ctx_read::is_instruction_file(&jailed_s) {
133            "full"
134        } else if budget_tokens > 0 {
135            let ratio = budget_tokens as f64 / total.max(1) as f64;
136            if ratio >= 0.8 {
137                "full"
138            } else if ratio >= 0.4 {
139                "map"
140            } else {
141                "signatures"
142            }
143        } else {
144            "signatures"
145        };
146
147        // The read path admits only inside a request scope; bind it so the
148        // cached copy is the admitted one for every caller.
149        let _ = crate::tools::admitted_sources::as_agent_acquisition(project_root, || {
150            crate::tools::ctx_read::handle_with_task_resolved(
151                cache, &jailed_s, mode, crp_mode, task,
152            )
153        });
154        prefetched.push((jailed_s, mode.to_string()));
155    }
156
157    let mut lines = vec![
158        format!(
159            "ctx_prefetch: prefetched {} file(s) (max_files={})",
160            prefetched.len(),
161            max_files
162        ),
163        format!("  root: {}", project_root),
164    ];
165    for (p, mode) in prefetched.iter().take(20) {
166        let r = cache.get_file_ref(p);
167        let short = protocol::shorten_path(p);
168        lines.push(format!("  - [{r}] {short} mode={mode}"));
169    }
170    lines.extend(withheld.into_iter().map(|line| format!("  - {line}")));
171    lines.join("\n")
172}
173
174fn blast_radius(gp: &GraphProvider, start_rel: &str, max_depth: usize) -> Vec<(String, usize)> {
175    let all_edges = gp.edges();
176    let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
177    for e in &all_edges {
178        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
179        adj.entry(e.to.as_str()).or_default().push(e.from.as_str());
180    }
181
182    let mut out = Vec::new();
183    let mut q: VecDeque<(String, usize)> = VecDeque::new();
184    let mut seen: BTreeSet<String> = BTreeSet::new();
185
186    q.push_back((start_rel.to_string(), 0));
187    seen.insert(start_rel.to_string());
188
189    while let Some((node, depth)) = q.pop_front() {
190        out.push((node.clone(), depth));
191        if depth >= max_depth {
192            continue;
193        }
194        if let Some(nbrs) = adj.get(node.as_str()) {
195            for &n in nbrs {
196                let ns = n.to_string();
197                if seen.insert(ns.clone()) {
198                    q.push_back((ns, depth + 1));
199                }
200            }
201        }
202    }
203    out
204}
205
206fn normalize_rel_path(path: &str, project_root: &str) -> String {
207    let p = Path::new(path);
208    if p.is_absolute()
209        && let Ok(stripped) = p.strip_prefix(project_root)
210    {
211        return stripped
212            .to_string_lossy()
213            .trim_start_matches('/')
214            .to_string();
215    }
216    path.trim_start_matches('/').to_string()
217}
218
219fn to_fs_path(project_root: &str, rel_or_abs: &str) -> String {
220    let p = Path::new(rel_or_abs);
221    if p.is_absolute() {
222        return rel_or_abs.to_string();
223    }
224    Path::new(project_root)
225        .join(rel_or_abs)
226        .to_string_lossy()
227        .to_string()
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
235
236    fn fixture() -> (tempfile::TempDir, String) {
237        let project = tempfile::tempdir().unwrap();
238        let root = std::fs::canonicalize(project.path())
239            .unwrap()
240            .to_string_lossy()
241            .into_owned();
242        (project, root)
243    }
244
245    fn cached(cache: &SessionCache, root: &str, name: &str) -> Option<String> {
246        cache.get_full_content(&Path::new(root).join(name).to_string_lossy())
247    }
248
249    /// G01: the old prefetch read each candidate raw and handed it to the read
250    /// path outside any request scope — the session cache then held a key
251    /// that `ctx_read` masks, and a restricted source was retained.
252    #[test]
253    fn prefetch_caches_admitted_storable_sources_only() {
254        let _data = lean_ctx::core::data_dir::isolated_data_dir();
255        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
256        let (_project, root) = fixture();
257        std::fs::write(Path::new(&root).join("clean.rs"), "pub fn clean() {}\n").unwrap();
258        std::fs::write(
259            Path::new(&root).join("keyed.rs"),
260            format!("pub fn connect() {{\n    let aws_key = \"{AWS_KEY}\";\n}}\n"),
261        )
262        .unwrap();
263        std::fs::write(
264            Path::new(&root).join("marked.rs"),
265            "// TOP SECRET\npub fn plan() {}\n",
266        )
267        .unwrap();
268        let changed = vec![
269            "clean.rs".to_string(),
270            "keyed.rs".to_string(),
271            "marked.rs".to_string(),
272        ];
273        let mut cache = SessionCache::new();
274        let out = handle(
275            &mut cache,
276            &root,
277            None,
278            Some(&changed),
279            0,
280            None,
281            CrpMode::Off,
282        );
283        assert!(
284            out.contains("clean.rs") && out.contains("keyed.rs"),
285            "{out}"
286        );
287        assert!(!out.contains("marked.rs"), "{out}");
288        assert!(
289            cached(&cache, &root, "keyed.rs").is_none_or(|text| !text.contains(AWS_KEY)),
290            "the session cache holds a raw key"
291        );
292        assert!(cached(&cache, &root, "marked.rs").is_none());
293    }
294
295    #[test]
296    fn a_withheld_candidate_is_named_and_not_cached() {
297        let _data = lean_ctx::core::data_dir::isolated_data_dir();
298        let policy = lean_ctx::core::policy::load(
299            "name = \"ctx-prefetch-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
300             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
301        )
302        .unwrap();
303        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
304        let (_project, root) = fixture();
305        std::fs::write(
306            Path::new(&root).join("plan.rs"),
307            "CONFIDENTIAL\npub fn hidden_marker() {}\n",
308        )
309        .unwrap();
310        let changed = vec!["plan.rs".to_string()];
311        let mut cache = SessionCache::new();
312        let out = handle(
313            &mut cache,
314            &root,
315            None,
316            Some(&changed),
317            0,
318            None,
319            CrpMode::Off,
320        );
321        assert!(out.contains("prefetched 0 file(s)"), "{out}");
322        assert!(out.contains("[withheld] "), "{out}");
323        assert!(!out.contains("hidden_marker"), "{out}");
324        assert!(cached(&cache, &root, "plan.rs").is_none());
325    }
326}