Skip to main content

ralph_core/
memory_store.rs

1//! Markdown-based memory storage.
2//!
3//! Provides `MarkdownMemoryStore` for reading, writing, and managing
4//! memories in the `.ralph/agent/memories.md` file format.
5//!
6//! # Multi-loop Safety
7//!
8//! When multiple Ralph loops run concurrently (in worktrees), this store uses
9//! file locking to ensure safe concurrent access:
10//!
11//! - **Shared locks** for reading: Multiple loops can read simultaneously
12//! - **Exclusive locks** for writing: Only one loop can write at a time
13//!
14//! The `MarkdownMemoryStore` is Clone because it doesn't hold the lock;
15//! locks are acquired for each operation.
16
17use std::fs;
18use std::io;
19use std::path::{Path, PathBuf};
20
21use crate::file_lock::FileLock;
22use crate::memory::{Memory, MemoryType};
23use crate::memory_parser::parse_memories;
24
25/// Default path for the memories file relative to the workspace root.
26pub const DEFAULT_MEMORIES_PATH: &str = ".ralph/agent/memories.md";
27
28/// A store for managing memories in markdown format.
29///
30/// This store uses a single markdown file (`.ralph/agent/memories.md`) to persist
31/// memories. The file format is human-readable and version-control friendly.
32///
33/// # Multi-loop Safety
34///
35/// All read operations use shared locks, and all write operations use
36/// exclusive locks. This ensures safe concurrent access from multiple
37/// Ralph loops running in worktrees.
38#[derive(Debug, Clone)]
39pub struct MarkdownMemoryStore {
40    path: PathBuf,
41}
42
43impl MarkdownMemoryStore {
44    /// Creates a new store at the given path.
45    ///
46    /// The path should point to a `.md` file (typically `.ralph/agent/memories.md`).
47    /// The file does not need to exist - it will be created when first written to.
48    #[must_use]
49    pub fn new(path: impl AsRef<Path>) -> Self {
50        Self {
51            path: path.as_ref().to_path_buf(),
52        }
53    }
54
55    /// Creates a store with the default path (`.ralph/agent/memories.md`) under the given root.
56    #[must_use]
57    pub fn with_default_path(root: impl AsRef<Path>) -> Self {
58        Self::new(root.as_ref().join(DEFAULT_MEMORIES_PATH))
59    }
60
61    /// Returns the path to the memories file.
62    #[must_use]
63    pub fn path(&self) -> &Path {
64        &self.path
65    }
66
67    /// Returns true if the memories file exists.
68    #[must_use]
69    pub fn exists(&self) -> bool {
70        self.path.exists()
71    }
72
73    /// Initializes the memories file with an empty template.
74    ///
75    /// If `force` is false and the file already exists, this returns an error.
76    /// Uses an exclusive lock to prevent concurrent writes.
77    pub fn init(&self, force: bool) -> io::Result<()> {
78        let lock = FileLock::new(&self.path)?;
79        let _guard = lock.exclusive()?;
80
81        if self.exists() && !force {
82            return Err(io::Error::new(
83                io::ErrorKind::AlreadyExists,
84                format!("Memories file already exists: {}", self.path.display()),
85            ));
86        }
87
88        // Ensure parent directory exists
89        if let Some(parent) = self.path.parent() {
90            fs::create_dir_all(parent)?;
91        }
92
93        fs::write(&self.path, self.template())
94    }
95
96    /// Reads all memories from the file.
97    ///
98    /// Returns an empty vector if the file doesn't exist.
99    /// Uses a shared lock to allow concurrent reads from multiple loops.
100    pub fn load(&self) -> io::Result<Vec<Memory>> {
101        if !self.exists() {
102            return Ok(Vec::new());
103        }
104
105        let lock = FileLock::new(&self.path)?;
106        let _guard = lock.shared()?;
107
108        let content = fs::read_to_string(&self.path)?;
109        Ok(parse_memories(&content))
110    }
111
112    /// Appends a new memory to the file.
113    ///
114    /// The memory is inserted into its appropriate section (based on type).
115    /// If the file doesn't exist, it's created with the template first.
116    /// Uses an exclusive lock to prevent concurrent writes.
117    pub fn append(&self, memory: &Memory) -> io::Result<()> {
118        let lock = FileLock::new(&self.path)?;
119        let _guard = lock.exclusive()?;
120
121        let content = if self.exists() {
122            fs::read_to_string(&self.path)?
123        } else {
124            // Ensure parent directory exists
125            if let Some(parent) = self.path.parent() {
126                fs::create_dir_all(parent)?;
127            }
128            self.template()
129        };
130
131        let section = format!("## {}", memory.memory_type.section_name());
132        let memory_block = self.format_memory(memory);
133
134        let new_content = if let Some(pos) = self.find_section_insert_point(&content, &section) {
135            format!("{}{}{}", &content[..pos], memory_block, &content[pos..])
136        } else {
137            // Section doesn't exist, append section + memory at end
138            format!("{}\n{}\n{}", content.trim_end(), section, memory_block)
139        };
140
141        fs::write(&self.path, new_content)
142    }
143
144    /// Deletes a memory by ID.
145    ///
146    /// Returns `Ok(true)` if the memory was found and deleted,
147    /// `Ok(false)` if the memory was not found.
148    /// Uses an exclusive lock to prevent concurrent writes.
149    pub fn delete(&self, id: &str) -> io::Result<bool> {
150        if !self.exists() {
151            return Ok(false);
152        }
153
154        let lock = FileLock::new(&self.path)?;
155        let _guard = lock.exclusive()?;
156
157        let content = fs::read_to_string(&self.path)?;
158        let memories = parse_memories(&content);
159
160        if !memories.iter().any(|m| m.id == id) {
161            return Ok(false);
162        }
163
164        // Rebuild the file without the deleted memory
165        let remaining: Vec<_> = memories.into_iter().filter(|m| m.id != id).collect();
166        self.write_all_internal(&remaining)?;
167
168        Ok(true)
169    }
170
171    /// Returns the memory with the given ID, if it exists.
172    pub fn get(&self, id: &str) -> io::Result<Option<Memory>> {
173        let memories = self.load()?;
174        Ok(memories.into_iter().find(|m| m.id == id))
175    }
176
177    /// Searches memories by query string.
178    ///
179    /// Matches against content and tags (case-insensitive).
180    pub fn search(&self, query: &str) -> io::Result<Vec<Memory>> {
181        let memories = self.load()?;
182        Ok(memories
183            .into_iter()
184            .filter(|m| m.matches_query(query))
185            .collect())
186    }
187
188    /// Filters memories by type.
189    pub fn filter_by_type(&self, memory_type: MemoryType) -> io::Result<Vec<Memory>> {
190        let memories = self.load()?;
191        Ok(memories
192            .into_iter()
193            .filter(|m| m.memory_type == memory_type)
194            .collect())
195    }
196
197    /// Filters memories by tags (OR logic - matches if any tag matches).
198    pub fn filter_by_tags(&self, tags: &[String]) -> io::Result<Vec<Memory>> {
199        let memories = self.load()?;
200        Ok(memories
201            .into_iter()
202            .filter(|m| m.has_any_tag(tags))
203            .collect())
204    }
205
206    /// Writes all memories to the file, replacing existing content.
207    ///
208    /// This is used internally for operations like delete that need
209    /// to rewrite the entire file. The caller must hold the exclusive lock.
210    fn write_all_internal(&self, memories: &[Memory]) -> io::Result<()> {
211        // Ensure parent directory exists
212        if let Some(parent) = self.path.parent() {
213            fs::create_dir_all(parent)?;
214        }
215
216        let mut content = String::from("# Memories\n");
217
218        // Group memories by type
219        for memory_type in MemoryType::all() {
220            let type_memories: Vec<_> = memories
221                .iter()
222                .filter(|m| m.memory_type == *memory_type)
223                .collect();
224
225            content.push_str(&format!("\n## {}\n", memory_type.section_name()));
226
227            for memory in type_memories {
228                content.push_str(&self.format_memory(memory));
229            }
230        }
231
232        fs::write(&self.path, content)
233    }
234
235    /// Formats a memory as a markdown block.
236    fn format_memory(&self, memory: &Memory) -> String {
237        // Escape newlines in content by prefixing each line with `> `
238        let content_lines: Vec<_> = memory
239            .content
240            .lines()
241            .map(|line| format!("> {}", line))
242            .collect();
243
244        format!(
245            "\n### {}\n{}\n<!-- tags: {} | created: {} -->\n",
246            memory.id,
247            content_lines.join("\n"),
248            memory.tags.join(", "),
249            memory.created,
250        )
251    }
252
253    /// Finds the insertion point for a new memory in the given section.
254    ///
255    /// Returns the byte offset where the new memory block should be inserted,
256    /// which is right after the section header line.
257    fn find_section_insert_point(&self, content: &str, section: &str) -> Option<usize> {
258        let section_start = content.find(section)?;
259        // Find the end of the section header line
260        let after_section = section_start + section.len();
261        // Skip to end of line (including the newline)
262        let newline_pos = content[after_section..].find('\n')?;
263        Some(after_section + newline_pos + 1)
264    }
265
266    /// Returns the empty template for a new memories file.
267    fn template(&self) -> String {
268        "# Memories\n\n## Patterns\n\n## Decisions\n\n## Fixes\n\n## Context\n".to_string()
269    }
270}
271
272/// Formats memories as markdown for context injection.
273///
274/// This produces a markdown document suitable for including in agent prompts:
275/// ```markdown
276/// # Memories
277///
278/// ## Patterns
279/// ### mem-xxx-xxxx
280/// > Memory content
281/// <!-- tags: tag1, tag2 | created: 2025-01-20 -->
282/// ```
283///
284/// Used by `ralph memory prime` and the event loop's auto-injection feature.
285#[must_use]
286pub fn format_memories_as_markdown(memories: &[Memory]) -> String {
287    if memories.is_empty() {
288        return String::new();
289    }
290
291    let mut output = String::from("# Memories\n");
292
293    // Group by type
294    for memory_type in MemoryType::all() {
295        let type_memories: Vec<_> = memories
296            .iter()
297            .filter(|m| m.memory_type == *memory_type)
298            .collect();
299
300        if type_memories.is_empty() {
301            continue;
302        }
303
304        output.push_str(&format!("\n## {}\n", memory_type.section_name()));
305
306        for memory in type_memories {
307            output.push_str(&format!(
308                "\n### {}\n> {}\n<!-- tags: {} | created: {} -->\n",
309                memory.id,
310                memory.content.replace('\n', "\n> "),
311                memory.tags.join(", "),
312                memory.created
313            ));
314        }
315    }
316
317    output
318}
319
320/// Truncates memory content to approximately fit within a token budget.
321///
322/// Uses a simple heuristic of ~4 characters per token. Tries to end
323/// at a natural break point (end of a memory block).
324///
325/// # Arguments
326/// * `content` - The markdown content to truncate
327/// * `budget` - Maximum tokens (0 = unlimited)
328///
329/// # Returns
330/// The truncated content with a truncation notice if applicable.
331#[must_use]
332pub fn truncate_to_budget(content: &str, budget: usize) -> String {
333    if budget == 0 || content.is_empty() {
334        return content.to_string();
335    }
336
337    // Rough estimate: 4 chars per token
338    let char_budget = budget * 4;
339
340    if content.len() <= char_budget {
341        return content.to_string();
342    }
343
344    // Find a good break point (end of a memory block)
345    let truncated = &content[..char_budget];
346
347    // Try to find the last complete memory block (ends with -->)
348    if let Some(last_complete) = truncated.rfind("-->") {
349        let end = last_complete + 3;
350        // Find the next newline after -->
351        let final_end = truncated[end..].find('\n').map_or(end, |n| end + n + 1);
352        format!(
353            "{}\n\n<!-- truncated: budget {} tokens exceeded -->",
354            &content[..final_end],
355            budget
356        )
357    } else {
358        format!(
359            "{}\n\n<!-- truncated: budget {} tokens exceeded -->",
360            truncated, budget
361        )
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use tempfile::TempDir;
369
370    fn create_temp_store() -> (TempDir, MarkdownMemoryStore) {
371        let temp_dir = TempDir::new().unwrap();
372        let store = MarkdownMemoryStore::with_default_path(temp_dir.path());
373        (temp_dir, store)
374    }
375
376    #[test]
377    fn test_init_creates_file() {
378        let (_temp_dir, store) = create_temp_store();
379
380        assert!(!store.exists());
381        store.init(false).unwrap();
382        assert!(store.exists());
383
384        let content = fs::read_to_string(store.path()).unwrap();
385        assert!(content.contains("# Memories"));
386        assert!(content.contains("## Patterns"));
387        assert!(content.contains("## Decisions"));
388        assert!(content.contains("## Fixes"));
389        assert!(content.contains("## Context"));
390    }
391
392    #[test]
393    fn test_init_fails_if_exists_without_force() {
394        let (_temp_dir, store) = create_temp_store();
395
396        store.init(false).unwrap();
397        let result = store.init(false);
398        assert!(result.is_err());
399        assert!(result.unwrap_err().kind() == io::ErrorKind::AlreadyExists);
400    }
401
402    #[test]
403    fn test_init_with_force_overwrites() {
404        let (_temp_dir, store) = create_temp_store();
405
406        store.init(false).unwrap();
407
408        // Add a memory
409        let memory = Memory::new(
410            MemoryType::Pattern,
411            "Test content".to_string(),
412            vec!["test".to_string()],
413        );
414        store.append(&memory).unwrap();
415
416        // Force reinit
417        store.init(true).unwrap();
418
419        // Should be empty again
420        let memories = store.load().unwrap();
421        assert!(memories.is_empty());
422    }
423
424    #[test]
425    fn test_append_creates_file_if_missing() {
426        let (_temp_dir, store) = create_temp_store();
427
428        let memory = Memory::new(
429            MemoryType::Pattern,
430            "Uses barrel exports".to_string(),
431            vec!["imports".to_string()],
432        );
433
434        assert!(!store.exists());
435        store.append(&memory).unwrap();
436        assert!(store.exists());
437
438        let memories = store.load().unwrap();
439        assert_eq!(memories.len(), 1);
440        assert_eq!(memories[0].content, "Uses barrel exports");
441    }
442
443    #[test]
444    fn test_append_to_existing_section() {
445        let (_temp_dir, store) = create_temp_store();
446        store.init(false).unwrap();
447
448        let memory1 = Memory::new(
449            MemoryType::Pattern,
450            "First pattern".to_string(),
451            vec!["first".to_string()],
452        );
453        let memory2 = Memory::new(
454            MemoryType::Pattern,
455            "Second pattern".to_string(),
456            vec!["second".to_string()],
457        );
458
459        store.append(&memory1).unwrap();
460        store.append(&memory2).unwrap();
461
462        let memories = store.load().unwrap();
463        assert_eq!(memories.len(), 2);
464        // Both should be in the Patterns section
465        assert!(
466            memories
467                .iter()
468                .all(|m| m.memory_type == MemoryType::Pattern)
469        );
470    }
471
472    #[test]
473    fn test_append_to_different_sections() {
474        let (_temp_dir, store) = create_temp_store();
475        store.init(false).unwrap();
476
477        let pattern = Memory::new(MemoryType::Pattern, "A pattern".to_string(), vec![]);
478        let decision = Memory::new(MemoryType::Decision, "A decision".to_string(), vec![]);
479        let fix = Memory::new(MemoryType::Fix, "A fix".to_string(), vec![]);
480
481        store.append(&pattern).unwrap();
482        store.append(&decision).unwrap();
483        store.append(&fix).unwrap();
484
485        let memories = store.load().unwrap();
486        assert_eq!(memories.len(), 3);
487
488        // Verify each type is present
489        assert!(
490            memories
491                .iter()
492                .any(|m| m.memory_type == MemoryType::Pattern)
493        );
494        assert!(
495            memories
496                .iter()
497                .any(|m| m.memory_type == MemoryType::Decision)
498        );
499        assert!(memories.iter().any(|m| m.memory_type == MemoryType::Fix));
500    }
501
502    #[test]
503    fn test_delete_removes_memory() {
504        let (_temp_dir, store) = create_temp_store();
505        store.init(false).unwrap();
506
507        let memory = Memory::new(MemoryType::Pattern, "To be deleted".to_string(), vec![]);
508        let id = memory.id.clone();
509
510        store.append(&memory).unwrap();
511        assert_eq!(store.load().unwrap().len(), 1);
512
513        let deleted = store.delete(&id).unwrap();
514        assert!(deleted);
515        assert!(store.load().unwrap().is_empty());
516    }
517
518    #[test]
519    fn test_delete_returns_false_for_nonexistent() {
520        let (_temp_dir, store) = create_temp_store();
521        store.init(false).unwrap();
522
523        let deleted = store.delete("mem-nonexistent-0000").unwrap();
524        assert!(!deleted);
525    }
526
527    #[test]
528    fn test_get_finds_memory() {
529        let (_temp_dir, store) = create_temp_store();
530
531        let memory = Memory::new(
532            MemoryType::Decision,
533            "Important decision".to_string(),
534            vec!["important".to_string()],
535        );
536        let id = memory.id.clone();
537
538        store.append(&memory).unwrap();
539
540        let found = store.get(&id).unwrap();
541        assert!(found.is_some());
542        assert_eq!(found.unwrap().content, "Important decision");
543    }
544
545    #[test]
546    fn test_get_returns_none_for_nonexistent() {
547        let (_temp_dir, store) = create_temp_store();
548        store.init(false).unwrap();
549
550        let found = store.get("mem-nonexistent-0000").unwrap();
551        assert!(found.is_none());
552    }
553
554    #[test]
555    fn test_search_matches_content() {
556        let (_temp_dir, store) = create_temp_store();
557
558        let memory1 = Memory::new(
559            MemoryType::Pattern,
560            "Uses barrel exports".to_string(),
561            vec![],
562        );
563        let memory2 = Memory::new(
564            MemoryType::Pattern,
565            "Uses named exports".to_string(),
566            vec![],
567        );
568
569        store.append(&memory1).unwrap();
570        store.append(&memory2).unwrap();
571
572        let results = store.search("barrel").unwrap();
573        assert_eq!(results.len(), 1);
574        assert!(results[0].content.contains("barrel"));
575    }
576
577    #[test]
578    fn test_search_matches_tags() {
579        let (_temp_dir, store) = create_temp_store();
580
581        let memory = Memory::new(
582            MemoryType::Fix,
583            "Docker fix".to_string(),
584            vec!["docker".to_string(), "debugging".to_string()],
585        );
586
587        store.append(&memory).unwrap();
588
589        let results = store.search("docker").unwrap();
590        assert_eq!(results.len(), 1);
591    }
592
593    #[test]
594    fn test_filter_by_type() {
595        let (_temp_dir, store) = create_temp_store();
596
597        store
598            .append(&Memory::new(MemoryType::Pattern, "P1".to_string(), vec![]))
599            .unwrap();
600        store
601            .append(&Memory::new(MemoryType::Decision, "D1".to_string(), vec![]))
602            .unwrap();
603        store
604            .append(&Memory::new(MemoryType::Pattern, "P2".to_string(), vec![]))
605            .unwrap();
606
607        let patterns = store.filter_by_type(MemoryType::Pattern).unwrap();
608        assert_eq!(patterns.len(), 2);
609
610        let decisions = store.filter_by_type(MemoryType::Decision).unwrap();
611        assert_eq!(decisions.len(), 1);
612    }
613
614    #[test]
615    fn test_filter_by_tags() {
616        let (_temp_dir, store) = create_temp_store();
617
618        store
619            .append(&Memory::new(
620                MemoryType::Pattern,
621                "M1".to_string(),
622                vec!["rust".to_string(), "async".to_string()],
623            ))
624            .unwrap();
625        store
626            .append(&Memory::new(
627                MemoryType::Pattern,
628                "M2".to_string(),
629                vec!["python".to_string()],
630            ))
631            .unwrap();
632        store
633            .append(&Memory::new(
634                MemoryType::Pattern,
635                "M3".to_string(),
636                vec!["rust".to_string()],
637            ))
638            .unwrap();
639
640        let rust_memories = store.filter_by_tags(&["rust".to_string()]).unwrap();
641        assert_eq!(rust_memories.len(), 2);
642
643        let python_or_async = store
644            .filter_by_tags(&["python".to_string(), "async".to_string()])
645            .unwrap();
646        assert_eq!(python_or_async.len(), 2);
647    }
648
649    #[test]
650    fn test_load_empty_file() {
651        let (_temp_dir, store) = create_temp_store();
652
653        // File doesn't exist
654        let memories = store.load().unwrap();
655        assert!(memories.is_empty());
656    }
657
658    #[test]
659    fn test_multiline_content_roundtrip() {
660        let (_temp_dir, store) = create_temp_store();
661
662        let memory = Memory::new(
663            MemoryType::Pattern,
664            "Line 1\nLine 2\nLine 3".to_string(),
665            vec!["multiline".to_string()],
666        );
667        let id = memory.id.clone();
668
669        store.append(&memory).unwrap();
670
671        let loaded = store.get(&id).unwrap().unwrap();
672        assert_eq!(loaded.content, "Line 1\nLine 2\nLine 3");
673    }
674
675    #[test]
676    fn test_format_memories_as_markdown_empty() {
677        let output = format_memories_as_markdown(&[]);
678        assert!(output.is_empty());
679    }
680
681    #[test]
682    fn test_format_memories_as_markdown_single() {
683        let memory = Memory {
684            id: "mem-123-abcd".to_string(),
685            memory_type: MemoryType::Pattern,
686            content: "Use barrel exports".to_string(),
687            tags: vec!["imports".to_string()],
688            created: "2025-01-20".to_string(),
689        };
690
691        let output = format_memories_as_markdown(&[memory]);
692
693        assert!(output.contains("# Memories"));
694        assert!(output.contains("## Patterns"));
695        assert!(output.contains("### mem-123-abcd"));
696        assert!(output.contains("> Use barrel exports"));
697        assert!(output.contains("tags: imports"));
698    }
699
700    #[test]
701    fn test_format_memories_as_markdown_grouped_by_type() {
702        let pattern = Memory {
703            id: "mem-1-p".to_string(),
704            memory_type: MemoryType::Pattern,
705            content: "A pattern".to_string(),
706            tags: vec![],
707            created: "2025-01-20".to_string(),
708        };
709        let decision = Memory {
710            id: "mem-2-d".to_string(),
711            memory_type: MemoryType::Decision,
712            content: "A decision".to_string(),
713            tags: vec![],
714            created: "2025-01-20".to_string(),
715        };
716
717        let output = format_memories_as_markdown(&[pattern, decision]);
718
719        // Both sections should be present
720        assert!(output.contains("## Patterns"));
721        assert!(output.contains("## Decisions"));
722
723        // Patterns section should come before Decisions
724        let patterns_pos = output.find("## Patterns").unwrap();
725        let decisions_pos = output.find("## Decisions").unwrap();
726        assert!(patterns_pos < decisions_pos);
727    }
728
729    #[test]
730    fn test_truncate_to_budget_no_truncation_needed() {
731        let content = "Short content";
732        let result = truncate_to_budget(content, 100);
733        assert_eq!(result, content);
734    }
735
736    #[test]
737    fn test_truncate_to_budget_zero_means_unlimited() {
738        let content = "This is some long content that would normally be truncated";
739        let result = truncate_to_budget(content, 0);
740        assert_eq!(result, content);
741    }
742
743    #[test]
744    fn test_truncate_to_budget_adds_notice() {
745        let content = "x".repeat(1000); // 1000 chars = ~250 tokens
746        let result = truncate_to_budget(&content, 10); // 10 tokens = 40 chars
747
748        assert!(result.len() < content.len());
749        assert!(result.contains("<!-- truncated:"));
750    }
751}