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 = crate::core::graph_index::load_or_build(&project_root);
27
28    let (task_files, task_keywords) = if let Some(task_desc) = task {
29        parse_task_hints(task_desc)
30    } else {
31        (vec![], vec![])
32    };
33
34    let has_task = !task_files.is_empty() || !task_keywords.is_empty();
35
36    let mut output = Vec::new();
37
38    if has_task {
39        let relevance = compute_relevance(&index, &task_files, &task_keywords);
40
41        if let Some(task_desc) = task {
42            let file_context: Vec<(String, usize)> = relevance
43                .iter()
44                .filter(|r| r.score >= 0.3)
45                .take(8)
46                .filter_map(|r| {
47                    std::fs::read_to_string(&r.path)
48                        .ok()
49                        .map(|c| (r.path.clone(), c.lines().count()))
50                })
51                .collect();
52            let briefing = crate::core::task_briefing::build_briefing(task_desc, &file_context);
53            output.push(crate::core::task_briefing::format_briefing(&briefing));
54        }
55
56        let high: Vec<&_> = relevance.iter().filter(|r| r.score >= 0.8).collect();
57        let medium: Vec<&_> = relevance
58            .iter()
59            .filter(|r| r.score >= 0.3 && r.score < 0.8)
60            .collect();
61        let low: Vec<&_> = relevance.iter().filter(|r| r.score < 0.3).collect();
62
63        output.push(format!(
64            "PROJECT OVERVIEW  {} files  task-filtered",
65            index.files.len()
66        ));
67        output.push(String::new());
68
69        if !high.is_empty() {
70            output.push("▸ DIRECTLY RELEVANT (use ctx_read full):".to_string());
71            for r in &high {
72                let line_count = file_line_count(&r.path);
73                let ref_id = cache.get_file_ref_readonly(&r.path);
74                let ref_str = ref_id.map_or(String::new(), |r| format!("{r}="));
75                output.push(format!(
76                    "  {ref_str}{} {line_count}L  score={:.1}",
77                    short_path(&r.path),
78                    r.score
79                ));
80            }
81            output.push(String::new());
82        }
83
84        if !medium.is_empty() {
85            output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
86            for r in medium.iter().take(20) {
87                let line_count = file_line_count(&r.path);
88                output.push(format!(
89                    "  {} {line_count}L  mode={}",
90                    short_path(&r.path),
91                    r.recommended_mode
92                ));
93            }
94            if medium.len() > 20 {
95                output.push(format!("  ... +{} more", medium.len() - 20));
96            }
97            output.push(String::new());
98        }
99
100        if !low.is_empty() {
101            output.push(format!(
102                "▸ DISTANT ({} files, not loaded unless needed)",
103                low.len()
104            ));
105            for r in low.iter().take(10) {
106                output.push(format!("  {}", short_path(&r.path)));
107            }
108            if low.len() > 10 {
109                output.push(format!("  ... +{} more", low.len() - 10));
110            }
111        }
112    } else {
113        // No task context: show project structure overview
114        output.push(format!(
115            "PROJECT OVERVIEW  {} files  {} edges",
116            index.files.len(),
117            index.edges.len()
118        ));
119        output.push(String::new());
120
121        // Group by directory
122        let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
123            std::collections::BTreeMap::new();
124
125        for file_entry in index.files.values() {
126            let dir = std::path::Path::new(&file_entry.path)
127                .parent()
128                .map(|p| p.to_string_lossy().to_string())
129                .unwrap_or_else(|| ".".to_string());
130            by_dir
131                .entry(dir)
132                .or_default()
133                .push(short_path(&file_entry.path));
134        }
135
136        for (dir, files) in &by_dir {
137            let dir_display = if dir.len() > 50 {
138                format!("...{}", &dir[dir.len() - 47..])
139            } else {
140                dir.clone()
141            };
142
143            if files.len() <= 5 {
144                output.push(format!("{dir_display}/  {}", files.join(" ")));
145            } else {
146                output.push(format!(
147                    "{dir_display}/  {} +{} more",
148                    files[..3].join(" "),
149                    files.len() - 3
150                ));
151            }
152        }
153
154        // Show top connected files (hub files)
155        output.push(String::new());
156        let mut connection_counts: std::collections::HashMap<&str, usize> =
157            std::collections::HashMap::new();
158        for edge in &index.edges {
159            *connection_counts.entry(&edge.from).or_insert(0) += 1;
160            *connection_counts.entry(&edge.to).or_insert(0) += 1;
161        }
162        let mut hubs: Vec<(&&str, &usize)> = connection_counts.iter().collect();
163        hubs.sort_by(|a, b| b.1.cmp(a.1));
164
165        if !hubs.is_empty() {
166            output.push("HUB FILES (most connected):".to_string());
167            for (path, count) in hubs.iter().take(8) {
168                output.push(format!("  {} ({count} edges)", short_path(path)));
169            }
170        }
171    }
172
173    let wakeup = build_wakeup_briefing(&project_root);
174    if !wakeup.is_empty() {
175        output.push(String::new());
176        output.push(wakeup);
177    }
178
179    let original = count_tokens(&format!("{} files", index.files.len())) * index.files.len();
180    let compressed = count_tokens(&output.join("\n"));
181    output.push(String::new());
182    output.push(crate::core::protocol::format_savings(original, compressed));
183
184    output.join("\n")
185}
186
187fn build_wakeup_briefing(project_root: &str) -> String {
188    let mut parts = Vec::new();
189
190    if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
191        let facts_line = knowledge.format_wakeup();
192        if !facts_line.is_empty() {
193            parts.push(facts_line);
194        }
195    }
196
197    if let Some(session) = crate::core::session::SessionState::load_latest() {
198        if let Some(ref task) = session.task {
199            parts.push(format!("LAST_TASK:{}", task.description));
200        }
201        if !session.decisions.is_empty() {
202            let recent: Vec<String> = session
203                .decisions
204                .iter()
205                .rev()
206                .take(3)
207                .map(|d| d.summary.clone())
208                .collect();
209            parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
210        }
211    }
212
213    let registry = crate::core::agents::AgentRegistry::load_or_create();
214    let active_agents: Vec<&crate::core::agents::AgentEntry> = registry
215        .agents
216        .iter()
217        .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
218        .collect();
219    if !active_agents.is_empty() {
220        let agents: Vec<String> = active_agents
221            .iter()
222            .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
223            .collect();
224        parts.push(format!("AGENTS:{}", agents.join(",")));
225    }
226
227    if parts.is_empty() {
228        return String::new();
229    }
230
231    format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
232}
233
234fn short_path(path: &str) -> String {
235    let parts: Vec<&str> = path.split('/').collect();
236    if parts.len() <= 2 {
237        return path.to_string();
238    }
239    parts[parts.len() - 2..].join("/")
240}
241
242fn file_line_count(path: &str) -> usize {
243    std::fs::read_to_string(path)
244        .map(|c| c.lines().count())
245        .unwrap_or(0)
246}