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