1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[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#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Episode {
23 pub session_id: String,
24 pub summary: String,
25 pub files_touched: Vec<String>,
26 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
37pub struct MemoryStore {
40 path: PathBuf,
41 data: MemoryData,
42}
43
44impl MemoryStore {
45 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 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 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 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 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 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 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 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
205fn 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}