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