Skip to main content

bamboo_memory/memory/
mod.rs

1//! External memory and note-taking compatibility layer for session-scoped context.
2//!
3//! Provides a backward-compatible API for storing and retrieving session-related
4//! notes that persist across conversations.
5//!
6//! ## Storage Layout (v2 — multi-topic session notes)
7//!
8//! ```text
9//! ${BAMBOO_DATA_DIR}/memory/v1/sessions/{session_id}/note/{topic}.md
10//! ```
11//!
12//! The default topic is `"default"`. Legacy single-file notes
13//! (`{session_id}.md`) are auto-migrated on first access.
14//!
15//! Dream notebook data is **not** part of this session-note API's canonical scope.
16//! Dream is handled separately as a derived view (`views/DREAM_NOTEBOOK.md`) by
17//! `MemoryStore` and `auto_dream`.
18
19use std::io;
20use std::path::PathBuf;
21
22use crate::memory_store::{MemoryStore, DEFAULT_SESSION_TOPIC};
23
24/// Default topic name when none is specified.
25pub const DEFAULT_TOPIC: &str = DEFAULT_SESSION_TOPIC;
26
27/// External memory manager for storing and retrieving session notes.
28#[derive(Debug, Clone)]
29pub struct ExternalMemory {
30    store: MemoryStore,
31}
32
33impl ExternalMemory {
34    /// Create a new external memory manager.
35    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
36        Self {
37            store: MemoryStore::new(data_dir),
38        }
39    }
40
41    /// Create with default settings.
42    ///
43    /// Uses the Bamboo data directory's session memory store.
44    pub fn with_defaults() -> Self {
45        Self {
46            store: MemoryStore::with_defaults(),
47        }
48    }
49
50    pub fn store(&self) -> &MemoryStore {
51        &self.store
52    }
53
54    // ── Validation ──────────────────────────────────────────────────────
55
56    fn validate_session_id(session_id: &str) -> io::Result<()> {
57        crate::memory_store::validate_session_id(session_id).map(|_| ())
58    }
59
60    fn validate_topic(topic: &str) -> io::Result<()> {
61        crate::memory_store::validate_session_topic(topic).map(|_| ())
62    }
63
64    // ── Path resolution ─────────────────────────────────────────────────
65
66    /// Returns the topic file path: `{memory/v1/sessions}/{session_id}/note/{topic}.md`
67    fn topic_path(&self, session_id: &str, topic: &str) -> io::Result<PathBuf> {
68        Self::validate_session_id(session_id)?;
69        Self::validate_topic(topic)?;
70        Ok(self.store.resolver().session_topic_path(session_id, topic))
71    }
72
73    // ── Topic-aware API (primary) ───────────────────────────────────────
74
75    /// Save a note for a specific topic.
76    pub async fn save_topic(
77        &self,
78        session_id: &str,
79        topic: &str,
80        note: &str,
81    ) -> io::Result<PathBuf> {
82        self.store
83            .write_session_topic(session_id, topic, note)
84            .await
85    }
86
87    /// Read a note for a specific topic.
88    pub async fn read_topic(&self, session_id: &str, topic: &str) -> io::Result<Option<String>> {
89        self.store.read_session_topic(session_id, topic).await
90    }
91
92    /// Delete a note for a specific topic.
93    pub async fn delete_topic(&self, session_id: &str, topic: &str) -> io::Result<bool> {
94        self.store.delete_session_topic(session_id, topic).await
95    }
96
97    /// Append to an existing topic note, or create a new one.
98    pub async fn append_topic(
99        &self,
100        session_id: &str,
101        topic: &str,
102        content: &str,
103    ) -> io::Result<PathBuf> {
104        self.store
105            .append_session_topic(session_id, topic, content)
106            .await
107    }
108
109    /// List all topics for a session.
110    pub async fn list_topics(&self, session_id: &str) -> io::Result<Vec<String>> {
111        self.store.list_session_topics(session_id).await
112    }
113
114    // ── Backward-compatible API (uses DEFAULT_TOPIC) ────────────────────
115
116    /// Save a note for a session (default topic).
117    pub async fn save_note(&self, session_id: &str, note: &str) -> io::Result<PathBuf> {
118        self.save_topic(session_id, DEFAULT_TOPIC, note).await
119    }
120
121    /// Read a note for a session (default topic).
122    pub async fn read_note(&self, session_id: &str) -> io::Result<Option<String>> {
123        self.read_topic(session_id, DEFAULT_TOPIC).await
124    }
125
126    /// Delete a note for a session (default topic).
127    pub async fn delete_note(&self, session_id: &str) -> io::Result<bool> {
128        self.delete_topic(session_id, DEFAULT_TOPIC).await
129    }
130
131    /// Append to an existing note (default topic).
132    pub async fn append_note(&self, session_id: &str, content: &str) -> io::Result<PathBuf> {
133        self.append_topic(session_id, DEFAULT_TOPIC, content).await
134    }
135
136    /// List all session IDs that have notes.
137    pub async fn list_sessions_with_notes(&self) -> io::Result<Vec<String>> {
138        let root = self.store.resolver().sessions_root();
139        let mut sessions = Vec::new();
140
141        if !root.exists() {
142            return Ok(sessions);
143        }
144
145        let mut entries = tokio::fs::read_dir(root).await?;
146        while let Some(entry) = entries.next_entry().await? {
147            let path = entry.path();
148            if path.is_dir() {
149                let note_dir = path.join("note");
150                if note_dir.exists() {
151                    if let Some(name) = path.file_name() {
152                        sessions.push(name.to_string_lossy().to_string());
153                    }
154                }
155            }
156        }
157
158        sessions.sort();
159        Ok(sessions)
160    }
161
162    /// Get the path to the default notes file for a session.
163    pub fn get_note_path(&self, session_id: &str) -> PathBuf {
164        self.topic_path(session_id, DEFAULT_TOPIC)
165            .unwrap_or_else(|_| {
166                self.store
167                    .resolver()
168                    .sessions_root()
169                    .join("invalid-session-id.md")
170            })
171    }
172
173    /// Check if a note exists for a session (default topic).
174    pub async fn has_note(&self, session_id: &str) -> bool {
175        self.get_note_path(session_id).exists()
176    }
177}
178
179/// Format a conversation summary as a note for external memory.
180pub fn format_summary_as_note(summary: &str, message_count: usize, token_count: u32) -> String {
181    let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
182
183    format!(
184        r#"# Conversation Summary
185
186**Generated:** {timestamp}
187**Messages Summarized:** {message_count}
188**Token Count:** {token_count}
189
190{summary}
191"#
192    )
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use tempfile::tempdir;
199
200    #[tokio::test]
201    async fn save_and_read_note() {
202        let dir = tempdir().unwrap();
203        let memory = ExternalMemory::new(dir.path());
204
205        let note = "This is a test note.";
206        memory.save_note("session-1", note).await.unwrap();
207
208        let read = memory.read_note("session-1").await.unwrap();
209        assert_eq!(read, Some(note.to_string()));
210    }
211
212    #[tokio::test]
213    async fn read_nonexistent_note() {
214        let dir = tempdir().unwrap();
215        let memory = ExternalMemory::new(dir.path());
216
217        let read = memory.read_note("nonexistent").await.unwrap();
218        assert!(read.is_none());
219    }
220
221    #[tokio::test]
222    async fn delete_note() {
223        let dir = tempdir().unwrap();
224        let memory = ExternalMemory::new(dir.path());
225
226        memory.save_note("session-1", "Note").await.unwrap();
227        let deleted = memory.delete_note("session-1").await.unwrap();
228        assert!(deleted);
229
230        let deleted_again = memory.delete_note("session-1").await.unwrap();
231        assert!(!deleted_again);
232    }
233
234    #[tokio::test]
235    async fn append_to_note() {
236        let dir = tempdir().unwrap();
237        let memory = ExternalMemory::new(dir.path());
238
239        memory.save_note("session-1", "First part").await.unwrap();
240        memory
241            .append_note("session-1", "Second part")
242            .await
243            .unwrap();
244
245        let read = memory.read_note("session-1").await.unwrap();
246        assert_eq!(read, Some("First part\n\nSecond part".to_string()));
247    }
248
249    #[tokio::test]
250    async fn list_sessions() {
251        let dir = tempdir().unwrap();
252        let memory = ExternalMemory::new(dir.path());
253
254        memory.save_note("session-1", "Note 1").await.unwrap();
255        memory.save_note("session-2", "Note 2").await.unwrap();
256
257        let sessions = memory.list_sessions_with_notes().await.unwrap();
258        assert_eq!(sessions.len(), 2);
259        assert!(sessions.contains(&"session-1".to_string()));
260        assert!(sessions.contains(&"session-2".to_string()));
261    }
262
263    #[tokio::test]
264    async fn rejects_invalid_session_id_characters() {
265        let dir = tempdir().unwrap();
266        let memory = ExternalMemory::new(dir.path());
267
268        let save = memory.save_note("../escape", "bad").await;
269        assert!(save.is_err());
270
271        let read = memory.read_note("bad/name").await;
272        assert!(read.is_err());
273    }
274
275    #[test]
276    fn format_summary_creates_markdown() {
277        let summary = "User asked about Rust. Assistant explained.";
278        let note = format_summary_as_note(summary, 10, 500);
279
280        assert!(note.contains("# Conversation Summary"));
281        assert!(note.contains("**Messages Summarized:** 10"));
282        assert!(note.contains("**Token Count:** 500"));
283        assert!(note.contains(summary));
284    }
285
286    // ── Multi-topic tests ───────────────────────────────────────────────
287
288    #[tokio::test]
289    async fn multi_topic_read_write() {
290        let dir = tempdir().unwrap();
291        let memory = ExternalMemory::new(dir.path());
292
293        memory
294            .save_topic("s1", "project-a", "Project A notes")
295            .await
296            .unwrap();
297        memory
298            .save_topic("s1", "project-b", "Project B notes")
299            .await
300            .unwrap();
301
302        assert_eq!(
303            memory.read_topic("s1", "project-a").await.unwrap(),
304            Some("Project A notes".to_string())
305        );
306        assert_eq!(
307            memory.read_topic("s1", "project-b").await.unwrap(),
308            Some("Project B notes".to_string())
309        );
310    }
311
312    #[tokio::test]
313    async fn list_topics_returns_sorted() {
314        let dir = tempdir().unwrap();
315        let memory = ExternalMemory::new(dir.path());
316
317        memory.save_topic("s1", "zebra", "z").await.unwrap();
318        memory.save_topic("s1", "alpha", "a").await.unwrap();
319        memory.save_topic("s1", "mid", "m").await.unwrap();
320
321        let topics = memory.list_topics("s1").await.unwrap();
322        assert_eq!(topics, vec!["alpha", "mid", "zebra"]);
323    }
324
325    #[tokio::test]
326    async fn legacy_migration_moves_file() {
327        let dir = tempdir().unwrap();
328        let memory = ExternalMemory::new(dir.path());
329
330        // Simulate legacy: create {data_dir}/notes/session-1.md directly
331        let legacy_notes_dir = dir.path().join("notes");
332        tokio::fs::create_dir_all(&legacy_notes_dir).await.unwrap();
333        let legacy_path = legacy_notes_dir.join("session-1.md");
334        tokio::fs::write(&legacy_path, "legacy content")
335            .await
336            .unwrap();
337
338        // Reading should trigger migration
339        let content = memory.read_note("session-1").await.unwrap();
340        assert_eq!(content, Some("legacy content".to_string()));
341
342        // Legacy file should be gone
343        assert!(!legacy_path.exists());
344
345        // New file should exist at memory/v1/sessions/session-1/note/default.md
346        let new_path = dir
347            .path()
348            .join("memory")
349            .join("v1")
350            .join("sessions")
351            .join("session-1")
352            .join("note")
353            .join("default.md");
354        assert!(new_path.exists());
355    }
356
357    #[tokio::test]
358    async fn legacy_migration_preserves_new_over_legacy() {
359        let dir = tempdir().unwrap();
360        let memory = ExternalMemory::new(dir.path());
361
362        // Create new-style note first
363        memory
364            .save_topic("session-1", "default", "new content")
365            .await
366            .unwrap();
367
368        // Simulate legacy file appearing (e.g., from an older version)
369        let legacy_notes_dir = dir.path().join("notes");
370        tokio::fs::create_dir_all(&legacy_notes_dir).await.unwrap();
371        let legacy_path = legacy_notes_dir.join("session-1.md");
372        tokio::fs::write(&legacy_path, "old content").await.unwrap();
373
374        // Reading should prefer new content and delete legacy
375        let content = memory.read_note("session-1").await.unwrap();
376        assert_eq!(content, Some("new content".to_string()));
377        assert!(!legacy_path.exists());
378    }
379
380    #[tokio::test]
381    async fn delete_specific_topic() {
382        let dir = tempdir().unwrap();
383        let memory = ExternalMemory::new(dir.path());
384
385        memory.save_topic("s1", "keep", "keep me").await.unwrap();
386        memory
387            .save_topic("s1", "remove", "delete me")
388            .await
389            .unwrap();
390
391        memory.delete_topic("s1", "remove").await.unwrap();
392
393        assert_eq!(
394            memory.read_topic("s1", "keep").await.unwrap(),
395            Some("keep me".to_string())
396        );
397        assert_eq!(memory.read_topic("s1", "remove").await.unwrap(), None);
398    }
399
400    #[tokio::test]
401    async fn rejects_invalid_topic_names() {
402        let dir = tempdir().unwrap();
403        let memory = ExternalMemory::new(dir.path());
404
405        assert!(memory.save_topic("s1", "../escape", "bad").await.is_err());
406        assert!(memory.save_topic("s1", "has space", "bad").await.is_err());
407        assert!(memory.save_topic("s1", "", "bad").await.is_err());
408
409        let long_name = "a".repeat(crate::memory_store::MAX_SESSION_TOPIC_LEN + 1);
410        assert!(memory.save_topic("s1", &long_name, "bad").await.is_err());
411    }
412
413    #[tokio::test]
414    async fn append_to_topic() {
415        let dir = tempdir().unwrap();
416        let memory = ExternalMemory::new(dir.path());
417
418        memory.save_topic("s1", "notes", "first").await.unwrap();
419        memory.append_topic("s1", "notes", "second").await.unwrap();
420
421        let content = memory.read_topic("s1", "notes").await.unwrap();
422        assert_eq!(content, Some("first\n\nsecond".to_string()));
423    }
424}