1use super::provider::{
4 MemoryProvider, PrefetchRequest, PrefetchResponse, PrefetchStatus, SessionEndRequest,
5 SessionEndResponse, SessionEndStatus, StartupInjectionShape, SyncTurnRequest,
6 SyncTurnResponse, SyncTurnStatus, SystemPromptBlock, SystemPromptRequest,
7 SystemPromptResponse,
8};
9use super::storage::{DailyNote, Memory};
10use parking_lot::Mutex;
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug)]
16pub struct MemoryManager {
17 _workspace: PathBuf,
19 memory_path: PathBuf,
21 notes_dir: PathBuf,
23 history_path: PathBuf,
25 handled_session_end_ids: Mutex<HashSet<String>>,
27}
28
29impl MemoryManager {
30 pub fn new<P: AsRef<Path>>(workspace: P) -> Self {
32 let workspace = workspace.as_ref().to_path_buf();
33 let memory_path = workspace.join("memory").join("MEMORY.md");
34 let history_path = workspace.join("memory").join("HISTORY.md");
35 let notes_dir = workspace.join("memory");
36
37 Self {
38 _workspace: workspace,
39 memory_path,
40 notes_dir,
41 history_path,
42 handled_session_end_ids: Mutex::new(HashSet::new()),
43 }
44 }
45
46 pub fn load_memory(&self) -> Memory {
48 if self.memory_path.exists() {
49 match std::fs::read_to_string(&self.memory_path) {
50 Ok(content) => Memory::with_content(content),
51 Err(_) => Memory::new(),
52 }
53 } else {
54 Memory::new()
55 }
56 }
57
58 pub fn save_memory(&self, memory: &Memory) -> crate::Result<()> {
60 if let Some(parent) = self.memory_path.parent() {
61 std::fs::create_dir_all(parent)?;
62 }
63 std::fs::write(&self.memory_path, &memory.content)?;
64 Ok(())
65 }
66
67 pub fn load_history(&self) -> String {
69 if self.history_path.exists() {
70 std::fs::read_to_string(&self.history_path).unwrap_or_default()
71 } else {
72 String::new()
73 }
74 }
75
76 pub fn append_history(&self, entry: &str) -> crate::Result<()> {
78 if entry.trim().is_empty() {
79 return Ok(());
80 }
81 if let Some(parent) = self.history_path.parent() {
82 std::fs::create_dir_all(parent)?;
83 }
84 let mut content = self.load_history();
85 if !content.is_empty() && !content.ends_with('\n') {
86 content.push('\n');
87 }
88 content.push_str(entry.trim_end());
89 content.push_str("\n\n");
90 std::fs::write(&self.history_path, content)?;
91 Ok(())
92 }
93
94 pub fn load_daily_note(&self, date: impl AsRef<str>) -> DailyNote {
96 let date = date.as_ref();
97 let path = self.notes_dir.join(format!("{}.md", date));
98
99 if path.exists() {
100 match std::fs::read_to_string(&path) {
101 Ok(content) => {
102 let mut note = DailyNote::for_date(date);
103 note.content = content;
104 note
105 }
106 Err(_) => DailyNote::for_date(date),
107 }
108 } else {
109 DailyNote::for_date(date)
110 }
111 }
112
113 pub fn load_today_note(&self) -> DailyNote {
115 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
116 self.load_daily_note(&today)
117 }
118
119 pub fn save_daily_note(&self, note: &DailyNote) -> crate::Result<()> {
121 std::fs::create_dir_all(&self.notes_dir)?;
122 let path = self.notes_dir.join(note.filename());
123 std::fs::write(&path, ¬e.content)?;
124 Ok(())
125 }
126
127 pub fn list_notes(&self) -> Vec<String> {
129 let mut notes = Vec::new();
130
131 if let Ok(entries) = std::fs::read_dir(&self.notes_dir) {
132 for entry in entries.flatten() {
133 if let Some(name) = entry.file_name().to_str() {
134 if name.ends_with(".md") && name != "MEMORY.md" {
135 let date = name.trim_end_matches(".md").to_string();
136 notes.push(date);
137 }
138 }
139 }
140 }
141
142 notes.sort_by(|a, b| b.cmp(a)); notes
144 }
145
146 pub fn memory_dir(&self) -> &Path {
148 &self.notes_dir
149 }
150
151 pub fn append_today(&self, content: &str) -> crate::Result<()> {
153 let mut note = self.load_today_note();
154
155 if note.content.is_empty() {
156 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
158 note.content = format!("# {}\n\n{}", today, content);
159 } else {
160 note.content.push('\n');
162 note.content.push_str(content);
163 }
164
165 self.save_daily_note(¬e)
166 }
167
168 pub fn get_recent_memories(&self, days: usize) -> String {
170 use chrono::Duration;
171
172 let mut memories = Vec::new();
173 let today = chrono::Local::now().date_naive();
174
175 for i in 0..days {
176 let date = today - Duration::days(i as i64);
177 let date_str = date.format("%Y-%m-%d").to_string();
178 let note = self.load_daily_note(&date_str);
179
180 if !note.content.is_empty() {
181 memories.push(note.content);
182 }
183 }
184
185 memories.join("\n\n---\n\n")
186 }
187
188 pub fn list_memory_files(&self) -> Vec<PathBuf> {
190 let mut files = Vec::new();
191
192 if let Ok(entries) = std::fs::read_dir(&self.notes_dir) {
193 for entry in entries.flatten() {
194 let path = entry.path();
195 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
196 if name.len() == 13 && name.ends_with(".md") && name != "MEMORY.md" {
198 let date_part = &name[..10];
199 if date_part.chars().filter(|c| *c == '-').count() == 2 {
201 files.push(path);
202 }
203 }
204 }
205 }
206 }
207
208 files.sort_by(|a, b| b.cmp(a));
210 files
211 }
212
213 pub fn get_memory_context(&self) -> String {
216 let memory = self.load_memory();
217 if memory.content.is_empty() {
218 String::new()
219 } else {
220 format!("## Long-term Memory\n{}", memory.content)
221 }
222 }
223
224}
225
226#[async_trait::async_trait]
227impl MemoryProvider for MemoryManager {
228 fn system_prompt_block(
229 &self,
230 _request: &SystemPromptRequest,
231 ) -> crate::Result<SystemPromptResponse> {
232 let context = self.get_memory_context();
233 if context.is_empty() {
234 Ok(SystemPromptResponse::degraded(
235 "startup continuity unavailable; no long-term memory available",
236 ))
237 } else {
238 Ok(SystemPromptResponse::ready(SystemPromptBlock {
239 shape: StartupInjectionShape::CompactRenderedMarkdown,
240 markdown: context,
241 }))
242 }
243 }
244
245 async fn prefetch(&self, request: PrefetchRequest) -> crate::Result<PrefetchResponse> {
246 if request.intent.trim().is_empty() {
247 return Ok(PrefetchResponse {
248 status: PrefetchStatus::SkippedNoIntent,
249 prompt_block: None,
250 });
251 }
252
253 Ok(PrefetchResponse {
254 status: PrefetchStatus::Failed {
255 reason: format!(
256 "prefetch recall is unavailable in the default MemoryManager for intent '{}'",
257 request.intent.trim()
258 ),
259 },
260 prompt_block: None,
261 })
262 }
263
264 async fn sync_turn(&self, request: SyncTurnRequest) -> crate::Result<SyncTurnResponse> {
265 let mut persisted = false;
266
267 if let Some(memory_update) = request.memory_update_markdown.as_deref() {
268 if !memory_update.trim().is_empty() {
269 let memory = Memory::with_content(memory_update);
270 if let Err(err) = self.save_memory(&memory) {
271 return Ok(SyncTurnResponse {
272 status: SyncTurnStatus::Failed {
273 reason: format!("failed to persist MEMORY.md: {err}"),
274 },
275 });
276 }
277 persisted = true;
278 }
279 }
280
281 if let Some(history_entry) = request.history_entry.as_deref() {
282 if !history_entry.trim().is_empty() {
283 if let Err(err) = self.append_history(history_entry) {
284 return Ok(SyncTurnResponse {
285 status: SyncTurnStatus::Failed {
286 reason: format!("failed to append HISTORY.md: {err}"),
287 },
288 });
289 }
290 persisted = true;
291 }
292 }
293
294 Ok(SyncTurnResponse {
295 status: if persisted {
296 SyncTurnStatus::Persisted
297 } else {
298 SyncTurnStatus::Noop
299 },
300 })
301 }
302
303 async fn on_session_end(
304 &self,
305 request: SessionEndRequest,
306 ) -> crate::Result<SessionEndResponse> {
307 if let Some(session_id) = request.session_id {
308 let mut handled = self.handled_session_end_ids.lock();
309 if !handled.insert(session_id) {
310 return Ok(SessionEndResponse {
311 status: SessionEndStatus::AlreadyHandled,
312 });
313 }
314 }
315
316 Ok(SessionEndResponse {
317 status: SessionEndStatus::Noop,
318 })
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::memory::{
326 MemoryProvider, PrefetchRequest, PrefetchStatus, SessionEndRequest, SessionEndStatus,
327 StartupStatus, SyncTurnRequest, SyncTurnStatus, SystemPromptRequest,
328 };
329 use tempfile::TempDir;
330
331 #[test]
332 fn test_append_history() {
333 let temp_dir = TempDir::new().unwrap();
334 let manager = MemoryManager::new(temp_dir.path());
335 manager
336 .append_history("[2026-02-12 09:00] Added memory event")
337 .unwrap();
338
339 let history = manager.load_history();
340 assert!(history.contains("Added memory event"));
341 }
342
343 #[tokio::test]
344 async fn test_memory_provider_system_prompt_block_reads_memory() {
345 let temp_dir = TempDir::new().unwrap();
346 let manager = MemoryManager::new(temp_dir.path());
347 manager
348 .save_memory(&Memory::with_content("Long term provider context"))
349 .unwrap();
350
351 let response = manager
352 .system_prompt_block(&SystemPromptRequest {
353 workspace_root: temp_dir.path().to_path_buf(),
354 })
355 .unwrap();
356
357 assert_eq!(response.status, StartupStatus::Ready);
358
359 let block = response
360 .prompt_block
361 .expect("memory-backed provider should emit a prompt block");
362
363 assert!(block.markdown.contains("## Long-term Memory"));
364 assert!(block.markdown.contains("Long term provider context"));
365 assert_eq!(
366 block.shape,
367 crate::memory::StartupInjectionShape::CompactRenderedMarkdown
368 );
369 }
370
371 #[tokio::test]
372 async fn test_memory_provider_sync_turn_persists_memory_and_history() {
373 let temp_dir = TempDir::new().unwrap();
374 let manager = MemoryManager::new(temp_dir.path());
375
376 let result = manager
377 .sync_turn(SyncTurnRequest {
378 workspace_root: temp_dir.path().to_path_buf(),
379 memory_update_markdown: Some("Updated memory from sync_turn".to_string()),
380 history_entry: Some("[2026-05-08 10:00 UTC] synchronized turn".to_string()),
381 })
382 .await
383 .unwrap();
384
385 assert_eq!(result.status, SyncTurnStatus::Persisted);
386 assert_eq!(manager.load_memory().content, "Updated memory from sync_turn");
387 assert!(manager.load_history().contains("synchronized turn"));
388 }
389
390 #[tokio::test]
391 async fn test_memory_provider_session_end_is_noop_by_default() {
392 let temp_dir = TempDir::new().unwrap();
393 let manager = MemoryManager::new(temp_dir.path());
394
395 let response = manager
396 .on_session_end(SessionEndRequest {
397 workspace_root: temp_dir.path().to_path_buf(),
398 session_id: Some("session-1".to_string()),
399 })
400 .await
401 .unwrap();
402
403 assert_eq!(response.status, SessionEndStatus::Noop);
404 }
405
406 #[tokio::test]
407 async fn test_memory_provider_returns_degraded_startup_when_no_context_is_available() {
408 let temp_dir = TempDir::new().unwrap();
409 let manager = MemoryManager::new(temp_dir.path());
410
411 let response = manager
412 .system_prompt_block(&SystemPromptRequest {
413 workspace_root: temp_dir.path().to_path_buf(),
414 })
415 .unwrap();
416
417 match response.status {
418 StartupStatus::Degraded {
419 reason,
420 last_usable_wakeup,
421 } => {
422 assert!(reason.contains("startup continuity unavailable"));
423 assert!(last_usable_wakeup.is_none());
424 }
425 other => panic!("expected degraded startup, got {other:?}"),
426 }
427
428 let block = response
429 .prompt_block
430 .expect("degraded startup should still provide explicit startup text");
431 assert!(block.markdown.contains("status: degraded"));
432 assert!(block.markdown.contains("last_usable_wakeup: omitted"));
433 }
434
435 #[tokio::test]
436 async fn test_memory_provider_prefetch_distinguishes_no_intent_from_failed_recall() {
437 let temp_dir = TempDir::new().unwrap();
438 let manager = MemoryManager::new(temp_dir.path());
439
440 let skipped = manager
441 .prefetch(PrefetchRequest {
442 workspace_root: temp_dir.path().to_path_buf(),
443 intent: " ".to_string(),
444 current_room: None,
445 user_message: Some("help".to_string()),
446 })
447 .await
448 .unwrap();
449 assert_eq!(skipped.status, PrefetchStatus::SkippedNoIntent);
450 assert!(skipped.prompt_block.is_none());
451
452 let failed = manager
453 .prefetch(PrefetchRequest {
454 workspace_root: temp_dir.path().to_path_buf(),
455 intent: "recall-project-status".to_string(),
456 current_room: Some("roadmap".to_string()),
457 user_message: Some("what changed?".to_string()),
458 })
459 .await
460 .unwrap();
461 match failed.status {
462 PrefetchStatus::Failed { reason } => {
463 assert!(reason.contains("prefetch recall is unavailable"));
464 }
465 other => panic!("expected failed recall, got {other:?}"),
466 }
467 assert!(failed.prompt_block.is_none());
468 }
469
470 #[tokio::test]
471 async fn test_memory_provider_sync_turn_failure_is_explicit() {
472 let temp_dir = TempDir::new().unwrap();
473 let workspace = temp_dir.path().to_path_buf();
474 std::fs::create_dir_all(workspace.join("memory")).unwrap();
475 std::fs::write(workspace.join("memory").join("MEMORY.md"), "locked").unwrap();
476 std::fs::remove_file(workspace.join("memory").join("MEMORY.md")).unwrap();
477 std::fs::create_dir_all(workspace.join("memory").join("MEMORY.md")).unwrap();
478
479 let manager = MemoryManager::new(&workspace);
480 let result = manager
481 .sync_turn(SyncTurnRequest {
482 workspace_root: workspace,
483 memory_update_markdown: Some("cannot persist".to_string()),
484 history_entry: None,
485 })
486 .await
487 .unwrap();
488
489 match result.status {
490 SyncTurnStatus::Failed { reason } => {
491 assert!(reason.contains("failed to persist MEMORY.md"));
492 }
493 other => panic!("expected sync failure, got {other:?}"),
494 }
495 }
496
497 #[tokio::test]
498 async fn test_memory_provider_session_end_is_idempotent_for_duplicates() {
499 let temp_dir = TempDir::new().unwrap();
500 let manager = MemoryManager::new(temp_dir.path());
501
502 let first = manager
503 .on_session_end(SessionEndRequest {
504 workspace_root: temp_dir.path().to_path_buf(),
505 session_id: Some("session-dup".to_string()),
506 })
507 .await
508 .unwrap();
509 assert_eq!(first.status, SessionEndStatus::Noop);
510
511 let duplicate = manager
512 .on_session_end(SessionEndRequest {
513 workspace_root: temp_dir.path().to_path_buf(),
514 session_id: Some("session-dup".to_string()),
515 })
516 .await
517 .unwrap();
518 assert_eq!(duplicate.status, SessionEndStatus::AlreadyHandled);
519 }
520}