Skip to main content

zeroclaw_memory/
markdown.rs

1use super::traits::{Memory, MemoryCategory, MemoryEntry, is_recent_recall_query};
2use async_trait::async_trait;
3use chrono::Local;
4use std::path::{Path, PathBuf};
5use tokio::fs;
6
7/// Markdown-based memory — plain files as source of truth
8///
9/// Layout:
10///   workspace/MEMORY.md          — curated long-term memory (core)
11///   workspace/memory/YYYY-MM-DD.md — daily logs (append-only)
12pub struct MarkdownMemory {
13    alias: String,
14    workspace_dir: PathBuf,
15}
16
17impl MarkdownMemory {
18    pub fn new(alias: &str, workspace_dir: &Path) -> Self {
19        Self {
20            alias: alias.to_string(),
21            workspace_dir: workspace_dir.to_path_buf(),
22        }
23    }
24
25    fn memory_dir(&self) -> PathBuf {
26        self.workspace_dir.join("memory")
27    }
28
29    fn core_path(&self) -> PathBuf {
30        self.workspace_dir.join("MEMORY.md")
31    }
32
33    fn daily_path(&self) -> PathBuf {
34        let date = Local::now().format("%Y-%m-%d").to_string();
35        self.memory_dir().join(format!("{date}.md"))
36    }
37
38    async fn ensure_dirs(&self) -> anyhow::Result<()> {
39        fs::create_dir_all(self.memory_dir()).await?;
40        Ok(())
41    }
42
43    async fn append_to_file(&self, path: &Path, content: &str) -> anyhow::Result<()> {
44        self.ensure_dirs().await?;
45
46        let existing = if path.exists() {
47            fs::read_to_string(path).await.unwrap_or_default()
48        } else {
49            String::new()
50        };
51
52        let updated = if existing.is_empty() {
53            let header = if path == self.core_path() {
54                "# Long-Term Memory\n\n"
55            } else {
56                let date = Local::now().format("%Y-%m-%d").to_string();
57                &format!("# Daily Log — {date}\n\n")
58            };
59            format!("{header}{content}\n")
60        } else {
61            format!("{existing}\n{content}\n")
62        };
63
64        fs::write(path, updated).await?;
65        Ok(())
66    }
67
68    fn parse_entries_from_file(
69        path: &Path,
70        content: &str,
71        category: &MemoryCategory,
72    ) -> Vec<MemoryEntry> {
73        let filename = path
74            .file_stem()
75            .and_then(|s| s.to_str())
76            .unwrap_or("unknown");
77
78        content
79            .lines()
80            .filter(|line| {
81                let trimmed = line.trim();
82                !trimmed.is_empty() && !trimmed.starts_with('#')
83            })
84            .enumerate()
85            .map(|(i, line)| {
86                let trimmed = line.trim();
87                let clean = trimmed.strip_prefix("- ").unwrap_or(trimmed);
88                MemoryEntry {
89                    id: format!("{filename}:{i}"),
90                    key: format!("{filename}:{i}"),
91                    content: clean.to_string(),
92                    category: category.clone(),
93                    timestamp: filename.to_string(),
94                    session_id: None,
95                    score: None,
96                    namespace: "default".into(),
97                    importance: None,
98                    superseded_by: None,
99                    agent_alias: None,
100                    agent_id: None,
101                }
102            })
103            .collect()
104    }
105
106    async fn read_all_entries(&self) -> anyhow::Result<Vec<MemoryEntry>> {
107        let mut entries = Vec::new();
108
109        // Read MEMORY.md (core)
110        let core_path = self.core_path();
111        if core_path.exists() {
112            let content = fs::read_to_string(&core_path).await?;
113            entries.extend(Self::parse_entries_from_file(
114                &core_path,
115                &content,
116                &MemoryCategory::Core,
117            ));
118        }
119
120        // Read daily logs
121        let mem_dir = self.memory_dir();
122        if mem_dir.exists() {
123            let mut dir = fs::read_dir(&mem_dir).await?;
124            while let Some(entry) = dir.next_entry().await? {
125                let path = entry.path();
126                if path.extension().and_then(|e| e.to_str()) == Some("md") {
127                    let content = fs::read_to_string(&path).await?;
128                    entries.extend(Self::parse_entries_from_file(
129                        &path,
130                        &content,
131                        &MemoryCategory::Daily,
132                    ));
133                }
134            }
135        }
136
137        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
138        Ok(entries)
139    }
140}
141
142#[async_trait]
143impl Memory for MarkdownMemory {
144    fn name(&self) -> &str {
145        "markdown"
146    }
147
148    async fn store(
149        &self,
150        key: &str,
151        content: &str,
152        category: MemoryCategory,
153        _session_id: Option<&str>,
154    ) -> anyhow::Result<()> {
155        let entry = format!("- **{key}**: {content}");
156        let path = match category {
157            MemoryCategory::Core => self.core_path(),
158            _ => self.daily_path(),
159        };
160        self.append_to_file(&path, &entry).await
161    }
162
163    async fn recall(
164        &self,
165        query: &str,
166        limit: usize,
167        _session_id: Option<&str>,
168        since: Option<&str>,
169        until: Option<&str>,
170    ) -> anyhow::Result<Vec<MemoryEntry>> {
171        let since_dt = since
172            .map(chrono::DateTime::parse_from_rfc3339)
173            .transpose()
174            .map_err(|e| {
175                ::zeroclaw_log::record!(
176                    WARN,
177                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
178                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
179                        .with_attrs(
180                            ::serde_json::json!({"field": "since", "error": format!("{}", e)})
181                        ),
182                    "recall window bound rejected"
183                );
184                anyhow::Error::msg(format!("invalid 'since' date (expected RFC 3339): {e}"))
185            })?;
186        let until_dt = until
187            .map(chrono::DateTime::parse_from_rfc3339)
188            .transpose()
189            .map_err(|e| {
190                ::zeroclaw_log::record!(
191                    WARN,
192                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
193                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
194                        .with_attrs(
195                            ::serde_json::json!({"field": "until", "error": format!("{}", e)})
196                        ),
197                    "recall window bound rejected"
198                );
199                anyhow::Error::msg(format!("invalid 'until' date (expected RFC 3339): {e}"))
200            })?;
201        if let (Some(s), Some(u)) = (&since_dt, &until_dt)
202            && s >= u
203        {
204            anyhow::bail!("'since' must be before 'until'");
205        }
206
207        let all = self.read_all_entries().await?;
208        let keywords: Vec<String> = if is_recent_recall_query(query) {
209            Vec::new()
210        } else {
211            query
212                .to_lowercase()
213                .split_whitespace()
214                .map(str::to_string)
215                .collect()
216        };
217
218        let mut scored: Vec<MemoryEntry> = all
219            .into_iter()
220            .filter_map(|mut entry| {
221                if let Some(ref s) = since_dt
222                    && let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
223                    && ts < *s
224                {
225                    return None;
226                }
227                if let Some(ref u) = until_dt
228                    && let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
229                    && ts > *u
230                {
231                    return None;
232                }
233                if keywords.is_empty() {
234                    entry.score = Some(1.0);
235                    return Some(entry);
236                }
237                let content_lower = entry.content.to_lowercase();
238                let matched = keywords
239                    .iter()
240                    .filter(|kw| content_lower.contains(kw.as_str()))
241                    .count();
242                if matched > 0 {
243                    #[allow(clippy::cast_precision_loss)]
244                    let score = matched as f64 / keywords.len() as f64;
245                    entry.score = Some(score);
246                    Some(entry)
247                } else {
248                    None
249                }
250            })
251            .collect();
252
253        scored.sort_by(|a, b| {
254            if keywords.is_empty() {
255                b.timestamp.as_str().cmp(a.timestamp.as_str())
256            } else {
257                b.score
258                    .partial_cmp(&a.score)
259                    .unwrap_or(std::cmp::Ordering::Equal)
260            }
261        });
262        scored.truncate(limit);
263        Ok(scored)
264    }
265
266    async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
267        let all = self.read_all_entries().await?;
268        Ok(all
269            .into_iter()
270            .find(|e| e.key == key || e.content.contains(key)))
271    }
272
273    async fn list(
274        &self,
275        category: Option<&MemoryCategory>,
276        _session_id: Option<&str>,
277    ) -> anyhow::Result<Vec<MemoryEntry>> {
278        let all = self.read_all_entries().await?;
279        match category {
280            Some(cat) => Ok(all.into_iter().filter(|e| &e.category == cat).collect()),
281            None => Ok(all),
282        }
283    }
284
285    async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
286        // Markdown memory is append-only by design (audit trail)
287        // Return false to indicate the entry wasn't removed
288        Ok(false)
289    }
290
291    async fn forget_for_agent(&self, _key: &str, _agent_id: &str) -> anyhow::Result<bool> {
292        Ok(false)
293    }
294
295    async fn count(&self) -> anyhow::Result<usize> {
296        let all = self.read_all_entries().await?;
297        Ok(all.len())
298    }
299
300    async fn health_check(&self) -> bool {
301        self.workspace_dir.exists()
302    }
303
304    async fn store_with_agent(
305        &self,
306        key: &str,
307        content: &str,
308        category: MemoryCategory,
309        session_id: Option<&str>,
310        _namespace: Option<&str>,
311        _importance: Option<f64>,
312        _agent_id: Option<&str>,
313    ) -> anyhow::Result<()> {
314        // Markdown's per-agent attribution is the on-disk path: the
315        // backend writes into `<workspace_dir>/MEMORY.md` and the
316        // workspace_dir is owned by the agent that constructed this
317        // backend. The agent_id parameter is redundant and ignored at
318        // the trait boundary; cross-agent reads merge multiple
319        // MarkdownMemory instances at the `AgentScopedMarkdownMemory`
320        // wrapper layer.
321        self.store(key, content, category, session_id).await
322    }
323
324    async fn recall_for_agents(
325        &self,
326        _allowed_agent_ids: &[&str],
327        query: &str,
328        limit: usize,
329        session_id: Option<&str>,
330        since: Option<&str>,
331        until: Option<&str>,
332    ) -> anyhow::Result<Vec<MemoryEntry>> {
333        // Same per-agent-path attribution model as `store_with_agent`:
334        // a single MarkdownMemory instance reads only its own
335        // workspace_dir. Cross-agent recall is composed by
336        // `AgentScopedMarkdownMemory`, which holds an own
337        // MarkdownMemory plus a Vec<(alias, MarkdownMemory)> peer set
338        // and unions their results with attribution.
339        self.recall(query, limit, session_id, since, until).await
340    }
341}
342
343impl ::zeroclaw_api::attribution::Attributable for MarkdownMemory {
344    fn role(&self) -> ::zeroclaw_api::attribution::Role {
345        ::zeroclaw_api::attribution::Role::Memory(::zeroclaw_api::attribution::MemoryKind::Markdown)
346    }
347    fn alias(&self) -> &str {
348        &self.alias
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use tempfile::TempDir;
356
357    fn temp_workspace() -> (TempDir, MarkdownMemory) {
358        let tmp = TempDir::new().unwrap();
359        let mem = MarkdownMemory::new("markdown", tmp.path());
360        (tmp, mem)
361    }
362
363    #[tokio::test]
364    async fn markdown_name() {
365        let (_tmp, mem) = temp_workspace();
366        assert_eq!(mem.name(), "markdown");
367    }
368
369    #[tokio::test]
370    async fn markdown_health_check() {
371        let (_tmp, mem) = temp_workspace();
372        assert!(mem.health_check().await);
373    }
374
375    #[tokio::test]
376    async fn markdown_store_core() {
377        let (_tmp, mem) = temp_workspace();
378        mem.store("pref", "User likes Rust", MemoryCategory::Core, None)
379            .await
380            .unwrap();
381        let content = fs::read_to_string(mem.core_path()).await.unwrap();
382        assert!(content.contains("User likes Rust"));
383    }
384
385    #[tokio::test]
386    async fn markdown_store_daily() {
387        let (_tmp, mem) = temp_workspace();
388        mem.store("note", "Finished tests", MemoryCategory::Daily, None)
389            .await
390            .unwrap();
391        let path = mem.daily_path();
392        let content = fs::read_to_string(path).await.unwrap();
393        assert!(content.contains("Finished tests"));
394    }
395
396    #[tokio::test]
397    async fn markdown_recall_keyword() {
398        let (_tmp, mem) = temp_workspace();
399        mem.store("a", "Rust is fast", MemoryCategory::Core, None)
400            .await
401            .unwrap();
402        mem.store("b", "Python is slow", MemoryCategory::Core, None)
403            .await
404            .unwrap();
405        mem.store("c", "Rust and safety", MemoryCategory::Core, None)
406            .await
407            .unwrap();
408
409        let results = mem.recall("Rust", 10, None, None, None).await.unwrap();
410        assert!(results.len() >= 2);
411        assert!(
412            results
413                .iter()
414                .all(|r| r.content.to_lowercase().contains("rust"))
415        );
416    }
417
418    #[tokio::test]
419    async fn markdown_recall_no_match() {
420        let (_tmp, mem) = temp_workspace();
421        mem.store("a", "Rust is great", MemoryCategory::Core, None)
422            .await
423            .unwrap();
424        let results = mem
425            .recall("javascript", 10, None, None, None)
426            .await
427            .unwrap();
428        assert!(results.is_empty());
429    }
430
431    #[tokio::test]
432    async fn markdown_recall_star_query_returns_recent_entries() {
433        let (_tmp, mem) = temp_workspace();
434        mem.store("a", "first memory", MemoryCategory::Core, None)
435            .await
436            .unwrap();
437        mem.store("b", "second memory", MemoryCategory::Daily, None)
438            .await
439            .unwrap();
440
441        let results = mem.recall("*", 10, None, None, None).await.unwrap();
442        assert_eq!(results.len(), 2);
443        assert!(
444            results
445                .iter()
446                .any(|entry| entry.content.contains("first memory"))
447        );
448        assert!(
449            results
450                .iter()
451                .any(|entry| entry.content.contains("second memory"))
452        );
453    }
454
455    #[tokio::test]
456    async fn markdown_count() {
457        let (_tmp, mem) = temp_workspace();
458        mem.store("a", "first", MemoryCategory::Core, None)
459            .await
460            .unwrap();
461        mem.store("b", "second", MemoryCategory::Core, None)
462            .await
463            .unwrap();
464        let count = mem.count().await.unwrap();
465        assert!(count >= 2);
466    }
467
468    #[tokio::test]
469    async fn markdown_list_by_category() {
470        let (_tmp, mem) = temp_workspace();
471        mem.store("a", "core fact", MemoryCategory::Core, None)
472            .await
473            .unwrap();
474        mem.store("b", "daily note", MemoryCategory::Daily, None)
475            .await
476            .unwrap();
477
478        let core = mem.list(Some(&MemoryCategory::Core), None).await.unwrap();
479        assert!(core.iter().all(|e| e.category == MemoryCategory::Core));
480
481        let daily = mem.list(Some(&MemoryCategory::Daily), None).await.unwrap();
482        assert!(daily.iter().all(|e| e.category == MemoryCategory::Daily));
483    }
484
485    #[tokio::test]
486    async fn markdown_forget_is_noop() {
487        let (_tmp, mem) = temp_workspace();
488        mem.store("a", "permanent", MemoryCategory::Core, None)
489            .await
490            .unwrap();
491        let removed = mem.forget("a").await.unwrap();
492        assert!(!removed, "Markdown memory is append-only");
493    }
494
495    #[tokio::test]
496    async fn markdown_empty_recall() {
497        let (_tmp, mem) = temp_workspace();
498        let results = mem.recall("anything", 10, None, None, None).await.unwrap();
499        assert!(results.is_empty());
500    }
501
502    #[tokio::test]
503    async fn markdown_empty_count() {
504        let (_tmp, mem) = temp_workspace();
505        assert_eq!(mem.count().await.unwrap(), 0);
506    }
507
508    // Markdown has no agents table and no UUID indirection. Rows return
509    // `agent_alias = agent_id = None`; the dashboard renders these as
510    // "unattributed". This locks that contract so a future change can't
511    // silently leak a synthesized UUID into `agent_alias` (the bug that
512    // bit the SQL backends before the JOIN landed).
513    #[tokio::test]
514    async fn markdown_entries_carry_no_agent_attribution() {
515        let (_tmp, mem) = temp_workspace();
516        mem.store("k", "v", MemoryCategory::Core, None)
517            .await
518            .unwrap();
519        let entry = mem.get("MEMORY.md:0").await.unwrap();
520        if let Some(entry) = entry {
521            assert!(
522                entry.agent_alias.is_none(),
523                "markdown rows must never claim an agent alias"
524            );
525            assert!(
526                entry.agent_id.is_none(),
527                "markdown rows must never claim a raw agent id either"
528            );
529        }
530        // list path must show the same shape regardless of how a row is
531        // surfaced (keyed lookup vs. enumeration).
532        let rows = mem.list(None, None).await.unwrap();
533        for row in rows {
534            assert!(
535                row.agent_alias.is_none(),
536                "list path must not synthesize aliases"
537            );
538            assert!(row.agent_id.is_none(), "list path must not synthesize ids");
539        }
540    }
541}