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