clawft-core 0.5.1

Core engine for clawft (agent loop, message bus, context management)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Long-term and session memory management.
//!
//! Manages two markdown files ported from the Python nanobot pattern:
//! - `MEMORY.md` -- long-term facts (append-only, periodically consolidated)
//! - `HISTORY.md` -- session summaries (grep-searchable log)
//!
//! File locations follow the discovery chain:
//! `~/.clawft/workspace/memory/` with fallback to `~/.nanobot/workspace/memory/`.
//!
//! All I/O goes through the [`Platform`] filesystem trait so the module
//! remains WASM-compatible and testable with mock filesystems.

use std::path::PathBuf;
use std::sync::Arc;

use tracing::{debug, warn};

use clawft_platform::Platform;
use clawft_types::{ClawftError, Result};

/// Long-term and session memory store.
///
/// Wraps two markdown files (`MEMORY.md` and `HISTORY.md`) behind the
/// platform filesystem abstraction. Reads are lock-free (each call reads
/// the file); writes go through the platform's append/write operations.
///
/// # Directory resolution
///
/// The constructor resolves the memory directory using the fallback chain:
/// 1. `~/.clawft/workspace/memory/`
/// 2. `~/.nanobot/workspace/memory/` (legacy fallback)
///
/// If neither exists, the `.clawft` path is used and created on first write.
pub struct MemoryStore<P: Platform> {
    memory_path: PathBuf,
    history_path: PathBuf,
    platform: Arc<P>,
}

impl<P: Platform> MemoryStore<P> {
    /// Create a new memory store.
    ///
    /// Resolves the workspace memory directory from the platform home
    /// directory. Prefers `~/.clawft/workspace/memory/`; falls back to
    /// `~/.nanobot/workspace/memory/` if the `.clawft` directory does
    /// not exist but `.nanobot` does.
    ///
    /// # Errors
    ///
    /// Returns [`ClawftError::ConfigInvalid`] if no home directory can
    /// be determined from the platform.
    pub fn new(platform: Arc<P>) -> Result<Self> {
        let home = platform
            .fs()
            .home_dir()
            .ok_or_else(|| ClawftError::ConfigInvalid {
                reason: "could not determine home directory".into(),
            })?;

        let clawft_memory = home.join(".clawft").join("workspace").join("memory");
        let nanobot_memory = home.join(".nanobot").join("workspace").join("memory");

        // Prefer .clawft; fall back to .nanobot only if it already exists
        // and .clawft does not. We use sync existence checks on the parent
        // dirs since home_dir resolution is sync.
        let memory_dir = if nanobot_memory.exists() && !clawft_memory.exists() {
            debug!(path = %nanobot_memory.display(), "using legacy nanobot memory path");
            nanobot_memory
        } else {
            debug!(path = %clawft_memory.display(), "using clawft memory path");
            clawft_memory
        };

        Ok(Self {
            memory_path: memory_dir.join("MEMORY.md"),
            history_path: memory_dir.join("HISTORY.md"),
            platform,
        })
    }

    /// Create a memory store with explicit paths (for testing).
    #[cfg(test)]
    pub(crate) fn with_paths(
        memory_path: PathBuf,
        history_path: PathBuf,
        platform: Arc<P>,
    ) -> Self {
        Self {
            memory_path,
            history_path,
            platform,
        }
    }

    /// Read long-term memory (`MEMORY.md`).
    ///
    /// Returns an empty string if the file does not exist yet, matching
    /// the Python nanobot behavior. I/O errors other than "not found"
    /// are propagated.
    pub async fn read_long_term(&self) -> Result<String> {
        self.read_file_or_empty(&self.memory_path).await
    }

    /// Write (overwrite) long-term memory.
    ///
    /// Creates parent directories if they do not exist.
    pub async fn write_long_term(&self, content: &str) -> Result<()> {
        let clean = crate::security::sanitize_content(content);
        self.platform
            .fs()
            .write_string(&self.memory_path, &clean)
            .await
            .map_err(ClawftError::Io)
    }

    /// Append an entry to long-term memory.
    ///
    /// Each entry is terminated with a double newline to form distinct
    /// paragraphs for later search.
    pub async fn append_long_term(&self, entry: &str) -> Result<()> {
        let clean = crate::security::sanitize_content(entry);
        let formatted = format!("{}\n\n", clean.trim_end());
        self.platform
            .fs()
            .append_string(&self.memory_path, &formatted)
            .await
            .map_err(ClawftError::Io)
    }

    /// Read the history file (`HISTORY.md`).
    ///
    /// Returns an empty string if the file does not exist yet.
    pub async fn read_history(&self) -> Result<String> {
        self.read_file_or_empty(&self.history_path).await
    }

    /// Append an entry to the history file.
    ///
    /// Each entry is terminated with a double newline to match the
    /// Python `append_history` behavior.
    pub async fn append_history(&self, entry: &str) -> Result<()> {
        let clean = crate::security::sanitize_content(entry);
        let formatted = format!("{}\n\n", clean.trim_end());
        self.platform
            .fs()
            .append_string(&self.history_path, &formatted)
            .await
            .map_err(ClawftError::Io)
    }

    /// Substring search across both memory files.
    ///
    /// Splits content by double-newline into paragraphs, then returns
    /// paragraphs containing `query` (case-insensitive). Results are
    /// returned in document order, capped at `max_results`.
    pub async fn search(&self, query: &str, max_results: usize) -> Vec<String> {
        if query.is_empty() || max_results == 0 {
            return Vec::new();
        }

        let query_lower = query.to_lowercase();
        let mut results = Vec::new();

        for content in [self.read_long_term().await, self.read_history().await] {
            let text = match content {
                Ok(t) => t,
                Err(e) => {
                    warn!(error = %e, "failed to read memory file during search");
                    continue;
                }
            };
            for paragraph in text.split("\n\n") {
                let trimmed = paragraph.trim();
                if !trimmed.is_empty() && trimmed.to_lowercase().contains(&query_lower) {
                    results.push(trimmed.to_string());
                    if results.len() >= max_results {
                        return results;
                    }
                }
            }
        }

        results
    }

    /// Path to the memory file (for diagnostics / context building).
    pub fn memory_path(&self) -> &PathBuf {
        &self.memory_path
    }

    /// Path to the history file (for diagnostics / context building).
    pub fn history_path(&self) -> &PathBuf {
        &self.history_path
    }

    /// Read a file, returning empty string on "not found".
    async fn read_file_or_empty(&self, path: &std::path::Path) -> Result<String> {
        if !self.platform.fs().exists(path).await {
            return Ok(String::new());
        }
        self.platform
            .fs()
            .read_to_string(path)
            .await
            .map_err(ClawftError::Io)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use clawft_platform::NativePlatform;
    use std::sync::atomic::{AtomicU64, Ordering};

    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);

    /// Generate a unique temp directory for each test.
    fn temp_dir(prefix: &str) -> PathBuf {
        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
        let pid = std::process::id();
        std::env::temp_dir().join(format!("clawft_mem_test_{prefix}_{pid}_{id}"))
    }

    /// Create a MemoryStore pointing at a temp directory.
    fn test_store(dir: &std::path::Path) -> MemoryStore<NativePlatform> {
        let platform = Arc::new(NativePlatform::new());
        MemoryStore::with_paths(dir.join("MEMORY.md"), dir.join("HISTORY.md"), platform)
    }

    #[tokio::test]
    async fn read_long_term_returns_empty_when_missing() {
        let dir = temp_dir("empty_lt");
        let store = test_store(&dir);
        let content = store.read_long_term().await.unwrap();
        assert!(content.is_empty());
    }

    #[tokio::test]
    async fn write_and_read_long_term() {
        let dir = temp_dir("write_lt");
        let store = test_store(&dir);

        store
            .write_long_term("fact: the sky is blue")
            .await
            .unwrap();
        let content = store.read_long_term().await.unwrap();
        assert_eq!(content, "fact: the sky is blue");

        // Cleanup
        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn append_long_term_adds_paragraphs() {
        let dir = temp_dir("append_lt");
        let store = test_store(&dir);

        store.append_long_term("first entry").await.unwrap();
        store.append_long_term("second entry").await.unwrap();

        let content = store.read_long_term().await.unwrap();
        assert!(content.contains("first entry"));
        assert!(content.contains("second entry"));
        // Entries separated by double newlines
        assert!(content.contains("\n\n"));

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn read_history_returns_empty_when_missing() {
        let dir = temp_dir("empty_hist");
        let store = test_store(&dir);
        let content = store.read_history().await.unwrap();
        assert!(content.is_empty());
    }

    #[tokio::test]
    async fn append_and_read_history() {
        let dir = temp_dir("append_hist");
        let store = test_store(&dir);

        store.append_history("session 1 summary").await.unwrap();
        store.append_history("session 2 summary").await.unwrap();

        let content = store.read_history().await.unwrap();
        assert!(content.contains("session 1 summary"));
        assert!(content.contains("session 2 summary"));

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn write_long_term_overwrites() {
        let dir = temp_dir("overwrite_lt");
        let store = test_store(&dir);

        store.write_long_term("old content").await.unwrap();
        store.write_long_term("new content").await.unwrap();

        let content = store.read_long_term().await.unwrap();
        assert_eq!(content, "new content");

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn search_finds_matching_paragraphs() {
        let dir = temp_dir("search");
        let store = test_store(&dir);

        store
            .write_long_term("The sky is blue.\n\nGrass is green.\n\nThe ocean is also blue.")
            .await
            .unwrap();

        let results = store.search("blue", 10).await;
        assert_eq!(results.len(), 2);
        assert_eq!(results[0], "The sky is blue.");
        assert_eq!(results[1], "The ocean is also blue.");

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn search_is_case_insensitive() {
        let dir = temp_dir("search_ci");
        let store = test_store(&dir);

        store.write_long_term("Rust is GREAT").await.unwrap();

        let results = store.search("great", 10).await;
        assert_eq!(results.len(), 1);
        assert_eq!(results[0], "Rust is GREAT");

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn search_respects_max_results() {
        let dir = temp_dir("search_max");
        let store = test_store(&dir);

        store
            .write_long_term("match one\n\nmatch two\n\nmatch three")
            .await
            .unwrap();

        let results = store.search("match", 2).await;
        assert_eq!(results.len(), 2);

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn search_spans_both_files() {
        let dir = temp_dir("search_both");
        let store = test_store(&dir);

        store.write_long_term("memory hit here").await.unwrap();
        store.append_history("history hit here").await.unwrap();

        let results = store.search("hit here", 10).await;
        assert_eq!(results.len(), 2);

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn search_empty_query_returns_empty() {
        let dir = temp_dir("search_empty");
        let store = test_store(&dir);
        store.write_long_term("some content").await.unwrap();

        let results = store.search("", 10).await;
        assert!(results.is_empty());

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn search_zero_max_returns_empty() {
        let dir = temp_dir("search_zero");
        let store = test_store(&dir);
        store.write_long_term("content").await.unwrap();

        let results = store.search("content", 0).await;
        assert!(results.is_empty());

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn search_no_matches() {
        let dir = temp_dir("search_none");
        let store = test_store(&dir);
        store.write_long_term("hello world").await.unwrap();

        let results = store.search("xyz_no_match", 10).await;
        assert!(results.is_empty());

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn append_trims_trailing_whitespace() {
        let dir = temp_dir("trim");
        let store = test_store(&dir);

        store
            .append_long_term("entry with trailing   \n\n")
            .await
            .unwrap();
        let content = store.read_long_term().await.unwrap();
        // Should be trimmed then followed by exactly one double newline
        assert_eq!(content, "entry with trailing\n\n");

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn new_resolves_home_dir() {
        let platform = Arc::new(NativePlatform::new());
        let store = MemoryStore::new(platform);
        assert!(store.is_ok());
        let store = store.unwrap();
        // Memory path should be absolute and end with MEMORY.md
        assert!(store.memory_path().is_absolute());
        assert!(
            store
                .memory_path()
                .file_name()
                .is_some_and(|n| n == "MEMORY.md")
        );
        assert!(
            store
                .history_path()
                .file_name()
                .is_some_and(|n| n == "HISTORY.md")
        );
    }

    #[tokio::test]
    async fn creates_parent_dirs_on_write() {
        let dir = temp_dir("mkdirs");
        let nested = dir.join("deep").join("nested");
        let platform = Arc::new(NativePlatform::new());
        let store = MemoryStore::with_paths(
            nested.join("MEMORY.md"),
            nested.join("HISTORY.md"),
            platform,
        );

        // Should create all parent dirs
        store.write_long_term("test").await.unwrap();
        assert_eq!(store.read_long_term().await.unwrap(), "test");

        let _ = tokio::fs::remove_dir_all(&dir).await;
    }
}