Skip to main content

ralph/
memory.rs

1/// Per-workspace persistent memory store.
2///
3/// Stores facts Ralph learns about a project and a log of past session episodes.
4/// Loaded at session start, injected into the system prompt, and updated via the
5/// `remember` tool.
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10// ── Types ─────────────────────────────────────────────────────────────────────
11
12/// A single fact Ralph has learned about the project.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ProjectFact {
15    pub key: String,
16    pub value: String,
17    pub updated_at: DateTime<Utc>,
18}
19
20/// A summary of one completed session.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Episode {
23    pub session_id: String,
24    pub summary: String,
25    pub files_touched: Vec<String>,
26    /// "done" | "failed" | "interrupted"
27    pub outcome: String,
28    pub timestamp: DateTime<Utc>,
29}
30
31#[derive(Debug, Default, Serialize, Deserialize)]
32struct MemoryData {
33    facts: Vec<ProjectFact>,
34    episodes: Vec<Episode>,
35}
36
37// ── Store ─────────────────────────────────────────────────────────────────────
38
39pub struct MemoryStore {
40    path: PathBuf,
41    data: MemoryData,
42}
43
44impl MemoryStore {
45    /// Load memory for `workspace`, or start empty if none exists.
46    pub fn load(workspace: &Path) -> Self {
47        let path = memory_path(workspace);
48        let data = if path.exists() {
49            std::fs::read_to_string(&path)
50                .ok()
51                .and_then(|s| serde_json::from_str(&s).ok())
52                .unwrap_or_default()
53        } else {
54            MemoryData::default()
55        };
56        Self { path, data }
57    }
58
59    /// Load memory from a specific file path (useful for testing).
60    pub fn from_path(path: PathBuf) -> Self {
61        let data = if path.exists() {
62            std::fs::read_to_string(&path)
63                .ok()
64                .and_then(|s| serde_json::from_str(&s).ok())
65                .unwrap_or_default()
66        } else {
67            MemoryData::default()
68        };
69        Self { path, data }
70    }
71
72    /// Persist the current state to disk.
73    pub fn save(&self) -> crate::errors::Result<()> {
74        if let Some(parent) = self.path.parent() {
75            std::fs::create_dir_all(parent).map_err(|e| crate::errors::RalphError::ToolFailed {
76                tool: "memory".to_string(),
77                message: format!("Failed to create memory directory: {}", e),
78            })?;
79        }
80        let json = serde_json::to_string_pretty(&self.data).map_err(|e| {
81            crate::errors::RalphError::ToolFailed {
82                tool: "memory".to_string(),
83                message: format!("Failed to serialize memory: {}", e),
84            }
85        })?;
86        std::fs::write(&self.path, json).map_err(|e| crate::errors::RalphError::ToolFailed {
87            tool: "memory".to_string(),
88            message: format!("Failed to write memory file: {}", e),
89        })?;
90        Ok(())
91    }
92
93    /// Add or update a fact by key (case-insensitive key match).
94    pub fn upsert(&mut self, key: &str, value: &str) {
95        let key = key.trim().to_string();
96        let value = value.trim().to_string();
97        if let Some(fact) = self
98            .data
99            .facts
100            .iter_mut()
101            .find(|f| f.key.eq_ignore_ascii_case(&key))
102        {
103            fact.value = value;
104            fact.updated_at = Utc::now();
105        } else {
106            self.data.facts.push(ProjectFact {
107                key,
108                value,
109                updated_at: Utc::now(),
110            });
111        }
112        if let Err(e) = self.save() {
113            eprintln!("[memory] Warning: failed to persist memory: {}", e);
114        }
115    }
116
117    /// Search facts whose key or value contains `query` (case-insensitive).
118    pub fn search(&self, query: &str) -> Vec<&ProjectFact> {
119        let q = query.to_lowercase();
120        self.data
121            .facts
122            .iter()
123            .filter(|f| f.key.to_lowercase().contains(&q) || f.value.to_lowercase().contains(&q))
124            .collect()
125    }
126
127    /// All facts, most recently updated first.
128    pub fn all_facts(&self) -> Vec<&ProjectFact> {
129        let mut v: Vec<&ProjectFact> = self.data.facts.iter().collect();
130        v.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
131        v
132    }
133
134    /// Append a session episode (keeps at most 50).
135    pub fn add_episode(
136        &mut self,
137        session_id: &str,
138        summary: &str,
139        files: Vec<String>,
140        outcome: &str,
141    ) {
142        self.data.episodes.push(Episode {
143            session_id: session_id.to_string(),
144            summary: summary.to_string(),
145            files_touched: files,
146            outcome: outcome.to_string(),
147            timestamp: Utc::now(),
148        });
149        if self.data.episodes.len() > 50 {
150            let excess = self.data.episodes.len() - 50;
151            self.data.episodes.drain(..excess);
152        }
153        if let Err(e) = self.save() {
154            eprintln!("[memory] Warning: failed to persist memory: {}", e);
155        }
156    }
157
158    /// Format facts and recent episodes as a prompt section.
159    /// Returns `None` if memory is empty.
160    pub fn format_for_prompt(&self) -> Option<String> {
161        if self.data.facts.is_empty() && self.data.episodes.is_empty() {
162            return None;
163        }
164
165        let mut out = String::from("## Project Memory\n\n");
166
167        if !self.data.facts.is_empty() {
168            out.push_str("**Known facts about this project:**\n");
169            for fact in &self.data.facts {
170                out.push_str(&format!("- {}: {}\n", fact.key, fact.value));
171            }
172            out.push('\n');
173        }
174
175        if !self.data.episodes.is_empty() {
176            out.push_str("**Recent session history (most recent first):**\n");
177            for ep in self.data.episodes.iter().rev().take(5) {
178                let files = if ep.files_touched.is_empty() {
179                    String::new()
180                } else {
181                    format!(" ({})", ep.files_touched.join(", "))
182                };
183                out.push_str(&format!(
184                    "- [{}] {} [{}]{}\n",
185                    ep.timestamp.format("%Y-%m-%d"),
186                    ep.summary,
187                    ep.outcome,
188                    files,
189                ));
190            }
191            out.push('\n');
192        }
193
194        Some(out)
195    }
196
197    pub fn facts_count(&self) -> usize {
198        self.data.facts.len()
199    }
200    pub fn episodes_count(&self) -> usize {
201        self.data.episodes.len()
202    }
203}
204
205// ── Path helpers ──────────────────────────────────────────────────────────────
206
207fn memory_path(workspace: &Path) -> PathBuf {
208    crate::config::ralph_data_dir()
209        .join("memory")
210        .join(workspace_hash(workspace))
211        .join("memory.json")
212}
213
214fn workspace_hash(workspace: &Path) -> String {
215    use std::collections::hash_map::DefaultHasher;
216    use std::hash::{Hash, Hasher};
217    let mut h = DefaultHasher::new();
218    workspace.to_string_lossy().as_ref().hash(&mut h);
219    format!("{:016x}", h.finish())
220}