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