Skip to main content

bamboo_agent/agent/core/memory/
mod.rs

1//! External memory and note-taking for conversation context persistence.
2//!
3//! Provides a way to store and retrieve session-related notes that can
4//! persist across conversations and be retrieved when resuming sessions.
5
6use std::io;
7use std::path::PathBuf;
8
9/// External memory manager for storing and retrieving session notes.
10#[derive(Debug)]
11pub struct ExternalMemory {
12    /// Directory to store notes
13    notes_dir: PathBuf,
14}
15
16impl ExternalMemory {
17    /// Create a new external memory manager.
18    pub fn new(notes_dir: impl Into<PathBuf>) -> Self {
19        Self {
20            notes_dir: notes_dir.into(),
21        }
22    }
23
24    /// Create with default settings.
25    ///
26    /// Uses `~/.bamboo/notes` as the storage directory.
27    pub fn with_defaults() -> Self {
28        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
29        let notes_dir = home.join(".bamboo").join("notes");
30        Self::new(notes_dir)
31    }
32
33    /// Save a note for a session.
34    ///
35    /// The note is stored as a markdown file named `{session_id}.md`.
36    pub async fn save_note(&self, session_id: &str, note: &str) -> io::Result<PathBuf> {
37        // Ensure notes directory exists
38        tokio::fs::create_dir_all(&self.notes_dir).await?;
39
40        let note_path = self.notes_dir.join(format!("{}.md", session_id));
41        tokio::fs::write(&note_path, note).await?;
42
43        Ok(note_path)
44    }
45
46    /// Read a note for a session.
47    ///
48    /// Returns None if no note exists for the session.
49    pub async fn read_note(&self, session_id: &str) -> io::Result<Option<String>> {
50        let note_path = self.notes_dir.join(format!("{}.md", session_id));
51
52        if !note_path.exists() {
53            return Ok(None);
54        }
55
56        let content = tokio::fs::read_to_string(&note_path).await?;
57        Ok(Some(content))
58    }
59
60    /// Delete a note for a session.
61    ///
62    /// Returns true if a note was deleted, false if no note existed.
63    pub async fn delete_note(&self, session_id: &str) -> io::Result<bool> {
64        let note_path = self.notes_dir.join(format!("{}.md", session_id));
65
66        if note_path.exists() {
67            tokio::fs::remove_file(&note_path).await?;
68            Ok(true)
69        } else {
70            Ok(false)
71        }
72    }
73
74    /// List all session IDs that have notes.
75    pub async fn list_sessions_with_notes(&self) -> io::Result<Vec<String>> {
76        let mut sessions = Vec::new();
77
78        if !self.notes_dir.exists() {
79            return Ok(sessions);
80        }
81
82        let mut entries = tokio::fs::read_dir(&self.notes_dir).await?;
83        while let Some(entry) = entries.next_entry().await? {
84            let path = entry.path();
85            if path.extension().is_some_and(|ext| ext == "md") {
86                if let Some(stem) = path.file_stem() {
87                    sessions.push(stem.to_string_lossy().to_string());
88                }
89            }
90        }
91
92        Ok(sessions)
93    }
94
95    /// Append to an existing note, or create a new one if it doesn't exist.
96    pub async fn append_note(&self, session_id: &str, content: &str) -> io::Result<PathBuf> {
97        let existing = self.read_note(session_id).await?;
98
99        let note = match existing {
100            Some(mut prev) => {
101                prev.push_str("\n\n");
102                prev.push_str(content);
103                prev
104            }
105            None => content.to_string(),
106        };
107
108        self.save_note(session_id, &note).await
109    }
110
111    /// Get the path to the notes file for a session.
112    pub fn get_note_path(&self, session_id: &str) -> PathBuf {
113        self.notes_dir.join(format!("{}.md", session_id))
114    }
115
116    /// Check if a note exists for a session.
117    pub async fn has_note(&self, session_id: &str) -> bool {
118        self.get_note_path(session_id).exists()
119    }
120}
121
122/// Format a conversation summary as a note for external memory.
123pub fn format_summary_as_note(summary: &str, message_count: usize, token_count: u32) -> String {
124    let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
125
126    format!(
127        r#"# Conversation Summary
128
129**Generated:** {timestamp}
130**Messages Summarized:** {message_count}
131**Token Count:** {token_count}
132
133{summary}
134"#
135    )
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use tempfile::tempdir;
142
143    #[tokio::test]
144    async fn save_and_read_note() {
145        let dir = tempdir().unwrap();
146        let memory = ExternalMemory::new(dir.path());
147
148        let note = "This is a test note.";
149        memory.save_note("session-1", note).await.unwrap();
150
151        let read = memory.read_note("session-1").await.unwrap();
152        assert_eq!(read, Some(note.to_string()));
153    }
154
155    #[tokio::test]
156    async fn read_nonexistent_note() {
157        let dir = tempdir().unwrap();
158        let memory = ExternalMemory::new(dir.path());
159
160        let read = memory.read_note("nonexistent").await.unwrap();
161        assert!(read.is_none());
162    }
163
164    #[tokio::test]
165    async fn delete_note() {
166        let dir = tempdir().unwrap();
167        let memory = ExternalMemory::new(dir.path());
168
169        memory.save_note("session-1", "Note").await.unwrap();
170        let deleted = memory.delete_note("session-1").await.unwrap();
171        assert!(deleted);
172
173        let deleted_again = memory.delete_note("session-1").await.unwrap();
174        assert!(!deleted_again);
175    }
176
177    #[tokio::test]
178    async fn append_to_note() {
179        let dir = tempdir().unwrap();
180        let memory = ExternalMemory::new(dir.path());
181
182        memory.save_note("session-1", "First part").await.unwrap();
183        memory
184            .append_note("session-1", "Second part")
185            .await
186            .unwrap();
187
188        let read = memory.read_note("session-1").await.unwrap();
189        assert_eq!(read, Some("First part\n\nSecond part".to_string()));
190    }
191
192    #[tokio::test]
193    async fn list_sessions() {
194        let dir = tempdir().unwrap();
195        let memory = ExternalMemory::new(dir.path());
196
197        memory.save_note("session-1", "Note 1").await.unwrap();
198        memory.save_note("session-2", "Note 2").await.unwrap();
199
200        let sessions = memory.list_sessions_with_notes().await.unwrap();
201        assert_eq!(sessions.len(), 2);
202        assert!(sessions.contains(&"session-1".to_string()));
203        assert!(sessions.contains(&"session-2".to_string()));
204    }
205
206    #[test]
207    fn format_summary_creates_markdown() {
208        let summary = "User asked about Rust. Assistant explained.";
209        let note = format_summary_as_note(summary, 10, 500);
210
211        assert!(note.contains("# Conversation Summary"));
212        // Format includes markdown bold markers (**)
213        assert!(note.contains("**Messages Summarized:** 10"));
214        assert!(note.contains("**Token Count:** 500"));
215        assert!(note.contains(summary));
216    }
217}