Skip to main content

lean_ctx/tools/
ctx_preload.rs

1use crate::core::cache::SessionCache;
2use crate::core::graph_provider::{self, GraphProvider};
3use crate::core::protocol;
4use crate::core::task_relevance::{compute_relevance, parse_task_hints, RelevanceScore};
5use crate::core::tokens::count_tokens;
6use crate::tools::CrpMode;
7
8const MAX_PRELOAD_FILES: usize = 8;
9const MAX_CRITICAL_LINES: usize = 15;
10const SIGNATURES_BUDGET: usize = 10;
11const TOTAL_TOKEN_BUDGET: usize = 4000;
12
13pub fn handle(
14    cache: &mut SessionCache,
15    task: &str,
16    path: Option<&str>,
17    crp_mode: CrpMode,
18) -> String {
19    if task.trim().is_empty() {
20        return "ERROR: ctx_preload requires a task description".to_string();
21    }
22
23    let project_root = path.map_or_else(|| ".".to_string(), std::string::ToString::to_string);
24    let jail_root = std::path::Path::new(&project_root);
25
26    let Some(open) = graph_provider::open_or_build(&project_root) else {
27        return format!("[task: {task}]\nNo graph available. Use ctx_overview for project map.");
28    };
29    let gp = &open.provider;
30
31    let session_intent =
32        crate::core::session::SessionState::load_latest().and_then(|s| s.active_structured_intent);
33
34    let (task_files, task_keywords) = parse_task_hints(task);
35    let mut relevance = if let Some(ref intent) = session_intent {
36        crate::core::task_relevance::compute_relevance_from_intent(gp, intent)
37    } else {
38        compute_relevance(gp, &task_files, &task_keywords)
39    };
40    // Git working-set boost (#497): uncommitted + recently-churned files rank up.
41    crate::core::git_signals::apply_boost(&mut relevance, &project_root);
42    // Active build errors outrank everything (#499).
43    crate::core::diagnostics_store::apply_boost(&mut relevance);
44    // Editor focus (#500): the file the developer is looking at ranks up.
45    crate::core::editor_signal::apply_boost(&mut relevance);
46
47    let mut scored: Vec<_> = relevance
48        .iter()
49        .filter(|r| r.score >= 0.1)
50        .take(MAX_PRELOAD_FILES + 10)
51        .collect();
52
53    apply_heat_ranking(&mut scored, gp, &project_root);
54
55    let pop = crate::core::pop_pruning::decide_for_candidates(task, &project_root, &scored);
56    let candidates =
57        crate::core::pop_pruning::filter_candidates_by_pop(&project_root, &scored, &pop);
58
59    if candidates.is_empty() {
60        return format!(
61            "[task: {task}]\nNo directly relevant files found. Use ctx_overview for project map."
62        );
63    }
64
65    // Boltzmann allocation: p(file_i) = exp(score_i / T) / Z
66    // Temperature T is derived from task specificity:
67    //   - Many keywords / specific file mentions → low T → concentrate budget
68    //   - Few keywords / broad task → high T → spread budget evenly
69    let task_specificity =
70        (task_files.len() as f64 * 0.3 + task_keywords.len() as f64 * 0.1).clamp(0.0, 1.0);
71    let temperature = 0.8 - task_specificity * 0.6; // range [0.2, 0.8]
72    let temperature = temperature.max(0.1);
73
74    let allocations = boltzmann_allocate(&candidates, TOTAL_TOKEN_BUDGET, temperature);
75
76    let file_context: Vec<(String, usize)> = candidates
77        .iter()
78        .filter_map(|c| {
79            let Ok((jailed, warning)) = crate::core::io_boundary::jail_and_check_path(
80                "ctx_preload",
81                std::path::Path::new(&c.path),
82                jail_root,
83            ) else {
84                return None;
85            };
86            if warning.is_some() {
87                return None;
88            }
89            // Don't hydrate cloud placeholders during automatic preload (#363).
90            if crate::core::cloud_files::is_cloud_placeholder(&jailed) {
91                return None;
92            }
93            std::fs::read_to_string(&jailed)
94                .ok()
95                .map(|content| (c.path.clone(), content.lines().count()))
96        })
97        .collect();
98    let briefing = crate::core::task_briefing::build_briefing(task, &file_context);
99    let briefing_block = crate::core::task_briefing::format_briefing(&briefing);
100
101    let multi_intents = crate::core::intent_engine::detect_multi_intent(task);
102    let primary = &multi_intents[0];
103    let complexity = crate::core::intent_engine::classify_complexity(task, primary);
104
105    let mut output = Vec::new();
106    output.push(briefing_block);
107
108    let complexity_label = complexity.instruction_suffix().lines().next().unwrap_or("");
109    if multi_intents.len() > 1 {
110        output.push(format!(
111            "[task: {task}] | {} | {} sub-intents",
112            complexity_label,
113            multi_intents.len()
114        ));
115        for (i, sub) in multi_intents.iter().enumerate() {
116            output.push(format!(
117                "  {}. {} ({:.0}%)",
118                i + 1,
119                sub.task_type.as_str(),
120                sub.confidence * 100.0
121            ));
122        }
123    } else {
124        output.push(format!("[task: {task}] | {complexity_label}"));
125    }
126
127    for r in crate::core::prospective_memory::reminders_for_task(&project_root, task) {
128        output.push(r);
129    }
130
131    if !pop.excluded_modules.is_empty() {
132        output.push("POP:".to_string());
133        for ex in &pop.excluded_modules {
134            output.push(format!(
135                "  - exclude {}/ ({} candidates) — {}",
136                ex.module, ex.candidate_files, ex.reason
137            ));
138        }
139    }
140
141    let mut total_estimated_saved = 0usize;
142    let mut critical_count = 0usize;
143    let git_signals = crate::core::git_signals::collect(&project_root);
144
145    for (rel, token_budget) in candidates.iter().zip(allocations.iter()) {
146        if *token_budget < 20 {
147            continue;
148        }
149        critical_count += 1;
150        if critical_count > MAX_PRELOAD_FILES {
151            break;
152        }
153
154        let Ok((jailed, warning)) = crate::core::io_boundary::jail_and_check_path(
155            "ctx_preload",
156            std::path::Path::new(&rel.path),
157            jail_root,
158        ) else {
159            continue;
160        };
161        if warning.is_some() {
162            continue;
163        }
164
165        let jailed_s = jailed.to_string_lossy().to_string();
166        let Ok(content) = std::fs::read_to_string(&jailed) else {
167            continue;
168        };
169
170        let file_ref = cache.get_file_ref(&jailed_s);
171        let short = protocol::shorten_path(&jailed_s);
172        let line_count = content.lines().count();
173        let file_tokens = count_tokens(&content);
174
175        let _ = cache.store(&jailed_s, &content);
176
177        let mode = budget_to_mode(*token_budget, file_tokens);
178
179        let critical_lines = extract_critical_lines(&content, &task_keywords, MAX_CRITICAL_LINES);
180        let sigs = extract_key_signatures(&content, SIGNATURES_BUDGET);
181        let imports = extract_imports(&content);
182
183        // Surface the git signal so the agent knows WHY a file ranked up (#497).
184        let git_marker = {
185            let recency = git_signals.recency_for(&rel.path, &project_root);
186            if recency >= 1.0 {
187                " ● uncommitted"
188            } else if recency > 0.5 {
189                " ● recent-commit"
190            } else {
191                ""
192            }
193        };
194        // Active diagnostics marker (#499): tell the agent which file is broken.
195        let diag_marker = {
196            let diags = crate::core::diagnostics_store::details_for(&rel.path);
197            diags
198                .iter()
199                .find(|(_, sev, _)| *sev == crate::core::diagnostics_store::Severity::Error)
200                .map(|(line, _, _)| match line {
201                    Some(l) => format!(" ✖ error L{l}"),
202                    None => " ✖ error".to_string(),
203                })
204                .unwrap_or_default()
205        };
206
207        output.push(format!(
208            "\nCRITICAL: {file_ref}={short} {line_count}L score={:.1} budget={token_budget}tok mode={mode}{git_marker}{diag_marker}",
209            rel.score
210        ));
211
212        if !critical_lines.is_empty() {
213            for (line_no, line) in &critical_lines {
214                output.push(format!("  :{line_no} {line}"));
215            }
216        }
217
218        if !imports.is_empty() {
219            output.push(format!("  imports: {}", imports.join(", ")));
220        }
221
222        if !sigs.is_empty() {
223            for sig in &sigs {
224                output.push(format!("  {sig}"));
225            }
226        }
227
228        total_estimated_saved += file_tokens;
229    }
230
231    let context_files: Vec<_> = relevance
232        .iter()
233        .filter(|r| r.score >= 0.1 && r.score < 0.3)
234        .take(10)
235        .collect();
236
237    if !context_files.is_empty() {
238        output.push("\nRELATED:".to_string());
239        for rel in &context_files {
240            let short = protocol::shorten_path(&rel.path);
241            output.push(format!(
242                "  {} mode={} score={:.1}",
243                short, rel.recommended_mode, rel.score
244            ));
245        }
246    }
247
248    let all_edges = gp.edges();
249    let graph_edges: Vec<_> = all_edges
250        .iter()
251        .filter(|e| {
252            candidates
253                .iter()
254                .any(|c| c.path == e.from || c.path == e.to)
255        })
256        .take(10)
257        .collect();
258
259    if !graph_edges.is_empty() {
260        output.push("\nGRAPH:".to_string());
261        for edge in &graph_edges {
262            let from_short = protocol::shorten_path(&edge.from);
263            let to_short = protocol::shorten_path(&edge.to);
264            output.push(format!("  {from_short} -> {to_short}"));
265        }
266    }
267
268    let preload_result = output.join("\n");
269    let preload_tokens = count_tokens(&preload_result);
270    let savings = protocol::format_savings(total_estimated_saved, preload_tokens);
271
272    if crp_mode.is_tdd() {
273        format!("{preload_result}\n{savings}")
274    } else {
275        format!("{preload_result}\n\nNext: ctx_read(path, mode=\"full\") for any file above.\n{savings}")
276    }
277}
278
279/// Boltzmann distribution for token budget allocation across files.
280/// p(file_i) = exp(score_i / T) / Z, then budget_i = total * p(file_i)
281fn boltzmann_allocate(
282    candidates: &[&crate::core::task_relevance::RelevanceScore],
283    total_budget: usize,
284    temperature: f64,
285) -> Vec<usize> {
286    if candidates.is_empty() {
287        return Vec::new();
288    }
289
290    let t = temperature.max(0.01);
291
292    // Compute exp(score / T) for each candidate, using log-sum-exp for numerical stability
293    let log_weights: Vec<f64> = candidates.iter().map(|c| c.score / t).collect();
294    let max_log = log_weights
295        .iter()
296        .copied()
297        .fold(f64::NEG_INFINITY, f64::max);
298    let exp_weights: Vec<f64> = log_weights.iter().map(|&lw| (lw - max_log).exp()).collect();
299    let z: f64 = exp_weights.iter().sum();
300
301    if z <= 0.0 {
302        return vec![total_budget / candidates.len().max(1); candidates.len()];
303    }
304
305    let mut allocations: Vec<usize> = exp_weights
306        .iter()
307        .map(|&w| ((w / z) * total_budget as f64).round() as usize)
308        .collect();
309
310    // Ensure total doesn't exceed budget
311    let sum: usize = allocations.iter().sum();
312    if sum > total_budget {
313        let overflow = sum - total_budget;
314        if let Some(last) = allocations.last_mut() {
315            *last = last.saturating_sub(overflow);
316        }
317    }
318
319    allocations
320}
321
322/// Map a token budget to a recommended compression mode.
323fn budget_to_mode(budget: usize, file_tokens: usize) -> &'static str {
324    let ratio = budget as f64 / file_tokens.max(1) as f64;
325    if ratio >= 0.8 {
326        "full"
327    } else if ratio >= 0.4 {
328        "signatures"
329    } else if ratio >= 0.15 {
330        "map"
331    } else {
332        "reference"
333    }
334}
335
336fn extract_critical_lines(content: &str, keywords: &[String], max: usize) -> Vec<(usize, String)> {
337    let kw_lower: Vec<String> = keywords.iter().map(|k| k.to_lowercase()).collect();
338
339    let mut hits: Vec<(usize, String, usize)> = content
340        .lines()
341        .enumerate()
342        .filter_map(|(i, line)| {
343            let trimmed = line.trim();
344            if trimmed.is_empty() {
345                return None;
346            }
347            let line_lower = trimmed.to_lowercase();
348            let hit_count = kw_lower
349                .iter()
350                .filter(|kw| line_lower.contains(kw.as_str()))
351                .count();
352
353            let is_error = trimmed.contains("Error")
354                || trimmed.contains("Err(")
355                || trimmed.contains("panic!")
356                || trimmed.contains("unwrap()")
357                || trimmed.starts_with("return Err");
358
359            if hit_count > 0 || is_error {
360                let priority = hit_count + if is_error { 2 } else { 0 };
361                Some((i + 1, trimmed.to_string(), priority))
362            } else {
363                None
364            }
365        })
366        .collect();
367
368    hits.sort_by_key(|x| std::cmp::Reverse(x.2));
369    hits.truncate(max);
370    hits.iter().map(|(n, l, _)| (*n, l.clone())).collect()
371}
372
373fn extract_key_signatures(content: &str, max: usize) -> Vec<String> {
374    let sig_starters = [
375        "pub fn ",
376        "pub async fn ",
377        "pub struct ",
378        "pub enum ",
379        "pub trait ",
380        "pub type ",
381        "pub const ",
382    ];
383
384    content
385        .lines()
386        .filter(|line| {
387            let trimmed = line.trim();
388            sig_starters.iter().any(|s| trimmed.starts_with(s))
389        })
390        .take(max)
391        .map(|line| {
392            let trimmed = line.trim();
393            if trimmed.len() > 120 {
394                format!("{}...", &trimmed[..trimmed.floor_char_boundary(117)])
395            } else {
396                trimmed.to_string()
397            }
398        })
399        .collect()
400}
401
402fn extract_imports(content: &str) -> Vec<String> {
403    content
404        .lines()
405        .filter(|line| {
406            let t = line.trim();
407            t.starts_with("use ") || t.starts_with("import ") || t.starts_with("from ")
408        })
409        .take(8)
410        .map(|line| {
411            let t = line.trim();
412            if let Some(rest) = t.strip_prefix("use ") {
413                rest.trim_end_matches(';').to_string()
414            } else {
415                t.to_string()
416            }
417        })
418        .collect()
419}
420
421fn apply_heat_ranking(candidates: &mut [&RelevanceScore], gp: &GraphProvider, root: &str) {
422    if gp.file_count() == 0 {
423        return;
424    }
425
426    let all_edges = gp.edges();
427    let mut connection_counts: std::collections::HashMap<String, usize> =
428        std::collections::HashMap::new();
429    for edge in &all_edges {
430        *connection_counts.entry(edge.from.clone()).or_default() += 1;
431        *connection_counts.entry(edge.to.clone()).or_default() += 1;
432    }
433
434    let mut max_tokens = 1usize;
435    for path in gp.file_paths() {
436        if let Some(entry) = gp.get_file_entry(&path) {
437            max_tokens = max_tokens.max(entry.token_count);
438        }
439    }
440    let max_tokens = max_tokens as f64;
441    let max_conn = connection_counts.values().max().copied().unwrap_or(1) as f64;
442
443    candidates.sort_by(|a, b| {
444        let heat_a = compute_heat(&a.path, root, gp, &connection_counts, max_tokens, max_conn);
445        let heat_b = compute_heat(&b.path, root, gp, &connection_counts, max_tokens, max_conn);
446        let combined_a = a.score * 0.6 + heat_a * 0.4;
447        let combined_b = b.score * 0.6 + heat_b * 0.4;
448        combined_b
449            .partial_cmp(&combined_a)
450            .unwrap_or(std::cmp::Ordering::Equal)
451    });
452}
453
454fn compute_heat(
455    path: &str,
456    root: &str,
457    gp: &GraphProvider,
458    connections: &std::collections::HashMap<String, usize>,
459    max_tokens: f64,
460    max_conn: f64,
461) -> f64 {
462    let rel = path
463        .strip_prefix(root)
464        .unwrap_or(path)
465        .trim_start_matches('/');
466
467    if let Some(entry) = gp.get_file_entry(rel) {
468        let conn = connections.get(rel).copied().unwrap_or(0);
469        let token_norm = entry.token_count as f64 / max_tokens;
470        let conn_norm = conn as f64 / max_conn;
471        token_norm * 0.4 + conn_norm * 0.6
472    } else {
473        0.0
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn extract_critical_lines_finds_keywords() {
483        let content = "fn main() {\n    let token = validate();\n    return Err(e);\n}\n";
484        let result = extract_critical_lines(content, &["validate".to_string()], 5);
485        assert!(!result.is_empty());
486        assert!(result.iter().any(|(_, l)| l.contains("validate")));
487    }
488
489    #[test]
490    fn extract_critical_lines_prioritizes_errors() {
491        let content = "fn main() {\n    let x = 1;\n    return Err(\"bad\");\n    let token = validate();\n}\n";
492        let result = extract_critical_lines(content, &["validate".to_string()], 5);
493        assert!(result.len() >= 2);
494        assert!(result[0].1.contains("Err"), "errors should be first");
495    }
496
497    #[test]
498    fn extract_key_signatures_finds_pub() {
499        let content = "use std::io;\nfn private() {}\npub fn public_one() {}\npub struct Foo {}\n";
500        let sigs = extract_key_signatures(content, 10);
501        assert_eq!(sigs.len(), 2);
502        assert!(sigs[0].contains("pub fn public_one"));
503        assert!(sigs[1].contains("pub struct Foo"));
504    }
505
506    #[test]
507    fn extract_imports_works() {
508        let content = "use std::io;\nuse crate::core::cache;\nfn main() {}\n";
509        let imports = extract_imports(content);
510        assert_eq!(imports.len(), 2);
511        assert!(imports[0].contains("std::io"));
512    }
513}