Skip to main content

ralph_core/
memory.rs

1//! Memory types for persistent learning across Ralph sessions.
2//!
3//! This module provides core data structures for the memories feature:
4//! - `Memory`: A single stored learning/insight
5//! - `MemoryType`: Classification of memory (pattern, decision, fix, context)
6//!
7//! Memories are stored in `.ralph/agent/memories.md` using a structured markdown format
8//! that is both human-readable and machine-parseable.
9
10use serde::{Deserialize, Serialize};
11
12/// Classification of a memory.
13///
14/// Memories are grouped by type in the markdown storage file,
15/// each with its own section header.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum MemoryType {
19    /// How this codebase does things (section: "## Patterns")
20    #[default]
21    Pattern,
22    /// Why something was chosen (section: "## Decisions")
23    Decision,
24    /// Solution to a recurring problem (section: "## Fixes")
25    Fix,
26    /// Project-specific knowledge (section: "## Context")
27    Context,
28}
29
30impl MemoryType {
31    /// Returns the markdown section header name for this memory type.
32    ///
33    /// Used when writing memories to `.ralph/agent/memories.md`.
34    #[must_use]
35    pub fn section_name(&self) -> &'static str {
36        match self {
37            Self::Pattern => "Patterns",
38            Self::Decision => "Decisions",
39            Self::Fix => "Fixes",
40            Self::Context => "Context",
41        }
42    }
43
44    /// Parses a section header name into a memory type.
45    ///
46    /// Returns `None` if the section name doesn't match any memory type.
47    #[must_use]
48    pub fn from_section(s: &str) -> Option<Self> {
49        match s {
50            "Patterns" => Some(Self::Pattern),
51            "Decisions" => Some(Self::Decision),
52            "Fixes" => Some(Self::Fix),
53            "Context" => Some(Self::Context),
54            _ => None,
55        }
56    }
57
58    /// Returns the emoji associated with this memory type.
59    ///
60    /// Used for CLI output formatting.
61    #[must_use]
62    pub fn emoji(&self) -> &'static str {
63        match self {
64            Self::Pattern => "🔄",
65            Self::Decision => "⚖️",
66            Self::Fix => "🔧",
67            Self::Context => "📍",
68        }
69    }
70
71    /// Returns all memory types in display order.
72    #[must_use]
73    pub fn all() -> &'static [Self] {
74        &[Self::Pattern, Self::Decision, Self::Fix, Self::Context]
75    }
76}
77
78impl std::fmt::Display for MemoryType {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::Pattern => write!(f, "pattern"),
82            Self::Decision => write!(f, "decision"),
83            Self::Fix => write!(f, "fix"),
84            Self::Context => write!(f, "context"),
85        }
86    }
87}
88
89impl std::str::FromStr for MemoryType {
90    type Err = String;
91
92    fn from_str(s: &str) -> Result<Self, Self::Err> {
93        match s.to_lowercase().as_str() {
94            "pattern" => Ok(Self::Pattern),
95            "decision" => Ok(Self::Decision),
96            "fix" => Ok(Self::Fix),
97            "context" => Ok(Self::Context),
98            _ => Err(format!(
99                "Invalid memory type: '{}'. Valid types: pattern, decision, fix, context",
100                s
101            )),
102        }
103    }
104}
105
106/// A single memory entry.
107///
108/// Memories are stored in `.ralph/agent/memories.md` with the following format:
109/// ```markdown
110/// ### mem-1737372000-a1b2
111/// > The actual memory content
112/// > Can span multiple lines
113/// <!-- tags: tag1, tag2 | created: 2025-01-20 -->
114/// ```
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Memory {
117    /// Unique identifier (format: `mem-{unix_timestamp}-{4_hex_chars}`)
118    pub id: String,
119
120    /// Classification of this memory
121    pub memory_type: MemoryType,
122
123    /// The actual memory content (may contain newlines)
124    pub content: String,
125
126    /// Tags for categorization and search
127    pub tags: Vec<String>,
128
129    /// Creation date (format: YYYY-MM-DD)
130    pub created: String,
131}
132
133impl Memory {
134    /// Creates a new memory with a generated ID.
135    ///
136    /// The ID is generated using the current Unix timestamp and random hex characters.
137    #[must_use]
138    pub fn new(memory_type: MemoryType, content: String, tags: Vec<String>) -> Self {
139        Self {
140            id: Self::generate_id(),
141            memory_type,
142            content,
143            tags,
144            created: chrono::Utc::now().format("%Y-%m-%d").to_string(),
145        }
146    }
147
148    /// Generates a unique memory ID.
149    ///
150    /// Format: `mem-{unix_timestamp}-{4_hex_chars}`
151    /// Example: `mem-1737372000-a1b2`
152    ///
153    /// The hex suffix is derived from the microsecond component of the timestamp,
154    /// providing sufficient uniqueness for typical usage without external dependencies.
155    #[must_use]
156    pub fn generate_id() -> String {
157        use std::time::{SystemTime, UNIX_EPOCH};
158
159        let duration = SystemTime::now()
160            .duration_since(UNIX_EPOCH)
161            .expect("Time went backwards");
162
163        let timestamp = duration.as_secs();
164        // Use microseconds as the hex suffix for uniqueness
165        let micros = duration.subsec_micros();
166        let hex_suffix = format!("{:04x}", micros % 0x10000);
167
168        format!("mem-{}-{}", timestamp, hex_suffix)
169    }
170
171    /// Returns true if this memory matches the given search query.
172    ///
173    /// Matches against content and tags (case-insensitive).
174    #[must_use]
175    pub fn matches_query(&self, query: &str) -> bool {
176        let query_lower = query.to_lowercase();
177        self.content.to_lowercase().contains(&query_lower)
178            || self
179                .tags
180                .iter()
181                .any(|tag| tag.to_lowercase().contains(&query_lower))
182    }
183
184    /// Returns true if this memory has any of the specified tags.
185    #[must_use]
186    pub fn has_any_tag(&self, tags: &[String]) -> bool {
187        let tags_lower: Vec<String> = tags.iter().map(|t| t.to_lowercase()).collect();
188        self.tags
189            .iter()
190            .any(|t| tags_lower.contains(&t.to_lowercase()))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_memory_type_section_names() {
200        assert_eq!(MemoryType::Pattern.section_name(), "Patterns");
201        assert_eq!(MemoryType::Decision.section_name(), "Decisions");
202        assert_eq!(MemoryType::Fix.section_name(), "Fixes");
203        assert_eq!(MemoryType::Context.section_name(), "Context");
204    }
205
206    #[test]
207    fn test_memory_type_from_section() {
208        assert_eq!(
209            MemoryType::from_section("Patterns"),
210            Some(MemoryType::Pattern)
211        );
212        assert_eq!(
213            MemoryType::from_section("Decisions"),
214            Some(MemoryType::Decision)
215        );
216        assert_eq!(MemoryType::from_section("Fixes"), Some(MemoryType::Fix));
217        assert_eq!(
218            MemoryType::from_section("Context"),
219            Some(MemoryType::Context)
220        );
221        assert_eq!(MemoryType::from_section("Unknown"), None);
222    }
223
224    #[test]
225    fn test_memory_type_emojis() {
226        assert_eq!(MemoryType::Pattern.emoji(), "🔄");
227        assert_eq!(MemoryType::Decision.emoji(), "⚖️");
228        assert_eq!(MemoryType::Fix.emoji(), "🔧");
229        assert_eq!(MemoryType::Context.emoji(), "📍");
230    }
231
232    #[test]
233    fn test_memory_type_from_str() {
234        assert_eq!(
235            "pattern".parse::<MemoryType>().unwrap(),
236            MemoryType::Pattern
237        );
238        assert_eq!(
239            "DECISION".parse::<MemoryType>().unwrap(),
240            MemoryType::Decision
241        );
242        assert_eq!("Fix".parse::<MemoryType>().unwrap(), MemoryType::Fix);
243        assert_eq!(
244            "context".parse::<MemoryType>().unwrap(),
245            MemoryType::Context
246        );
247        assert!("invalid".parse::<MemoryType>().is_err());
248    }
249
250    #[test]
251    fn test_memory_type_display() {
252        assert_eq!(format!("{}", MemoryType::Pattern), "pattern");
253        assert_eq!(format!("{}", MemoryType::Decision), "decision");
254        assert_eq!(format!("{}", MemoryType::Fix), "fix");
255        assert_eq!(format!("{}", MemoryType::Context), "context");
256    }
257
258    #[test]
259    fn test_memory_new() {
260        let memory = Memory::new(
261            MemoryType::Pattern,
262            "Uses barrel exports".to_string(),
263            vec!["imports".to_string(), "structure".to_string()],
264        );
265
266        assert!(memory.id.starts_with("mem-"));
267        assert_eq!(memory.memory_type, MemoryType::Pattern);
268        assert_eq!(memory.content, "Uses barrel exports");
269        assert_eq!(memory.tags, vec!["imports", "structure"]);
270        // Created date should be today
271        let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
272        assert_eq!(memory.created, today);
273    }
274
275    #[test]
276    fn test_memory_id_format() {
277        let id = Memory::generate_id();
278        assert!(id.starts_with("mem-"));
279
280        // Should have format mem-{timestamp}-{4hex}
281        let parts: Vec<&str> = id.split('-').collect();
282        assert_eq!(parts.len(), 3);
283        assert_eq!(parts[0], "mem");
284        assert!(parts[1].parse::<u64>().is_ok()); // timestamp
285        assert_eq!(parts[2].len(), 4); // 4 hex chars
286    }
287
288    #[test]
289    fn test_memory_matches_query() {
290        let memory = Memory {
291            id: "mem-123-abcd".to_string(),
292            memory_type: MemoryType::Pattern,
293            content: "Uses barrel exports for modules".to_string(),
294            tags: vec!["imports".to_string(), "structure".to_string()],
295            created: "2025-01-20".to_string(),
296        };
297
298        // Match in content
299        assert!(memory.matches_query("barrel"));
300        assert!(memory.matches_query("BARREL")); // case-insensitive
301
302        // Match in tags
303        assert!(memory.matches_query("imports"));
304        assert!(memory.matches_query("STRUCTURE"));
305
306        // No match
307        assert!(!memory.matches_query("authentication"));
308    }
309
310    #[test]
311    fn test_memory_has_any_tag() {
312        let memory = Memory {
313            id: "mem-123-abcd".to_string(),
314            memory_type: MemoryType::Fix,
315            content: "Docker fix".to_string(),
316            tags: vec!["docker".to_string(), "debugging".to_string()],
317            created: "2025-01-20".to_string(),
318        };
319
320        assert!(memory.has_any_tag(&["docker".to_string()]));
321        assert!(memory.has_any_tag(&["DEBUGGING".to_string()])); // case-insensitive
322        assert!(memory.has_any_tag(&["other".to_string(), "docker".to_string()]));
323        assert!(!memory.has_any_tag(&["unrelated".to_string()]));
324    }
325
326    #[test]
327    fn test_memory_type_all() {
328        let all = MemoryType::all();
329        assert_eq!(all.len(), 4);
330        assert_eq!(all[0], MemoryType::Pattern);
331        assert_eq!(all[1], MemoryType::Decision);
332        assert_eq!(all[2], MemoryType::Fix);
333        assert_eq!(all[3], MemoryType::Context);
334    }
335
336    #[test]
337    fn test_memory_type_default() {
338        assert_eq!(MemoryType::default(), MemoryType::Pattern);
339    }
340
341    #[test]
342    fn test_memory_serde_roundtrip() {
343        let memory = Memory {
344            id: "mem-123-abcd".to_string(),
345            memory_type: MemoryType::Decision,
346            content: "Chose Postgres".to_string(),
347            tags: vec!["database".to_string()],
348            created: "2025-01-20".to_string(),
349        };
350
351        let json = serde_json::to_string(&memory).unwrap();
352        let deserialized: Memory = serde_json::from_str(&json).unwrap();
353
354        assert_eq!(deserialized.id, memory.id);
355        assert_eq!(deserialized.memory_type, memory.memory_type);
356        assert_eq!(deserialized.content, memory.content);
357        assert_eq!(deserialized.tags, memory.tags);
358        assert_eq!(deserialized.created, memory.created);
359    }
360
361    #[test]
362    fn test_memory_type_serde() {
363        // Test that memory type serializes as lowercase
364        let mt = MemoryType::Decision;
365        let json = serde_json::to_string(&mt).unwrap();
366        assert_eq!(json, "\"decision\"");
367
368        // Test deserialization
369        let deserialized: MemoryType = serde_json::from_str("\"fix\"").unwrap();
370        assert_eq!(deserialized, MemoryType::Fix);
371    }
372}