clawft_core/agent/
memory.rs1use std::path::PathBuf;
14use std::sync::Arc;
15
16use tracing::{debug, warn};
17
18use clawft_platform::Platform;
19use clawft_types::{ClawftError, Result};
20
21pub struct MemoryStore<P: Platform> {
35 memory_path: PathBuf,
36 history_path: PathBuf,
37 platform: Arc<P>,
38}
39
40impl<P: Platform> MemoryStore<P> {
41 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 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 #[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 pub async fn read_long_term(&self) -> Result<String> {
101 self.read_file_or_empty(&self.memory_path).await
102 }
103
104 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 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 pub async fn read_history(&self) -> Result<String> {
134 self.read_file_or_empty(&self.history_path).await
135 }
136
137 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 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 pub fn memory_path(&self) -> &PathBuf {
188 &self.memory_path
189 }
190
191 pub fn history_path(&self) -> &PathBuf {
193 &self.history_path
194 }
195
196 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 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 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 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 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 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 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 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}