Skip to main content

enact_memory/
markdown.rs

1use crate::traits::{Memory, MemoryCategory, MemoryEntry};
2use async_trait::async_trait;
3use chrono::Utc;
4use std::path::{Path, PathBuf};
5
6pub struct MarkdownMemory {
7    root: PathBuf,
8}
9
10impl MarkdownMemory {
11    pub fn new(workspace_dir: &Path) -> Self {
12        Self {
13            root: workspace_dir.join("memory"),
14        }
15    }
16
17    fn file_for(&self, key: &str) -> PathBuf {
18        let safe = key
19            .chars()
20            .map(|c| {
21                if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
22                    c
23                } else {
24                    '_'
25                }
26            })
27            .collect::<String>();
28        self.root.join(format!("{safe}.md"))
29    }
30
31    fn parse_entry(path: &Path, content: &str) -> Option<MemoryEntry> {
32        let key = path.file_stem()?.to_str()?.to_string();
33        Some(MemoryEntry {
34            id: key.clone(),
35            key,
36            content: content.to_string(),
37            category: MemoryCategory::Conversation,
38            timestamp: Utc::now().to_rfc3339(),
39            session_id: None,
40            score: None,
41        })
42    }
43}
44
45#[async_trait]
46impl Memory for MarkdownMemory {
47    fn name(&self) -> &str {
48        "markdown"
49    }
50
51    async fn store(
52        &self,
53        key: &str,
54        content: &str,
55        _category: MemoryCategory,
56        _session_id: Option<&str>,
57    ) -> anyhow::Result<()> {
58        tokio::fs::create_dir_all(&self.root).await?;
59        tokio::fs::write(self.file_for(key), content).await?;
60        Ok(())
61    }
62
63    async fn recall(
64        &self,
65        query: &str,
66        limit: usize,
67        _session_id: Option<&str>,
68    ) -> anyhow::Result<Vec<MemoryEntry>> {
69        let mut entries = self.list(None, None).await?;
70        let q = query.to_ascii_lowercase();
71        entries.retain(|e| {
72            e.key.to_ascii_lowercase().contains(&q) || e.content.to_ascii_lowercase().contains(&q)
73        });
74        entries.truncate(limit);
75        Ok(entries)
76    }
77
78    async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
79        let path = self.file_for(key);
80        if !path.exists() {
81            return Ok(None);
82        }
83        let content = tokio::fs::read_to_string(&path).await?;
84        Ok(Self::parse_entry(&path, &content))
85    }
86
87    async fn list(
88        &self,
89        _category: Option<&MemoryCategory>,
90        _session_id: Option<&str>,
91    ) -> anyhow::Result<Vec<MemoryEntry>> {
92        let mut out = Vec::new();
93        if !self.root.exists() {
94            return Ok(out);
95        }
96
97        let mut dir = tokio::fs::read_dir(&self.root).await?;
98        while let Some(entry) = dir.next_entry().await? {
99            let path = entry.path();
100            if path.extension().and_then(|e| e.to_str()) != Some("md") {
101                continue;
102            }
103            let content = tokio::fs::read_to_string(&path).await?;
104            if let Some(parsed) = Self::parse_entry(&path, &content) {
105                out.push(parsed);
106            }
107        }
108        Ok(out)
109    }
110
111    async fn forget(&self, key: &str) -> anyhow::Result<bool> {
112        let path = self.file_for(key);
113        if !path.exists() {
114            return Ok(false);
115        }
116        tokio::fs::remove_file(path).await?;
117        Ok(true)
118    }
119
120    async fn count(&self) -> anyhow::Result<usize> {
121        Ok(self.list(None, None).await?.len())
122    }
123
124    async fn health_check(&self) -> bool {
125        tokio::fs::create_dir_all(&self.root).await.is_ok()
126    }
127}