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