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 output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
102 for r in medium.iter().take(20) {
103 let line_count = file_line_count(&r.path);
104 output.push(format!(
105 " {} {line_count}L mode={}",
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 output.push(format!(
148 "PROJECT OVERVIEW {} files {} edges",
149 index.files.len(),
150 index.edges.len()
151 ));
152 output.push(String::new());
153
154 let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
156 std::collections::BTreeMap::new();
157
158 for file_entry in index.files.values() {
159 let dir = std::path::Path::new(&file_entry.path)
160 .parent()
161 .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
162 by_dir
163 .entry(dir)
164 .or_default()
165 .push(short_path(&file_entry.path));
166 }
167
168 for (dir, files) in &by_dir {
169 let dir_display = if dir.len() > 50 {
170 let start = truncate_start_char_boundary(dir, 47);
171 format!("...{}", &dir[start..])
172 } else {
173 dir.clone()
174 };
175
176 if files.len() <= 5 {
177 output.push(format!("{dir_display}/ {}", files.join(" ")));
178 } else {
179 output.push(format!(
180 "{dir_display}/ {} +{} more",
181 files[..3].join(" "),
182 files.len() - 3
183 ));
184 }
185 }
186 }
187
188 if let Some(task_desc) = task {
189 append_knowledge_task_section(&mut output, &index.project_root, task_desc);
190 }
191 append_graph_hotspots_section(&mut output, &index.project_root, &index);
192
193 let wakeup = build_wakeup_briefing(&project_root, task);
194 if !wakeup.is_empty() {
195 output.push(String::new());
196 output.push(wakeup);
197 }
198
199 if !auto_loaded.is_empty() {
200 output.push(String::new());
201 output.push(format!(
202 "CONTEXT PACKAGES AUTO-LOADED: {}",
203 auto_loaded.join(", ")
204 ));
205 }
206
207 let original = count_tokens(&format!("{} files", index.files.len())) * index.files.len();
208 let compressed = count_tokens(&output.join("\n"));
209 output.push(String::new());
210 output.push(crate::core::protocol::format_savings(original, compressed));
211
212 output.join("\n")
213}
214
215fn append_knowledge_task_section(output: &mut Vec<String>, project_root: &str, task: &str) {
216 let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) else {
217 return;
218 };
219 let hits: Vec<_> = knowledge.recall(task).into_iter().take(5).collect();
220 if hits.is_empty() {
221 return;
222 }
223 let n = hits.len();
224 output.push(String::new());
225 output.push(format!("[knowledge: {n} relevant facts]"));
226 for f in hits {
227 let text = compact_fact_phrase(f);
228 output.push(format!(" \"{text}\" (confidence: {:.1})", f.confidence));
229 }
230}
231
232fn compact_fact_phrase(f: &crate::core::knowledge::KnowledgeFact) -> String {
233 let v = f.value.trim();
234 let k = f.key.trim();
235 let raw = if !v.is_empty() && (k.is_empty() || v.contains(' ') || v.len() >= k.len()) {
236 v.to_string()
237 } else if !k.is_empty() && !v.is_empty() {
238 format!("{k}: {v}")
239 } else {
240 k.to_string()
241 };
242 let neutral = crate::core::sanitize::neutralize_metadata(&raw);
243 const MAX: usize = 100;
244 if neutral.chars().count() > MAX {
245 let trimmed: String = neutral.chars().take(MAX.saturating_sub(1)).collect();
246 format!("{trimmed}…")
247 } else {
248 neutral
249 }
250}
251
252fn append_graph_hotspots_section(
253 output: &mut Vec<String>,
254 project_root: &str,
255 index: &crate::core::graph_index::ProjectIndex,
256) {
257 let rows = graph_hotspot_rows(project_root, index);
258 if rows.is_empty() {
259 return;
260 }
261 let n = rows.len();
262 output.push(String::new());
263 output.push(format!("[graph: {n} architectural hotspots]"));
264 for (path, imp, cal) in rows {
265 let p = short_path(&path);
266 if cal > 0 {
267 output.push(format!(" {p} ({imp} imports, {cal} calls)"));
268 } else {
269 output.push(format!(" {p} ({imp} imports)"));
270 }
271 }
272}
273
274fn graph_hotspot_rows(
277 project_root: &str,
278 index: &crate::core::graph_index::ProjectIndex,
279) -> Vec<(String, usize, usize)> {
280 let root = std::path::Path::new(project_root);
281 if let Ok(graph) = crate::core::property_graph::CodeGraph::open(root) {
282 let sql = "
283 WITH edge_files AS (
284 SELECT e.kind AS kind, ns.file_path AS fp
285 FROM edges e
286 JOIN nodes ns ON e.source_id = ns.id
287 WHERE e.kind IN ('imports', 'calls')
288 UNION ALL
289 SELECT e.kind, nt.file_path
290 FROM edges e
291 JOIN nodes nt ON e.target_id = nt.id
292 WHERE e.kind IN ('imports', 'calls')
293 )
294 SELECT fp,
295 SUM(CASE WHEN kind = 'imports' THEN 1 ELSE 0 END) AS imp,
296 SUM(CASE WHEN kind = 'calls' THEN 1 ELSE 0 END) AS cal
297 FROM edge_files
298 GROUP BY fp
299 ORDER BY (imp + cal) DESC
300 LIMIT 5
301 ";
302 let conn = graph.connection();
303 if let Ok(mut stmt) = conn.prepare(sql) {
304 let mapped = stmt.query_map([], |row| {
305 Ok((
306 row.get::<_, String>(0)?,
307 row.get::<_, i64>(1)? as usize,
308 row.get::<_, i64>(2)? as usize,
309 ))
310 });
311 if let Ok(iter) = mapped {
312 let collected: Vec<_> = iter.filter_map(std::result::Result::ok).collect();
313 if !collected.is_empty() {
314 return collected;
315 }
316 }
317 }
318 }
319 index_import_hotspots(index, 5)
320}
321
322fn index_import_hotspots(
323 index: &crate::core::graph_index::ProjectIndex,
324 limit: usize,
325) -> Vec<(String, usize, usize)> {
326 use std::collections::HashMap;
327
328 let mut imp: HashMap<String, usize> = HashMap::new();
329 for e in &index.edges {
330 if e.kind != "import" {
331 continue;
332 }
333 *imp.entry(e.from.clone()).or_insert(0) += 1;
334 *imp.entry(e.to.clone()).or_insert(0) += 1;
335 }
336 let mut v: Vec<(String, usize, usize)> =
337 imp.into_iter().map(|(p, c)| (p, c, 0_usize)).collect();
338 v.sort_by_key(|x| std::cmp::Reverse(x.1 + x.2));
339 v.truncate(limit);
340 v
341}
342
343fn build_wakeup_briefing(project_root: &str, task: Option<&str>) -> String {
344 let mut parts = Vec::new();
345
346 if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
347 let facts_line = knowledge.format_wakeup();
348 if !facts_line.is_empty() {
349 parts.push(facts_line);
350 }
351 }
352
353 if let Some(session) = crate::core::session::SessionState::load_latest() {
354 if let Some(ref task) = session.task {
355 parts.push(format!("LAST_TASK:{}", task.description));
356 }
357 if !session.decisions.is_empty() {
358 let recent: Vec<String> = session
359 .decisions
360 .iter()
361 .rev()
362 .take(3)
363 .map(|d| d.summary.clone())
364 .collect();
365 parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
366 }
367 }
368
369 if let Some(t) = task {
370 for r in crate::core::prospective_memory::reminders_for_task(project_root, t) {
371 parts.push(r);
372 }
373 }
374
375 let registry = crate::core::agents::AgentRegistry::load_or_create();
376 let active_agents: Vec<&crate::core::agents::AgentEntry> = registry
377 .agents
378 .iter()
379 .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
380 .collect();
381 if !active_agents.is_empty() {
382 let agents: Vec<String> = active_agents
383 .iter()
384 .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
385 .collect();
386 parts.push(format!("AGENTS:{}", agents.join(",")));
387 }
388
389 if parts.is_empty() {
390 return String::new();
391 }
392
393 format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
394}
395
396fn short_path(path: &str) -> String {
397 let parts: Vec<&str> = path.split('/').collect();
398 if parts.len() <= 2 {
399 return path.to_string();
400 }
401 parts[parts.len() - 2..].join("/")
402}
403
404fn truncate_start_char_boundary(s: &str, max_tail_bytes: usize) -> usize {
407 if max_tail_bytes >= s.len() {
408 return 0;
409 }
410 let mut start = s.len() - max_tail_bytes;
411 while start < s.len() && !s.is_char_boundary(start) {
412 start += 1;
413 }
414 start
415}
416
417fn file_line_count(path: &str) -> usize {
418 std::fs::read_to_string(path).map_or(0, |c| c.lines().count())
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn truncate_start_ascii() {
427 let s = "abcdefghij"; assert_eq!(truncate_start_char_boundary(s, 5), 5);
429 assert_eq!(&s[5..], "fghij");
430 }
431
432 #[test]
433 fn truncate_start_multibyte_chinese() {
434 let s = "文档/examples/extensions/custom-provider-anthropic";
436 let start = truncate_start_char_boundary(s, 47);
437 assert!(s.is_char_boundary(start));
438 let tail = &s[start..];
439 assert!(tail.len() <= 47);
440 }
441
442 #[test]
443 fn truncate_start_all_multibyte() {
444 let s = "这是一个很长的中文目录路径用于测试字符边界处理";
445 let start = truncate_start_char_boundary(s, 20);
446 assert!(s.is_char_boundary(start));
447 }
448
449 #[test]
450 fn truncate_start_larger_than_string() {
451 let s = "short";
452 assert_eq!(truncate_start_char_boundary(s, 100), 0);
453 }
454
455 #[test]
456 fn truncate_start_emoji() {
457 let s = "/home/user/🎉🎉🎉/src/components/deeply/nested";
458 let start = truncate_start_char_boundary(s, 30);
459 assert!(s.is_char_boundary(start));
460 }
461}