1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SessionSnapshotConfig {
19 pub enabled: bool,
21
22 pub snapshot_dir: String,
24
25 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#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct EpisodicMemoryConfig {
42 pub daily_logs_dir: String,
44
45 pub max_daily_entries: usize,
47
48 pub auto_consolidate: bool,
50
51 pub consolidation_time: Option<String>,
53
54 pub retention_days: Option<u32>,
56
57 pub include_timestamps: bool,
59
60 pub session_snapshots: SessionSnapshotConfig,
62
63 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 pub fn builder() -> EpisodicMemoryConfigBuilder {
85 EpisodicMemoryConfigBuilder::default()
86 }
87
88 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 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 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 pub fn snapshots_path(&self) -> PathBuf {
115 PathBuf::from(&self.session_snapshots.snapshot_dir)
116 }
117
118 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 return now.hour() > target_hour
134 || (now.hour() == target_hour && now.minute() >= target_minute);
135 }
136 }
137 }
138
139 false
140 }
141}
142
143#[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
214pub struct EpisodicMemoryStore {
218 config: EpisodicMemoryConfig,
219 workspace_dir: PathBuf,
220}
221
222impl EpisodicMemoryStore {
223 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 fn today_log_path(&self) -> PathBuf {
233 self.workspace_dir.join(self.config.daily_log_path())
234 }
235
236 fn log_path_for(&self, date: NaiveDate) -> PathBuf {
238 self.workspace_dir.join(self.config.log_path_for_date(date))
239 }
240
241 pub async fn append(&self, content: &str) -> Result<()> {
243 let path = self.today_log_path();
244
245 if let Some(parent) = path.parent() {
247 fs::create_dir_all(parent).await?;
248 }
249
250 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 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 pub async fn append_with_category(&self, category: &str, content: &str) -> Result<()> {
272 let path = self.today_log_path();
273
274 if let Some(parent) = path.parent() {
276 fs::create_dir_all(parent).await?;
277 }
278
279 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 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 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 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 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 pub async fn today_entry_count(&self) -> Result<usize> {
329 Ok(self.read_today().await?.len())
330 }
331
332 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 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 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 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 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 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 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 #[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 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}