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
7pub 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 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 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 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 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 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 #[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 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}