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