1use crate::claudemd::{self};
12use crate::graph::{GraphMemory, GraphStats};
13use crate::memdir::{self, MemoryFile, MemoryFileMeta, MemoryType};
14use crate::session_storage;
15use cersei_types::*;
16use std::path::{Path, PathBuf};
17
18pub struct MemoryManager {
20 project_root: PathBuf,
22 memory_dir: PathBuf,
24 sessions_dir: PathBuf,
26 graph: Option<GraphMemory>,
28}
29
30impl MemoryManager {
31 pub fn new(project_root: &Path) -> Self {
33 let memory_dir = memdir::auto_memory_path(project_root);
34 let sanitized = memdir::sanitize_path_component(&project_root.display().to_string());
35 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
36 let sessions_dir = home.join(".claude").join("projects").join(&sanitized);
37
38 Self {
39 project_root: project_root.to_path_buf(),
40 memory_dir,
41 sessions_dir,
42 graph: None,
43 }
44 }
45
46 pub fn with_graph(mut self, path: &Path) -> Result<Self> {
49 self.graph = Some(GraphMemory::open(path)?);
50 Ok(self)
51 }
52
53 pub fn with_graph_in_memory(mut self) -> Result<Self> {
56 self.graph = Some(GraphMemory::open_in_memory()?);
57 Ok(self)
58 }
59
60 pub fn with_memory_dir(mut self, dir: PathBuf) -> Self {
62 self.memory_dir = dir;
63 self
64 }
65
66 pub fn with_sessions_dir(mut self, dir: PathBuf) -> Self {
68 self.sessions_dir = dir;
69 self
70 }
71
72 pub fn build_context(&self) -> String {
77 let mut parts = Vec::new();
78
79 let claude_files = claudemd::load_all_memory_files(&self.project_root);
81 let claude_prompt = claudemd::build_memory_prompt(&claude_files);
82 if !claude_prompt.is_empty() {
83 parts.push(claude_prompt);
84 }
85
86 let memdir_content = memdir::build_memory_prompt_content(&self.memory_dir);
88 if !memdir_content.is_empty() {
89 parts.push(memdir_content);
90 }
91
92 parts.join("\n\n")
93 }
94
95 pub fn scan(&self) -> Vec<MemoryFileMeta> {
99 memdir::scan_memory_dir(&self.memory_dir)
100 }
101
102 pub fn load_file(&self, path: &Path) -> Option<MemoryFile> {
104 memdir::load_memory_file(path)
105 }
106
107 pub fn store_memory(
109 &self,
110 content: &str,
111 mem_type: MemoryType,
112 confidence: f32,
113 ) -> Option<String> {
114 if let Some(graph) = &self.graph {
115 graph.store_memory(content, mem_type, confidence).ok()
116 } else {
117 None
118 }
119 }
120
121 pub fn recall(&self, query: &str, limit: usize) -> Vec<String> {
124 if let Some(graph) = &self.graph {
126 let results = graph.recall(query, limit);
127 if !results.is_empty() {
128 return results;
129 }
130 }
131
132 let query_lower = query.to_lowercase();
134 let metas = self.scan();
135 let mut results = Vec::new();
136
137 for meta in metas.iter().take(limit * 2) {
138 if let Some(file) = memdir::load_memory_file(&meta.path) {
139 if file.content.to_lowercase().contains(&query_lower)
140 || meta
141 .name
142 .as_deref()
143 .unwrap_or("")
144 .to_lowercase()
145 .contains(&query_lower)
146 || meta
147 .description
148 .as_deref()
149 .unwrap_or("")
150 .to_lowercase()
151 .contains(&query_lower)
152 {
153 results.push(file.content);
154 if results.len() >= limit {
155 break;
156 }
157 }
158 }
159 }
160
161 results
162 }
163
164 pub fn by_type(&self, mem_type: MemoryType) -> Vec<String> {
166 if let Some(graph) = &self.graph {
167 graph.by_type(mem_type)
168 } else {
169 Vec::new()
170 }
171 }
172
173 pub fn by_topic(&self, topic: &str) -> Vec<String> {
175 if let Some(graph) = &self.graph {
176 graph.by_topic(topic)
177 } else {
178 Vec::new()
179 }
180 }
181
182 pub fn session_path(&self, session_id: &str) -> PathBuf {
186 self.sessions_dir.join(format!("{}.jsonl", session_id))
187 }
188
189 pub fn write_user_message(
191 &self,
192 session_id: &str,
193 message: Message,
194 ) -> std::io::Result<String> {
195 let path = self.session_path(session_id);
196 let cwd = self.project_root.display().to_string();
197 session_storage::write_user_entry(&path, session_id, message, &cwd)
198 }
199
200 pub fn write_assistant_message(
202 &self,
203 session_id: &str,
204 message: Message,
205 parent_uuid: Option<&str>,
206 ) -> std::io::Result<String> {
207 let path = self.session_path(session_id);
208 let cwd = self.project_root.display().to_string();
209 session_storage::write_assistant_entry(&path, session_id, message, &cwd, parent_uuid)
210 }
211
212 pub fn load_session_messages(&self, session_id: &str) -> Result<Vec<Message>> {
214 let path = self.session_path(session_id);
215 if !path.exists() {
216 return Ok(Vec::new());
217 }
218 let entries = session_storage::load_transcript(&path)?;
219 Ok(session_storage::messages_from_transcript(&entries))
220 }
221
222 pub fn list_sessions(&self) -> Vec<SessionInfo> {
224 let mut sessions = Vec::new();
225 let entries = match std::fs::read_dir(&self.sessions_dir) {
226 Ok(e) => e,
227 Err(_) => return sessions,
228 };
229
230 for entry in entries.flatten() {
231 let path = entry.path();
232 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
233 continue;
234 }
235 let id = path
236 .file_stem()
237 .and_then(|s| s.to_str())
238 .unwrap_or("")
239 .to_string();
240 let created_at = std::fs::metadata(&path)
241 .and_then(|m| m.created())
242 .ok()
243 .and_then(|t| {
244 let d = t.duration_since(std::time::UNIX_EPOCH).ok()?;
245 chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
246 })
247 .unwrap_or_else(chrono::Utc::now);
248
249 sessions.push(SessionInfo {
250 id,
251 created_at,
252 message_count: 0, model: None,
254 });
255 }
256
257 sessions
258 }
259
260 pub fn has_graph(&self) -> bool {
264 self.graph.is_some()
265 }
266
267 pub fn graph_stats(&self) -> GraphStats {
269 self.graph.as_ref().map(|g| g.stats()).unwrap_or_default()
270 }
271
272 pub fn tag_memory(&self, memory_id: &str, topic: &str) {
274 if let Some(graph) = &self.graph {
275 let _ = graph.tag_memory(memory_id, topic);
276 }
277 }
278
279 pub fn link_memories(&self, from_id: &str, to_id: &str, relationship: &str) {
281 if let Some(graph) = &self.graph {
282 let _ = graph.link_memories(from_id, to_id, relationship);
283 }
284 }
285
286 pub fn memory_dir(&self) -> &Path {
288 &self.memory_dir
289 }
290 pub fn sessions_dir(&self) -> &Path {
291 &self.sessions_dir
292 }
293 pub fn project_root(&self) -> &Path {
294 &self.project_root
295 }
296}
297
298#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_manager_basic() {
306 let tmp = tempfile::tempdir().unwrap();
307 let manager = MemoryManager::new(tmp.path())
308 .with_memory_dir(tmp.path().join("memory"))
309 .with_sessions_dir(tmp.path().join("sessions"));
310
311 assert!(!manager.has_graph());
312 assert_eq!(manager.graph_stats().memory_count, 0);
313 }
314
315 #[test]
316 fn test_manager_context_with_claude_md() {
317 let tmp = tempfile::tempdir().unwrap();
318 std::fs::write(
319 tmp.path().join("CLAUDE.md"),
320 "# Project Rules\nUse Rust only.",
321 )
322 .unwrap();
323
324 let mem_dir = tmp.path().join("memory");
325 std::fs::create_dir_all(&mem_dir).unwrap();
326 std::fs::write(mem_dir.join("MEMORY.md"), "- [pref](pref.md) — user prefs").unwrap();
327
328 let manager = MemoryManager::new(tmp.path()).with_memory_dir(mem_dir);
329
330 let context = manager.build_context();
331 assert!(context.contains("Use Rust only"));
332 assert!(context.contains("user prefs"));
333 }
334
335 #[test]
336 fn test_manager_session_write_load() {
337 let tmp = tempfile::tempdir().unwrap();
338 let manager = MemoryManager::new(tmp.path()).with_sessions_dir(tmp.path().join("sessions"));
339
340 let uuid = manager
341 .write_user_message("s1", Message::user("Hello"))
342 .unwrap();
343 manager
344 .write_assistant_message("s1", Message::assistant("Hi!"), Some(&uuid))
345 .unwrap();
346
347 let messages = manager.load_session_messages("s1").unwrap();
348 assert_eq!(messages.len(), 2);
349 assert_eq!(messages[0].get_text().unwrap(), "Hello");
350 assert_eq!(messages[1].get_text().unwrap(), "Hi!");
351 }
352
353 #[test]
354 fn test_manager_recall_fallback() {
355 let tmp = tempfile::tempdir().unwrap();
356 let mem_dir = tmp.path().join("memory");
357 std::fs::create_dir_all(&mem_dir).unwrap();
358 std::fs::write(
359 mem_dir.join("rust_tips.md"),
360 "---\nname: Rust Tips\n---\n\nAlways use clippy for linting.",
361 )
362 .unwrap();
363 std::fs::write(
364 mem_dir.join("python_tips.md"),
365 "---\nname: Python Tips\n---\n\nUse ruff for linting.",
366 )
367 .unwrap();
368
369 let manager = MemoryManager::new(tmp.path()).with_memory_dir(mem_dir);
370
371 let results = manager.recall("clippy", 10);
372 assert_eq!(results.len(), 1);
373 assert!(results[0].contains("clippy"));
374
375 let results = manager.recall("linting", 10);
376 assert_eq!(results.len(), 2);
377 }
378
379 #[test]
380 fn test_manager_scan() {
381 let tmp = tempfile::tempdir().unwrap();
382 let mem_dir = tmp.path().join("memory");
383 std::fs::create_dir_all(&mem_dir).unwrap();
384 std::fs::write(mem_dir.join("a.md"), "content a").unwrap();
385 std::fs::write(mem_dir.join("b.md"), "content b").unwrap();
386 std::fs::write(mem_dir.join("MEMORY.md"), "index").unwrap();
387
388 let manager = MemoryManager::new(tmp.path()).with_memory_dir(mem_dir);
389
390 let metas = manager.scan();
391 assert_eq!(metas.len(), 2); }
393
394 #[test]
395 fn test_manager_list_sessions() {
396 let tmp = tempfile::tempdir().unwrap();
397 let sessions_dir = tmp.path().join("sessions");
398 std::fs::create_dir_all(&sessions_dir).unwrap();
399 std::fs::write(sessions_dir.join("s1.jsonl"), "{}").unwrap();
400 std::fs::write(sessions_dir.join("s2.jsonl"), "{}").unwrap();
401 std::fs::write(sessions_dir.join("not-a-session.txt"), "x").unwrap();
402
403 let manager = MemoryManager::new(tmp.path()).with_sessions_dir(sessions_dir);
404
405 let sessions = manager.list_sessions();
406 assert_eq!(sessions.len(), 2);
407 }
408}