1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::store::db::Database;
11use crate::store::memory::{CreateMemoryInput, Importance, Memory, MemoryType, Scope};
12
13#[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#[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#[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
57pub 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 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 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 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 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 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 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 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 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
429pub 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}