Skip to main content

lean_ctx/core/
consolidation_engine.rs

1use chrono::Utc;
2
3use crate::core::knowledge::ProjectKnowledge;
4use crate::core::session::SessionState;
5
6#[derive(Debug, Clone)]
7pub struct ConsolidationBudgets {
8    pub max_decisions: usize,
9    pub max_findings: usize,
10}
11
12impl Default for ConsolidationBudgets {
13    fn default() -> Self {
14        Self {
15            max_decisions: 5,
16            max_findings: 8,
17        }
18    }
19}
20
21#[derive(Debug, Clone)]
22pub struct ConsolidationOutcome {
23    pub promoted: u32,
24    pub promoted_decisions: u32,
25    pub promoted_findings: u32,
26    pub lifecycle_archived: usize,
27    pub lifecycle_remaining: usize,
28}
29
30pub fn consolidate_latest(
31    project_root: &str,
32    budgets: ConsolidationBudgets,
33) -> Result<ConsolidationOutcome, String> {
34    let session = SessionState::load_latest().ok_or_else(|| "no active session".to_string())?;
35
36    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
37
38    let mut promoted_decisions = 0u32;
39    let mut promoted_findings = 0u32;
40
41    let mut decisions = session.decisions.clone();
42    decisions.sort_by_key(|x| std::cmp::Reverse(x.timestamp));
43    decisions.truncate(budgets.max_decisions);
44    for d in &decisions {
45        let key = slug_key(&d.summary, 50);
46        knowledge.remember("decision", &key, &d.summary, &session.id, 0.9);
47        promoted_decisions += 1;
48    }
49
50    let mut findings = session.findings.clone();
51    findings.sort_by_key(|x| std::cmp::Reverse(x.timestamp));
52    let mut kept = Vec::new();
53    for f in &findings {
54        if kept.len() >= budgets.max_findings {
55            break;
56        }
57        if finding_salience(&f.summary) < 45 {
58            continue;
59        }
60        kept.push(f.clone());
61    }
62
63    for f in &kept {
64        let key = if let Some(ref file) = f.file {
65            if let Some(line) = f.line {
66                format!("{file}:{line}")
67            } else {
68                file.clone()
69            }
70        } else {
71            format!("finding-{}", slug_key(&f.summary, 36))
72        };
73        knowledge.remember("finding", &key, &f.summary, &session.id, 0.75);
74        promoted_findings += 1;
75    }
76
77    // One compact history entry (no prose output to user; stored for auditability).
78    let task_desc = session
79        .task
80        .as_ref()
81        .map(|t| t.description.clone())
82        .unwrap_or_else(|| "(no task)".into());
83    let summary = format!(
84        "consolidate@{} session={} task=\"{}\" decisions={} findings={}",
85        Utc::now().format("%Y-%m-%d"),
86        session.id,
87        task_desc,
88        promoted_decisions,
89        promoted_findings
90    );
91    knowledge.consolidate(&summary, vec![session.id.clone()]);
92
93    let lifecycle = knowledge.run_memory_lifecycle();
94    knowledge.save()?;
95
96    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
97        category: "memory".to_string(),
98        key: "consolidation".to_string(),
99        action: "run".to_string(),
100    });
101
102    Ok(ConsolidationOutcome {
103        promoted: promoted_decisions + promoted_findings,
104        promoted_decisions,
105        promoted_findings,
106        lifecycle_archived: lifecycle.archived_count,
107        lifecycle_remaining: lifecycle.remaining_facts,
108    })
109}
110
111fn slug_key(s: &str, max: usize) -> String {
112    let mut out = String::new();
113    for ch in s.chars() {
114        if out.len() >= max {
115            break;
116        }
117        if ch.is_ascii_alphanumeric() {
118            out.push(ch.to_ascii_lowercase());
119        } else if (ch.is_whitespace() || ch == '-' || ch == '_')
120            && !out.ends_with('-')
121            && !out.is_empty()
122        {
123            out.push('-');
124        }
125    }
126    out.trim_matches('-').to_string()
127}
128
129fn finding_salience(summary: &str) -> u32 {
130    let s = summary.to_lowercase();
131    let mut score = 20u32;
132
133    let boosts = [
134        ("error", 25),
135        ("failed", 25),
136        ("panic", 30),
137        ("assert", 20),
138        ("forbidden", 25),
139        ("timeout", 20),
140        ("deadlock", 25),
141        ("security", 25),
142        ("vuln", 25),
143        ("e0", 15), // rust error codes often start with E0xxx
144    ];
145
146    for (pat, b) in boosts {
147        if s.contains(pat) {
148            score = score.saturating_add(b);
149        }
150    }
151
152    score
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn consolidate_promotes_decisions_and_salient_findings_only() {
161        let _lock = crate::core::data_dir::test_env_lock();
162        let tmp = tempfile::tempdir().expect("tempdir");
163        std::env::set_var(
164            "LEAN_CTX_DATA_DIR",
165            tmp.path().to_string_lossy().to_string(),
166        );
167
168        let project_root = tmp.path().join("proj");
169        std::fs::create_dir_all(&project_root).expect("mkdir");
170        let project_root_str = project_root.to_string_lossy().to_string();
171
172        let mut session = SessionState::new();
173        session.project_root = Some(project_root_str.clone());
174        session.add_decision("Use archive-only memory lifecycle", None);
175        session.add_finding(None, None, "panic: index out of bounds");
176        session.add_finding(None, None, "just a note");
177        session.save().expect("save session");
178
179        let out = consolidate_latest(
180            &project_root_str,
181            ConsolidationBudgets {
182                max_decisions: 5,
183                max_findings: 5,
184            },
185        )
186        .expect("consolidate");
187        assert!(out.promoted_decisions >= 1);
188        assert!(out.promoted_findings >= 1);
189
190        let k = ProjectKnowledge::load(&project_root_str).expect("knowledge saved");
191        let active = k.facts.iter().filter(|f| f.is_current()).count();
192        assert!(active >= 2, "expected promoted facts");
193
194        std::env::remove_var("LEAN_CTX_DATA_DIR");
195    }
196}