1use crate::core::cache::SessionCache;
2use crate::core::graph_provider::{self, GraphProvider};
3use crate::core::task_relevance::{compute_relevance, parse_task_hints};
4use crate::core::tokens::count_tokens;
5use crate::tools::CrpMode;
6
7pub fn handle(
21 _cache: &SessionCache,
22 task: Option<&str>,
23 path: Option<&str>,
24 _crp_mode: CrpMode,
25) -> (String, bool) {
26 let project_root = path.map_or_else(|| ".".to_string(), std::string::ToString::to_string);
27
28 let auto_loaded = crate::core::context_package::auto_load_packages(&project_root);
29
30 let Some(open) = graph_provider::open_or_build(&project_root) else {
31 crate::core::index_orchestrator::ensure_all_background(&project_root);
32 return (partial_overview(&project_root), false);
33 };
34 let gp = &open.provider;
35
36 let (task_files, task_keywords) = if let Some(task_desc) = task {
37 parse_task_hints(task_desc)
38 } else {
39 (vec![], vec![])
40 };
41
42 let has_task = !task_files.is_empty() || !task_keywords.is_empty();
43
44 let mut output = Vec::new();
45
46 if has_task {
47 let mut relevance = compute_relevance(gp, &task_files, &task_keywords);
48 crate::core::git_signals::apply_boost(&mut relevance, &project_root);
49 crate::core::diagnostics_store::apply_boost(&mut relevance);
50 crate::core::editor_signal::apply_boost(&mut relevance);
51
52 output.push(format!(
53 "PROJECT OVERVIEW {} files task-filtered",
54 gp.file_count()
55 ));
56 output.push(String::new());
57
58 let high: Vec<&_> = relevance.iter().filter(|r| r.score >= 0.8).collect();
59 let medium: Vec<&_> = relevance
60 .iter()
61 .filter(|r| r.score >= 0.3 && r.score < 0.8)
62 .collect();
63 let low: Vec<&_> = relevance.iter().filter(|r| r.score < 0.3).collect();
64
65 if !high.is_empty() {
66 use crate::core::context_field::{ContextItemId, ContextKind, ViewCosts};
67 use crate::core::context_handles::HandleRegistry;
68
69 let mut handle_reg = HandleRegistry::new();
70 output.push("▸ DIRECTLY RELEVANT (use ctx_read or ctx_expand @ref):".to_string());
71 for r in &high {
72 let line_count = file_line_count(&r.path);
73 let item_id = ContextItemId::from_file(&r.path);
74 let view_costs = ViewCosts::from_full_tokens(line_count * 5);
75 let handle = handle_reg.register(
76 item_id,
77 ContextKind::File,
78 &r.path,
79 &format!(
80 "{} {}L score={:.1}",
81 short_path(&r.path),
82 line_count,
83 r.score
84 ),
85 &view_costs,
86 r.score,
87 false,
88 );
89 output.push(format!(
90 " @{} {} {}L phi={:.2} mode={}",
91 handle.ref_label,
92 short_path(&r.path),
93 line_count,
94 r.score,
95 r.recommended_mode
96 ));
97 }
98 output.push(String::new());
99 }
100
101 if !medium.is_empty() {
102 let knowledge = crate::core::knowledge::ProjectKnowledge::load(&project_root);
103 output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
104 for r in medium.iter().take(20) {
105 let line_count = file_line_count(&r.path);
106 let doc = extract_module_doc(&r.path)
107 .or_else(|| knowledge_doc_for_file(knowledge.as_ref(), &r.path))
108 .map(|d| format!(" — {d}"))
109 .unwrap_or_default();
110 output.push(format!(
111 " {} {line_count}L mode={}{doc}",
112 short_path(&r.path),
113 r.recommended_mode
114 ));
115 }
116 if medium.len() > 20 {
117 output.push(format!(" ... +{} more", medium.len() - 20));
118 }
119 output.push(String::new());
120 }
121
122 if !low.is_empty() {
123 output.push(format!(
124 "▸ DISTANT ({} files, not loaded unless needed)",
125 low.len()
126 ));
127 for r in low.iter().take(10) {
128 output.push(format!(" {}", short_path(&r.path)));
129 }
130 if low.len() > 10 {
131 output.push(format!(" ... +{} more", low.len() - 10));
132 }
133 }
134
135 if let Some(task_desc) = task {
137 let file_context: Vec<(String, usize)> = relevance
138 .iter()
139 .filter(|r| r.score >= 0.3)
140 .take(8)
141 .filter_map(|r| {
142 std::fs::read_to_string(&r.path)
143 .ok()
144 .map(|c| (r.path.clone(), c.lines().count()))
145 })
146 .collect();
147 let briefing = crate::core::task_briefing::build_briefing(task_desc, &file_context);
148 output.push(String::new());
149 output.push(crate::core::task_briefing::format_briefing(&briefing));
150 }
151 } else {
152 let last_scan = gp.last_scan();
154 let scan_age = chrono::NaiveDateTime::parse_from_str(&last_scan, "%Y-%m-%d %H:%M:%S")
155 .ok()
156 .map(|t| {
157 let elapsed = chrono::Local::now().naive_local().signed_duration_since(t);
158 if elapsed.num_hours() < 1 {
159 format!("{}m ago", elapsed.num_minutes())
160 } else if elapsed.num_hours() < 24 {
161 format!("{}h ago", elapsed.num_hours())
162 } else {
163 format!("{}d ago", elapsed.num_days())
164 }
165 })
166 .unwrap_or_default();
167 let scan_info = if scan_age.is_empty() {
168 String::new()
169 } else {
170 format!(" scanned {scan_age}")
171 };
172 let call_edges =
177 crate::core::call_graph::CallGraph::load(&project_root).map_or(0, |g| g.edges.len());
178 let call_info = if call_edges > 0 {
179 format!(" {call_edges} call edges")
180 } else {
181 String::new()
182 };
183 output.push(format!(
184 "PROJECT OVERVIEW {} files {} edges{call_info}{scan_info}",
185 gp.file_count(),
186 gp.edge_count().unwrap_or(0)
187 ));
188 output.push(String::new());
189
190 let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
191 std::collections::BTreeMap::new();
192
193 for path in gp.file_paths() {
194 let dir = std::path::Path::new(&path)
195 .parent()
196 .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
197 by_dir.entry(dir).or_default().push(short_path(&path));
198 }
199
200 for (dir, files) in &by_dir {
201 let dir_display = if dir.len() > 50 {
202 let start = truncate_start_char_boundary(dir, 47);
203 format!("...{}", &dir[start..])
204 } else {
205 dir.clone()
206 };
207
208 if files.len() <= 5 {
209 output.push(format!("{dir_display}/ {}", files.join(" ")));
210 } else {
211 output.push(format!(
212 "{dir_display}/ {} +{} more",
213 files[..3].join(" "),
214 files.len() - 3
215 ));
216 }
217 }
218 }
219
220 if let Some(task_desc) = task {
221 append_knowledge_task_section(&mut output, &project_root, task_desc);
222 }
223 append_graph_hotspots_section(&mut output, &project_root, gp);
224
225 let cfg = crate::core::config::Config::load();
226 if cfg.enable_wakeup_ctx {
227 let wakeup = build_wakeup_briefing(&project_root, task);
228 if !wakeup.is_empty() {
229 output.push(String::new());
230 output.push(wakeup);
231 }
232 }
233
234 if !auto_loaded.is_empty() {
235 output.push(String::new());
236 output.push(format!(
237 "CONTEXT PACKAGES AUTO-LOADED: {}",
238 auto_loaded.join(", ")
239 ));
240 }
241
242 let fc = gp.file_count();
243 let original = count_tokens(&format!("{fc} files")) * fc;
244 let compressed = count_tokens(&output.join("\n"));
245 output.push(String::new());
246 output.push(crate::core::protocol::format_savings(original, compressed));
247
248 (output.join("\n"), true)
249}
250
251fn append_knowledge_task_section(output: &mut Vec<String>, project_root: &str, task: &str) {
252 let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) else {
253 return;
254 };
255 let hits: Vec<_> = knowledge.recall(task).into_iter().take(5).collect();
256 if hits.is_empty() {
257 return;
258 }
259 let n = hits.len();
260 output.push(String::new());
261 output.push(format!("[knowledge: {n} relevant facts]"));
262 for f in hits {
263 let text = compact_fact_phrase(f);
264 output.push(format!(" \"{text}\" (confidence: {:.1})", f.confidence));
265 }
266}
267
268fn compact_fact_phrase(f: &crate::core::knowledge::KnowledgeFact) -> String {
269 let v = f.value.trim();
270 let k = f.key.trim();
271 let raw = if !v.is_empty() && (k.is_empty() || v.contains(' ') || v.len() >= k.len()) {
272 v.to_string()
273 } else if !k.is_empty() && !v.is_empty() {
274 format!("{k}: {v}")
275 } else {
276 k.to_string()
277 };
278 let neutral = crate::core::sanitize::neutralize_metadata(&raw);
279 const MAX: usize = 100;
280 if neutral.chars().count() > MAX {
281 let trimmed: String = neutral.chars().take(MAX.saturating_sub(1)).collect();
282 format!("{trimmed}…")
283 } else {
284 neutral
285 }
286}
287
288fn append_graph_hotspots_section(output: &mut Vec<String>, project_root: &str, gp: &GraphProvider) {
289 let rows = graph_hotspot_rows(project_root, gp);
290 if rows.is_empty() {
291 return;
292 }
293 let n = rows.len();
294 output.push(String::new());
295 output.push(format!("[graph: {n} architectural hotspots]"));
296 for (path, imp, cal) in rows {
297 let p = short_path(&path);
298 if cal > 0 {
299 output.push(format!(" {p} ({imp} imports, {cal} calls)"));
300 } else {
301 output.push(format!(" {p} ({imp} imports)"));
302 }
303 }
304}
305
306fn graph_hotspot_rows(project_root: &str, gp: &GraphProvider) -> Vec<(String, usize, usize)> {
307 if let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) {
308 let sql = "
309 WITH edge_files AS (
310 SELECT e.kind AS kind, ps.path AS fp
311 FROM edges e
312 JOIN nodes ns ON e.source_id = ns.id
313 JOIN paths ps ON ps.id = ns.file_id
314 WHERE e.kind IN ('imports', 'calls')
315 UNION ALL
316 SELECT e.kind, pt.path
317 FROM edges e
318 JOIN nodes nt ON e.target_id = nt.id
319 JOIN paths pt ON pt.id = nt.file_id
320 WHERE e.kind IN ('imports', 'calls')
321 )
322 SELECT fp,
323 SUM(CASE WHEN kind = 'imports' THEN 1 ELSE 0 END) AS imp,
324 SUM(CASE WHEN kind = 'calls' THEN 1 ELSE 0 END) AS cal
325 FROM edge_files
326 GROUP BY fp
327 ORDER BY (imp + cal) DESC
328 LIMIT 5
329 ";
330 let conn = graph.connection();
331 if let Ok(mut stmt) = conn.prepare(sql) {
332 let mapped = stmt.query_map([], |row| {
333 Ok((
334 row.get::<_, String>(0)?,
335 row.get::<_, i64>(1)? as usize,
336 row.get::<_, i64>(2)? as usize,
337 ))
338 });
339 if let Ok(iter) = mapped {
340 let collected: Vec<_> = iter.filter_map(std::result::Result::ok).collect();
341 if !collected.is_empty() {
342 return collected;
343 }
344 }
345 }
346 }
347 import_hotspots_from_edges(gp, 5)
348}
349
350fn import_hotspots_from_edges(gp: &GraphProvider, limit: usize) -> Vec<(String, usize, usize)> {
351 use std::collections::HashMap;
352
353 let mut imp: HashMap<String, usize> = HashMap::new();
354 for e in gp.edges_by_kind("import") {
355 *imp.entry(e.from.clone()).or_insert(0) += 1;
356 *imp.entry(e.to.clone()).or_insert(0) += 1;
357 }
358 let mut v: Vec<(String, usize, usize)> =
359 imp.into_iter().map(|(p, c)| (p, c, 0_usize)).collect();
360 v.sort_by_key(|x| std::cmp::Reverse(x.1 + x.2));
361 v.truncate(limit);
362 v
363}
364
365pub fn build_wakeup_briefing(project_root: &str, task: Option<&str>) -> String {
366 let mut parts = Vec::new();
367
368 if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
369 let facts_line = knowledge.format_wakeup();
370 if !facts_line.is_empty() {
371 parts.push(facts_line);
372 }
373 }
374
375 if let Some(session) = crate::core::session::SessionState::load_latest() {
376 if let Some(ref task) = session.task {
377 parts.push(format!("LAST_TASK:{}", task.description));
378 }
379 if !session.decisions.is_empty() {
380 let recent: Vec<String> = session
381 .decisions
382 .iter()
383 .rev()
384 .take(3)
385 .map(|d| d.summary.clone())
386 .collect();
387 parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
388 }
389 }
390
391 if let Some(t) = task {
392 for r in crate::core::prospective_memory::reminders_for_task(project_root, t) {
393 parts.push(r);
394 }
395 }
396
397 let registry = crate::core::agents::AgentRegistry::mutate_locked(|registry| {
402 registry.cleanup_stale(24);
403 })
404 .map(|(registry, ())| registry)
405 .unwrap_or_default();
406 let active_agents = registry.list_active(Some(project_root));
407 if !active_agents.is_empty() {
408 let agents: Vec<String> = active_agents
409 .iter()
410 .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
411 .collect();
412 parts.push(format!("AGENTS:{}", agents.join(",")));
413 }
414
415 if parts.is_empty() {
416 return String::new();
417 }
418
419 format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
420}
421
422fn extract_module_doc(path: &str) -> Option<String> {
425 let content = std::fs::read_to_string(path).ok()?;
426 let mut lines = content.lines();
427
428 let first = lines.next()?.trim();
430 let search_start = if first.starts_with("#!") {
431 lines.next()
432 } else {
433 Some(first)
434 };
435
436 let first_meaningful = search_start?;
437
438 if first_meaningful.starts_with("//!") {
440 let doc = first_meaningful.trim_start_matches("//!").trim();
441 if !doc.is_empty() {
442 return Some(truncate_doc(doc));
443 }
444 }
445
446 if first_meaningful.starts_with("\"\"\"") || first_meaningful.starts_with("'''") {
448 let doc = first_meaningful
449 .trim_start_matches("\"\"\"")
450 .trim_start_matches("'''")
451 .trim();
452 let doc = doc
453 .trim_end_matches("\"\"\"")
454 .trim_end_matches("'''")
455 .trim();
456 if !doc.is_empty() {
457 return Some(truncate_doc(doc));
458 }
459 }
460
461 if first_meaningful.starts_with("/**") {
463 let doc = first_meaningful
464 .trim_start_matches("/**")
465 .trim_end_matches("*/")
466 .trim_start_matches('*')
467 .trim();
468 if !doc.is_empty() {
469 return Some(truncate_doc(doc));
470 }
471 }
472
473 if first_meaningful.starts_with("# ") && !first_meaningful.starts_with("# !") {
475 let doc = first_meaningful.trim_start_matches('#').trim();
476 if !doc.is_empty() {
477 return Some(truncate_doc(doc));
478 }
479 }
480
481 None
482}
483
484fn knowledge_doc_for_file(
486 knowledge: Option<&crate::core::knowledge::ProjectKnowledge>,
487 path: &str,
488) -> Option<String> {
489 let knowledge = knowledge?;
490 let filename = std::path::Path::new(path).file_name()?.to_str()?;
491 let hits = knowledge.recall(filename);
492 let fact = hits.first()?;
493 let val = fact.value.trim();
494 if val.is_empty() || val.len() < 5 {
495 return None;
496 }
497 Some(truncate_doc(val))
498}
499
500fn truncate_doc(doc: &str) -> String {
501 if doc.len() > 80 {
502 let mut end = 77;
503 while end > 0 && !doc.is_char_boundary(end) {
504 end -= 1;
505 }
506 format!("{}...", &doc[..end])
507 } else {
508 doc.to_string()
509 }
510}
511
512fn short_path(path: &str) -> String {
513 let parts: Vec<&str> = path.split('/').collect();
514 if parts.len() <= 2 {
515 return path.to_string();
516 }
517 parts[parts.len() - 2..].join("/")
518}
519
520fn truncate_start_char_boundary(s: &str, max_tail_bytes: usize) -> usize {
523 if max_tail_bytes >= s.len() {
524 return 0;
525 }
526 let mut start = s.len() - max_tail_bytes;
527 while start < s.len() && !s.is_char_boundary(start) {
528 start += 1;
529 }
530 start
531}
532
533fn file_line_count(path: &str) -> usize {
534 std::fs::read_to_string(path).map_or(0, |c| c.lines().count())
535}
536
537fn partial_overview(project_root: &str) -> String {
543 let mut out = Vec::new();
544 out.push("PROJECT OVERVIEW (partial — knowledge graph indexing in background)".to_string());
545 out.push(format!("Project: {project_root}"));
546
547 let markers = detected_markers(project_root);
548 if !markers.is_empty() {
549 out.push(format!("Markers: {}", markers.join(", ")));
550 }
551 out.push(String::new());
552
553 let (tree, _) = crate::tools::ctx_tree::handle(project_root, 2, false, true);
555 if !tree.trim().is_empty() {
556 out.push("STRUCTURE (depth 2):".to_string());
557 out.push(tree);
558 out.push(String::new());
559 }
560
561 if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
563 let mut facts: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
564 facts.sort_by_key(|f| std::cmp::Reverse(f.created_at));
565 if !facts.is_empty() {
566 out.push("KNOWN FACTS (from prior sessions):".to_string());
567 for f in facts.iter().take(5) {
568 let val: String = f.value.chars().take(80).collect();
569 out.push(format!(" • [{}] {}: {}", f.category, f.key, val));
570 }
571 out.push(String::new());
572 }
573 }
574
575 out.push(
576 "The full task-relevant graph view (signatures, neighbors, relevance) will be \
577 available shortly — re-run ctx_overview to get it."
578 .to_string(),
579 );
580 out.join("\n")
581}
582
583fn detected_markers(project_root: &str) -> Vec<String> {
584 const MARKERS: &[&str] = &[
585 ".git",
586 "Cargo.toml",
587 "package.json",
588 "go.mod",
589 "pyproject.toml",
590 "pom.xml",
591 "build.gradle",
592 ".lean-ctx.toml",
593 ];
594 let root = std::path::Path::new(project_root);
595 MARKERS
596 .iter()
597 .filter(|m| root.join(m).exists())
598 .map(|m| (*m).to_string())
599 .collect()
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605
606 #[test]
607 fn truncate_start_ascii() {
608 let s = "abcdefghij"; assert_eq!(truncate_start_char_boundary(s, 5), 5);
610 assert_eq!(&s[5..], "fghij");
611 }
612
613 #[test]
614 fn truncate_start_multibyte_chinese() {
615 let s = "文档/examples/extensions/custom-provider-anthropic";
617 let start = truncate_start_char_boundary(s, 47);
618 assert!(s.is_char_boundary(start));
619 let tail = &s[start..];
620 assert!(tail.len() <= 47);
621 }
622
623 #[test]
624 fn truncate_start_all_multibyte() {
625 let s = "这是一个很长的中文目录路径用于测试字符边界处理";
626 let start = truncate_start_char_boundary(s, 20);
627 assert!(s.is_char_boundary(start));
628 }
629
630 #[test]
631 fn truncate_start_larger_than_string() {
632 let s = "short";
633 assert_eq!(truncate_start_char_boundary(s, 100), 0);
634 }
635
636 #[test]
637 fn truncate_start_emoji() {
638 let s = "/home/user/🎉🎉🎉/src/components/deeply/nested";
639 let start = truncate_start_char_boundary(s, 30);
640 assert!(s.is_char_boundary(start));
641 }
642
643 #[test]
647 fn overview_header_surfaces_persisted_call_edges() {
648 use crate::core::call_graph::{CallEdge, CallGraph};
649 use crate::core::graph_index::{FileEntry, ProjectIndex};
650
651 let _lock = crate::core::data_dir::test_env_lock();
652 let tmp = tempfile::tempdir().expect("tempdir");
653 let data = tempfile::tempdir().expect("data dir");
654 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path().to_str().unwrap());
655
656 let root = tmp.path();
657 std::fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
658 std::fs::create_dir_all(root.join("src")).unwrap();
659 std::fs::write(
660 root.join("src/main.rs"),
661 "fn greet() {}\nfn main() { greet(); }\n",
662 )
663 .unwrap();
664 let root_str = root.to_str().unwrap();
665
666 let mut idx = ProjectIndex::new(root_str);
669 idx.files.insert(
670 "src/main.rs".to_string(),
671 FileEntry {
672 path: "src/main.rs".to_string(),
673 hash: "h".to_string(),
674 language: "rs".to_string(),
675 line_count: 2,
676 token_count: 10,
677 exports: vec![],
678 summary: String::new(),
679 },
680 );
681 idx.save().expect("save pre-built index");
682
683 let mut graph = CallGraph::new(root_str);
684 graph.edges.push(CallEdge {
685 caller_file: "src/main.rs".into(),
686 caller_symbol: "main".into(),
687 caller_line: 2,
688 callee_name: "greet".into(),
689 });
690 graph.save().expect("persist call graph");
691
692 let cache = SessionCache::new();
693 let (out, complete) = handle(&cache, None, Some(root_str), CrpMode::Off);
694 assert!(complete, "graph-backed overview must not report as partial");
695 assert!(
696 out.contains("1 call edges"),
697 "overview header must show persisted call edges, got:\n{out}"
698 );
699
700 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
701 }
702}