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