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(
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 crate::core::git_signals::apply_boost(&mut relevance, &project_root);
51 crate::core::diagnostics_store::apply_boost(&mut relevance);
53 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 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 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; 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 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 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 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
303fn 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 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 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
346fn 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 #[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}