lean_ctx/tools/
ctx_overview.rs1use 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
23 .map(|p| p.to_string())
24 .unwrap_or_else(|| ".".to_string());
25
26 let index =
27 if let Some(idx) = crate::core::index_orchestrator::try_load_graph_index(&project_root) {
28 idx
29 } else {
30 crate::core::index_orchestrator::ensure_all_background(&project_root);
31 return format!(
32 "INDEXING IN PROGRESS\n\n\
33 The knowledge graph for this project is being built in the background.\n\
34 Project: {}\n\n\
35 Because this is a large project, the initial scan may take a moment.\n\
36 Please try this command again in 1-2 minutes.",
37 project_root
38 );
39 };
40
41 let (task_files, task_keywords) = if let Some(task_desc) = task {
42 parse_task_hints(task_desc)
43 } else {
44 (vec![], vec![])
45 };
46
47 let has_task = !task_files.is_empty() || !task_keywords.is_empty();
48
49 let mut output = Vec::new();
50
51 if has_task {
52 let relevance = compute_relevance(&index, &task_files, &task_keywords);
53
54 if let Some(task_desc) = task {
55 let file_context: Vec<(String, usize)> = relevance
56 .iter()
57 .filter(|r| r.score >= 0.3)
58 .take(8)
59 .filter_map(|r| {
60 std::fs::read_to_string(&r.path)
61 .ok()
62 .map(|c| (r.path.clone(), c.lines().count()))
63 })
64 .collect();
65 let briefing = crate::core::task_briefing::build_briefing(task_desc, &file_context);
66 output.push(crate::core::task_briefing::format_briefing(&briefing));
67 }
68
69 let high: Vec<&_> = relevance.iter().filter(|r| r.score >= 0.8).collect();
70 let medium: Vec<&_> = relevance
71 .iter()
72 .filter(|r| r.score >= 0.3 && r.score < 0.8)
73 .collect();
74 let low: Vec<&_> = relevance.iter().filter(|r| r.score < 0.3).collect();
75
76 output.push(format!(
77 "PROJECT OVERVIEW {} files task-filtered",
78 index.files.len()
79 ));
80 output.push(String::new());
81
82 if !high.is_empty() {
83 output.push("▸ DIRECTLY RELEVANT (use ctx_read full):".to_string());
84 for r in &high {
85 let line_count = file_line_count(&r.path);
86 let ref_id = cache.get_file_ref_readonly(&r.path);
87 let ref_str = ref_id.map_or(String::new(), |r| format!("{r}="));
88 output.push(format!(
89 " {ref_str}{} {line_count}L score={:.1}",
90 short_path(&r.path),
91 r.score
92 ));
93 }
94 output.push(String::new());
95 }
96
97 if !medium.is_empty() {
98 output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
99 for r in medium.iter().take(20) {
100 let line_count = file_line_count(&r.path);
101 output.push(format!(
102 " {} {line_count}L mode={}",
103 short_path(&r.path),
104 r.recommended_mode
105 ));
106 }
107 if medium.len() > 20 {
108 output.push(format!(" ... +{} more", medium.len() - 20));
109 }
110 output.push(String::new());
111 }
112
113 if !low.is_empty() {
114 output.push(format!(
115 "▸ DISTANT ({} files, not loaded unless needed)",
116 low.len()
117 ));
118 for r in low.iter().take(10) {
119 output.push(format!(" {}", short_path(&r.path)));
120 }
121 if low.len() > 10 {
122 output.push(format!(" ... +{} more", low.len() - 10));
123 }
124 }
125 } else {
126 output.push(format!(
128 "PROJECT OVERVIEW {} files {} edges",
129 index.files.len(),
130 index.edges.len()
131 ));
132 output.push(String::new());
133
134 let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
136 std::collections::BTreeMap::new();
137
138 for file_entry in index.files.values() {
139 let dir = std::path::Path::new(&file_entry.path)
140 .parent()
141 .map(|p| p.to_string_lossy().to_string())
142 .unwrap_or_else(|| ".".to_string());
143 by_dir
144 .entry(dir)
145 .or_default()
146 .push(short_path(&file_entry.path));
147 }
148
149 for (dir, files) in &by_dir {
150 let dir_display = if dir.len() > 50 {
151 let start = truncate_start_char_boundary(dir, 47);
152 format!("...{}", &dir[start..])
153 } else {
154 dir.clone()
155 };
156
157 if files.len() <= 5 {
158 output.push(format!("{dir_display}/ {}", files.join(" ")));
159 } else {
160 output.push(format!(
161 "{dir_display}/ {} +{} more",
162 files[..3].join(" "),
163 files.len() - 3
164 ));
165 }
166 }
167
168 output.push(String::new());
170 let mut connection_counts: std::collections::HashMap<&str, usize> =
171 std::collections::HashMap::new();
172 for edge in &index.edges {
173 *connection_counts.entry(&edge.from).or_insert(0) += 1;
174 *connection_counts.entry(&edge.to).or_insert(0) += 1;
175 }
176 let mut hubs: Vec<(&&str, &usize)> = connection_counts.iter().collect();
177 hubs.sort_by(|a, b| b.1.cmp(a.1));
178
179 if !hubs.is_empty() {
180 output.push("HUB FILES (most connected):".to_string());
181 for (path, count) in hubs.iter().take(8) {
182 output.push(format!(" {} ({count} edges)", short_path(path)));
183 }
184 }
185 }
186
187 let wakeup = build_wakeup_briefing(&project_root, task);
188 if !wakeup.is_empty() {
189 output.push(String::new());
190 output.push(wakeup);
191 }
192
193 let original = count_tokens(&format!("{} files", index.files.len())) * index.files.len();
194 let compressed = count_tokens(&output.join("\n"));
195 output.push(String::new());
196 output.push(crate::core::protocol::format_savings(original, compressed));
197
198 output.join("\n")
199}
200
201fn build_wakeup_briefing(project_root: &str, task: Option<&str>) -> String {
202 let mut parts = Vec::new();
203
204 if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
205 let facts_line = knowledge.format_wakeup();
206 if !facts_line.is_empty() {
207 parts.push(facts_line);
208 }
209 }
210
211 if let Some(session) = crate::core::session::SessionState::load_latest() {
212 if let Some(ref task) = session.task {
213 parts.push(format!("LAST_TASK:{}", task.description));
214 }
215 if !session.decisions.is_empty() {
216 let recent: Vec<String> = session
217 .decisions
218 .iter()
219 .rev()
220 .take(3)
221 .map(|d| d.summary.clone())
222 .collect();
223 parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
224 }
225 }
226
227 if let Some(t) = task {
228 for r in crate::core::prospective_memory::reminders_for_task(project_root, t) {
229 parts.push(r);
230 }
231 }
232
233 let registry = crate::core::agents::AgentRegistry::load_or_create();
234 let active_agents: Vec<&crate::core::agents::AgentEntry> = registry
235 .agents
236 .iter()
237 .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
238 .collect();
239 if !active_agents.is_empty() {
240 let agents: Vec<String> = active_agents
241 .iter()
242 .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
243 .collect();
244 parts.push(format!("AGENTS:{}", agents.join(",")));
245 }
246
247 if parts.is_empty() {
248 return String::new();
249 }
250
251 format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
252}
253
254fn short_path(path: &str) -> String {
255 let parts: Vec<&str> = path.split('/').collect();
256 if parts.len() <= 2 {
257 return path.to_string();
258 }
259 parts[parts.len() - 2..].join("/")
260}
261
262fn truncate_start_char_boundary(s: &str, max_tail_bytes: usize) -> usize {
265 if max_tail_bytes >= s.len() {
266 return 0;
267 }
268 let mut start = s.len() - max_tail_bytes;
269 while start < s.len() && !s.is_char_boundary(start) {
270 start += 1;
271 }
272 start
273}
274
275fn file_line_count(path: &str) -> usize {
276 std::fs::read_to_string(path)
277 .map(|c| c.lines().count())
278 .unwrap_or(0)
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn truncate_start_ascii() {
287 let s = "abcdefghij"; assert_eq!(truncate_start_char_boundary(s, 5), 5);
289 assert_eq!(&s[5..], "fghij");
290 }
291
292 #[test]
293 fn truncate_start_multibyte_chinese() {
294 let s = "文档/examples/extensions/custom-provider-anthropic";
296 let start = truncate_start_char_boundary(s, 47);
297 assert!(s.is_char_boundary(start));
298 let tail = &s[start..];
299 assert!(tail.len() <= 47);
300 }
301
302 #[test]
303 fn truncate_start_all_multibyte() {
304 let s = "这是一个很长的中文目录路径用于测试字符边界处理";
305 let start = truncate_start_char_boundary(s, 20);
306 assert!(s.is_char_boundary(start));
307 }
308
309 #[test]
310 fn truncate_start_larger_than_string() {
311 let s = "short";
312 assert_eq!(truncate_start_char_boundary(s, 100), 0);
313 }
314
315 #[test]
316 fn truncate_start_emoji() {
317 let s = "/home/user/🎉🎉🎉/src/components/deeply/nested";
318 let start = truncate_start_char_boundary(s, 30);
319 assert!(s.is_char_boundary(start));
320 }
321}