Skip to main content

cersei_memory/
manager.rs

1//! Unified Memory Manager: composes all memory layers into a single API.
2//!
3//! Layers (in query order):
4//! 1. Graph (optional) — Grafeo for relationship-aware recall
5//! 2. Memdir — flat file scanning for MEMORY.md and topic files
6//! 3. CLAUDE.md — hierarchical instruction loading
7//! 4. Session storage — JSONL transcript persistence
8//!
9//! The manager delegates to the appropriate layer for each operation.
10
11use 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
18/// Unified memory manager composing all layers.
19pub struct MemoryManager {
20    /// Project root for resolving paths.
21    project_root: PathBuf,
22    /// Memory directory path.
23    memory_dir: PathBuf,
24    /// Session storage directory.
25    sessions_dir: PathBuf,
26    /// Optional graph memory layer.
27    graph: Option<GraphMemory>,
28}
29
30impl MemoryManager {
31    /// Create a new memory manager for a project.
32    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    /// Enable graph memory at a given path.
47    /// Requires the `graph` feature.
48    pub fn with_graph(mut self, path: &Path) -> Result<Self> {
49        self.graph = Some(GraphMemory::open(path)?);
50        Ok(self)
51    }
52
53    /// Enable in-memory graph (no persistence).
54    /// Requires the `graph` feature.
55    pub fn with_graph_in_memory(mut self) -> Result<Self> {
56        self.graph = Some(GraphMemory::open_in_memory()?);
57        Ok(self)
58    }
59
60    /// Set a custom memory directory.
61    pub fn with_memory_dir(mut self, dir: PathBuf) -> Self {
62        self.memory_dir = dir;
63        self
64    }
65
66    /// Set a custom sessions directory.
67    pub fn with_sessions_dir(mut self, dir: PathBuf) -> Self {
68        self.sessions_dir = dir;
69        self
70    }
71
72    // ─── Context building ────────────────────────────────────────────────
73
74    /// Build the complete memory context for the system prompt.
75    /// Includes MEMORY.md index + CLAUDE.md hierarchy.
76    pub fn build_context(&self) -> String {
77        let mut parts = Vec::new();
78
79        // CLAUDE.md hierarchy
80        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        // MEMORY.md index
87        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    // ─── Memory operations ───────────────────────────────────────────────
96
97    /// Scan memory directory for all memory file metadata.
98    pub fn scan(&self) -> Vec<MemoryFileMeta> {
99        memdir::scan_memory_dir(&self.memory_dir)
100    }
101
102    /// Load a specific memory file by path.
103    pub fn load_file(&self, path: &Path) -> Option<MemoryFile> {
104        memdir::load_memory_file(path)
105    }
106
107    /// Store a memory (writes to graph if available, always returns success).
108    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    /// Recall memories matching a query.
122    /// Uses graph if available, falls back to memdir scan + text matching.
123    pub fn recall(&self, query: &str, limit: usize) -> Vec<String> {
124        // Try graph first
125        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        // Fallback: scan memdir and text-match
133        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    /// Get memories by type (graph only, returns empty without graph).
165    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    /// Get memories by topic (graph only).
174    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    // ─── Session operations ──────────────────────────────────────────────
183
184    /// Get the transcript path for a session.
185    pub fn session_path(&self, session_id: &str) -> PathBuf {
186        self.sessions_dir.join(format!("{}.jsonl", session_id))
187    }
188
189    /// Write a user message to the session transcript.
190    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    /// Write an assistant message to the session transcript.
201    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    /// Load a session's messages.
213    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    /// List all session files.
223    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, // would need to parse to count
253                model: None,
254            });
255        }
256
257        sessions
258    }
259
260    // ─── Graph operations ────────────────────────────────────────────────
261
262    /// Check if graph memory is available.
263    pub fn has_graph(&self) -> bool {
264        self.graph.is_some()
265    }
266
267    /// Get graph statistics (returns default if no graph).
268    pub fn graph_stats(&self) -> GraphStats {
269        self.graph.as_ref().map(|g| g.stats()).unwrap_or_default()
270    }
271
272    /// Tag a memory in the graph (no-op without graph).
273    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    /// Link two memories in the graph (no-op without graph).
280    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    /// Access paths.
287    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// ─── Tests ───────────────────────────────────────────────────────────────────
299
300#[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); // excludes MEMORY.md
392    }
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}