1use crate::tools::CrpMode;
3use crate::tools::admitted_sources::SourceRead;
4use lean_ctx::core::cache::SessionCache;
5use lean_ctx::core::graph_provider::{self, GraphProvider};
6use lean_ctx::core::protocol;
7use lean_ctx::core::task_relevance::{RelevanceScore, compute_relevance, parse_task_hints};
8use lean_ctx::core::tokens::count_tokens;
9
10const TOOL: &str = "ctx_preload";
12const MAX_PRELOAD_FILES: usize = 8;
13const MAX_CRITICAL_LINES: usize = 15;
14const SIGNATURES_BUDGET: usize = 10;
15const TOTAL_TOKEN_BUDGET: usize = 4000;
16
17pub fn handle(
21 cache: &mut SessionCache,
22 task: &str,
23 path: Option<&str>,
24 _crp_mode: CrpMode,
25) -> (String, bool) {
26 if task.trim().is_empty() {
27 return (
28 "ERROR: ctx_preload requires a task description".to_string(),
29 false,
30 );
31 }
32
33 let project_root = path.map_or_else(|| ".".to_string(), std::string::ToString::to_string);
34 let jail_root = std::path::Path::new(&project_root);
35
36 let Some(open) = graph_provider::open_or_build(&project_root) else {
37 return (
38 format!(
39 "[task: {task}]\nNo graph available. Use ctx_compose(task=…) for a ranked project map."
40 ),
41 false,
42 );
43 };
44 let gp = &open.provider;
45
46 let session_intent =
47 lean_ctx::core::session::caller::with(|s| s.active_structured_intent.clone()).flatten();
48
49 let (task_files, task_keywords) = parse_task_hints(task);
50 let mut relevance = if let Some(ref intent) = session_intent {
51 lean_ctx::core::task_relevance::compute_relevance_from_intent(gp, intent)
52 } else {
53 compute_relevance(gp, &task_files, &task_keywords)
54 };
55 lean_ctx::core::git_signals::apply_boost(&mut relevance, &project_root);
57 lean_ctx::core::diagnostics_store::apply_boost(&mut relevance);
59 lean_ctx::core::editor_signal::apply_boost(&mut relevance);
61
62 let mut scored: Vec<_> = relevance
63 .iter()
64 .filter(|r| r.score >= 0.1)
65 .take(MAX_PRELOAD_FILES + 10)
66 .collect();
67
68 apply_heat_ranking(&mut scored, gp, &project_root);
69
70 let pop = lean_ctx::core::pop_pruning::decide_for_candidates(task, &project_root, &scored);
71 let candidates =
72 lean_ctx::core::pop_pruning::filter_candidates_by_pop(&project_root, &scored, &pop);
73
74 if candidates.is_empty() {
75 return (
76 format!(
77 "[task: {task}]\nNo directly relevant files found. Use ctx_compose(task=…) for a ranked project map."
78 ),
79 false,
80 );
81 }
82
83 const MIN_USEFUL_SCORE: f64 = 0.25;
88 let max_score = candidates.iter().map(|c| c.score).fold(0.0_f64, f64::max);
89 if max_score < MIN_USEFUL_SCORE {
90 return (
91 format!(
92 "[task: {task}]\nNo sufficiently relevant files (max score {max_score:.1} < {MIN_USEFUL_SCORE}). Use ctx_compose(task=…) for a ranked project map."
93 ),
94 false,
95 );
96 }
97
98 let task_specificity =
103 (task_files.len() as f64 * 0.3 + task_keywords.len() as f64 * 0.1).clamp(0.0, 1.0);
104 let temperature = 0.8 - task_specificity * 0.6; let temperature = temperature.max(0.1);
106
107 let allocations = boltzmann_allocate(&candidates, TOTAL_TOKEN_BUDGET, temperature);
108
109 let mut remaining =
114 lean_ctx::core::limits::max_read_bytes().saturating_mul(MAX_PRELOAD_FILES + 10);
115 let acquired: std::collections::HashMap<&str, Acquired> = candidates
116 .iter()
117 .filter_map(|c| {
118 acquire(&c.path, &project_root, jail_root, &mut remaining)
119 .map(|acquired| (c.path.as_str(), acquired))
120 })
121 .collect();
122
123 let file_context: Vec<(String, usize)> = candidates
124 .iter()
125 .filter_map(|c| {
126 acquired
127 .get(c.path.as_str())
128 .and_then(|acquired| acquired.read.text())
129 .map(|text| (c.path.clone(), text.lines().count()))
130 })
131 .collect();
132 let briefing = lean_ctx::core::task_briefing::build_briefing(task, &file_context);
133 let briefing_block = lean_ctx::core::task_briefing::format_briefing(&briefing);
134
135 let multi_intents = lean_ctx::core::intent_engine::detect_multi_intent(task);
136 let primary = &multi_intents[0];
137 let complexity = lean_ctx::core::intent_engine::classify_complexity(task, primary);
138
139 let mut output = Vec::new();
140 output.push(briefing_block);
141
142 let complexity_label = complexity.instruction_suffix().lines().next().unwrap_or("");
143 if multi_intents.len() > 1 {
144 output.push(format!(
145 "[task: {task}] | {} | {} sub-intents",
146 complexity_label,
147 multi_intents.len()
148 ));
149 for (i, sub) in multi_intents.iter().enumerate() {
150 output.push(format!(
151 " {}. {} ({:.0}%)",
152 i + 1,
153 sub.task_type.as_str(),
154 sub.confidence * 100.0
155 ));
156 }
157 } else {
158 output.push(format!("[task: {task}] | {complexity_label}"));
159 }
160
161 for r in lean_ctx::core::prospective_memory::reminders_for_task(&project_root, task) {
162 output.push(r);
163 }
164
165 if !pop.excluded_modules.is_empty() {
166 output.push("POP:".to_string());
167 for ex in &pop.excluded_modules {
168 output.push(format!(
169 " - exclude {}/ ({} candidates) — {}",
170 ex.module, ex.candidate_files, ex.reason
171 ));
172 }
173 }
174
175 let mut total_estimated_saved = 0usize;
176 let mut critical_count = 0usize;
177 let git_signals = lean_ctx::core::git_signals::collect(&project_root);
178
179 for (rel, token_budget) in candidates.iter().zip(allocations.iter()) {
180 if *token_budget < 20 {
181 continue;
182 }
183 critical_count += 1;
184 if critical_count > MAX_PRELOAD_FILES {
185 break;
186 }
187
188 let Some(source) = acquired.get(rel.path.as_str()) else {
189 continue;
190 };
191 let jailed_s = source.path.as_str();
192 let short = protocol::shorten_path(jailed_s);
193
194 seed_cache(cache, source);
197
198 let content = match &source.read {
199 SourceRead::Admitted(read) => read.content.as_str(),
200 SourceRead::Withheld(reason) => {
201 output.push(format!(
202 "\n{}",
203 crate::tools::admitted_sources::withheld_line(&short, reason)
204 ));
205 continue;
206 }
207 SourceRead::Unavailable(_) => continue,
208 };
209
210 let file_ref = cache.get_file_ref(jailed_s);
211 let line_count = content.lines().count();
212 let file_tokens = count_tokens(content);
213
214 let mode = budget_to_mode(*token_budget, file_tokens);
215
216 let critical_lines = extract_critical_lines(content, &task_keywords, MAX_CRITICAL_LINES);
217 let sigs = extract_key_signatures(content, SIGNATURES_BUDGET);
218 let imports = extract_imports(content);
219
220 let git_marker = {
222 let recency = git_signals.recency_for(&rel.path, &project_root);
223 if recency >= 1.0 {
224 " ● uncommitted"
225 } else if recency > 0.5 {
226 " ● recent-commit"
227 } else {
228 ""
229 }
230 };
231 let diag_marker = {
233 let diags = lean_ctx::core::diagnostics_store::details_for(&rel.path);
234 diags
235 .iter()
236 .find(|(_, sev, _)| *sev == lean_ctx::core::diagnostics_store::Severity::Error)
237 .map(|(line, _, _)| match line {
238 Some(l) => format!(" ✖ error L{l}"),
239 None => " ✖ error".to_string(),
240 })
241 .unwrap_or_default()
242 };
243
244 output.push(format!(
245 "\nCRITICAL: {file_ref}={short} {line_count}L score={:.1} budget={token_budget}tok mode={mode}{git_marker}{diag_marker}",
246 rel.score
247 ));
248
249 if !critical_lines.is_empty() {
250 for (line_no, line) in &critical_lines {
251 output.push(format!(" :{line_no} {line}"));
252 }
253 }
254
255 if !imports.is_empty() {
256 output.push(format!(" imports: {}", imports.join(", ")));
257 }
258
259 if !sigs.is_empty() {
260 for sig in &sigs {
261 output.push(format!(" {sig}"));
262 }
263 }
264
265 total_estimated_saved += file_tokens;
266 }
267
268 let context_files: Vec<_> = relevance
269 .iter()
270 .filter(|r| r.score >= 0.1 && r.score < 0.3)
271 .take(10)
272 .collect();
273
274 if !context_files.is_empty() {
275 output.push("\nRELATED:".to_string());
276 for rel in &context_files {
277 let short = protocol::shorten_path(&rel.path);
278 output.push(format!(
279 " {} mode={} score={:.1}",
280 short, rel.recommended_mode, rel.score
281 ));
282 }
283 }
284
285 let all_edges = gp.edges();
286 let graph_edges: Vec<_> = all_edges
287 .iter()
288 .filter(|e| {
289 candidates
290 .iter()
291 .any(|c| c.path == e.from || c.path == e.to)
292 })
293 .take(10)
294 .collect();
295
296 if !graph_edges.is_empty() {
297 output.push("\nGRAPH:".to_string());
298 for edge in &graph_edges {
299 let from_short = protocol::shorten_path(&edge.from);
300 let to_short = protocol::shorten_path(&edge.to);
301 output.push(format!(" {from_short} -> {to_short}"));
302 }
303 }
304
305 let preload_result = output.join("\n");
306 let preload_tokens = count_tokens(&preload_result);
307 let savings = protocol::format_savings(total_estimated_saved, preload_tokens);
308
309 let body = format!("{preload_result}\n{savings}");
310 (body, true)
311}
312
313struct Acquired {
316 path: String,
317 read: SourceRead,
318}
319
320fn acquire(
324 candidate: &str,
325 project_root: &str,
326 jail_root: &std::path::Path,
327 remaining: &mut usize,
328) -> Option<Acquired> {
329 let (jailed, warning) = lean_ctx::core::io_boundary::jail_and_check_path(
330 TOOL,
331 std::path::Path::new(candidate),
332 jail_root,
333 )
334 .ok()?;
335 if warning.is_some() || lean_ctx::core::cloud_files::is_cloud_placeholder(&jailed) {
336 return None;
337 }
338 let path = jailed.to_string_lossy().into_owned();
339 let read = crate::tools::admitted_sources::read(&path, project_root, TOOL, remaining);
340 Some(Acquired { path, read })
341}
342
343fn seed_cache(cache: &mut SessionCache, source: &Acquired) {
349 match (&source.read, source.read.storable()) {
350 (_, Some(text)) => {
351 let _ = cache.store(&source.path, text);
352 }
353 (SourceRead::Admitted(_) | SourceRead::Withheld(_), None) => {
354 let _ = cache.invalidate(&source.path);
355 }
356 (SourceRead::Unavailable(_), None) => {}
357 }
358}
359
360fn boltzmann_allocate(
363 candidates: &[&lean_ctx::core::task_relevance::RelevanceScore],
364 total_budget: usize,
365 temperature: f64,
366) -> Vec<usize> {
367 if candidates.is_empty() {
368 return Vec::new();
369 }
370
371 let t = temperature.max(0.01);
372
373 let log_weights: Vec<f64> = candidates.iter().map(|c| c.score / t).collect();
375 let max_log = log_weights
376 .iter()
377 .copied()
378 .fold(f64::NEG_INFINITY, f64::max);
379 let exp_weights: Vec<f64> = log_weights.iter().map(|&lw| (lw - max_log).exp()).collect();
380 let z: f64 = exp_weights.iter().sum();
381
382 if z <= 0.0 {
383 return vec![total_budget / candidates.len().max(1); candidates.len()];
384 }
385
386 let mut allocations: Vec<usize> = exp_weights
387 .iter()
388 .map(|&w| ((w / z) * total_budget as f64).round() as usize)
389 .collect();
390
391 let sum: usize = allocations.iter().sum();
393 if sum > total_budget {
394 let overflow = sum - total_budget;
395 if let Some(last) = allocations.last_mut() {
396 *last = last.saturating_sub(overflow);
397 }
398 }
399
400 allocations
401}
402
403fn budget_to_mode(budget: usize, file_tokens: usize) -> &'static str {
405 let ratio = budget as f64 / file_tokens.max(1) as f64;
406 if ratio >= 0.8 {
407 "full"
408 } else if ratio >= 0.4 {
409 "signatures"
410 } else if ratio >= 0.15 {
411 "map"
412 } else {
413 "reference"
414 }
415}
416
417fn extract_critical_lines(content: &str, keywords: &[String], max: usize) -> Vec<(usize, String)> {
418 let kw_lower: Vec<String> = keywords.iter().map(|k| k.to_lowercase()).collect();
419
420 let mut hits: Vec<(usize, String, usize)> = content
421 .lines()
422 .enumerate()
423 .filter_map(|(i, line)| {
424 let trimmed = line.trim();
425 if trimmed.is_empty() {
426 return None;
427 }
428 let line_lower = trimmed.to_lowercase();
429 let hit_count = kw_lower
430 .iter()
431 .filter(|kw| line_lower.contains(kw.as_str()))
432 .count();
433
434 let is_error = trimmed.contains("Error")
435 || trimmed.contains("Err(")
436 || trimmed.contains("panic!")
437 || trimmed.contains("unwrap()")
438 || trimmed.starts_with("return Err");
439
440 if hit_count > 0 || is_error {
441 let priority = hit_count + if is_error { 2 } else { 0 };
442 Some((i + 1, trimmed.to_string(), priority))
443 } else {
444 None
445 }
446 })
447 .collect();
448
449 hits.sort_by_key(|x| std::cmp::Reverse(x.2));
450 hits.truncate(max);
451 hits.iter().map(|(n, l, _)| (*n, l.clone())).collect()
452}
453
454fn extract_key_signatures(content: &str, max: usize) -> Vec<String> {
455 let sig_starters = [
456 "pub fn ",
457 "pub async fn ",
458 "pub struct ",
459 "pub enum ",
460 "pub trait ",
461 "pub type ",
462 "pub const ",
463 ];
464
465 content
466 .lines()
467 .filter(|line| {
468 let trimmed = line.trim();
469 sig_starters.iter().any(|s| trimmed.starts_with(s))
470 })
471 .take(max)
472 .map(|line| {
473 let trimmed = line.trim();
474 if trimmed.len() > 120 {
475 format!("{}...", &trimmed[..trimmed.floor_char_boundary(117)])
476 } else {
477 trimmed.to_string()
478 }
479 })
480 .collect()
481}
482
483fn extract_imports(content: &str) -> Vec<String> {
484 content
485 .lines()
486 .filter(|line| {
487 let t = line.trim();
488 t.starts_with("use ") || t.starts_with("import ") || t.starts_with("from ")
489 })
490 .take(8)
491 .map(|line| {
492 let t = line.trim();
493 if let Some(rest) = t.strip_prefix("use ") {
494 rest.trim_end_matches(';').to_string()
495 } else {
496 t.to_string()
497 }
498 })
499 .collect()
500}
501
502fn apply_heat_ranking(candidates: &mut [&RelevanceScore], gp: &GraphProvider, root: &str) {
503 if gp.file_count() == 0 {
504 return;
505 }
506
507 let all_edges = gp.edges();
508 let mut connection_counts: std::collections::HashMap<String, usize> =
509 std::collections::HashMap::new();
510 for edge in &all_edges {
511 *connection_counts.entry(edge.from.clone()).or_default() += 1;
512 *connection_counts.entry(edge.to.clone()).or_default() += 1;
513 }
514
515 let mut max_tokens = 1usize;
516 for path in gp.file_paths() {
517 if let Some(entry) = gp.get_file_entry(&path) {
518 max_tokens = max_tokens.max(entry.token_count);
519 }
520 }
521 let max_tokens = max_tokens as f64;
522 let max_conn = connection_counts.values().max().copied().unwrap_or(1) as f64;
523
524 candidates.sort_by(|a, b| {
525 let heat_a = compute_heat(&a.path, root, gp, &connection_counts, max_tokens, max_conn);
526 let heat_b = compute_heat(&b.path, root, gp, &connection_counts, max_tokens, max_conn);
527 let combined_a = a.score * 0.6 + heat_a * 0.4;
528 let combined_b = b.score * 0.6 + heat_b * 0.4;
529 combined_b
530 .partial_cmp(&combined_a)
531 .unwrap_or(std::cmp::Ordering::Equal)
532 });
533}
534
535fn compute_heat(
536 path: &str,
537 root: &str,
538 gp: &GraphProvider,
539 connections: &std::collections::HashMap<String, usize>,
540 max_tokens: f64,
541 max_conn: f64,
542) -> f64 {
543 let rel = path
544 .strip_prefix(root)
545 .unwrap_or(path)
546 .trim_start_matches('/');
547
548 if let Some(entry) = gp.get_file_entry(rel) {
549 let conn = connections.get(rel).copied().unwrap_or(0);
550 let token_norm = entry.token_count as f64 / max_tokens;
551 let conn_norm = conn as f64 / max_conn;
552 token_norm * 0.4 + conn_norm * 0.6
553 } else {
554 0.0
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
566 fn guidance_only_preloads_report_themselves_as_unusable() {
567 let mut cache = SessionCache::new();
568 let (body, usable) = handle(&mut cache, " ", None, CrpMode::Off);
569 assert!(!usable, "an empty task carries no context: {body}");
570
571 let dir = tempfile::tempdir().unwrap();
572 let root = dir.path().to_string_lossy().to_string();
573 let (body, usable) = handle(&mut cache, "unrelated task", Some(&root), CrpMode::Off);
574 assert!(
575 !usable,
576 "a preload with no candidate files carries no context: {body}"
577 );
578 }
579
580 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
581
582 fn project_file(root: &std::path::Path, name: &str, content: &str) {
583 std::fs::write(root.join(name), content).unwrap();
584 }
585
586 #[test]
592 fn the_session_cache_is_seeded_with_admitted_storable_sources_only() {
593 let _data = lean_ctx::core::data_dir::isolated_data_dir();
594 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
595 let project = tempfile::tempdir().unwrap();
596 let root = project.path().to_string_lossy().into_owned();
597 project_file(project.path(), "clean.rs", "pub fn clean() {}\n");
598 project_file(
599 project.path(),
600 "keyed.rs",
601 &format!("pub fn connect() {{\n let aws_key = \"{AWS_KEY}\";\n}}\n"),
602 );
603 project_file(
604 project.path(),
605 "marked.rs",
606 "// TOP SECRET\npub fn plan() {}\n",
607 );
608 let mut cache = SessionCache::new();
609 let mut remaining = lean_ctx::core::limits::max_read_bytes().saturating_mul(4);
610
611 let clean = acquire("clean.rs", &root, project.path(), &mut remaining).unwrap();
613 seed_cache(&mut cache, &clean);
614 assert_eq!(
615 cache.get_full_content(&clean.path).as_deref(),
616 Some("pub fn clean() {}\n")
617 );
618
619 let keyed = acquire("keyed.rs", &root, project.path(), &mut remaining).unwrap();
620 seed_cache(&mut cache, &keyed);
621 let cached = cache
622 .get_full_content(&keyed.path)
623 .expect("the masked source is cached");
624 assert!(!cached.contains(AWS_KEY), "the cache holds a raw key");
625 assert!(cached.contains("pub fn connect"), "{cached}");
626
627 let marked = acquire("marked.rs", &root, project.path(), &mut remaining).unwrap();
630 let _ = cache.store(&marked.path, "// TOP SECRET\npub fn plan() {}\n");
631 seed_cache(&mut cache, &marked);
632 assert!(marked.read.text().is_some_and(|text| text.contains("plan")));
633 assert!(cache.get_full_content(&marked.path).is_none());
634 }
635
636 #[test]
637 fn a_withheld_candidate_is_not_cached_and_loses_its_earlier_copy() {
638 let _data = lean_ctx::core::data_dir::isolated_data_dir();
639 let policy = lean_ctx::core::policy::load(
640 "name = \"ctx-preload-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
641 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
642 )
643 .unwrap();
644 let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
645 let project = tempfile::tempdir().unwrap();
646 let root = project.path().to_string_lossy().into_owned();
647 let source = "CONFIDENTIAL\npub fn hidden_marker() {}\n";
648 project_file(project.path(), "plan.rs", source);
649 let mut cache = SessionCache::new();
650 let mut remaining = lean_ctx::core::limits::max_read_bytes();
651
652 let plan = acquire("plan.rs", &root, project.path(), &mut remaining).unwrap();
653 let _ = cache.store(&plan.path, source);
654 seed_cache(&mut cache, &plan);
655 let reason = plan.read.withheld().expect("blocked by the policy");
656 assert!(!reason.contains("hidden_marker"), "{reason}");
657 assert!(cache.get_full_content(&plan.path).is_none());
658 }
659
660 #[test]
661 fn extract_critical_lines_finds_keywords() {
662 let content = "fn main() {\n let token = validate();\n return Err(e);\n}\n";
663 let result = extract_critical_lines(content, &["validate".to_string()], 5);
664 assert!(!result.is_empty(), "`result` is empty");
665 assert!(result.iter().any(|(_, l)| l.contains("validate")));
666 }
667
668 #[test]
669 fn extract_critical_lines_prioritizes_errors() {
670 let content = "fn main() {\n let x = 1;\n return Err(\"bad\");\n let token = validate();\n}\n";
671 let result = extract_critical_lines(content, &["validate".to_string()], 5);
672 assert!(result.len() >= 2);
673 assert!(result[0].1.contains("Err"), "errors should be first");
674 }
675
676 #[test]
677 fn extract_key_signatures_finds_pub() {
678 let content = "use std::io;\nfn private() {}\npub fn public_one() {}\npub struct Foo {}\n";
679 let sigs = extract_key_signatures(content, 10);
680 assert_eq!(sigs.len(), 2);
681 assert!(sigs[0].contains("pub fn public_one"));
682 assert!(sigs[1].contains("pub struct Foo"));
683 }
684
685 #[test]
686 fn extract_imports_works() {
687 let content = "use std::io;\nuse crate::core::cache;\nfn main() {}\n";
688 let imports = extract_imports(content);
689 assert_eq!(imports.len(), 2);
690 assert!(imports[0].contains("std::io"));
691 }
692}