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