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