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(crate) 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(crate) fn capture_finding(project_root: &str, finding: &AutoFinding) {
20    capture_finding_from_tool(project_root, finding, "ctx_knowledge", &finding.summary);
21}
22
23pub fn capture_finding_from_tool(
24    project_root: &str,
25    finding: &AutoFinding,
26    source_tool: &str,
27    original_content: &str,
28) {
29    if !is_enabled() {
30        return;
31    }
32
33    // Load-modify-save under the shared in-process + cross-process lock so this
34    // background capture never clobbers facts a concurrent foreground
35    // `remember`/`relate` commits in between (issue #326): a bare
36    // `load_or_create` + `save` loads a stale (possibly empty) snapshot and its
37    // save silently drops just-written facts.
38    let _ = ProjectKnowledge::with_project_lock_checked(project_root, || {
39        if !is_enabled() {
40            return;
41        }
42        let Ok(policy) = crate::core::config::Config::load().memory_policy_effective() else {
43            return;
44        };
45        let Ok(active) =
46            crate::core::policy::runtime::for_project(std::path::Path::new(project_root))
47        else {
48            return;
49        };
50        if active
51            .as_ref()
52            .is_some_and(|p| !p.tool_allowed("ctx_knowledge") || !p.tool_allowed(source_tool))
53        {
54            return;
55        }
56        if active.as_ref().is_some_and(|policy| {
57            crate::core::policy::content::evaluate_text(original_content, policy).blocked
58        }) {
59            return;
60        }
61        let Some(safe) = crate::core::policy::diagnostics::inspect(
62            &serde_json::json!({"file":finding.file,"summary":finding.summary}),
63            active.as_deref(),
64        ) else {
65            return;
66        };
67        let summary = safe["summary"].as_str().unwrap_or_default();
68        let category = classify_category(summary);
69        let key = derive_key(finding);
70        let key_needs_masking = active.as_ref().is_some_and(|active| {
71            crate::core::policy::content::evaluate_text(&key, active).text != key
72        });
73        let key = if key_needs_masking {
74            format!("auto:{}", blake3::hash(key.as_bytes()).to_hex())
75        } else {
76            key
77        };
78        let Ok(mut knowledge) = ProjectKnowledge::load_for_checked_capture(project_root) else {
79            return;
80        };
81        knowledge.remember(&category, &key, summary, "auto-capture", 0.6, &policy);
82        let _ = knowledge.save_policy_checked(source_tool, original_content);
83    });
84}
85
86fn classify_category(summary: &str) -> String {
87    let s = summary.to_lowercase();
88    if s.contains("solution-debt") || s.contains("solution_debt") {
89        "solution-debt".to_string()
90    } else if s.contains("solution-decision") || s.contains("solution_decision") {
91        "solution-decision".to_string()
92    } else if s.contains("error") || s.contains("fail") || s.contains("panic") {
93        "blocker".to_string()
94    } else if s.contains("test") || s.contains("assert") {
95        "pattern".to_string()
96    } else if s.contains("config") || s.contains("setting") {
97        "decision".to_string()
98    } else {
99        "finding".to_string()
100    }
101}
102
103fn derive_key(finding: &AutoFinding) -> String {
104    if let Some(ref file) = finding.file {
105        let short = file.rsplit('/').next().unwrap_or(file);
106        format!("auto:{short}")
107    } else {
108        let first_word = finding.summary.split_whitespace().next().unwrap_or("item");
109        format!("auto:{first_word}")
110    }
111}
112
113/// Extract knowledge-worthy patterns from tool output that auto_findings misses.
114pub fn extract_extra(tool_name: &str, output: &str) -> Option<AutoFinding> {
115    match tool_name {
116        "ctx_edit" | "ctx_multi_edit" => extract_edit_finding(output),
117        "ctx_diff" => extract_diff_finding(output),
118        _ => None,
119    }
120}
121
122fn extract_edit_finding(output: &str) -> Option<AutoFinding> {
123    let first_line = output.lines().next()?;
124    if first_line.contains("Applied")
125        || first_line.contains("✓")
126        || first_line.starts_with("patched ")
127    {
128        let file = first_line
129            .split_whitespace()
130            .find(|w| w.contains('/') || w.contains('.'))
131            .map(|s| {
132                s.trim_matches(|c: char| {
133                    !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
134                })
135                .to_string()
136            });
137        Some(AutoFinding {
138            file,
139            summary: truncate(first_line, 120),
140        })
141    } else {
142        None
143    }
144}
145
146fn extract_diff_finding(output: &str) -> Option<AutoFinding> {
147    let lines: Vec<&str> = output.lines().take(5).collect();
148    if lines.is_empty() {
149        return None;
150    }
151
152    let added = output
153        .lines()
154        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
155        .count();
156    let removed = output
157        .lines()
158        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
159        .count();
160
161    if added + removed == 0 {
162        return None;
163    }
164
165    let file = lines
166        .iter()
167        .find(|l| l.starts_with("--- ") || l.starts_with("+++ "))
168        .and_then(|l| l.split_whitespace().nth(1))
169        .map(std::string::ToString::to_string);
170
171    Some(AutoFinding {
172        file,
173        summary: format!("+{added}/-{removed} lines changed"),
174    })
175}
176
177fn truncate(s: &str, max: usize) -> String {
178    if s.len() <= max {
179        s.to_string()
180    } else {
181        format!("{}...", &s[..s.floor_char_boundary(max)])
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn classify_solution_categories() {
191        assert_eq!(
192            classify_category("solution-decision: chose HashMap"),
193            "solution-decision"
194        );
195        assert_eq!(
196            classify_category("solution-debt: defer migration"),
197            "solution-debt"
198        );
199    }
200
201    #[test]
202    fn classify_error_category() {
203        assert_eq!(classify_category("compilation error in build"), "blocker");
204    }
205
206    #[test]
207    fn classify_pattern_category() {
208        assert_eq!(classify_category("test suite passed 42 tests"), "pattern");
209    }
210
211    #[test]
212    fn classify_decision_category() {
213        assert_eq!(classify_category("config option added"), "decision");
214    }
215
216    #[test]
217    fn classify_finding_default() {
218        assert_eq!(classify_category("read file main.rs"), "finding");
219    }
220
221    #[test]
222    fn derive_key_with_file() {
223        let f = AutoFinding {
224            file: Some("src/core/config.rs".into()),
225            summary: "something".into(),
226        };
227        assert_eq!(derive_key(&f), "auto:config.rs");
228    }
229
230    #[test]
231    fn derive_key_without_file() {
232        let f = AutoFinding {
233            file: None,
234            summary: "compilation error".into(),
235        };
236        assert_eq!(derive_key(&f), "auto:compilation");
237    }
238
239    #[test]
240    fn extract_edit_result() {
241        let output = "✓ Applied to src/main.rs (3 replacements)";
242        let finding = extract_edit_finding(output);
243        assert!(finding.is_some());
244    }
245
246    #[test]
247    fn extract_diff_counts() {
248        let output = "--- a/file.rs\n+++ b/file.rs\n-old line\n+new line\n+another";
249        let finding = extract_diff_finding(output);
250        assert!(finding.is_some());
251        let summary = finding.unwrap().summary;
252        assert!(summary.contains("+2/-1"), "expected +2/-1 got: {summary}");
253    }
254}