Skip to main content

mneme/
consolidate.rs

1//! Memory consolidation and memory blocks (Letta-inspired).
2//!
3//! Consolidation: compact stale/old/unused memories into auto-generated summary memories.
4//! Memory blocks: `human`, `persona`, `workflow` slots per project — inspired by Letta's
5//! memory blocks architecture.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::store::db::Database;
11use crate::store::memory::{CreateMemoryInput, Importance, Memory, MemoryType, Scope};
12
13/// Resultado de una corrida de consolidación.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct ConsolidationResult {
16    pub project: String,
17    pub total_analyzed: u32,
18    pub total_consolidated: u32,
19    pub total_removed: u32,
20    pub summary_memory_title: Option<String>,
21    pub summary_memory_id: Option<String>,
22    pub details: Vec<String>,
23}
24
25/// Un block de memoria (Letta-style slot).
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MemoryBlock {
28    pub id: i64,
29    pub project: String,
30    pub slot: String,
31    pub memory_id: String,
32    pub title: String,
33    pub content: String,
34    pub updated_at: DateTime<Utc>,
35}
36
37/// Estrategia de consolidación.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum ConsolidationStrategy {
40    Auto,
41    Age,
42    Deprecated,
43    Unused,
44}
45
46impl std::fmt::Display for ConsolidationStrategy {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            ConsolidationStrategy::Auto => write!(f, "auto"),
50            ConsolidationStrategy::Age => write!(f, "age"),
51            ConsolidationStrategy::Deprecated => write!(f, "deprecated"),
52            ConsolidationStrategy::Unused => write!(f, "unused"),
53        }
54    }
55}
56
57/// Motor de consolidación.
58pub struct ConsolidationEngine {
59    db: std::sync::Arc<Database>,
60}
61
62impl ConsolidationEngine {
63    pub fn new(db: std::sync::Arc<Database>) -> Self {
64        Self { db }
65    }
66
67    /// Runs consolidation: finds stale/old/deprecated memories, generates a summary,
68    /// and optionally soft-deletes the originals.
69    pub fn consolidate(
70        &self,
71        project: &str,
72        strategy: ConsolidationStrategy,
73        days_threshold: u64,
74        dry_run: bool,
75    ) -> crate::error::Result<ConsolidationResult> {
76        let store = self.db.memories();
77        let conn = self.db.get_conn();
78
79        let cutoff = Utc::now() - chrono::Duration::days(days_threshold as i64);
80        let cutoff_str = cutoff.to_rfc3339();
81        let mut result = ConsolidationResult {
82            project: project.to_string(),
83            ..Default::default()
84        };
85
86        // 1. Find candidates
87        let candidates: Vec<Memory> = {
88            let conn_guard = conn
89                .lock()
90                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
91            let (where_clause, note) = match strategy {
92                ConsolidationStrategy::Age => ("(m.updated_at < ?1 OR m.last_accessed_at < ?1)", "by age"),
93                ConsolidationStrategy::Deprecated => ("m.deprecated_at IS NOT NULL", "by deprecation"),
94                ConsolidationStrategy::Unused => ("m.access_count = 0 AND m.created_at < ?1", "by unused"),
95                ConsolidationStrategy::Auto => (
96                    "(m.updated_at < ?1 OR m.last_accessed_at < ?1 OR m.deprecated_at IS NOT NULL OR m.access_count = 0)",
97                    "by auto (age + deprecated + unused)",
98                ),
99            };
100            let sql = format!(
101                "SELECT m.id, m.project, m.scope, m.title, m.content, m.what, m.why, m.context, m.learned,
102                        m.memory_type, m.importance, m.tags, m.topic_key, m.access_count, m.revision_count,
103                        m.duplicate_count, m.normalized_hash, m.created_at, m.updated_at, m.last_accessed_at, m.last_seen_at, m.deleted_at,
104                        m.deprecated_at, m.deprecated_reason, m.supersedes_id, m.context_inject_count, m.origin_peer,
105                        m.is_encrypted, m.encrypted_for, m.valid_from, m.valid_until, m.provenance
106                 FROM memories m
107                 WHERE m.project = ?2 AND m.deleted_at IS NULL
108                 AND {}  LIMIT 200",
109                where_clause
110            );
111            let mut stmt = conn_guard.prepare(&sql)?;
112            let params: Vec<Box<dyn rusqlite::ToSql>> =
113                if strategy == ConsolidationStrategy::Deprecated {
114                    vec![Box::new(project.to_string())]
115                } else {
116                    vec![Box::new(cutoff_str.clone()), Box::new(project.to_string())]
117                };
118            let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
119            let rows = stmt.query_map(param_refs.as_slice(), |row| {
120                crate::store::memory::MemoryStore::row_to_memory(row)
121                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
122            })?;
123            let mut mems = Vec::new();
124            for m in rows.flatten() {
125                mems.push(m);
126            }
127            result.total_analyzed = mems.len() as u32;
128            if !mems.is_empty() {
129                result
130                    .details
131                    .push(format!("Found {} candidates {}", mems.len(), note));
132            }
133            mems
134        };
135
136        if candidates.is_empty() {
137            result.details.push("No candidates found".to_string());
138            return Ok(result);
139        }
140
141        // 2. Generate summary memory
142        let summary_content = self.generate_summary(&candidates);
143        let summary_title = format!(
144            "[Consolidation] {} stale memories ({})",
145            candidates.len(),
146            strategy
147        );
148
149        if !dry_run {
150            let input = CreateMemoryInput {
151                project: project.to_string(),
152                scope: Some(Scope::Project),
153                title: summary_title.clone(),
154                content: summary_content,
155                what: Some(format!("Consolidated {} stale memories", candidates.len())),
156                why: Some(format!(
157                    "Strategy: {}, threshold: {} days",
158                    strategy, days_threshold
159                )),
160                context: None,
161                learned: Some(format!(
162                    "Memories consolidated: {}",
163                    candidates
164                        .iter()
165                        .map(|m| m.title.as_str())
166                        .collect::<Vec<_>>()
167                        .join(", ")
168                )),
169                memory_type: MemoryType::Note,
170                importance: Importance::Low,
171                tags: vec!["consolidation".to_string(), strategy.to_string()],
172                topic_key: Some(format!("consolidation/{}", strategy)),
173                capture_prompt: None,
174                encrypt: false,
175                valid_from: None,
176                valid_until: None,
177                provenance: Some(format!(
178                    "consolidation/{}/{}",
179                    strategy,
180                    Utc::now().to_rfc3339()
181                )),
182            };
183            let summary = store.save(input, None, None)?;
184            result.summary_memory_title = Some(summary.title);
185            result.summary_memory_id = Some(summary.id.to_string());
186            result.total_consolidated = candidates.len() as u32;
187
188            // 3. Soft-delete originals
189            for mem in &candidates {
190                if mem.deprecated_at.is_none() {
191                    store.delete(mem.id, false)?;
192                    result.total_removed += 1;
193                }
194            }
195
196            // 4. Log the consolidation
197            let rationale = format!(
198                "Consolidation of {} memories using {} strategy ({}d threshold). {} removed.",
199                candidates.len(),
200                strategy,
201                days_threshold,
202                result.total_removed
203            );
204            let conn_guard2 = conn
205                .lock()
206                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
207            conn_guard2.execute(
208                "INSERT INTO consolidation_log (project, run_at, total_consolidated, total_removed, summary_memory_id, strategy, rationale)
209                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
210                rusqlite::params![
211                    project,
212                    Utc::now().to_rfc3339(),
213                    result.total_consolidated as i64,
214                    result.total_removed as i64,
215                    result.summary_memory_id,
216                    strategy.to_string(),
217                    rationale,
218                ],
219            )?;
220            result.details.push(rationale);
221        } else {
222            result.total_consolidated = candidates.len() as u32;
223            result.details.push(format!(
224                "[dry-run] Would consolidate {} memories",
225                candidates.len()
226            ));
227        }
228
229        Ok(result)
230    }
231
232    fn generate_summary(&self, candidates: &[Memory]) -> String {
233        let mut parts = vec!["# Consolidation Summary\n".to_string()];
234        parts.push(format!("Generated: {}\n", Utc::now().to_rfc3339()));
235        parts.push(format!("Total memories: {}\n", candidates.len()));
236
237        // By type
238        let mut by_type: std::collections::BTreeMap<String, u32> =
239            std::collections::BTreeMap::new();
240        for m in candidates {
241            *by_type.entry(m.memory_type.to_string()).or_insert(0) += 1;
242        }
243        parts.push("## By Type\n".to_string());
244        for (t, c) in &by_type {
245            parts.push(format!("- {}: {}\n", t, c));
246        }
247
248        parts.push("\n## Memories Consolidated\n".to_string());
249        for m in candidates {
250            let age = Utc::now().signed_duration_since(m.updated_at).num_days();
251            let access = m.access_count;
252            let deprecated = if m.deprecated_at.is_some() {
253                " [DEPRECATED]"
254            } else {
255                ""
256            };
257            parts.push(format!(
258                "- {} ({}d ago, {} accesses){}: {}",
259                m.title,
260                age,
261                access,
262                deprecated,
263                m.content.chars().take(80).collect::<String>()
264            ));
265        }
266        parts.join("\n")
267    }
268
269    /// Gets the consolidation log for a project.
270    pub fn get_log(
271        &self,
272        project: &str,
273        limit: u32,
274    ) -> crate::error::Result<Vec<serde_json::Value>> {
275        let conn = self.db.get_conn();
276        let conn = conn
277            .lock()
278            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
279        let mut stmt = conn.prepare(
280            "SELECT run_at, total_consolidated, total_removed, summary_memory_id, strategy, rationale
281             FROM consolidation_log WHERE project = ?1 ORDER BY run_at DESC LIMIT ?2"
282        )?;
283        let rows = stmt.query_map(rusqlite::params![project, limit as i64], |row| {
284            Ok(serde_json::json!({
285                "run_at": row.get::<_, String>(0)?,
286                "consolidated": row.get::<_, i64>(1)?,
287                "removed": row.get::<_, i64>(2)?,
288                "summary_id": row.get::<_, Option<String>>(3)?,
289                "strategy": row.get::<_, String>(4)?,
290                "rationale": row.get::<_, String>(5)?,
291            }))
292        })?;
293        let mut log = Vec::new();
294        for row in rows {
295            log.push(row?);
296        }
297        Ok(log)
298    }
299
300    // === Memory Blocks (Letta-inspired) ===
301
302    pub fn set_block(
303        &self,
304        project: &str,
305        slot: &str,
306        title: &str,
307        content: &str,
308    ) -> crate::error::Result<MemoryBlock> {
309        let store = self.db.memories();
310        let input = CreateMemoryInput {
311            project: project.to_string(),
312            scope: Some(Scope::Project),
313            title: format!("[{}] {}", slot, title),
314            content: content.to_string(),
315            what: Some(format!("Memory block: {}", slot)),
316            why: None,
317            context: None,
318            learned: None,
319            memory_type: MemoryType::Convention,
320            importance: Importance::High,
321            tags: vec![slot.to_string(), "block".to_string()],
322            topic_key: Some(format!("block/{}", slot)),
323            capture_prompt: None,
324            encrypt: false,
325            valid_from: None,
326            valid_until: None,
327            provenance: Some(format!("block/{}/auto", slot)),
328        };
329        let memory = store.save(input, None, None)?;
330
331        let conn = self.db.get_conn();
332        let conn = conn
333            .lock()
334            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
335        conn.execute(
336            "INSERT OR REPLACE INTO memory_blocks (project, slot, memory_id, title, updated_at)
337             VALUES (?1, ?2, ?3, ?4, ?5)",
338            rusqlite::params![
339                project,
340                slot,
341                memory.id.to_string(),
342                title,
343                Utc::now().to_rfc3339()
344            ],
345        )?;
346        let id = conn.last_insert_rowid();
347
348        Ok(MemoryBlock {
349            id,
350            project: project.to_string(),
351            slot: slot.to_string(),
352            memory_id: memory.id.to_string(),
353            title: title.to_string(),
354            content: content.to_string(),
355            updated_at: Utc::now(),
356        })
357    }
358
359    pub fn get_block(
360        &self,
361        project: &str,
362        slot: &str,
363    ) -> crate::error::Result<Option<MemoryBlock>> {
364        let conn = self.db.get_conn();
365        let conn = conn
366            .lock()
367            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
368        let result = conn.query_row(
369            "SELECT mb.id, mb.project, mb.slot, mb.memory_id, mb.title, mb.updated_at, m.content
370             FROM memory_blocks mb
371             JOIN memories m ON m.id = mb.memory_id
372             WHERE mb.project = ?1 AND mb.slot = ?2 AND m.deleted_at IS NULL",
373            rusqlite::params![project, slot],
374            |row| {
375                Ok(MemoryBlock {
376                    id: row.get(0)?,
377                    project: row.get(1)?,
378                    slot: row.get(2)?,
379                    memory_id: row.get(3)?,
380                    title: row.get(4)?,
381                    content: row.get::<_, String>(6)?,
382                    updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)
383                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
384                        .with_timezone(&Utc),
385                })
386            },
387        );
388        match result {
389            Ok(block) => Ok(Some(block)),
390            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
391            Err(e) => Err(e.into()),
392        }
393    }
394
395    pub fn list_blocks(&self, project: &str) -> crate::error::Result<Vec<MemoryBlock>> {
396        let conn = self.db.get_conn();
397        let conn = conn
398            .lock()
399            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
400        let mut stmt = conn.prepare(
401            "SELECT mb.id, mb.project, mb.slot, mb.memory_id, mb.title, mb.updated_at, m.content
402             FROM memory_blocks mb
403             JOIN memories m ON m.id = mb.memory_id
404             WHERE mb.project = ?1 AND m.deleted_at IS NULL
405             ORDER BY mb.slot",
406        )?;
407        let rows = stmt.query_map(rusqlite::params![project], |row| {
408            let updated_at_str: String = row.get(5)?;
409            Ok(MemoryBlock {
410                id: row.get(0)?,
411                project: row.get(1)?,
412                slot: row.get(2)?,
413                memory_id: row.get(3)?,
414                title: row.get(4)?,
415                content: row.get::<_, String>(6)?,
416                updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at_str)
417                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?
418                    .with_timezone(&Utc),
419            })
420        })?;
421        let mut blocks = Vec::new();
422        for row in rows {
423            blocks.push(row?);
424        }
425        Ok(blocks)
426    }
427}
428
429/// Formats a consolidation result for display.
430pub fn format_consolidation_result(result: &ConsolidationResult) -> String {
431    let mut out = String::new();
432    out.push_str(&format!("# Consolidation: {}\n\n", result.project));
433    out.push_str(&format!("- Analyzed: {}\n", result.total_analyzed));
434    out.push_str(&format!("- Consolidated: {}\n", result.total_consolidated));
435    out.push_str(&format!("- Removed: {}\n", result.total_removed));
436    if let Some(ref title) = result.summary_memory_title {
437        out.push_str(&format!("- Summary: {}\n", title));
438    }
439    if !result.details.is_empty() {
440        out.push_str("\n## Details\n\n");
441        for d in &result.details {
442            out.push_str(&format!("- {}\n", d));
443        }
444    }
445    out
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn make_db() -> std::sync::Arc<Database> {
453        let path = std::path::PathBuf::from(format!(
454            "/tmp/mneme_consolidate_test_{}.db",
455            uuid::Uuid::new_v4()
456        ));
457        std::sync::Arc::new(Database::open(&path).unwrap())
458    }
459
460    #[test]
461    fn test_consolidation_empty_project() {
462        let db = make_db();
463        let eng = ConsolidationEngine::new(db);
464        let result = eng
465            .consolidate("nonexistent", ConsolidationStrategy::Auto, 30, false)
466            .unwrap();
467        assert_eq!(result.total_analyzed, 0);
468        assert_eq!(result.total_consolidated, 0);
469    }
470
471    #[test]
472    fn test_consolidation_dry_run_on_empty() {
473        let db = make_db();
474        let eng = ConsolidationEngine::new(db);
475        let result = eng
476            .consolidate("empty", ConsolidationStrategy::Age, 7, true)
477            .unwrap();
478        assert_eq!(result.total_analyzed, 0);
479    }
480
481    #[test]
482    fn test_memory_block_set_and_get() {
483        let db = make_db();
484        let eng = ConsolidationEngine::new(db);
485        let block = eng
486            .set_block(
487                "test-proj",
488                "human",
489                "User Identity",
490                "I am Daniel, a Rust developer.",
491            )
492            .unwrap();
493        assert_eq!(block.slot, "human");
494        assert_eq!(block.title, "User Identity");
495        assert!(block.memory_id.len() > 10);
496
497        let fetched = eng.get_block("test-proj", "human").unwrap().unwrap();
498        assert_eq!(fetched.title, "User Identity");
499        assert!(fetched.content.contains("Daniel"));
500    }
501
502    #[test]
503    fn test_memory_block_get_nonexistent() {
504        let db = make_db();
505        let eng = ConsolidationEngine::new(db);
506        let result = eng.get_block("nonexistent", "human").unwrap();
507        assert!(result.is_none());
508    }
509
510    #[test]
511    fn test_memory_block_list() {
512        let db = make_db();
513        let eng = ConsolidationEngine::new(db);
514        eng.set_block("proj1", "human", "User", "I am user")
515            .unwrap();
516        eng.set_block("proj1", "persona", "Assistant", "I am assistant")
517            .unwrap();
518        let blocks = eng.list_blocks("proj1").unwrap();
519        assert_eq!(blocks.len(), 2);
520    }
521
522    #[test]
523    fn test_consolidation_strategy_display() {
524        assert_eq!(ConsolidationStrategy::Auto.to_string(), "auto");
525        assert_eq!(ConsolidationStrategy::Age.to_string(), "age");
526        assert_eq!(ConsolidationStrategy::Deprecated.to_string(), "deprecated");
527        assert_eq!(ConsolidationStrategy::Unused.to_string(), "unused");
528    }
529
530    #[test]
531    fn test_format_consolidation_result() {
532        let result = ConsolidationResult {
533            project: "test".to_string(),
534            total_analyzed: 10,
535            total_consolidated: 5,
536            total_removed: 3,
537            summary_memory_title: Some("[Consolidation] 5 memories".to_string()),
538            summary_memory_id: Some("uuid".to_string()),
539            details: vec!["Found 5 candidates by age".to_string()],
540        };
541        let s = format_consolidation_result(&result);
542        assert!(s.contains("Analyzed: 10"));
543        assert!(s.contains("Consolidated: 5"));
544        assert!(s.contains("Removed: 3"));
545        assert!(s.contains("[Consolidation]"));
546    }
547}