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
27    let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
28        return;
29    };
30
31    // Load-modify-save under the shared in-process + cross-process lock so this
32    // background capture never clobbers facts a concurrent foreground
33    // `remember`/`relate` commits in between (issue #326): a bare
34    // `load_or_create` + `save` loads a stale (possibly empty) snapshot and its
35    // save silently drops just-written facts.
36    let _ = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
37        knowledge.remember(
38            &category,
39            &key,
40            &finding.summary,
41            "auto-capture",
42            0.6,
43            &policy,
44        );
45    });
46}
47
48fn classify_category(summary: &str) -> String {
49    let s = summary.to_lowercase();
50    if s.contains("error") || s.contains("fail") || s.contains("panic") {
51        "blocker".to_string()
52    } else if s.contains("test") || s.contains("assert") {
53        "pattern".to_string()
54    } else if s.contains("config") || s.contains("setting") {
55        "decision".to_string()
56    } else {
57        "finding".to_string()
58    }
59}
60
61fn derive_key(finding: &AutoFinding) -> String {
62    if let Some(ref file) = finding.file {
63        let short = file.rsplit('/').next().unwrap_or(file);
64        format!("auto:{short}")
65    } else {
66        let first_word = finding.summary.split_whitespace().next().unwrap_or("item");
67        format!("auto:{first_word}")
68    }
69}
70
71/// Extract knowledge-worthy patterns from tool output that auto_findings misses.
72pub fn extract_extra(tool_name: &str, output: &str) -> Option<AutoFinding> {
73    match tool_name {
74        "ctx_edit" | "ctx_multi_edit" => extract_edit_finding(output),
75        "ctx_diff" => extract_diff_finding(output),
76        _ => None,
77    }
78}
79
80fn extract_edit_finding(output: &str) -> Option<AutoFinding> {
81    let first_line = output.lines().next()?;
82    if first_line.contains("Applied") || first_line.contains("✓") {
83        let file = first_line
84            .split_whitespace()
85            .find(|w| w.contains('/') || w.contains('.'))
86            .map(|s| {
87                s.trim_matches(|c: char| {
88                    !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
89                })
90                .to_string()
91            });
92        Some(AutoFinding {
93            file,
94            summary: truncate(first_line, 120),
95        })
96    } else {
97        None
98    }
99}
100
101fn extract_diff_finding(output: &str) -> Option<AutoFinding> {
102    let lines: Vec<&str> = output.lines().take(5).collect();
103    if lines.is_empty() {
104        return None;
105    }
106
107    let added = output
108        .lines()
109        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
110        .count();
111    let removed = output
112        .lines()
113        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
114        .count();
115
116    if added + removed == 0 {
117        return None;
118    }
119
120    let file = lines
121        .iter()
122        .find(|l| l.starts_with("--- ") || l.starts_with("+++ "))
123        .and_then(|l| l.split_whitespace().nth(1))
124        .map(std::string::ToString::to_string);
125
126    Some(AutoFinding {
127        file,
128        summary: format!("+{added}/-{removed} lines changed"),
129    })
130}
131
132fn truncate(s: &str, max: usize) -> String {
133    if s.len() <= max {
134        s.to_string()
135    } else {
136        format!("{}...", &s[..s.floor_char_boundary(max)])
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn classify_error_category() {
146        assert_eq!(classify_category("compilation error in build"), "blocker");
147    }
148
149    #[test]
150    fn classify_pattern_category() {
151        assert_eq!(classify_category("test suite passed 42 tests"), "pattern");
152    }
153
154    #[test]
155    fn classify_decision_category() {
156        assert_eq!(classify_category("config option added"), "decision");
157    }
158
159    #[test]
160    fn classify_finding_default() {
161        assert_eq!(classify_category("read file main.rs"), "finding");
162    }
163
164    #[test]
165    fn derive_key_with_file() {
166        let f = AutoFinding {
167            file: Some("src/core/config.rs".into()),
168            summary: "something".into(),
169        };
170        assert_eq!(derive_key(&f), "auto:config.rs");
171    }
172
173    #[test]
174    fn derive_key_without_file() {
175        let f = AutoFinding {
176            file: None,
177            summary: "compilation error".into(),
178        };
179        assert_eq!(derive_key(&f), "auto:compilation");
180    }
181
182    #[test]
183    fn extract_edit_result() {
184        let output = "✓ Applied to src/main.rs (3 replacements)";
185        let finding = extract_edit_finding(output);
186        assert!(finding.is_some());
187    }
188
189    #[test]
190    fn extract_diff_counts() {
191        let output = "--- a/file.rs\n+++ b/file.rs\n-old line\n+new line\n+another";
192        let finding = extract_diff_finding(output);
193        assert!(finding.is_some());
194        let summary = finding.unwrap().summary;
195        assert!(summary.contains("+2/-1"), "expected +2/-1 got: {summary}");
196    }
197}