Skip to main content

clawft_core/agent/
memory.rs

1//! Long-term and session memory management.
2//!
3//! Manages two markdown files ported from the Python nanobot pattern:
4//! - `MEMORY.md` -- long-term facts (append-only, periodically consolidated)
5//! - `HISTORY.md` -- session summaries (grep-searchable log)
6//!
7//! File locations follow the discovery chain:
8//! `~/.clawft/workspace/memory/` with fallback to `~/.nanobot/workspace/memory/`.
9//!
10//! All I/O goes through the [`Platform`] filesystem trait so the module
11//! remains WASM-compatible and testable with mock filesystems.
12
13use std::path::PathBuf;
14use std::sync::Arc;
15
16use tracing::{debug, warn};
17
18use clawft_platform::Platform;
19use clawft_types::{ClawftError, Result};
20
21/// Long-term and session memory store.
22///
23/// Wraps two markdown files (`MEMORY.md` and `HISTORY.md`) behind the
24/// platform filesystem abstraction. Reads are lock-free (each call reads
25/// the file); writes go through the platform's append/write operations.
26///
27/// # Directory resolution
28///
29/// The constructor resolves the memory directory using the fallback chain:
30/// 1. `~/.clawft/workspace/memory/`
31/// 2. `~/.nanobot/workspace/memory/` (legacy fallback)
32///
33/// If neither exists, the `.clawft` path is used and created on first write.
34pub struct MemoryStore<P: Platform> {
35    memory_path: PathBuf,
36    history_path: PathBuf,
37    platform: Arc<P>,
38}
39
40impl<P: Platform> MemoryStore<P> {
41    /// Create a new memory store.
42    ///
43    /// Resolves the workspace memory directory from the platform home
44    /// directory. Prefers `~/.clawft/workspace/memory/`; falls back to
45    /// `~/.nanobot/workspace/memory/` if the `.clawft` directory does
46    /// not exist but `.nanobot` does.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`ClawftError::ConfigInvalid`] if no home directory can
51    /// be determined from the platform.
52    pub fn new(platform: Arc<P>) -> Result<Self> {
53        let home = platform
54            .fs()
55            .home_dir()
56            .ok_or_else(|| ClawftError::ConfigInvalid {
57                reason: "could not determine home directory".into(),
58            })?;
59
60        let clawft_memory = home.join(".clawft").join("workspace").join("memory");
61        let nanobot_memory = home.join(".nanobot").join("workspace").join("memory");
62
63        // Prefer .clawft; fall back to .nanobot only if it already exists
64        // and .clawft does not. We use sync existence checks on the parent
65        // dirs since home_dir resolution is sync.
66        let memory_dir = if nanobot_memory.exists() && !clawft_memory.exists() {
67            debug!(path = %nanobot_memory.display(), "using legacy nanobot memory path");
68            nanobot_memory
69        } else {
70            debug!(path = %clawft_memory.display(), "using clawft memory path");
71            clawft_memory
72        };
73
74        Ok(Self {
75            memory_path: memory_dir.join("MEMORY.md"),
76            history_path: memory_dir.join("HISTORY.md"),
77            platform,
78        })
79    }
80
81    /// Create a memory store with explicit paths (for testing).
82    #[cfg(test)]
83    pub(crate) fn with_paths(
84        memory_path: PathBuf,
85        history_path: PathBuf,
86        platform: Arc<P>,
87    ) -> Self {
88        Self {
89            memory_path,
90            history_path,
91            platform,
92        }
93    }
94
95    /// Read long-term memory (`MEMORY.md`).
96    ///
97    /// Returns an empty string if the file does not exist yet, matching
98    /// the Python nanobot behavior. I/O errors other than "not found"
99    /// are propagated.
100    pub async fn read_long_term(&self) -> Result<String> {
101        self.read_file_or_empty(&self.memory_path).await
102    }
103
104    /// Write (overwrite) long-term memory.
105    ///
106    /// Creates parent directories if they do not exist.
107    pub async fn write_long_term(&self, content: &str) -> Result<()> {
108        let clean = crate::security::sanitize_content(content);
109        self.platform
110            .fs()
111            .write_string(&self.memory_path, &clean)
112            .await
113            .map_err(ClawftError::Io)
114    }
115
116    /// Append an entry to long-term memory.
117    ///
118    /// Each entry is terminated with a double newline to form distinct
119    /// paragraphs for later search.
120    pub async fn append_long_term(&self, entry: &str) -> Result<()> {
121        let clean = crate::security::sanitize_content(entry);
122        let formatted = format!("{}\n\n", clean.trim_end());
123        self.platform
124            .fs()
125            .append_string(&self.memory_path, &formatted)
126            .await
127            .map_err(ClawftError::Io)
128    }
129
130    /// Read the history file (`HISTORY.md`).
131    ///
132    /// Returns an empty string if the file does not exist yet.
133    pub async fn read_history(&self) -> Result<String> {
134        self.read_file_or_empty(&self.history_path).await
135    }
136
137    /// Append an entry to the history file.
138    ///
139    /// Each entry is terminated with a double newline to match the
140    /// Python `append_history` behavior.
141    pub async fn append_history(&self, entry: &str) -> Result<()> {
142        let clean = crate::security::sanitize_content(entry);
143        let formatted = format!("{}\n\n", clean.trim_end());
144        self.platform
145            .fs()
146            .append_string(&self.history_path, &formatted)
147            .await
148            .map_err(ClawftError::Io)
149    }
150
151    /// Substring search across both memory files.
152    ///
153    /// Splits content by double-newline into paragraphs, then returns
154    /// paragraphs containing `query` (case-insensitive). Results are
155    /// returned in document order, capped at `max_results`.
156    pub async fn search(&self, query: &str, max_results: usize) -> Vec<String> {
157        if query.is_empty() || max_results == 0 {
158            return Vec::new();
159        }
160
161        let query_lower = query.to_lowercase();
162        let mut results = Vec::new();
163
164        for content in [self.read_long_term().await, self.read_history().await] {
165            let text = match content {
166                Ok(t) => t,
167                Err(e) => {
168                    warn!(error = %e, "failed to read memory file during search");
169                    continue;
170                }
171            };
172            for paragraph in text.split("\n\n") {
173                let trimmed = paragraph.trim();
174                if !trimmed.is_empty() && trimmed.to_lowercase().contains(&query_lower) {
175                    results.push(trimmed.to_string());
176                    if results.len() >= max_results {
177                        return results;
178                    }
179                }
180            }
181        }
182
183        results
184    }
185
186    /// Path to the memory file (for diagnostics / context building).
187    pub fn memory_path(&self) -> &PathBuf {
188        &self.memory_path
189    }
190
191    /// Path to the history file (for diagnostics / context building).
192    pub fn history_path(&self) -> &PathBuf {
193        &self.history_path
194    }
195
196    /// Read a file, returning empty string on "not found".
197    async fn read_file_or_empty(&self, path: &std::path::Path) -> Result<String> {
198        if !self.platform.fs().exists(path).await {
199            return Ok(String::new());
200        }
201        self.platform
202            .fs()
203            .read_to_string(path)
204            .await
205            .map_err(ClawftError::Io)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use clawft_platform::NativePlatform;
213    use std::sync::atomic::{AtomicU64, Ordering};
214
215    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
216
217    /// Generate a unique temp directory for each test.
218    fn temp_dir(prefix: &str) -> PathBuf {
219        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
220        let pid = std::process::id();
221        std::env::temp_dir().join(format!("clawft_mem_test_{prefix}_{pid}_{id}"))
222    }
223
224    /// Create a MemoryStore pointing at a temp directory.
225    fn test_store(dir: &std::path::Path) -> MemoryStore<NativePlatform> {
226        let platform = Arc::new(NativePlatform::new());
227        MemoryStore::with_paths(dir.join("MEMORY.md"), dir.join("HISTORY.md"), platform)
228    }
229
230    #[tokio::test]
231    async fn read_long_term_returns_empty_when_missing() {
232        let dir = temp_dir("empty_lt");
233        let store = test_store(&dir);
234        let content = store.read_long_term().await.unwrap();
235        assert!(content.is_empty());
236    }
237
238    #[tokio::test]
239    async fn write_and_read_long_term() {
240        let dir = temp_dir("write_lt");
241        let store = test_store(&dir);
242
243        store
244            .write_long_term("fact: the sky is blue")
245            .await
246            .unwrap();
247        let content = store.read_long_term().await.unwrap();
248        assert_eq!(content, "fact: the sky is blue");
249
250        // Cleanup
251        let _ = tokio::fs::remove_dir_all(&dir).await;
252    }
253
254    #[tokio::test]
255    async fn append_long_term_adds_paragraphs() {
256        let dir = temp_dir("append_lt");
257        let store = test_store(&dir);
258
259        store.append_long_term("first entry").await.unwrap();
260        store.append_long_term("second entry").await.unwrap();
261
262        let content = store.read_long_term().await.unwrap();
263        assert!(content.contains("first entry"));
264        assert!(content.contains("second entry"));
265        // Entries separated by double newlines
266        assert!(content.contains("\n\n"));
267
268        let _ = tokio::fs::remove_dir_all(&dir).await;
269    }
270
271    #[tokio::test]
272    async fn read_history_returns_empty_when_missing() {
273        let dir = temp_dir("empty_hist");
274        let store = test_store(&dir);
275        let content = store.read_history().await.unwrap();
276        assert!(content.is_empty());
277    }
278
279    #[tokio::test]
280    async fn append_and_read_history() {
281        let dir = temp_dir("append_hist");
282        let store = test_store(&dir);
283
284        store.append_history("session 1 summary").await.unwrap();
285        store.append_history("session 2 summary").await.unwrap();
286
287        let content = store.read_history().await.unwrap();
288        assert!(content.contains("session 1 summary"));
289        assert!(content.contains("session 2 summary"));
290
291        let _ = tokio::fs::remove_dir_all(&dir).await;
292    }
293
294    #[tokio::test]
295    async fn write_long_term_overwrites() {
296        let dir = temp_dir("overwrite_lt");
297        let store = test_store(&dir);
298
299        store.write_long_term("old content").await.unwrap();
300        store.write_long_term("new content").await.unwrap();
301
302        let content = store.read_long_term().await.unwrap();
303        assert_eq!(content, "new content");
304
305        let _ = tokio::fs::remove_dir_all(&dir).await;
306    }
307
308    #[tokio::test]
309    async fn search_finds_matching_paragraphs() {
310        let dir = temp_dir("search");
311        let store = test_store(&dir);
312
313        store
314            .write_long_term("The sky is blue.\n\nGrass is green.\n\nThe ocean is also blue.")
315            .await
316            .unwrap();
317
318        let results = store.search("blue", 10).await;
319        assert_eq!(results.len(), 2);
320        assert_eq!(results[0], "The sky is blue.");
321        assert_eq!(results[1], "The ocean is also blue.");
322
323        let _ = tokio::fs::remove_dir_all(&dir).await;
324    }
325
326    #[tokio::test]
327    async fn search_is_case_insensitive() {
328        let dir = temp_dir("search_ci");
329        let store = test_store(&dir);
330
331        store.write_long_term("Rust is GREAT").await.unwrap();
332
333        let results = store.search("great", 10).await;
334        assert_eq!(results.len(), 1);
335        assert_eq!(results[0], "Rust is GREAT");
336
337        let _ = tokio::fs::remove_dir_all(&dir).await;
338    }
339
340    #[tokio::test]
341    async fn search_respects_max_results() {
342        let dir = temp_dir("search_max");
343        let store = test_store(&dir);
344
345        store
346            .write_long_term("match one\n\nmatch two\n\nmatch three")
347            .await
348            .unwrap();
349
350        let results = store.search("match", 2).await;
351        assert_eq!(results.len(), 2);
352
353        let _ = tokio::fs::remove_dir_all(&dir).await;
354    }
355
356    #[tokio::test]
357    async fn search_spans_both_files() {
358        let dir = temp_dir("search_both");
359        let store = test_store(&dir);
360
361        store.write_long_term("memory hit here").await.unwrap();
362        store.append_history("history hit here").await.unwrap();
363
364        let results = store.search("hit here", 10).await;
365        assert_eq!(results.len(), 2);
366
367        let _ = tokio::fs::remove_dir_all(&dir).await;
368    }
369
370    #[tokio::test]
371    async fn search_empty_query_returns_empty() {
372        let dir = temp_dir("search_empty");
373        let store = test_store(&dir);
374        store.write_long_term("some content").await.unwrap();
375
376        let results = store.search("", 10).await;
377        assert!(results.is_empty());
378
379        let _ = tokio::fs::remove_dir_all(&dir).await;
380    }
381
382    #[tokio::test]
383    async fn search_zero_max_returns_empty() {
384        let dir = temp_dir("search_zero");
385        let store = test_store(&dir);
386        store.write_long_term("content").await.unwrap();
387
388        let results = store.search("content", 0).await;
389        assert!(results.is_empty());
390
391        let _ = tokio::fs::remove_dir_all(&dir).await;
392    }
393
394    #[tokio::test]
395    async fn search_no_matches() {
396        let dir = temp_dir("search_none");
397        let store = test_store(&dir);
398        store.write_long_term("hello world").await.unwrap();
399
400        let results = store.search("xyz_no_match", 10).await;
401        assert!(results.is_empty());
402
403        let _ = tokio::fs::remove_dir_all(&dir).await;
404    }
405
406    #[tokio::test]
407    async fn append_trims_trailing_whitespace() {
408        let dir = temp_dir("trim");
409        let store = test_store(&dir);
410
411        store
412            .append_long_term("entry with trailing   \n\n")
413            .await
414            .unwrap();
415        let content = store.read_long_term().await.unwrap();
416        // Should be trimmed then followed by exactly one double newline
417        assert_eq!(content, "entry with trailing\n\n");
418
419        let _ = tokio::fs::remove_dir_all(&dir).await;
420    }
421
422    #[tokio::test]
423    async fn new_resolves_home_dir() {
424        let platform = Arc::new(NativePlatform::new());
425        let store = MemoryStore::new(platform);
426        assert!(store.is_ok());
427        let store = store.unwrap();
428        // Memory path should be absolute and end with MEMORY.md
429        assert!(store.memory_path().is_absolute());
430        assert!(
431            store
432                .memory_path()
433                .file_name()
434                .is_some_and(|n| n == "MEMORY.md")
435        );
436        assert!(
437            store
438                .history_path()
439                .file_name()
440                .is_some_and(|n| n == "HISTORY.md")
441        );
442    }
443
444    #[tokio::test]
445    async fn creates_parent_dirs_on_write() {
446        let dir = temp_dir("mkdirs");
447        let nested = dir.join("deep").join("nested");
448        let platform = Arc::new(NativePlatform::new());
449        let store = MemoryStore::with_paths(
450            nested.join("MEMORY.md"),
451            nested.join("HISTORY.md"),
452            platform,
453        );
454
455        // Should create all parent dirs
456        store.write_long_term("test").await.unwrap();
457        assert_eq!(store.read_long_term().await.unwrap(), "test");
458
459        let _ = tokio::fs::remove_dir_all(&dir).await;
460    }
461}