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}