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
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 crate::core::git_signals::apply_boost(&mut relevance, &project_root);
42 crate::core::diagnostics_store::apply_boost(&mut relevance);
44 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 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; 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 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 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 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!(
276 "{preload_result}\n\nNext: ctx_read(path, mode=\"full\") for any file above.\n{savings}"
277 )
278 }
279}
280
281fn boltzmann_allocate(
284 candidates: &[&crate::core::task_relevance::RelevanceScore],
285 total_budget: usize,
286 temperature: f64,
287) -> Vec<usize> {
288 if candidates.is_empty() {
289 return Vec::new();
290 }
291
292 let t = temperature.max(0.01);
293
294 let log_weights: Vec<f64> = candidates.iter().map(|c| c.score / t).collect();
296 let max_log = log_weights
297 .iter()
298 .copied()
299 .fold(f64::NEG_INFINITY, f64::max);
300 let exp_weights: Vec<f64> = log_weights.iter().map(|&lw| (lw - max_log).exp()).collect();
301 let z: f64 = exp_weights.iter().sum();
302
303 if z <= 0.0 {
304 return vec![total_budget / candidates.len().max(1); candidates.len()];
305 }
306
307 let mut allocations: Vec<usize> = exp_weights
308 .iter()
309 .map(|&w| ((w / z) * total_budget as f64).round() as usize)
310 .collect();
311
312 let sum: usize = allocations.iter().sum();
314 if sum > total_budget {
315 let overflow = sum - total_budget;
316 if let Some(last) = allocations.last_mut() {
317 *last = last.saturating_sub(overflow);
318 }
319 }
320
321 allocations
322}
323
324fn budget_to_mode(budget: usize, file_tokens: usize) -> &'static str {
326 let ratio = budget as f64 / file_tokens.max(1) as f64;
327 if ratio >= 0.8 {
328 "full"
329 } else if ratio >= 0.4 {
330 "signatures"
331 } else if ratio >= 0.15 {
332 "map"
333 } else {
334 "reference"
335 }
336}
337
338fn extract_critical_lines(content: &str, keywords: &[String], max: usize) -> Vec<(usize, String)> {
339 let kw_lower: Vec<String> = keywords.iter().map(|k| k.to_lowercase()).collect();
340
341 let mut hits: Vec<(usize, String, usize)> = content
342 .lines()
343 .enumerate()
344 .filter_map(|(i, line)| {
345 let trimmed = line.trim();
346 if trimmed.is_empty() {
347 return None;
348 }
349 let line_lower = trimmed.to_lowercase();
350 let hit_count = kw_lower
351 .iter()
352 .filter(|kw| line_lower.contains(kw.as_str()))
353 .count();
354
355 let is_error = trimmed.contains("Error")
356 || trimmed.contains("Err(")
357 || trimmed.contains("panic!")
358 || trimmed.contains("unwrap()")
359 || trimmed.starts_with("return Err");
360
361 if hit_count > 0 || is_error {
362 let priority = hit_count + if is_error { 2 } else { 0 };
363 Some((i + 1, trimmed.to_string(), priority))
364 } else {
365 None
366 }
367 })
368 .collect();
369
370 hits.sort_by_key(|x| std::cmp::Reverse(x.2));
371 hits.truncate(max);
372 hits.iter().map(|(n, l, _)| (*n, l.clone())).collect()
373}
374
375fn extract_key_signatures(content: &str, max: usize) -> Vec<String> {
376 let sig_starters = [
377 "pub fn ",
378 "pub async fn ",
379 "pub struct ",
380 "pub enum ",
381 "pub trait ",
382 "pub type ",
383 "pub const ",
384 ];
385
386 content
387 .lines()
388 .filter(|line| {
389 let trimmed = line.trim();
390 sig_starters.iter().any(|s| trimmed.starts_with(s))
391 })
392 .take(max)
393 .map(|line| {
394 let trimmed = line.trim();
395 if trimmed.len() > 120 {
396 format!("{}...", &trimmed[..trimmed.floor_char_boundary(117)])
397 } else {
398 trimmed.to_string()
399 }
400 })
401 .collect()
402}
403
404fn extract_imports(content: &str) -> Vec<String> {
405 content
406 .lines()
407 .filter(|line| {
408 let t = line.trim();
409 t.starts_with("use ") || t.starts_with("import ") || t.starts_with("from ")
410 })
411 .take(8)
412 .map(|line| {
413 let t = line.trim();
414 if let Some(rest) = t.strip_prefix("use ") {
415 rest.trim_end_matches(';').to_string()
416 } else {
417 t.to_string()
418 }
419 })
420 .collect()
421}
422
423fn apply_heat_ranking(candidates: &mut [&RelevanceScore], gp: &GraphProvider, root: &str) {
424 if gp.file_count() == 0 {
425 return;
426 }
427
428 let all_edges = gp.edges();
429 let mut connection_counts: std::collections::HashMap<String, usize> =
430 std::collections::HashMap::new();
431 for edge in &all_edges {
432 *connection_counts.entry(edge.from.clone()).or_default() += 1;
433 *connection_counts.entry(edge.to.clone()).or_default() += 1;
434 }
435
436 let mut max_tokens = 1usize;
437 for path in gp.file_paths() {
438 if let Some(entry) = gp.get_file_entry(&path) {
439 max_tokens = max_tokens.max(entry.token_count);
440 }
441 }
442 let max_tokens = max_tokens as f64;
443 let max_conn = connection_counts.values().max().copied().unwrap_or(1) as f64;
444
445 candidates.sort_by(|a, b| {
446 let heat_a = compute_heat(&a.path, root, gp, &connection_counts, max_tokens, max_conn);
447 let heat_b = compute_heat(&b.path, root, gp, &connection_counts, max_tokens, max_conn);
448 let combined_a = a.score * 0.6 + heat_a * 0.4;
449 let combined_b = b.score * 0.6 + heat_b * 0.4;
450 combined_b
451 .partial_cmp(&combined_a)
452 .unwrap_or(std::cmp::Ordering::Equal)
453 });
454}
455
456fn compute_heat(
457 path: &str,
458 root: &str,
459 gp: &GraphProvider,
460 connections: &std::collections::HashMap<String, usize>,
461 max_tokens: f64,
462 max_conn: f64,
463) -> f64 {
464 let rel = path
465 .strip_prefix(root)
466 .unwrap_or(path)
467 .trim_start_matches('/');
468
469 if let Some(entry) = gp.get_file_entry(rel) {
470 let conn = connections.get(rel).copied().unwrap_or(0);
471 let token_norm = entry.token_count as f64 / max_tokens;
472 let conn_norm = conn as f64 / max_conn;
473 token_norm * 0.4 + conn_norm * 0.6
474 } else {
475 0.0
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn extract_critical_lines_finds_keywords() {
485 let content = "fn main() {\n let token = validate();\n return Err(e);\n}\n";
486 let result = extract_critical_lines(content, &["validate".to_string()], 5);
487 assert!(!result.is_empty());
488 assert!(result.iter().any(|(_, l)| l.contains("validate")));
489 }
490
491 #[test]
492 fn extract_critical_lines_prioritizes_errors() {
493 let content = "fn main() {\n let x = 1;\n return Err(\"bad\");\n let token = validate();\n}\n";
494 let result = extract_critical_lines(content, &["validate".to_string()], 5);
495 assert!(result.len() >= 2);
496 assert!(result[0].1.contains("Err"), "errors should be first");
497 }
498
499 #[test]
500 fn extract_key_signatures_finds_pub() {
501 let content = "use std::io;\nfn private() {}\npub fn public_one() {}\npub struct Foo {}\n";
502 let sigs = extract_key_signatures(content, 10);
503 assert_eq!(sigs.len(), 2);
504 assert!(sigs[0].contains("pub fn public_one"));
505 assert!(sigs[1].contains("pub struct Foo"));
506 }
507
508 #[test]
509 fn extract_imports_works() {
510 let content = "use std::io;\nuse crate::core::cache;\nfn main() {}\n";
511 let imports = extract_imports(content);
512 assert_eq!(imports.len(), 2);
513 assert!(imports[0].contains("std::io"));
514 }
515}