Skip to main content

cersei_memory/
lib.rs

1//! cersei-memory: Memory trait and backends for the Cersei SDK.
2//!
3//! Memory provides session persistence and retrieval, enabling resumable
4//! conversations and long-term knowledge storage.
5//!
6//! ## Modules
7//! - `memdir` — Flat file memory scanning
8//! - `claudemd` — Hierarchical CLAUDE.md loading
9//! - `session_storage` — JSONL transcript persistence
10
11pub mod claudemd;
12#[cfg(feature = "embed")]
13pub mod embedding_memory;
14pub mod graph;
15pub mod graph_migrate;
16pub mod manager;
17pub mod memdir;
18pub mod session_storage;
19
20use async_trait::async_trait;
21use cersei_types::*;
22use std::path::PathBuf;
23
24/// Strip YAML frontmatter from content.
25pub fn strip_frontmatter(content: &str) -> String {
26    if content.starts_with("---") {
27        if let Some(close_pos) = content[3..].find("\n---") {
28            return content[3 + close_pos + 4..]
29                .trim_start_matches('\n')
30                .to_string();
31        }
32    }
33    content.to_string()
34}
35
36// ─── Memory trait ────────────────────────────────────────────────────────────
37
38#[async_trait]
39pub trait Memory: Send + Sync {
40    /// Store conversation messages for a session.
41    async fn store(&self, session_id: &str, messages: &[Message]) -> Result<()>;
42
43    /// Load conversation history for a session.
44    async fn load(&self, session_id: &str) -> Result<Vec<Message>>;
45
46    /// Search memories relevant to a query (for RAG-style retrieval).
47    async fn search(&self, query: &str, limit: usize) -> Result<Vec<MemoryEntry>>;
48
49    /// List available sessions.
50    async fn sessions(&self) -> Result<Vec<SessionInfo>>;
51
52    /// Delete a session.
53    async fn delete(&self, session_id: &str) -> Result<()>;
54}
55
56// ─── JSONL Memory ────────────────────────────────────────────────────────────
57
58/// File-based memory backend using JSONL format.
59/// Each session is stored as a `.jsonl` file with one message per line.
60pub struct JsonlMemory {
61    dir: PathBuf,
62}
63
64impl JsonlMemory {
65    pub fn new(dir: impl Into<PathBuf>) -> Self {
66        Self { dir: dir.into() }
67    }
68
69    fn session_path(&self, session_id: &str) -> PathBuf {
70        self.dir.join(format!("{}.jsonl", session_id))
71    }
72}
73
74#[async_trait]
75impl Memory for JsonlMemory {
76    async fn store(&self, session_id: &str, messages: &[Message]) -> Result<()> {
77        tokio::fs::create_dir_all(&self.dir).await?;
78        let path = self.session_path(session_id);
79        let mut content = String::new();
80        for msg in messages {
81            let line = serde_json::to_string(msg)?;
82            content.push_str(&line);
83            content.push('\n');
84        }
85        tokio::fs::write(&path, content).await?;
86        Ok(())
87    }
88
89    async fn load(&self, session_id: &str) -> Result<Vec<Message>> {
90        let path = self.session_path(session_id);
91        if !path.exists() {
92            return Ok(Vec::new());
93        }
94        let content = tokio::fs::read_to_string(&path).await?;
95        let mut messages = Vec::new();
96        for line in content.lines() {
97            if line.trim().is_empty() {
98                continue;
99            }
100            let msg: Message = serde_json::from_str(line)?;
101            messages.push(msg);
102        }
103        Ok(messages)
104    }
105
106    async fn search(&self, _query: &str, _limit: usize) -> Result<Vec<MemoryEntry>> {
107        // JSONL memory doesn't support semantic search
108        Ok(Vec::new())
109    }
110
111    async fn sessions(&self) -> Result<Vec<SessionInfo>> {
112        let mut sessions = Vec::new();
113        if !self.dir.exists() {
114            return Ok(sessions);
115        }
116        let mut entries = tokio::fs::read_dir(&self.dir).await?;
117        while let Some(entry) = entries.next_entry().await? {
118            let path = entry.path();
119            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
120                let id = path
121                    .file_stem()
122                    .and_then(|s| s.to_str())
123                    .unwrap_or("")
124                    .to_string();
125                let metadata = tokio::fs::metadata(&path).await?;
126                let created_at = metadata
127                    .created()
128                    .ok()
129                    .and_then(|t| {
130                        let dur = t.duration_since(std::time::UNIX_EPOCH).ok()?;
131                        chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
132                    })
133                    .unwrap_or_else(chrono::Utc::now);
134                let content = tokio::fs::read_to_string(&path).await.unwrap_or_default();
135                let message_count = content.lines().filter(|l| !l.trim().is_empty()).count();
136                sessions.push(SessionInfo {
137                    id,
138                    created_at,
139                    message_count,
140                    model: None,
141                });
142            }
143        }
144        Ok(sessions)
145    }
146
147    async fn delete(&self, session_id: &str) -> Result<()> {
148        let path = self.session_path(session_id);
149        if path.exists() {
150            tokio::fs::remove_file(&path).await?;
151        }
152        Ok(())
153    }
154}
155
156// ─── In-Memory Store ─────────────────────────────────────────────────────────
157
158/// In-memory store for tests and short-lived agents.
159pub struct InMemory {
160    store: std::sync::Arc<parking_lot::Mutex<std::collections::HashMap<String, Vec<Message>>>>,
161}
162
163impl InMemory {
164    pub fn new() -> Self {
165        Self {
166            store: std::sync::Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())),
167        }
168    }
169}
170
171impl Default for InMemory {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177#[async_trait]
178impl Memory for InMemory {
179    async fn store(&self, session_id: &str, messages: &[Message]) -> Result<()> {
180        self.store
181            .lock()
182            .insert(session_id.to_string(), messages.to_vec());
183        Ok(())
184    }
185
186    async fn load(&self, session_id: &str) -> Result<Vec<Message>> {
187        Ok(self
188            .store
189            .lock()
190            .get(session_id)
191            .cloned()
192            .unwrap_or_default())
193    }
194
195    async fn search(&self, _query: &str, _limit: usize) -> Result<Vec<MemoryEntry>> {
196        Ok(Vec::new())
197    }
198
199    async fn sessions(&self) -> Result<Vec<SessionInfo>> {
200        let store = self.store.lock();
201        Ok(store
202            .iter()
203            .map(|(id, msgs)| SessionInfo {
204                id: id.clone(),
205                created_at: chrono::Utc::now(),
206                message_count: msgs.len(),
207                model: None,
208            })
209            .collect())
210    }
211
212    async fn delete(&self, session_id: &str) -> Result<()> {
213        self.store.lock().remove(session_id);
214        Ok(())
215    }
216}