Skip to main content

lean_ctx/tools/
ctx_overview.rs

1use crate::core::cache::SessionCache;
2use crate::core::graph_provider::{self, GraphProvider};
3use crate::core::task_relevance::{compute_relevance, parse_task_hints};
4use crate::core::tokens::count_tokens;
5use crate::tools::CrpMode;
6
7/// Multi-resolution context overview.
8///
9/// Provides a compact map of the entire project, organized by task relevance.
10/// Files are shown at different detail levels based on their relevance score:
11/// - Level 0 (full): directly task-relevant files → full content (use ctx_read)
12/// - Level 1 (signatures): graph neighbors → key signatures
13/// - Level 2 (reference): distant files → name + line count only
14///
15/// This implements lazy evaluation for context: start with the overview,
16/// then zoom into specific files as needed.
17pub fn handle(
18    _cache: &SessionCache,
19    task: Option<&str>,
20    path: Option<&str>,
21    _crp_mode: CrpMode,
22) -> String {
23    let project_root = path.map_or_else(|| ".".to_string(), std::string::ToString::to_string);
24
25    let auto_loaded = crate::core::context_package::auto_load_packages(&project_root);
26
27    let Some(open) = graph_provider::open_or_build(&project_root) else {
28        crate::core::index_orchestrator::ensure_all_background(&project_root);
29        return format!(
30            "INDEXING IN PROGRESS\n\n\
31            The knowledge graph for this project is being built in the background.\n\
32            Project: {project_root}\n\n\
33            Because this is a large project, the initial scan may take a moment.\n\
34            Please try this command again in 1-2 minutes."
35        );
36    };
37    let gp = &open.provider;
38
39    let (task_files, task_keywords) = if let Some(task_desc) = task {
40        parse_task_hints(task_desc)
41    } else {
42        (vec![], vec![])
43    };
44
45    let has_task = !task_files.is_empty() || !task_keywords.is_empty();
46
47    let mut output = Vec::new();
48
49    if has_task {
50        let relevance = compute_relevance(gp, &task_files, &task_keywords);
51
52        output.push(format!(
53            "PROJECT OVERVIEW  {} files  task-filtered",
54            gp.file_count()
55        ));
56        output.push(String::new());
57
58        let high: Vec<&_> = relevance.iter().filter(|r| r.score >= 0.8).collect();
59        let medium: Vec<&_> = relevance
60            .iter()
61            .filter(|r| r.score >= 0.3 && r.score < 0.8)
62            .collect();
63        let low: Vec<&_> = relevance.iter().filter(|r| r.score < 0.3).collect();
64
65        if !high.is_empty() {
66            use crate::core::context_field::{ContextItemId, ContextKind, ViewCosts};
67            use crate::core::context_handles::HandleRegistry;
68
69            let mut handle_reg = HandleRegistry::new();
70            output.push("▸ DIRECTLY RELEVANT (use ctx_read or ctx_expand @ref):".to_string());
71            for r in &high {
72                let line_count = file_line_count(&r.path);
73                let item_id = ContextItemId::from_file(&r.path);
74                let view_costs = ViewCosts::from_full_tokens(line_count * 5);
75                let handle = handle_reg.register(
76                    item_id,
77                    ContextKind::File,
78                    &r.path,
79                    &format!(
80                        "{} {}L score={:.1}",
81                        short_path(&r.path),
82                        line_count,
83                        r.score
84                    ),
85                    &view_costs,
86                    r.score,
87                    false,
88                );
89                output.push(format!(
90                    "  @{} {} {}L  phi={:.2}  mode={}",
91                    handle.ref_label,
92                    short_path(&r.path),
93                    line_count,
94                    r.score,
95                    r.recommended_mode
96                ));
97            }
98            output.push(String::new());
99        }
100
101        if !medium.is_empty() {
102            let knowledge = crate::core::knowledge::ProjectKnowledge::load(&project_root);
103            output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
104            for r in medium.iter().take(20) {
105                let line_count = file_line_count(&r.path);
106                let doc = extract_module_doc(&r.path)
107                    .or_else(|| knowledge_doc_for_file(knowledge.as_ref(), &r.path))
108                    .map(|d| format!(" — {d}"))
109                    .unwrap_or_default();
110                output.push(format!(
111                    "  {} {line_count}L  mode={}{doc}",
112                    short_path(&r.path),
113                    r.recommended_mode
114                ));
115            }
116            if medium.len() > 20 {
117                output.push(format!("  ... +{} more", medium.len() - 20));
118            }
119            output.push(String::new());
120        }
121
122        if !low.is_empty() {
123            output.push(format!(
124                "▸ DISTANT ({} files, not loaded unless needed)",
125                low.len()
126            ));
127            for r in low.iter().take(10) {
128                output.push(format!("  {}", short_path(&r.path)));
129            }
130            if low.len() > 10 {
131                output.push(format!("  ... +{} more", low.len() - 10));
132            }
133        }
134
135        // Dynamic task-specific briefing last (prefix-cache-friendly)
136        if let Some(task_desc) = task {
137            let file_context: Vec<(String, usize)> = relevance
138                .iter()
139                .filter(|r| r.score >= 0.3)
140                .take(8)
141                .filter_map(|r| {
142                    std::fs::read_to_string(&r.path)
143                        .ok()
144                        .map(|c| (r.path.clone(), c.lines().count()))
145                })
146                .collect();
147            let briefing = crate::core::task_briefing::build_briefing(task_desc, &file_context);
148            output.push(String::new());
149            output.push(crate::core::task_briefing::format_briefing(&briefing));
150        }
151    } else {
152        // No task context: show project structure overview
153        let last_scan = gp.last_scan();
154        let scan_age = chrono::NaiveDateTime::parse_from_str(&last_scan, "%Y-%m-%d %H:%M:%S")
155            .ok()
156            .map(|t| {
157                let elapsed = chrono::Local::now().naive_local().signed_duration_since(t);
158                if elapsed.num_hours() < 1 {
159                    format!("{}m ago", elapsed.num_minutes())
160                } else if elapsed.num_hours() < 24 {
161                    format!("{}h ago", elapsed.num_hours())
162                } else {
163                    format!("{}d ago", elapsed.num_days())
164                }
165            })
166            .unwrap_or_default();
167        let scan_info = if scan_age.is_empty() {
168            String::new()
169        } else {
170            format!("  scanned {scan_age}")
171        };
172        output.push(format!(
173            "PROJECT OVERVIEW  {} files  {} edges{scan_info}",
174            gp.file_count(),
175            gp.edge_count().unwrap_or(0)
176        ));
177        output.push(String::new());
178
179        let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
180            std::collections::BTreeMap::new();
181
182        for path in gp.file_paths() {
183            let dir = std::path::Path::new(&path)
184                .parent()
185                .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
186            by_dir.entry(dir).or_default().push(short_path(&path));
187        }
188
189        for (dir, files) in &by_dir {
190            let dir_display = if dir.len() > 50 {
191                let start = truncate_start_char_boundary(dir, 47);
192                format!("...{}", &dir[start..])
193            } else {
194                dir.clone()
195            };
196
197            if files.len() <= 5 {
198                output.push(format!("{dir_display}/  {}", files.join(" ")));
199            } else {
200                output.push(format!(
201                    "{dir_display}/  {} +{} more",
202                    files[..3].join(" "),
203                    files.len() - 3
204                ));
205            }
206        }
207    }
208
209    if let Some(task_desc) = task {
210        append_knowledge_task_section(&mut output, &project_root, task_desc);
211    }
212    append_graph_hotspots_section(&mut output, &project_root, gp);
213
214    let cfg = crate::core::config::Config::load();
215    if cfg.enable_wakeup_ctx {
216        let wakeup = build_wakeup_briefing(&project_root, task);
217        if !wakeup.is_empty() {
218            output.push(String::new());
219            output.push(wakeup);
220        }
221    }
222
223    if !auto_loaded.is_empty() {
224        output.push(String::new());
225        output.push(format!(
226            "CONTEXT PACKAGES AUTO-LOADED: {}",
227            auto_loaded.join(", ")
228        ));
229    }
230
231    let fc = gp.file_count();
232    let original = count_tokens(&format!("{fc} files")) * fc;
233    let compressed = count_tokens(&output.join("\n"));
234    output.push(String::new());
235    output.push(crate::core::protocol::format_savings(original, compressed));
236
237    output.join("\n")
238}
239
240fn append_knowledge_task_section(output: &mut Vec<String>, project_root: &str, task: &str) {
241    let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) else {
242        return;
243    };
244    let hits: Vec<_> = knowledge.recall(task).into_iter().take(5).collect();
245    if hits.is_empty() {
246        return;
247    }
248    let n = hits.len();
249    output.push(String::new());
250    output.push(format!("[knowledge: {n} relevant facts]"));
251    for f in hits {
252        let text = compact_fact_phrase(f);
253        output.push(format!("  \"{text}\" (confidence: {:.1})", f.confidence));
254    }
255}
256
257fn compact_fact_phrase(f: &crate::core::knowledge::KnowledgeFact) -> String {
258    let v = f.value.trim();
259    let k = f.key.trim();
260    let raw = if !v.is_empty() && (k.is_empty() || v.contains(' ') || v.len() >= k.len()) {
261        v.to_string()
262    } else if !k.is_empty() && !v.is_empty() {
263        format!("{k}: {v}")
264    } else {
265        k.to_string()
266    };
267    let neutral = crate::core::sanitize::neutralize_metadata(&raw);
268    const MAX: usize = 100;
269    if neutral.chars().count() > MAX {
270        let trimmed: String = neutral.chars().take(MAX.saturating_sub(1)).collect();
271        format!("{trimmed}…")
272    } else {
273        neutral
274    }
275}
276
277fn append_graph_hotspots_section(output: &mut Vec<String>, project_root: &str, gp: &GraphProvider) {
278    let rows = graph_hotspot_rows(project_root, gp);
279    if rows.is_empty() {
280        return;
281    }
282    let n = rows.len();
283    output.push(String::new());
284    output.push(format!("[graph: {n} architectural hotspots]"));
285    for (path, imp, cal) in rows {
286        let p = short_path(&path);
287        if cal > 0 {
288            output.push(format!("  {p} ({imp} imports, {cal} calls)"));
289        } else {
290            output.push(format!("  {p} ({imp} imports)"));
291        }
292    }
293}
294
295fn graph_hotspot_rows(project_root: &str, gp: &GraphProvider) -> Vec<(String, usize, usize)> {
296    if let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) {
297        let sql = "
298            WITH edge_files AS (
299              SELECT e.kind AS kind, ns.file_path AS fp
300              FROM edges e
301              JOIN nodes ns ON e.source_id = ns.id
302              WHERE e.kind IN ('imports', 'calls')
303              UNION ALL
304              SELECT e.kind, nt.file_path
305              FROM edges e
306              JOIN nodes nt ON e.target_id = nt.id
307              WHERE e.kind IN ('imports', 'calls')
308            )
309            SELECT fp,
310                   SUM(CASE WHEN kind = 'imports' THEN 1 ELSE 0 END) AS imp,
311                   SUM(CASE WHEN kind = 'calls' THEN 1 ELSE 0 END) AS cal
312            FROM edge_files
313            GROUP BY fp
314            ORDER BY (imp + cal) DESC
315            LIMIT 5
316        ";
317        let conn = graph.connection();
318        if let Ok(mut stmt) = conn.prepare(sql) {
319            let mapped = stmt.query_map([], |row| {
320                Ok((
321                    row.get::<_, String>(0)?,
322                    row.get::<_, i64>(1)? as usize,
323                    row.get::<_, i64>(2)? as usize,
324                ))
325            });
326            if let Ok(iter) = mapped {
327                let collected: Vec<_> = iter.filter_map(std::result::Result::ok).collect();
328                if !collected.is_empty() {
329                    return collected;
330                }
331            }
332        }
333    }
334    import_hotspots_from_edges(gp, 5)
335}
336
337fn import_hotspots_from_edges(gp: &GraphProvider, limit: usize) -> Vec<(String, usize, usize)> {
338    use std::collections::HashMap;
339
340    let mut imp: HashMap<String, usize> = HashMap::new();
341    for e in gp.edges_by_kind("import") {
342        *imp.entry(e.from.clone()).or_insert(0) += 1;
343        *imp.entry(e.to.clone()).or_insert(0) += 1;
344    }
345    let mut v: Vec<(String, usize, usize)> =
346        imp.into_iter().map(|(p, c)| (p, c, 0_usize)).collect();
347    v.sort_by_key(|x| std::cmp::Reverse(x.1 + x.2));
348    v.truncate(limit);
349    v
350}
351
352fn build_wakeup_briefing(project_root: &str, task: Option<&str>) -> String {
353    let mut parts = Vec::new();
354
355    if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
356        let facts_line = knowledge.format_wakeup();
357        if !facts_line.is_empty() {
358            parts.push(facts_line);
359        }
360    }
361
362    if let Some(session) = crate::core::session::SessionState::load_latest() {
363        if let Some(ref task) = session.task {
364            parts.push(format!("LAST_TASK:{}", task.description));
365        }
366        if !session.decisions.is_empty() {
367            let recent: Vec<String> = session
368                .decisions
369                .iter()
370                .rev()
371                .take(3)
372                .map(|d| d.summary.clone())
373                .collect();
374            parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
375        }
376    }
377
378    if let Some(t) = task {
379        for r in crate::core::prospective_memory::reminders_for_task(project_root, t) {
380            parts.push(r);
381        }
382    }
383
384    let registry = crate::core::agents::AgentRegistry::load_or_create();
385    let active_agents: Vec<&crate::core::agents::AgentEntry> = registry
386        .agents
387        .iter()
388        .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
389        .collect();
390    if !active_agents.is_empty() {
391        let agents: Vec<String> = active_agents
392            .iter()
393            .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
394            .collect();
395        parts.push(format!("AGENTS:{}", agents.join(",")));
396    }
397
398    if parts.is_empty() {
399        return String::new();
400    }
401
402    format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
403}
404
405/// Extracts a 1-line module documentation from a file's first lines.
406/// Looks for Rust `//!`, Python `"""..."""` / `#`, JS/TS `/** ... */`, or generic `# description`.
407fn extract_module_doc(path: &str) -> Option<String> {
408    let content = std::fs::read_to_string(path).ok()?;
409    let mut lines = content.lines();
410
411    // Skip shebang
412    let first = lines.next()?.trim();
413    let search_start = if first.starts_with("#!") {
414        lines.next()
415    } else {
416        Some(first)
417    };
418
419    let first_meaningful = search_start?;
420
421    // Rust: //! module doc
422    if first_meaningful.starts_with("//!") {
423        let doc = first_meaningful.trim_start_matches("//!").trim();
424        if !doc.is_empty() {
425            return Some(truncate_doc(doc));
426        }
427    }
428
429    // Python: """ or '''
430    if first_meaningful.starts_with("\"\"\"") || first_meaningful.starts_with("'''") {
431        let doc = first_meaningful
432            .trim_start_matches("\"\"\"")
433            .trim_start_matches("'''")
434            .trim();
435        let doc = doc
436            .trim_end_matches("\"\"\"")
437            .trim_end_matches("'''")
438            .trim();
439        if !doc.is_empty() {
440            return Some(truncate_doc(doc));
441        }
442    }
443
444    // JS/TS: /** ... */
445    if first_meaningful.starts_with("/**") {
446        let doc = first_meaningful
447            .trim_start_matches("/**")
448            .trim_end_matches("*/")
449            .trim_start_matches('*')
450            .trim();
451        if !doc.is_empty() {
452            return Some(truncate_doc(doc));
453        }
454    }
455
456    // Generic: first # comment (markdown, python, shell)
457    if first_meaningful.starts_with("# ") && !first_meaningful.starts_with("# !") {
458        let doc = first_meaningful.trim_start_matches('#').trim();
459        if !doc.is_empty() {
460            return Some(truncate_doc(doc));
461        }
462    }
463
464    None
465}
466
467/// Falls back to Knowledge-Facts for a file description if no source-level doc found.
468fn knowledge_doc_for_file(
469    knowledge: Option<&crate::core::knowledge::ProjectKnowledge>,
470    path: &str,
471) -> Option<String> {
472    let knowledge = knowledge?;
473    let filename = std::path::Path::new(path).file_name()?.to_str()?;
474    let hits = knowledge.recall(filename);
475    let fact = hits.first()?;
476    let val = fact.value.trim();
477    if val.is_empty() || val.len() < 5 {
478        return None;
479    }
480    Some(truncate_doc(val))
481}
482
483fn truncate_doc(doc: &str) -> String {
484    if doc.len() > 80 {
485        let mut end = 77;
486        while end > 0 && !doc.is_char_boundary(end) {
487            end -= 1;
488        }
489        format!("{}...", &doc[..end])
490    } else {
491        doc.to_string()
492    }
493}
494
495fn short_path(path: &str) -> String {
496    let parts: Vec<&str> = path.split('/').collect();
497    if parts.len() <= 2 {
498        return path.to_string();
499    }
500    parts[parts.len() - 2..].join("/")
501}
502
503/// Find a byte offset at most `max_tail_bytes` from the end of `s`
504/// that falls on a valid UTF-8 char boundary.
505fn truncate_start_char_boundary(s: &str, max_tail_bytes: usize) -> usize {
506    if max_tail_bytes >= s.len() {
507        return 0;
508    }
509    let mut start = s.len() - max_tail_bytes;
510    while start < s.len() && !s.is_char_boundary(start) {
511        start += 1;
512    }
513    start
514}
515
516fn file_line_count(path: &str) -> usize {
517    std::fs::read_to_string(path).map_or(0, |c| c.lines().count())
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn truncate_start_ascii() {
526        let s = "abcdefghij"; // 10 bytes
527        assert_eq!(truncate_start_char_boundary(s, 5), 5);
528        assert_eq!(&s[5..], "fghij");
529    }
530
531    #[test]
532    fn truncate_start_multibyte_chinese() {
533        // "文档/examples/extensions/custom-provider-anthropic" = multi-byte prefix
534        let s = "文档/examples/extensions/custom-provider-anthropic";
535        let start = truncate_start_char_boundary(s, 47);
536        assert!(s.is_char_boundary(start));
537        let tail = &s[start..];
538        assert!(tail.len() <= 47);
539    }
540
541    #[test]
542    fn truncate_start_all_multibyte() {
543        let s = "这是一个很长的中文目录路径用于测试字符边界处理";
544        let start = truncate_start_char_boundary(s, 20);
545        assert!(s.is_char_boundary(start));
546    }
547
548    #[test]
549    fn truncate_start_larger_than_string() {
550        let s = "short";
551        assert_eq!(truncate_start_char_boundary(s, 100), 0);
552    }
553
554    #[test]
555    fn truncate_start_emoji() {
556        let s = "/home/user/🎉🎉🎉/src/components/deeply/nested";
557        let start = truncate_start_char_boundary(s, 30);
558        assert!(s.is_char_boundary(start));
559    }
560}