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 format!(
30 "INDEXING IN PROGRESS\n\n\
31 The knowledge graph for this project is being built in the background.\n\
32 Project: {project_root}\n\n\
33 Because this is a large project, the initial scan may take a moment.\n\
34 Please try this command again in 1-2 minutes."
35 );
36 };
37 let gp = &open.provider;
38
39 let (task_files, task_keywords) = if let Some(task_desc) = task {
40 parse_task_hints(task_desc)
41 } else {
42 (vec![], vec![])
43 };
44
45 let has_task = !task_files.is_empty() || !task_keywords.is_empty();
46
47 let mut output = Vec::new();
48
49 if has_task {
50 let relevance = compute_relevance(gp, &task_files, &task_keywords);
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 output.push(format!(
173 "PROJECT OVERVIEW {} files {} edges{scan_info}",
174 gp.file_count(),
175 gp.edge_count().unwrap_or(0)
176 ));
177 output.push(String::new());
178
179 let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
180 std::collections::BTreeMap::new();
181
182 for path in gp.file_paths() {
183 let dir = std::path::Path::new(&path)
184 .parent()
185 .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
186 by_dir.entry(dir).or_default().push(short_path(&path));
187 }
188
189 for (dir, files) in &by_dir {
190 let dir_display = if dir.len() > 50 {
191 let start = truncate_start_char_boundary(dir, 47);
192 format!("...{}", &dir[start..])
193 } else {
194 dir.clone()
195 };
196
197 if files.len() <= 5 {
198 output.push(format!("{dir_display}/ {}", files.join(" ")));
199 } else {
200 output.push(format!(
201 "{dir_display}/ {} +{} more",
202 files[..3].join(" "),
203 files.len() - 3
204 ));
205 }
206 }
207 }
208
209 if let Some(task_desc) = task {
210 append_knowledge_task_section(&mut output, &project_root, task_desc);
211 }
212 append_graph_hotspots_section(&mut output, &project_root, gp);
213
214 let cfg = crate::core::config::Config::load();
215 if cfg.enable_wakeup_ctx {
216 let wakeup = build_wakeup_briefing(&project_root, task);
217 if !wakeup.is_empty() {
218 output.push(String::new());
219 output.push(wakeup);
220 }
221 }
222
223 if !auto_loaded.is_empty() {
224 output.push(String::new());
225 output.push(format!(
226 "CONTEXT PACKAGES AUTO-LOADED: {}",
227 auto_loaded.join(", ")
228 ));
229 }
230
231 let fc = gp.file_count();
232 let original = count_tokens(&format!("{fc} files")) * fc;
233 let compressed = count_tokens(&output.join("\n"));
234 output.push(String::new());
235 output.push(crate::core::protocol::format_savings(original, compressed));
236
237 output.join("\n")
238}
239
240fn append_knowledge_task_section(output: &mut Vec<String>, project_root: &str, task: &str) {
241 let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) else {
242 return;
243 };
244 let hits: Vec<_> = knowledge.recall(task).into_iter().take(5).collect();
245 if hits.is_empty() {
246 return;
247 }
248 let n = hits.len();
249 output.push(String::new());
250 output.push(format!("[knowledge: {n} relevant facts]"));
251 for f in hits {
252 let text = compact_fact_phrase(f);
253 output.push(format!(" \"{text}\" (confidence: {:.1})", f.confidence));
254 }
255}
256
257fn compact_fact_phrase(f: &crate::core::knowledge::KnowledgeFact) -> String {
258 let v = f.value.trim();
259 let k = f.key.trim();
260 let raw = if !v.is_empty() && (k.is_empty() || v.contains(' ') || v.len() >= k.len()) {
261 v.to_string()
262 } else if !k.is_empty() && !v.is_empty() {
263 format!("{k}: {v}")
264 } else {
265 k.to_string()
266 };
267 let neutral = crate::core::sanitize::neutralize_metadata(&raw);
268 const MAX: usize = 100;
269 if neutral.chars().count() > MAX {
270 let trimmed: String = neutral.chars().take(MAX.saturating_sub(1)).collect();
271 format!("{trimmed}…")
272 } else {
273 neutral
274 }
275}
276
277fn append_graph_hotspots_section(output: &mut Vec<String>, project_root: &str, gp: &GraphProvider) {
278 let rows = graph_hotspot_rows(project_root, gp);
279 if rows.is_empty() {
280 return;
281 }
282 let n = rows.len();
283 output.push(String::new());
284 output.push(format!("[graph: {n} architectural hotspots]"));
285 for (path, imp, cal) in rows {
286 let p = short_path(&path);
287 if cal > 0 {
288 output.push(format!(" {p} ({imp} imports, {cal} calls)"));
289 } else {
290 output.push(format!(" {p} ({imp} imports)"));
291 }
292 }
293}
294
295fn graph_hotspot_rows(project_root: &str, gp: &GraphProvider) -> Vec<(String, usize, usize)> {
296 if let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) {
297 let sql = "
298 WITH edge_files AS (
299 SELECT e.kind AS kind, ns.file_path AS fp
300 FROM edges e
301 JOIN nodes ns ON e.source_id = ns.id
302 WHERE e.kind IN ('imports', 'calls')
303 UNION ALL
304 SELECT e.kind, nt.file_path
305 FROM edges e
306 JOIN nodes nt ON e.target_id = nt.id
307 WHERE e.kind IN ('imports', 'calls')
308 )
309 SELECT fp,
310 SUM(CASE WHEN kind = 'imports' THEN 1 ELSE 0 END) AS imp,
311 SUM(CASE WHEN kind = 'calls' THEN 1 ELSE 0 END) AS cal
312 FROM edge_files
313 GROUP BY fp
314 ORDER BY (imp + cal) DESC
315 LIMIT 5
316 ";
317 let conn = graph.connection();
318 if let Ok(mut stmt) = conn.prepare(sql) {
319 let mapped = stmt.query_map([], |row| {
320 Ok((
321 row.get::<_, String>(0)?,
322 row.get::<_, i64>(1)? as usize,
323 row.get::<_, i64>(2)? as usize,
324 ))
325 });
326 if let Ok(iter) = mapped {
327 let collected: Vec<_> = iter.filter_map(std::result::Result::ok).collect();
328 if !collected.is_empty() {
329 return collected;
330 }
331 }
332 }
333 }
334 import_hotspots_from_edges(gp, 5)
335}
336
337fn import_hotspots_from_edges(gp: &GraphProvider, limit: usize) -> Vec<(String, usize, usize)> {
338 use std::collections::HashMap;
339
340 let mut imp: HashMap<String, usize> = HashMap::new();
341 for e in gp.edges_by_kind("import") {
342 *imp.entry(e.from.clone()).or_insert(0) += 1;
343 *imp.entry(e.to.clone()).or_insert(0) += 1;
344 }
345 let mut v: Vec<(String, usize, usize)> =
346 imp.into_iter().map(|(p, c)| (p, c, 0_usize)).collect();
347 v.sort_by_key(|x| std::cmp::Reverse(x.1 + x.2));
348 v.truncate(limit);
349 v
350}
351
352fn build_wakeup_briefing(project_root: &str, task: Option<&str>) -> String {
353 let mut parts = Vec::new();
354
355 if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
356 let facts_line = knowledge.format_wakeup();
357 if !facts_line.is_empty() {
358 parts.push(facts_line);
359 }
360 }
361
362 if let Some(session) = crate::core::session::SessionState::load_latest() {
363 if let Some(ref task) = session.task {
364 parts.push(format!("LAST_TASK:{}", task.description));
365 }
366 if !session.decisions.is_empty() {
367 let recent: Vec<String> = session
368 .decisions
369 .iter()
370 .rev()
371 .take(3)
372 .map(|d| d.summary.clone())
373 .collect();
374 parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
375 }
376 }
377
378 if let Some(t) = task {
379 for r in crate::core::prospective_memory::reminders_for_task(project_root, t) {
380 parts.push(r);
381 }
382 }
383
384 let registry = crate::core::agents::AgentRegistry::load_or_create();
385 let active_agents: Vec<&crate::core::agents::AgentEntry> = registry
386 .agents
387 .iter()
388 .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
389 .collect();
390 if !active_agents.is_empty() {
391 let agents: Vec<String> = active_agents
392 .iter()
393 .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
394 .collect();
395 parts.push(format!("AGENTS:{}", agents.join(",")));
396 }
397
398 if parts.is_empty() {
399 return String::new();
400 }
401
402 format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
403}
404
405fn extract_module_doc(path: &str) -> Option<String> {
408 let content = std::fs::read_to_string(path).ok()?;
409 let mut lines = content.lines();
410
411 let first = lines.next()?.trim();
413 let search_start = if first.starts_with("#!") {
414 lines.next()
415 } else {
416 Some(first)
417 };
418
419 let first_meaningful = search_start?;
420
421 if first_meaningful.starts_with("//!") {
423 let doc = first_meaningful.trim_start_matches("//!").trim();
424 if !doc.is_empty() {
425 return Some(truncate_doc(doc));
426 }
427 }
428
429 if first_meaningful.starts_with("\"\"\"") || first_meaningful.starts_with("'''") {
431 let doc = first_meaningful
432 .trim_start_matches("\"\"\"")
433 .trim_start_matches("'''")
434 .trim();
435 let doc = doc
436 .trim_end_matches("\"\"\"")
437 .trim_end_matches("'''")
438 .trim();
439 if !doc.is_empty() {
440 return Some(truncate_doc(doc));
441 }
442 }
443
444 if first_meaningful.starts_with("/**") {
446 let doc = first_meaningful
447 .trim_start_matches("/**")
448 .trim_end_matches("*/")
449 .trim_start_matches('*')
450 .trim();
451 if !doc.is_empty() {
452 return Some(truncate_doc(doc));
453 }
454 }
455
456 if first_meaningful.starts_with("# ") && !first_meaningful.starts_with("# !") {
458 let doc = first_meaningful.trim_start_matches('#').trim();
459 if !doc.is_empty() {
460 return Some(truncate_doc(doc));
461 }
462 }
463
464 None
465}
466
467fn knowledge_doc_for_file(
469 knowledge: Option<&crate::core::knowledge::ProjectKnowledge>,
470 path: &str,
471) -> Option<String> {
472 let knowledge = knowledge?;
473 let filename = std::path::Path::new(path).file_name()?.to_str()?;
474 let hits = knowledge.recall(filename);
475 let fact = hits.first()?;
476 let val = fact.value.trim();
477 if val.is_empty() || val.len() < 5 {
478 return None;
479 }
480 Some(truncate_doc(val))
481}
482
483fn truncate_doc(doc: &str) -> String {
484 if doc.len() > 80 {
485 let mut end = 77;
486 while end > 0 && !doc.is_char_boundary(end) {
487 end -= 1;
488 }
489 format!("{}...", &doc[..end])
490 } else {
491 doc.to_string()
492 }
493}
494
495fn short_path(path: &str) -> String {
496 let parts: Vec<&str> = path.split('/').collect();
497 if parts.len() <= 2 {
498 return path.to_string();
499 }
500 parts[parts.len() - 2..].join("/")
501}
502
503fn truncate_start_char_boundary(s: &str, max_tail_bytes: usize) -> usize {
506 if max_tail_bytes >= s.len() {
507 return 0;
508 }
509 let mut start = s.len() - max_tail_bytes;
510 while start < s.len() && !s.is_char_boundary(start) {
511 start += 1;
512 }
513 start
514}
515
516fn file_line_count(path: &str) -> usize {
517 std::fs::read_to_string(path).map_or(0, |c| c.lines().count())
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn truncate_start_ascii() {
526 let s = "abcdefghij"; assert_eq!(truncate_start_char_boundary(s, 5), 5);
528 assert_eq!(&s[5..], "fghij");
529 }
530
531 #[test]
532 fn truncate_start_multibyte_chinese() {
533 let s = "文档/examples/extensions/custom-provider-anthropic";
535 let start = truncate_start_char_boundary(s, 47);
536 assert!(s.is_char_boundary(start));
537 let tail = &s[start..];
538 assert!(tail.len() <= 47);
539 }
540
541 #[test]
542 fn truncate_start_all_multibyte() {
543 let s = "这是一个很长的中文目录路径用于测试字符边界处理";
544 let start = truncate_start_char_boundary(s, 20);
545 assert!(s.is_char_boundary(start));
546 }
547
548 #[test]
549 fn truncate_start_larger_than_string() {
550 let s = "short";
551 assert_eq!(truncate_start_char_boundary(s, 100), 0);
552 }
553
554 #[test]
555 fn truncate_start_emoji() {
556 let s = "/home/user/🎉🎉🎉/src/components/deeply/nested";
557 let start = truncate_start_char_boundary(s, 30);
558 assert!(s.is_char_boundary(start));
559 }
560}