Skip to main content

lean_ctx/tools/
ctx_overview.rs

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
6/// Multi-resolution context overview.
7///
8/// Provides a compact map of the entire project, organized by task relevance.
9/// Files are shown at different detail levels based on their relevance score:
10/// - Level 0 (full): directly task-relevant files → full content (use ctx_read)
11/// - Level 1 (signatures): graph neighbors → key signatures
12/// - Level 2 (reference): distant files → name + line count only
13///
14/// This implements lazy evaluation for context: start with the overview,
15/// then zoom into specific files as needed.
16pub 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        // No task context: show project structure overview
127        output.push(format!(
128            "PROJECT OVERVIEW  {} files  {} edges",
129            index.files.len(),
130            index.edges.len()
131        ));
132        output.push(String::new());
133
134        // Group by directory
135        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        // Show top connected files (hub files)
169        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
262/// Find a byte offset at most `max_tail_bytes` from the end of `s`
263/// that falls on a valid UTF-8 char boundary.
264fn 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"; // 10 bytes
288        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        // "文档/examples/extensions/custom-provider-anthropic" = multi-byte prefix
295        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}