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.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        // Static project-level header first (prefix-cache-friendly)
51        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        // Dynamic task-specific briefing last (prefix-cache-friendly)
130        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        // No task context: show project structure overview
147        output.push(format!(
148            "PROJECT OVERVIEW  {} files  {} edges",
149            index.files.len(),
150            index.edges.len()
151        ));
152        output.push(String::new());
153
154        // Group by directory
155        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
274/// Import/call edge touches per file from SQLite graph when available; otherwise
275/// import-edge degree from the JSON graph index (calls omitted).
276fn 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
404/// Find a byte offset at most `max_tail_bytes` from the end of `s`
405/// that falls on a valid UTF-8 char boundary.
406fn 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"; // 10 bytes
428        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        // "文档/examples/extensions/custom-provider-anthropic" = multi-byte prefix
435        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}