Skip to main content

lean_ctx/core/
auto_capture.rs

1//! Opt-in automatic knowledge capture from tool outputs.
2//!
3//! When enabled (`auto_capture = true` in config), interesting patterns from
4//! tool results are automatically persisted as knowledge facts without requiring
5//! manual `ctx_knowledge(action="remember")` calls.
6
7use crate::core::auto_findings::AutoFinding;
8use crate::core::knowledge::ProjectKnowledge;
9
10/// Check if auto-capture is enabled.
11pub fn is_enabled() -> bool {
12    if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CAPTURE") {
13        return matches!(v.trim(), "1" | "true" | "on");
14    }
15    crate::core::config::Config::load().auto_capture
16}
17
18/// Persist an auto-finding as a knowledge fact if auto-capture is enabled.
19pub fn capture_finding(project_root: &str, finding: &AutoFinding) {
20    if !is_enabled() {
21        return;
22    }
23
24    let category = classify_category(&finding.summary);
25    let key = derive_key(finding);
26    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
27
28    let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
29        return;
30    };
31
32    knowledge.remember(
33        &category,
34        &key,
35        &finding.summary,
36        "auto-capture",
37        0.6,
38        &policy,
39    );
40    let _ = knowledge.save();
41}
42
43fn classify_category(summary: &str) -> String {
44    let s = summary.to_lowercase();
45    if s.contains("error") || s.contains("fail") || s.contains("panic") {
46        "blocker".to_string()
47    } else if s.contains("test") || s.contains("assert") {
48        "pattern".to_string()
49    } else if s.contains("config") || s.contains("setting") {
50        "decision".to_string()
51    } else {
52        "finding".to_string()
53    }
54}
55
56fn derive_key(finding: &AutoFinding) -> String {
57    if let Some(ref file) = finding.file {
58        let short = file.rsplit('/').next().unwrap_or(file);
59        format!("auto:{short}")
60    } else {
61        let first_word = finding.summary.split_whitespace().next().unwrap_or("item");
62        format!("auto:{first_word}")
63    }
64}
65
66/// Extract knowledge-worthy patterns from tool output that auto_findings misses.
67pub fn extract_extra(tool_name: &str, output: &str) -> Option<AutoFinding> {
68    match tool_name {
69        "ctx_edit" | "ctx_multi_edit" => extract_edit_finding(output),
70        "ctx_diff" => extract_diff_finding(output),
71        _ => None,
72    }
73}
74
75fn extract_edit_finding(output: &str) -> Option<AutoFinding> {
76    let first_line = output.lines().next()?;
77    if first_line.contains("Applied") || first_line.contains("✓") {
78        let file = first_line
79            .split_whitespace()
80            .find(|w| w.contains('/') || w.contains('.'))
81            .map(|s| {
82                s.trim_matches(|c: char| {
83                    !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
84                })
85                .to_string()
86            });
87        Some(AutoFinding {
88            file,
89            summary: truncate(first_line, 120),
90        })
91    } else {
92        None
93    }
94}
95
96fn extract_diff_finding(output: &str) -> Option<AutoFinding> {
97    let lines: Vec<&str> = output.lines().take(5).collect();
98    if lines.is_empty() {
99        return None;
100    }
101
102    let added = output
103        .lines()
104        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
105        .count();
106    let removed = output
107        .lines()
108        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
109        .count();
110
111    if added + removed == 0 {
112        return None;
113    }
114
115    let file = lines
116        .iter()
117        .find(|l| l.starts_with("--- ") || l.starts_with("+++ "))
118        .and_then(|l| l.split_whitespace().nth(1))
119        .map(std::string::ToString::to_string);
120
121    Some(AutoFinding {
122        file,
123        summary: format!("+{added}/-{removed} lines changed"),
124    })
125}
126
127fn truncate(s: &str, max: usize) -> String {
128    if s.len() <= max {
129        s.to_string()
130    } else {
131        format!("{}...", &s[..s.floor_char_boundary(max)])
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn classify_error_category() {
141        assert_eq!(classify_category("compilation error in build"), "blocker");
142    }
143
144    #[test]
145    fn classify_pattern_category() {
146        assert_eq!(classify_category("test suite passed 42 tests"), "pattern");
147    }
148
149    #[test]
150    fn classify_decision_category() {
151        assert_eq!(classify_category("config option added"), "decision");
152    }
153
154    #[test]
155    fn classify_finding_default() {
156        assert_eq!(classify_category("read file main.rs"), "finding");
157    }
158
159    #[test]
160    fn derive_key_with_file() {
161        let f = AutoFinding {
162            file: Some("src/core/config.rs".into()),
163            summary: "something".into(),
164        };
165        assert_eq!(derive_key(&f), "auto:config.rs");
166    }
167
168    #[test]
169    fn derive_key_without_file() {
170        let f = AutoFinding {
171            file: None,
172            summary: "compilation error".into(),
173        };
174        assert_eq!(derive_key(&f), "auto:compilation");
175    }
176
177    #[test]
178    fn extract_edit_result() {
179        let output = "✓ Applied to src/main.rs (3 replacements)";
180        let finding = extract_edit_finding(output);
181        assert!(finding.is_some());
182    }
183
184    #[test]
185    fn extract_diff_counts() {
186        let output = "--- a/file.rs\n+++ b/file.rs\n-old line\n+new line\n+another";
187        let finding = extract_diff_finding(output);
188        assert!(finding.is_some());
189        let summary = finding.unwrap().summary;
190        assert!(summary.contains("+2/-1"), "expected +2/-1 got: {summary}");
191    }
192}