Skip to main content

enact_memory/
episodic.rs

1//! Episodic memory configuration and storage
2//!
3//! Configures daily logs, session snapshots, and consolidation rules
4//! for episodic (short-term) memory storage.
5//!
6//! Episodic memory stores day-to-day interactions and learnings,
7//! which can later be consolidated into semantic (long-term) memory.
8
9use anyhow::Result;
10use chrono::{Local, NaiveDate, Timelike};
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13use tokio::fs::{self, OpenOptions};
14use tokio::io::AsyncWriteExt;
15
16/// Configuration for session snapshots
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SessionSnapshotConfig {
19    /// Whether to create session snapshots
20    pub enabled: bool,
21
22    /// Directory for session snapshots (relative to workspace)
23    pub snapshot_dir: String,
24
25    /// Maximum snapshots to retain before cleanup
26    pub max_snapshots: usize,
27}
28
29impl Default for SessionSnapshotConfig {
30    fn default() -> Self {
31        Self {
32            enabled: true,
33            snapshot_dir: "memory/sessions".to_string(),
34            max_snapshots: 50,
35        }
36    }
37}
38
39/// Configuration for episodic memory storage
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct EpisodicMemoryConfig {
42    /// Directory for daily log files (relative to workspace)
43    pub daily_logs_dir: String,
44
45    /// Maximum entries per daily log before rolling
46    pub max_daily_entries: usize,
47
48    /// Whether to automatically consolidate to semantic memory
49    pub auto_consolidate: bool,
50
51    /// Time of day to run consolidation (HH:MM format)
52    pub consolidation_time: Option<String>,
53
54    /// Number of days to retain episodic logs (None = forever)
55    pub retention_days: Option<u32>,
56
57    /// Whether to include timestamps in entries
58    pub include_timestamps: bool,
59
60    /// Session snapshot configuration
61    pub session_snapshots: SessionSnapshotConfig,
62
63    /// File format for daily logs (markdown, json)
64    pub format: String,
65}
66
67impl Default for EpisodicMemoryConfig {
68    fn default() -> Self {
69        Self {
70            daily_logs_dir: "memory".to_string(),
71            max_daily_entries: 100,
72            auto_consolidate: true,
73            consolidation_time: Some("03:00".to_string()),
74            retention_days: Some(30),
75            include_timestamps: true,
76            session_snapshots: SessionSnapshotConfig::default(),
77            format: "markdown".to_string(),
78        }
79    }
80}
81
82impl EpisodicMemoryConfig {
83    /// Create a new builder for EpisodicMemoryConfig
84    pub fn builder() -> EpisodicMemoryConfigBuilder {
85        EpisodicMemoryConfigBuilder::default()
86    }
87
88    /// Get the path for today's daily log
89    pub fn daily_log_path(&self) -> PathBuf {
90        let today = Local::now().format("%Y-%m-%d").to_string();
91        let extension = if self.format == "json" { "json" } else { "md" };
92        PathBuf::from(&self.daily_logs_dir).join(format!("{}.{}", today, extension))
93    }
94
95    /// Get the path for a specific date's log
96    pub fn log_path_for_date(&self, date: NaiveDate) -> PathBuf {
97        let date_str = date.format("%Y-%m-%d").to_string();
98        let extension = if self.format == "json" { "json" } else { "md" };
99        PathBuf::from(&self.daily_logs_dir).join(format!("{}.{}", date_str, extension))
100    }
101
102    /// Check if a log date is within retention period
103    pub fn is_within_retention(&self, date: NaiveDate) -> bool {
104        match self.retention_days {
105            None => true,
106            Some(days) => {
107                let cutoff = Local::now().date_naive() - chrono::Duration::days(days as i64);
108                date >= cutoff
109            }
110        }
111    }
112
113    /// Get the session snapshots directory path
114    pub fn snapshots_path(&self) -> PathBuf {
115        PathBuf::from(&self.session_snapshots.snapshot_dir)
116    }
117
118    /// Check if the consolidation time has passed today
119    pub fn should_consolidate_now(&self) -> bool {
120        if !self.auto_consolidate {
121            return false;
122        }
123
124        if let Some(ref time_str) = self.consolidation_time {
125            let parts: Vec<&str> = time_str.split(':').collect();
126            if parts.len() == 2 {
127                if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
128                    let now = Local::now();
129                    let target_hour = hour;
130                    let target_minute = minute;
131
132                    // Simple check: is it past the consolidation time?
133                    return now.hour() > target_hour
134                        || (now.hour() == target_hour && now.minute() >= target_minute);
135                }
136            }
137        }
138
139        false
140    }
141}
142
143/// Builder for EpisodicMemoryConfig
144#[derive(Debug, Default)]
145pub struct EpisodicMemoryConfigBuilder {
146    daily_logs_dir: Option<String>,
147    max_daily_entries: Option<usize>,
148    auto_consolidate: Option<bool>,
149    consolidation_time: Option<String>,
150    retention_days: Option<u32>,
151    include_timestamps: Option<bool>,
152    session_snapshots: Option<SessionSnapshotConfig>,
153    format: Option<String>,
154}
155
156impl EpisodicMemoryConfigBuilder {
157    pub fn daily_logs_dir(mut self, dir: impl Into<String>) -> Self {
158        self.daily_logs_dir = Some(dir.into());
159        self
160    }
161
162    pub fn max_daily_entries(mut self, max: usize) -> Self {
163        self.max_daily_entries = Some(max);
164        self
165    }
166
167    pub fn auto_consolidate(mut self, enabled: bool) -> Self {
168        self.auto_consolidate = Some(enabled);
169        self
170    }
171
172    pub fn consolidation_time(mut self, time: impl Into<String>) -> Self {
173        self.consolidation_time = Some(time.into());
174        self
175    }
176
177    pub fn retention_days(mut self, days: u32) -> Self {
178        self.retention_days = Some(days);
179        self
180    }
181
182    pub fn include_timestamps(mut self, enabled: bool) -> Self {
183        self.include_timestamps = Some(enabled);
184        self
185    }
186
187    pub fn session_snapshots(mut self, config: SessionSnapshotConfig) -> Self {
188        self.session_snapshots = Some(config);
189        self
190    }
191
192    pub fn format(mut self, format: impl Into<String>) -> Self {
193        self.format = Some(format.into());
194        self
195    }
196
197    pub fn build(self) -> EpisodicMemoryConfig {
198        let default = EpisodicMemoryConfig::default();
199        EpisodicMemoryConfig {
200            daily_logs_dir: self.daily_logs_dir.unwrap_or(default.daily_logs_dir),
201            max_daily_entries: self.max_daily_entries.unwrap_or(default.max_daily_entries),
202            auto_consolidate: self.auto_consolidate.unwrap_or(default.auto_consolidate),
203            consolidation_time: self.consolidation_time.or(default.consolidation_time),
204            retention_days: self.retention_days.or(default.retention_days),
205            include_timestamps: self
206                .include_timestamps
207                .unwrap_or(default.include_timestamps),
208            session_snapshots: self.session_snapshots.unwrap_or(default.session_snapshots),
209            format: self.format.unwrap_or(default.format),
210        }
211    }
212}
213
214/// Store for episodic memory operations
215///
216/// Handles reading and writing episodic memory entries to daily log files.
217pub struct EpisodicMemoryStore {
218    config: EpisodicMemoryConfig,
219    workspace_dir: PathBuf,
220}
221
222impl EpisodicMemoryStore {
223    /// Create a new episodic memory store
224    pub fn new(config: EpisodicMemoryConfig, workspace_dir: impl Into<PathBuf>) -> Self {
225        Self {
226            config,
227            workspace_dir: workspace_dir.into(),
228        }
229    }
230
231    /// Get the absolute path for today's log
232    fn today_log_path(&self) -> PathBuf {
233        self.workspace_dir.join(self.config.daily_log_path())
234    }
235
236    /// Get the absolute path for a specific date's log
237    fn log_path_for(&self, date: NaiveDate) -> PathBuf {
238        self.workspace_dir.join(self.config.log_path_for_date(date))
239    }
240
241    /// Append an entry to today's log
242    pub async fn append(&self, content: &str) -> Result<()> {
243        let path = self.today_log_path();
244
245        // Ensure directory exists
246        if let Some(parent) = path.parent() {
247            fs::create_dir_all(parent).await?;
248        }
249
250        // Build entry with optional timestamp
251        let entry = if self.config.include_timestamps {
252            let timestamp = Local::now().format("%H:%M:%S");
253            format!("- [{}] {}\n", timestamp, content)
254        } else {
255            format!("- {}\n", content)
256        };
257
258        // Append to file
259        let mut file = OpenOptions::new()
260            .create(true)
261            .append(true)
262            .open(&path)
263            .await?;
264
265        file.write_all(entry.as_bytes()).await?;
266
267        Ok(())
268    }
269
270    /// Append an entry with a specific category
271    pub async fn append_with_category(&self, category: &str, content: &str) -> Result<()> {
272        let path = self.today_log_path();
273
274        // Ensure directory exists
275        if let Some(parent) = path.parent() {
276            fs::create_dir_all(parent).await?;
277        }
278
279        // Build entry with category and optional timestamp
280        let entry = if self.config.include_timestamps {
281            let timestamp = Local::now().format("%H:%M:%S");
282            format!("- [{}] [{}] {}\n", timestamp, category, content)
283        } else {
284            format!("- [{}] {}\n", category, content)
285        };
286
287        // Append to file
288        let mut file = OpenOptions::new()
289            .create(true)
290            .append(true)
291            .open(&path)
292            .await?;
293
294        file.write_all(entry.as_bytes()).await?;
295
296        Ok(())
297    }
298
299    /// Read today's log entries
300    pub async fn read_today(&self) -> Result<Vec<String>> {
301        let path = self.today_log_path();
302        self.read_log(&path).await
303    }
304
305    /// Read entries from a specific date
306    pub async fn read_date(&self, date: NaiveDate) -> Result<Vec<String>> {
307        let path = self.log_path_for(date);
308        self.read_log(&path).await
309    }
310
311    /// Read entries from a log file
312    async fn read_log(&self, path: &PathBuf) -> Result<Vec<String>> {
313        if !path.exists() {
314            return Ok(vec![]);
315        }
316
317        let content = fs::read_to_string(path).await?;
318        let entries: Vec<String> = content
319            .lines()
320            .filter(|line| line.starts_with("- "))
321            .map(|line| line.trim_start_matches("- ").to_string())
322            .collect();
323
324        Ok(entries)
325    }
326
327    /// Get entry count for today
328    pub async fn today_entry_count(&self) -> Result<usize> {
329        Ok(self.read_today().await?.len())
330    }
331
332    /// Check if today's log needs rolling (exceeded max entries)
333    pub async fn needs_rolling(&self) -> Result<bool> {
334        let count = self.today_entry_count().await?;
335        Ok(count >= self.config.max_daily_entries)
336    }
337
338    /// List all log dates
339    pub async fn list_log_dates(&self) -> Result<Vec<NaiveDate>> {
340        let log_dir = self.workspace_dir.join(&self.config.daily_logs_dir);
341        if !log_dir.exists() {
342            return Ok(vec![]);
343        }
344
345        let mut dates = Vec::new();
346        let mut entries = fs::read_dir(&log_dir).await?;
347
348        while let Some(entry) = entries.next_entry().await? {
349            let path = entry.path();
350            if let Some(name) = path.file_stem() {
351                if let Ok(date) = NaiveDate::parse_from_str(&name.to_string_lossy(), "%Y-%m-%d") {
352                    dates.push(date);
353                }
354            }
355        }
356
357        dates.sort();
358        Ok(dates)
359    }
360
361    /// Clean up old logs based on retention policy
362    pub async fn cleanup_old_logs(&self) -> Result<usize> {
363        let retention_days = match self.config.retention_days {
364            None => return Ok(0),
365            Some(days) => days,
366        };
367
368        let log_dir = self.workspace_dir.join(&self.config.daily_logs_dir);
369        if !log_dir.exists() {
370            return Ok(0);
371        }
372
373        let cutoff = Local::now().date_naive() - chrono::Duration::days(retention_days as i64);
374        let mut removed = 0;
375
376        let mut entries = fs::read_dir(&log_dir).await?;
377        while let Some(entry) = entries.next_entry().await? {
378            let path = entry.path();
379            if let Some(name) = path.file_stem() {
380                if let Ok(date) = NaiveDate::parse_from_str(&name.to_string_lossy(), "%Y-%m-%d") {
381                    if date < cutoff {
382                        fs::remove_file(&path).await?;
383                        removed += 1;
384                    }
385                }
386            }
387        }
388
389        Ok(removed)
390    }
391
392    /// Search entries across all logs for a pattern
393    pub async fn search(&self, pattern: &str) -> Result<Vec<(NaiveDate, String)>> {
394        let dates = self.list_log_dates().await?;
395        let mut results = Vec::new();
396        let pattern_lower = pattern.to_lowercase();
397
398        for date in dates {
399            let entries = self.read_date(date).await?;
400            for entry in entries {
401                if entry.to_lowercase().contains(&pattern_lower) {
402                    results.push((date, entry));
403                }
404            }
405        }
406
407        Ok(results)
408    }
409
410    /// Get the configuration
411    pub fn config(&self) -> &EpisodicMemoryConfig {
412        &self.config
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn test_default_episodic_config() {
422        let config = EpisodicMemoryConfig::default();
423        assert_eq!(config.daily_logs_dir, "memory");
424        assert_eq!(config.max_daily_entries, 100);
425        assert!(config.auto_consolidate);
426        assert_eq!(config.retention_days, Some(30));
427        assert!(config.include_timestamps);
428        assert_eq!(config.format, "markdown");
429    }
430
431    #[test]
432    fn test_episodic_config_builder() {
433        let config = EpisodicMemoryConfig::builder()
434            .daily_logs_dir("custom/logs")
435            .max_daily_entries(50)
436            .retention_days(14)
437            .auto_consolidate(false)
438            .build();
439
440        assert_eq!(config.daily_logs_dir, "custom/logs");
441        assert_eq!(config.max_daily_entries, 50);
442        assert_eq!(config.retention_days, Some(14));
443        assert!(!config.auto_consolidate);
444    }
445
446    #[test]
447    fn test_daily_log_path_markdown() {
448        let config = EpisodicMemoryConfig::default();
449        let path = config.daily_log_path();
450        assert!(path.to_string_lossy().ends_with(".md"));
451        assert!(path.to_string_lossy().contains("memory"));
452    }
453
454    #[test]
455    fn test_daily_log_path_json() {
456        let config = EpisodicMemoryConfig::builder().format("json").build();
457        let path = config.daily_log_path();
458        assert!(path.to_string_lossy().ends_with(".json"));
459    }
460
461    #[test]
462    fn test_log_path_for_date() {
463        let config = EpisodicMemoryConfig::default();
464        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
465        let path = config.log_path_for_date(date);
466        assert!(path.to_string_lossy().contains("2024-03-15"));
467    }
468
469    #[test]
470    fn test_is_within_retention_no_limit() {
471        // Test with None (no retention limit)
472        let config = EpisodicMemoryConfig {
473            retention_days: None,
474            ..Default::default()
475        };
476
477        let old_date = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
478        assert!(config.is_within_retention(old_date));
479    }
480
481    #[test]
482    fn test_is_within_retention_with_limit() {
483        let config = EpisodicMemoryConfig::builder().retention_days(7).build();
484
485        let today = Local::now().date_naive();
486        assert!(config.is_within_retention(today));
487
488        let yesterday = today - chrono::Duration::days(1);
489        assert!(config.is_within_retention(yesterday));
490
491        let old_date = today - chrono::Duration::days(30);
492        assert!(!config.is_within_retention(old_date));
493    }
494
495    #[test]
496    fn test_session_snapshot_config_default() {
497        let config = SessionSnapshotConfig::default();
498        assert!(config.enabled);
499        assert_eq!(config.snapshot_dir, "memory/sessions");
500        assert_eq!(config.max_snapshots, 50);
501    }
502
503    #[test]
504    fn test_snapshots_path() {
505        let config = EpisodicMemoryConfig::default();
506        let path = config.snapshots_path();
507        assert_eq!(path, PathBuf::from("memory/sessions"));
508    }
509
510    #[test]
511    fn test_consolidation_time_parsing() {
512        let config = EpisodicMemoryConfig::builder()
513            .consolidation_time("00:00")
514            .build();
515
516        // At midnight or later, should_consolidate_now should return true
517        // (this is a simple test, actual behavior depends on current time)
518        // Just test that it doesn't panic
519        let _ = config.should_consolidate_now();
520    }
521
522    #[test]
523    fn test_config_serialization() {
524        let config = EpisodicMemoryConfig::default();
525        let json = serde_json::to_string(&config).unwrap();
526        let deserialized: EpisodicMemoryConfig = serde_json::from_str(&json).unwrap();
527        assert_eq!(deserialized.daily_logs_dir, config.daily_logs_dir);
528        assert_eq!(deserialized.max_daily_entries, config.max_daily_entries);
529    }
530
531    // Async tests for EpisodicMemoryStore
532
533    #[tokio::test]
534    async fn test_store_append_and_read() {
535        let temp_dir = tempfile::tempdir().unwrap();
536        let config = EpisodicMemoryConfig::builder()
537            .daily_logs_dir("logs")
538            .include_timestamps(false)
539            .build();
540
541        let store = EpisodicMemoryStore::new(config, temp_dir.path());
542
543        store.append("Test entry 1").await.unwrap();
544        store.append("Test entry 2").await.unwrap();
545
546        let entries = store.read_today().await.unwrap();
547        assert_eq!(entries.len(), 2);
548        assert_eq!(entries[0], "Test entry 1");
549        assert_eq!(entries[1], "Test entry 2");
550    }
551
552    #[tokio::test]
553    async fn test_store_append_with_category() {
554        let temp_dir = tempfile::tempdir().unwrap();
555        let config = EpisodicMemoryConfig::builder()
556            .daily_logs_dir("logs")
557            .include_timestamps(false)
558            .build();
559
560        let store = EpisodicMemoryStore::new(config, temp_dir.path());
561
562        store
563            .append_with_category("learning", "Learned about Rust lifetimes")
564            .await
565            .unwrap();
566
567        let entries = store.read_today().await.unwrap();
568        assert_eq!(entries.len(), 1);
569        assert!(entries[0].contains("[learning]"));
570        assert!(entries[0].contains("Rust lifetimes"));
571    }
572
573    #[tokio::test]
574    async fn test_store_append_with_timestamps() {
575        let temp_dir = tempfile::tempdir().unwrap();
576        let config = EpisodicMemoryConfig::builder()
577            .daily_logs_dir("logs")
578            .include_timestamps(true)
579            .build();
580
581        let store = EpisodicMemoryStore::new(config, temp_dir.path());
582
583        store.append("Timestamped entry").await.unwrap();
584
585        let entries = store.read_today().await.unwrap();
586        assert_eq!(entries.len(), 1);
587        // Entry should contain a timestamp in format [HH:MM:SS]
588        assert!(entries[0].contains("]"));
589        assert!(entries[0].contains("Timestamped entry"));
590    }
591
592    #[tokio::test]
593    async fn test_store_entry_count() {
594        let temp_dir = tempfile::tempdir().unwrap();
595        let config = EpisodicMemoryConfig::builder()
596            .daily_logs_dir("logs")
597            .include_timestamps(false)
598            .build();
599
600        let store = EpisodicMemoryStore::new(config, temp_dir.path());
601
602        assert_eq!(store.today_entry_count().await.unwrap(), 0);
603
604        store.append("Entry 1").await.unwrap();
605        store.append("Entry 2").await.unwrap();
606        store.append("Entry 3").await.unwrap();
607
608        assert_eq!(store.today_entry_count().await.unwrap(), 3);
609    }
610
611    #[tokio::test]
612    async fn test_store_needs_rolling() {
613        let temp_dir = tempfile::tempdir().unwrap();
614        let config = EpisodicMemoryConfig::builder()
615            .daily_logs_dir("logs")
616            .max_daily_entries(3)
617            .include_timestamps(false)
618            .build();
619
620        let store = EpisodicMemoryStore::new(config, temp_dir.path());
621
622        assert!(!store.needs_rolling().await.unwrap());
623
624        store.append("Entry 1").await.unwrap();
625        store.append("Entry 2").await.unwrap();
626        assert!(!store.needs_rolling().await.unwrap());
627
628        store.append("Entry 3").await.unwrap();
629        assert!(store.needs_rolling().await.unwrap());
630    }
631
632    #[tokio::test]
633    async fn test_store_read_empty() {
634        let temp_dir = tempfile::tempdir().unwrap();
635        let config = EpisodicMemoryConfig::builder()
636            .daily_logs_dir("logs")
637            .build();
638
639        let store = EpisodicMemoryStore::new(config, temp_dir.path());
640
641        let entries = store.read_today().await.unwrap();
642        assert!(entries.is_empty());
643    }
644
645    #[tokio::test]
646    async fn test_store_search() {
647        let temp_dir = tempfile::tempdir().unwrap();
648        let config = EpisodicMemoryConfig::builder()
649            .daily_logs_dir("logs")
650            .include_timestamps(false)
651            .build();
652
653        let store = EpisodicMemoryStore::new(config, temp_dir.path());
654
655        store.append("Learning about Rust").await.unwrap();
656        store.append("Working on Python project").await.unwrap();
657        store.append("More Rust practice").await.unwrap();
658
659        let results = store.search("Rust").await.unwrap();
660        assert_eq!(results.len(), 2);
661        assert!(results[0].1.contains("Rust"));
662        assert!(results[1].1.contains("Rust"));
663    }
664}