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            let knowledge = crate::core::knowledge::ProjectKnowledge::load(&project_root);
102            output.push("▸ CONTEXT (use ctx_read signatures/map):".to_string());
103            for r in medium.iter().take(20) {
104                let line_count = file_line_count(&r.path);
105                let doc = extract_module_doc(&r.path)
106                    .or_else(|| knowledge_doc_for_file(knowledge.as_ref(), &r.path))
107                    .map(|d| format!(" — {d}"))
108                    .unwrap_or_default();
109                output.push(format!(
110                    "  {} {line_count}L  mode={}{doc}",
111                    short_path(&r.path),
112                    r.recommended_mode
113                ));
114            }
115            if medium.len() > 20 {
116                output.push(format!("  ... +{} more", medium.len() - 20));
117            }
118            output.push(String::new());
119        }
120
121        if !low.is_empty() {
122            output.push(format!(
123                "▸ DISTANT ({} files, not loaded unless needed)",
124                low.len()
125            ));
126            for r in low.iter().take(10) {
127                output.push(format!("  {}", short_path(&r.path)));
128            }
129            if low.len() > 10 {
130                output.push(format!("  ... +{} more", low.len() - 10));
131            }
132        }
133
134        // Dynamic task-specific briefing last (prefix-cache-friendly)
135        if let Some(task_desc) = task {
136            let file_context: Vec<(String, usize)> = relevance
137                .iter()
138                .filter(|r| r.score >= 0.3)
139                .take(8)
140                .filter_map(|r| {
141                    std::fs::read_to_string(&r.path)
142                        .ok()
143                        .map(|c| (r.path.clone(), c.lines().count()))
144                })
145                .collect();
146            let briefing = crate::core::task_briefing::build_briefing(task_desc, &file_context);
147            output.push(String::new());
148            output.push(crate::core::task_briefing::format_briefing(&briefing));
149        }
150    } else {
151        // No task context: show project structure overview
152        let scan_age = chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
153            .ok()
154            .map(|t| {
155                let elapsed = chrono::Local::now().naive_local().signed_duration_since(t);
156                if elapsed.num_hours() < 1 {
157                    format!("{}m ago", elapsed.num_minutes())
158                } else if elapsed.num_hours() < 24 {
159                    format!("{}h ago", elapsed.num_hours())
160                } else {
161                    format!("{}d ago", elapsed.num_days())
162                }
163            })
164            .unwrap_or_default();
165        let scan_info = if scan_age.is_empty() {
166            String::new()
167        } else {
168            format!("  scanned {scan_age}")
169        };
170        output.push(format!(
171            "PROJECT OVERVIEW  {} files  {} edges{scan_info}",
172            index.files.len(),
173            index.edges.len()
174        ));
175        output.push(String::new());
176
177        // Group by directory
178        let mut by_dir: std::collections::BTreeMap<String, Vec<String>> =
179            std::collections::BTreeMap::new();
180
181        for file_entry in index.files.values() {
182            let dir = std::path::Path::new(&file_entry.path)
183                .parent()
184                .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
185            by_dir
186                .entry(dir)
187                .or_default()
188                .push(short_path(&file_entry.path));
189        }
190
191        for (dir, files) in &by_dir {
192            let dir_display = if dir.len() > 50 {
193                let start = truncate_start_char_boundary(dir, 47);
194                format!("...{}", &dir[start..])
195            } else {
196                dir.clone()
197            };
198
199            if files.len() <= 5 {
200                output.push(format!("{dir_display}/  {}", files.join(" ")));
201            } else {
202                output.push(format!(
203                    "{dir_display}/  {} +{} more",
204                    files[..3].join(" "),
205                    files.len() - 3
206                ));
207            }
208        }
209    }
210
211    if let Some(task_desc) = task {
212        append_knowledge_task_section(&mut output, &index.project_root, task_desc);
213    }
214    append_graph_hotspots_section(&mut output, &index.project_root, &index);
215
216    let cfg = crate::core::config::Config::load();
217    if cfg.enable_wakeup_ctx {
218        let wakeup = build_wakeup_briefing(&project_root, task);
219        if !wakeup.is_empty() {
220            output.push(String::new());
221            output.push(wakeup);
222        }
223    }
224
225    if !auto_loaded.is_empty() {
226        output.push(String::new());
227        output.push(format!(
228            "CONTEXT PACKAGES AUTO-LOADED: {}",
229            auto_loaded.join(", ")
230        ));
231    }
232
233    let original = count_tokens(&format!("{} files", index.files.len())) * index.files.len();
234    let compressed = count_tokens(&output.join("\n"));
235    output.push(String::new());
236    output.push(crate::core::protocol::format_savings(original, compressed));
237
238    output.join("\n")
239}
240
241fn append_knowledge_task_section(output: &mut Vec<String>, project_root: &str, task: &str) {
242    let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) else {
243        return;
244    };
245    let hits: Vec<_> = knowledge.recall(task).into_iter().take(5).collect();
246    if hits.is_empty() {
247        return;
248    }
249    let n = hits.len();
250    output.push(String::new());
251    output.push(format!("[knowledge: {n} relevant facts]"));
252    for f in hits {
253        let text = compact_fact_phrase(f);
254        output.push(format!("  \"{text}\" (confidence: {:.1})", f.confidence));
255    }
256}
257
258fn compact_fact_phrase(f: &crate::core::knowledge::KnowledgeFact) -> String {
259    let v = f.value.trim();
260    let k = f.key.trim();
261    let raw = if !v.is_empty() && (k.is_empty() || v.contains(' ') || v.len() >= k.len()) {
262        v.to_string()
263    } else if !k.is_empty() && !v.is_empty() {
264        format!("{k}: {v}")
265    } else {
266        k.to_string()
267    };
268    let neutral = crate::core::sanitize::neutralize_metadata(&raw);
269    const MAX: usize = 100;
270    if neutral.chars().count() > MAX {
271        let trimmed: String = neutral.chars().take(MAX.saturating_sub(1)).collect();
272        format!("{trimmed}…")
273    } else {
274        neutral
275    }
276}
277
278fn append_graph_hotspots_section(
279    output: &mut Vec<String>,
280    project_root: &str,
281    index: &crate::core::graph_index::ProjectIndex,
282) {
283    let rows = graph_hotspot_rows(project_root, index);
284    if rows.is_empty() {
285        return;
286    }
287    let n = rows.len();
288    output.push(String::new());
289    output.push(format!("[graph: {n} architectural hotspots]"));
290    for (path, imp, cal) in rows {
291        let p = short_path(&path);
292        if cal > 0 {
293            output.push(format!("  {p} ({imp} imports, {cal} calls)"));
294        } else {
295            output.push(format!("  {p} ({imp} imports)"));
296        }
297    }
298}
299
300/// Import/call edge touches per file from SQLite graph when available; otherwise
301/// import-edge degree from the JSON graph index (calls omitted).
302fn graph_hotspot_rows(
303    project_root: &str,
304    index: &crate::core::graph_index::ProjectIndex,
305) -> Vec<(String, usize, usize)> {
306    if let Ok(graph) = crate::core::property_graph::CodeGraph::open(project_root) {
307        let sql = "
308            WITH edge_files AS (
309              SELECT e.kind AS kind, ns.file_path AS fp
310              FROM edges e
311              JOIN nodes ns ON e.source_id = ns.id
312              WHERE e.kind IN ('imports', 'calls')
313              UNION ALL
314              SELECT e.kind, nt.file_path
315              FROM edges e
316              JOIN nodes nt ON e.target_id = nt.id
317              WHERE e.kind IN ('imports', 'calls')
318            )
319            SELECT fp,
320                   SUM(CASE WHEN kind = 'imports' THEN 1 ELSE 0 END) AS imp,
321                   SUM(CASE WHEN kind = 'calls' THEN 1 ELSE 0 END) AS cal
322            FROM edge_files
323            GROUP BY fp
324            ORDER BY (imp + cal) DESC
325            LIMIT 5
326        ";
327        let conn = graph.connection();
328        if let Ok(mut stmt) = conn.prepare(sql) {
329            let mapped = stmt.query_map([], |row| {
330                Ok((
331                    row.get::<_, String>(0)?,
332                    row.get::<_, i64>(1)? as usize,
333                    row.get::<_, i64>(2)? as usize,
334                ))
335            });
336            if let Ok(iter) = mapped {
337                let collected: Vec<_> = iter.filter_map(std::result::Result::ok).collect();
338                if !collected.is_empty() {
339                    return collected;
340                }
341            }
342        }
343    }
344    index_import_hotspots(index, 5)
345}
346
347fn index_import_hotspots(
348    index: &crate::core::graph_index::ProjectIndex,
349    limit: usize,
350) -> Vec<(String, usize, usize)> {
351    use std::collections::HashMap;
352
353    let mut imp: HashMap<String, usize> = HashMap::new();
354    for e in &index.edges {
355        if e.kind != "import" {
356            continue;
357        }
358        *imp.entry(e.from.clone()).or_insert(0) += 1;
359        *imp.entry(e.to.clone()).or_insert(0) += 1;
360    }
361    let mut v: Vec<(String, usize, usize)> =
362        imp.into_iter().map(|(p, c)| (p, c, 0_usize)).collect();
363    v.sort_by_key(|x| std::cmp::Reverse(x.1 + x.2));
364    v.truncate(limit);
365    v
366}
367
368fn build_wakeup_briefing(project_root: &str, task: Option<&str>) -> String {
369    let mut parts = Vec::new();
370
371    if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root) {
372        let facts_line = knowledge.format_wakeup();
373        if !facts_line.is_empty() {
374            parts.push(facts_line);
375        }
376    }
377
378    if let Some(session) = crate::core::session::SessionState::load_latest() {
379        if let Some(ref task) = session.task {
380            parts.push(format!("LAST_TASK:{}", task.description));
381        }
382        if !session.decisions.is_empty() {
383            let recent: Vec<String> = session
384                .decisions
385                .iter()
386                .rev()
387                .take(3)
388                .map(|d| d.summary.clone())
389                .collect();
390            parts.push(format!("RECENT_DECISIONS:{}", recent.join("|")));
391        }
392    }
393
394    if let Some(t) = task {
395        for r in crate::core::prospective_memory::reminders_for_task(project_root, t) {
396            parts.push(r);
397        }
398    }
399
400    let registry = crate::core::agents::AgentRegistry::load_or_create();
401    let active_agents: Vec<&crate::core::agents::AgentEntry> = registry
402        .agents
403        .iter()
404        .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
405        .collect();
406    if !active_agents.is_empty() {
407        let agents: Vec<String> = active_agents
408            .iter()
409            .map(|a| format!("{}({})", a.agent_id, a.role.as_deref().unwrap_or("-")))
410            .collect();
411        parts.push(format!("AGENTS:{}", agents.join(",")));
412    }
413
414    if parts.is_empty() {
415        return String::new();
416    }
417
418    format!("WAKE-UP BRIEFING:\n{}", parts.join("\n"))
419}
420
421/// Extracts a 1-line module documentation from a file's first lines.
422/// Looks for Rust `//!`, Python `"""..."""` / `#`, JS/TS `/** ... */`, or generic `# description`.
423fn extract_module_doc(path: &str) -> Option<String> {
424    let content = std::fs::read_to_string(path).ok()?;
425    let mut lines = content.lines();
426
427    // Skip shebang
428    let first = lines.next()?.trim();
429    let search_start = if first.starts_with("#!") {
430        lines.next()
431    } else {
432        Some(first)
433    };
434
435    let first_meaningful = search_start?;
436
437    // Rust: //! module doc
438    if first_meaningful.starts_with("//!") {
439        let doc = first_meaningful.trim_start_matches("//!").trim();
440        if !doc.is_empty() {
441            return Some(truncate_doc(doc));
442        }
443    }
444
445    // Python: """ or '''
446    if first_meaningful.starts_with("\"\"\"") || first_meaningful.starts_with("'''") {
447        let doc = first_meaningful
448            .trim_start_matches("\"\"\"")
449            .trim_start_matches("'''")
450            .trim();
451        let doc = doc
452            .trim_end_matches("\"\"\"")
453            .trim_end_matches("'''")
454            .trim();
455        if !doc.is_empty() {
456            return Some(truncate_doc(doc));
457        }
458    }
459
460    // JS/TS: /** ... */
461    if first_meaningful.starts_with("/**") {
462        let doc = first_meaningful
463            .trim_start_matches("/**")
464            .trim_end_matches("*/")
465            .trim_start_matches('*')
466            .trim();
467        if !doc.is_empty() {
468            return Some(truncate_doc(doc));
469        }
470    }
471
472    // Generic: first # comment (markdown, python, shell)
473    if first_meaningful.starts_with("# ") && !first_meaningful.starts_with("# !") {
474        let doc = first_meaningful.trim_start_matches('#').trim();
475        if !doc.is_empty() {
476            return Some(truncate_doc(doc));
477        }
478    }
479
480    None
481}
482
483/// Falls back to Knowledge-Facts for a file description if no source-level doc found.
484fn knowledge_doc_for_file(
485    knowledge: Option<&crate::core::knowledge::ProjectKnowledge>,
486    path: &str,
487) -> Option<String> {
488    let knowledge = knowledge?;
489    let filename = std::path::Path::new(path).file_name()?.to_str()?;
490    let hits = knowledge.recall(filename);
491    let fact = hits.first()?;
492    let val = fact.value.trim();
493    if val.is_empty() || val.len() < 5 {
494        return None;
495    }
496    Some(truncate_doc(val))
497}
498
499fn truncate_doc(doc: &str) -> String {
500    if doc.len() > 80 {
501        let mut end = 77;
502        while end > 0 && !doc.is_char_boundary(end) {
503            end -= 1;
504        }
505        format!("{}...", &doc[..end])
506    } else {
507        doc.to_string()
508    }
509}
510
511fn short_path(path: &str) -> String {
512    let parts: Vec<&str> = path.split('/').collect();
513    if parts.len() <= 2 {
514        return path.to_string();
515    }
516    parts[parts.len() - 2..].join("/")
517}
518
519/// Find a byte offset at most `max_tail_bytes` from the end of `s`
520/// that falls on a valid UTF-8 char boundary.
521fn truncate_start_char_boundary(s: &str, max_tail_bytes: usize) -> usize {
522    if max_tail_bytes >= s.len() {
523        return 0;
524    }
525    let mut start = s.len() - max_tail_bytes;
526    while start < s.len() && !s.is_char_boundary(start) {
527        start += 1;
528    }
529    start
530}
531
532fn file_line_count(path: &str) -> usize {
533    std::fs::read_to_string(path).map_or(0, |c| c.lines().count())
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn truncate_start_ascii() {
542        let s = "abcdefghij"; // 10 bytes
543        assert_eq!(truncate_start_char_boundary(s, 5), 5);
544        assert_eq!(&s[5..], "fghij");
545    }
546
547    #[test]
548    fn truncate_start_multibyte_chinese() {
549        // "文档/examples/extensions/custom-provider-anthropic" = multi-byte prefix
550        let s = "文档/examples/extensions/custom-provider-anthropic";
551        let start = truncate_start_char_boundary(s, 47);
552        assert!(s.is_char_boundary(start));
553        let tail = &s[start..];
554        assert!(tail.len() <= 47);
555    }
556
557    #[test]
558    fn truncate_start_all_multibyte() {
559        let s = "这是一个很长的中文目录路径用于测试字符边界处理";
560        let start = truncate_start_char_boundary(s, 20);
561        assert!(s.is_char_boundary(start));
562    }
563
564    #[test]
565    fn truncate_start_larger_than_string() {
566        let s = "short";
567        assert_eq!(truncate_start_char_boundary(s, 100), 0);
568    }
569
570    #[test]
571    fn truncate_start_emoji() {
572        let s = "/home/user/🎉🎉🎉/src/components/deeply/nested";
573        let start = truncate_start_char_boundary(s, 30);
574        assert!(s.is_char_boundary(start));
575    }
576}