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