bamboo_agent/agent/core/memory/
mod.rs1use std::io;
7use std::path::PathBuf;
8
9#[derive(Debug)]
11pub struct ExternalMemory {
12 notes_dir: PathBuf,
14}
15
16impl ExternalMemory {
17 pub fn new(notes_dir: impl Into<PathBuf>) -> Self {
19 Self {
20 notes_dir: notes_dir.into(),
21 }
22 }
23
24 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 pub async fn save_note(&self, session_id: &str, note: &str) -> io::Result<PathBuf> {
37 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(¬e_path, note).await?;
42
43 Ok(note_path)
44 }
45
46 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(¬e_path).await?;
57 Ok(Some(content))
58 }
59
60 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(¬e_path).await?;
68 Ok(true)
69 } else {
70 Ok(false)
71 }
72 }
73
74 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 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, ¬e).await
109 }
110
111 pub fn get_note_path(&self, session_id: &str) -> PathBuf {
113 self.notes_dir.join(format!("{}.md", session_id))
114 }
115
116 pub async fn has_note(&self, session_id: &str) -> bool {
118 self.get_note_path(session_id).exists()
119 }
120}
121
122pub 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 assert!(note.contains("**Messages Summarized:** 10"));
214 assert!(note.contains("**Token Count:** 500"));
215 assert!(note.contains(summary));
216 }
217}