Skip to main content

roma_memory/
lib.rs

1//! Layered memory system (L0-L4).
2//!
3//! Provides [`FileMemoryStore`] as the default file-backed implementation.
4//! The [`MemoryStore`] trait, [`MemoryError`], [`MemoryLevel`], and
5//! [`NullMemoryStore`] are defined in `roma-core` and re-exported here
6//! for backward compatibility.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use roma_core::safe_resolve;
14use tokio::fs;
15use tracing::{debug, warn};
16
17pub use roma_core::{MemoryError, MemoryLevel, MemoryStore, NullMemoryStore, PatchError};
18
19// ---------------------------------------------------------------------------
20// Validators
21// ---------------------------------------------------------------------------
22
23/// Structural validator for a memory level.
24pub trait MemoryValidator: Send + Sync {
25    fn validate(&self, content: &str) -> Result<(), MemoryError>;
26}
27
28/// L1 validator: enforce a maximum line count.
29pub struct L1MaxLines {
30    pub max: usize,
31}
32
33impl MemoryValidator for L1MaxLines {
34    fn validate(&self, content: &str) -> Result<(), MemoryError> {
35        let lines = content.lines().count();
36        if lines > self.max {
37            return Err(MemoryError::ValidationFailed {
38                level: MemoryLevel::L1,
39                reason: format!("{lines} lines exceed maximum of {}", self.max),
40            });
41        }
42        Ok(())
43    }
44}
45
46/// L2 validator: enforce required markdown sections exist.
47pub struct L2SectionGuard {
48    pub required: Vec<String>,
49}
50
51impl MemoryValidator for L2SectionGuard {
52    fn validate(&self, content: &str) -> Result<(), MemoryError> {
53        for section in &self.required {
54            let header = format!("# {section}");
55            let header2 = format!("## {section}");
56            if !content.contains(&header) && !content.contains(&header2) {
57                return Err(MemoryError::ValidationFailed {
58                    level: MemoryLevel::L2,
59                    reason: format!("missing required section: {section}"),
60                });
61            }
62        }
63        Ok(())
64    }
65}
66
67// ---------------------------------------------------------------------------
68// FileMemoryStore
69// ---------------------------------------------------------------------------
70
71/// File-backed memory store with per-level validators.
72///
73/// Directory layout:
74/// ```text
75/// base_dir/L0/...
76/// base_dir/L1/...
77/// ...
78/// ```
79///
80/// Paths passed to read/write/patch/delete are relative to a level directory.
81/// The format is `<level_prefix>/<name>`, e.g. `"L1/key_info.md"`. The level
82/// prefix is automatically stripped to resolve the filesystem path.
83pub struct FileMemoryStore {
84    base_dir: PathBuf,
85    validators: HashMap<MemoryLevel, Box<dyn MemoryValidator>>,
86}
87
88impl FileMemoryStore {
89    /// Create a new file-backed store rooted at `base_dir`.
90    /// Level subdirectories are created lazily on first write.
91    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
92        Self {
93            base_dir: base_dir.into(),
94            validators: HashMap::new(),
95        }
96    }
97
98    /// Attach a validator for a specific memory level.
99    pub fn with_validator(
100        mut self,
101        level: MemoryLevel,
102        validator: Box<dyn MemoryValidator>,
103    ) -> Self {
104        self.validators.insert(level, validator);
105        self
106    }
107
108    /// Parse a logical path like `"L1/key_info.md"` into `(MemoryLevel, relative_path)`.
109    ///
110    /// Performs only lexical validation: rejects `..` components and
111    /// absolute sub-paths. Full filesystem-boundary resolution (including
112    /// symlink following) happens in [`Self::safe_fs_path`].
113    fn parse_path(&self, path: &str) -> Result<(MemoryLevel, PathBuf), MemoryError> {
114        let (level, rest) = path
115            .split_once('/')
116            .ok_or_else(|| MemoryError::NotFound(format!("invalid memory path: {path}")))?;
117        let level = match level {
118            "L0" => MemoryLevel::L0,
119            "L1" => MemoryLevel::L1,
120            "L2" => MemoryLevel::L2,
121            "L3" => MemoryLevel::L3,
122            "L4" => MemoryLevel::L4,
123            _ => return Err(MemoryError::NotFound(format!("unknown level: {level}"))),
124        };
125        let relative = PathBuf::from(rest);
126        // Lexical rejection of traversal and absolute paths. Symlink
127        // escape is caught later by `safe_fs_path`.
128        for comp in relative.components() {
129            match comp {
130                std::path::Component::ParentDir => {
131                    return Err(MemoryError::PathDenied(format!(
132                        "path traversal rejected: {path}"
133                    )));
134                }
135                std::path::Component::RootDir | std::path::Component::Prefix(_) => {
136                    return Err(MemoryError::PathDenied(format!(
137                        "absolute sub-path rejected: {path}"
138                    )));
139                }
140                _ => {}
141            }
142        }
143        Ok((level, relative))
144    }
145
146    /// Ensure the level directory exists, then resolve `relative` to an
147    /// absolute path that is guaranteed to stay inside
148    /// `base_dir/<level>/`, following symlinks.
149    async fn safe_fs_path(
150        &self,
151        level: MemoryLevel,
152        relative: &Path,
153    ) -> Result<PathBuf, MemoryError> {
154        let level_dir = self.base_dir.join(level.dir_name());
155        if !level_dir.exists() {
156            fs::create_dir_all(&level_dir).await?;
157        }
158        let resolved = safe_resolve(&level_dir, relative)?;
159        Ok(resolved)
160    }
161}
162
163#[async_trait]
164impl MemoryStore for FileMemoryStore {
165    async fn read(&self, path: &str) -> Result<String, MemoryError> {
166        let (level, relative) = self.parse_path(path)?;
167        let level_dir = self.base_dir.join(level.dir_name());
168        // If the level directory doesn't exist yet, nothing can be read
169        // there — short-circuit to NotFound without creating the dir.
170        if !level_dir.exists() {
171            return Err(MemoryError::NotFound(path.to_string()));
172        }
173        let full = self.safe_fs_path(level, &relative).await?;
174        fs::read_to_string(&full).await.map_err(|e| {
175            if e.kind() == std::io::ErrorKind::NotFound {
176                MemoryError::NotFound(path.to_string())
177            } else {
178                MemoryError::Io(e)
179            }
180        })
181    }
182
183    async fn write(&self, path: &str, content: &str) -> Result<(), MemoryError> {
184        let (level, relative) = self.parse_path(path)?;
185        self.validate(level, content)?;
186        let full = self.safe_fs_path(level, &relative).await?;
187        if let Some(parent) = full.parent() {
188            fs::create_dir_all(parent).await?;
189        }
190        debug!(path = %full.display(), "writing memory file");
191        fs::write(&full, content).await?;
192        Ok(())
193    }
194
195    async fn patch(&self, path: &str, old: &str, new: &str) -> Result<(), MemoryError> {
196        let content = self.read(path).await?;
197        let count = content.matches(old).count();
198        if count == 0 {
199            return Err(MemoryError::Patch(PatchError::NotFound));
200        }
201        if count > 1 {
202            return Err(MemoryError::Patch(PatchError::NotUnique { count }));
203        }
204        let patched = content.replacen(old, new, 1);
205        self.write(path, &patched).await
206    }
207
208    async fn delete(&self, path: &str) -> Result<(), MemoryError> {
209        let (level, relative) = self.parse_path(path)?;
210        let level_dir = self.base_dir.join(level.dir_name());
211        if !level_dir.exists() {
212            // Nothing to delete; stay idempotent.
213            return Ok(());
214        }
215        let full = self.safe_fs_path(level, &relative).await?;
216        match fs::remove_file(&full).await {
217            Ok(()) => {
218                debug!(path = %full.display(), "deleted memory file");
219                Ok(())
220            }
221            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
222                warn!(path = %full.display(), "delete called on non-existent file");
223                Ok(())
224            }
225            Err(e) => Err(MemoryError::Io(e)),
226        }
227    }
228
229    async fn list(&self, level: MemoryLevel) -> Result<Vec<String>, MemoryError> {
230        let dir = self.base_dir.join(level.dir_name());
231        let mut result = Vec::new();
232        // Walk directory tree recursively using an explicit stack.
233        let mut stack = vec![(dir, String::new())];
234        while let Some((current_dir, prefix)) = stack.pop() {
235            let mut entries = match fs::read_dir(&current_dir).await {
236                Ok(rd) => rd,
237                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
238                Err(e) => return Err(MemoryError::Io(e)),
239            };
240            while let Ok(Some(entry)) = entries.next_entry().await {
241                let name = entry.file_name();
242                let name_str = match name.to_str() {
243                    Some(s) => s,
244                    None => continue,
245                };
246                let path = entry.path();
247                if path.is_dir() {
248                    let sub_prefix = if prefix.is_empty() {
249                        format!("{}/", name_str)
250                    } else {
251                        format!("{prefix}{name_str}/")
252                    };
253                    stack.push((path, sub_prefix));
254                } else {
255                    let logical = if prefix.is_empty() {
256                        format!("{}/{}", level.dir_name(), name_str)
257                    } else {
258                        format!("{}/{}{}", level.dir_name(), prefix, name_str)
259                    };
260                    result.push(logical);
261                }
262            }
263        }
264        result.sort();
265        Ok(result)
266    }
267
268    fn validate(&self, level: MemoryLevel, content: &str) -> Result<(), MemoryError> {
269        if let Some(v) = self.validators.get(&level) {
270            v.validate(content)?;
271        }
272        Ok(())
273    }
274
275    async fn read_root(&self, name: &str) -> Result<String, MemoryError> {
276        // Reject traversal attempts.
277        if name.contains("..") || name.contains('/') || name.contains('\\') {
278            return Err(MemoryError::PathDenied(format!(
279                "root file name must be plain: {name}"
280            )));
281        }
282        // Resolve symlinks and verify the canonical path stays within base_dir.
283        let resolved = safe_resolve(&self.base_dir, Path::new(name))?;
284        fs::read_to_string(&resolved).await.map_err(|e| {
285            if e.kind() == std::io::ErrorKind::NotFound {
286                MemoryError::NotFound(name.to_string())
287            } else {
288                MemoryError::Io(e)
289            }
290        })
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Forgetter trait
296// ---------------------------------------------------------------------------
297
298/// Strategy for pruning/consolidating long-term memory when it grows too large.
299///
300/// Implementations decide *which* files to merge, *how* to merge them, and
301/// what to discard. The trait is intentionally minimal — the "how" of merging
302/// (LLM summarization, simple concatenation, etc.) is left to the implementer.
303#[async_trait]
304pub trait Forgetter: Send + Sync {
305    /// Run consolidation if the L3 file count exceeds `threshold`.
306    /// Returns the number of files removed.
307    async fn maybe_consolidate(
308        &self,
309        store: &Arc<dyn MemoryStore>,
310        threshold: usize,
311    ) -> Result<usize, MemoryError>;
312}
313
314/// A no-op forgetter that never consolidates. Useful as a default.
315pub struct NullForgetter;
316
317#[async_trait]
318impl Forgetter for NullForgetter {
319    async fn maybe_consolidate(
320        &self,
321        _store: &Arc<dyn MemoryStore>,
322        _threshold: usize,
323    ) -> Result<usize, MemoryError> {
324        Ok(0)
325    }
326}
327
328/// Simple forgetter that merges the oldest L3 files when the count exceeds
329/// the threshold. Files are concatenated into a single `_consolidated_<n>.md`
330/// and the originals are deleted.
331pub struct SimpleForgetter;
332
333#[async_trait]
334impl Forgetter for SimpleForgetter {
335    async fn maybe_consolidate(
336        &self,
337        store: &Arc<dyn MemoryStore>,
338        threshold: usize,
339    ) -> Result<usize, MemoryError> {
340        let files = store.list(MemoryLevel::L3).await?;
341        if files.len() <= threshold {
342            return Ok(0);
343        }
344
345        let excess = files.len() - threshold;
346        let to_merge = &files[..excess];
347        let mut merged = String::from("# Consolidated Memory\n\n");
348        let mut read_ok: Vec<&str> = Vec::new();
349        for path in to_merge {
350            match store.read(path).await {
351                Ok(content) => {
352                    let name = path.rsplit('/').next().unwrap_or(path);
353                    merged.push_str(&format!("## {name}\n\n{content}\n\n"));
354                    read_ok.push(path);
355                }
356                Err(e) => {
357                    warn!(path, error = %e, "failed to read file during consolidation, skipping");
358                }
359            }
360        }
361
362        let n = std::time::SystemTime::now()
363            .duration_since(std::time::UNIX_EPOCH)
364            .unwrap_or_default();
365        let consolidated_path = format!("L3/_consolidated_{}{}.md", n.as_secs(), n.subsec_millis());
366        store.write(&consolidated_path, &merged).await?;
367
368        let mut removed = 0;
369        for path in read_ok {
370            match store.delete(path).await {
371                Ok(()) | Err(MemoryError::NotFound(_)) => removed += 1,
372                Err(e) => {
373                    warn!(path, error = %e, "failed to delete consolidated file");
374                }
375            }
376        }
377
378        debug!(
379            consolidated = consolidated_path,
380            removed, "L3 consolidation complete"
381        );
382        Ok(removed)
383    }
384}
385
386#[cfg(test)]
387#[allow(clippy::expect_used, clippy::unwrap_used)]
388mod tests {
389    use super::*;
390    use tempfile::TempDir;
391
392    fn make_store(dir: &TempDir) -> FileMemoryStore {
393        FileMemoryStore::new(dir.path())
394            .with_validator(MemoryLevel::L1, Box::new(L1MaxLines { max: 30 }))
395            .with_validator(
396                MemoryLevel::L2,
397                Box::new(L2SectionGuard {
398                    required: vec!["Findings".into()],
399                }),
400            )
401    }
402
403    #[tokio::test]
404    async fn write_and_read_roundtrip() {
405        let dir = TempDir::new().unwrap();
406        let store = make_store(&dir);
407        store.write("L0/rules.md", "be helpful").await.unwrap();
408        let content = store.read("L0/rules.md").await.unwrap();
409        assert_eq!(content, "be helpful");
410    }
411
412    #[tokio::test]
413    async fn read_missing_returns_not_found() {
414        let dir = TempDir::new().unwrap();
415        let store = make_store(&dir);
416        let err = store.read("L0/absent.md").await.unwrap_err();
417        assert!(matches!(err, MemoryError::NotFound(_)));
418    }
419
420    #[tokio::test]
421    async fn patch_unique_replaces_content() {
422        let dir = TempDir::new().unwrap();
423        let store = make_store(&dir);
424        store.write("L0/rules.md", "old value here").await.unwrap();
425        store.patch("L0/rules.md", "old", "new").await.unwrap();
426        assert_eq!(store.read("L0/rules.md").await.unwrap(), "new value here");
427    }
428
429    #[tokio::test]
430    async fn patch_not_unique_returns_error() {
431        let dir = TempDir::new().unwrap();
432        let store = make_store(&dir);
433        store
434            .write("L0/rules.md", "foo and foo again")
435            .await
436            .unwrap();
437        let err = store.patch("L0/rules.md", "foo", "bar").await.unwrap_err();
438        assert!(matches!(
439            err,
440            MemoryError::Patch(PatchError::NotUnique { count: 2 })
441        ));
442    }
443
444    #[tokio::test]
445    async fn patch_not_found_returns_error() {
446        let dir = TempDir::new().unwrap();
447        let store = make_store(&dir);
448        store.write("L0/rules.md", "hello").await.unwrap();
449        let err = store
450            .patch("L0/rules.md", "absent", "new")
451            .await
452            .unwrap_err();
453        assert!(matches!(err, MemoryError::Patch(PatchError::NotFound)));
454    }
455
456    #[tokio::test]
457    async fn delete_removes_file() {
458        let dir = TempDir::new().unwrap();
459        let store = make_store(&dir);
460        store.write("L0/rules.md", "temp").await.unwrap();
461        store.delete("L0/rules.md").await.unwrap();
462        assert!(matches!(
463            store.read("L0/rules.md").await.unwrap_err(),
464            MemoryError::NotFound(_)
465        ));
466    }
467
468    #[tokio::test]
469    async fn delete_nonexistent_is_ok() {
470        let dir = TempDir::new().unwrap();
471        let store = make_store(&dir);
472        store.delete("L0/ghost.md").await.unwrap();
473    }
474
475    #[tokio::test]
476    async fn list_returns_files_in_level() {
477        let dir = TempDir::new().unwrap();
478        let store = make_store(&dir);
479        store.write("L0/rules.md", "r").await.unwrap();
480        store.write("L0/sop.md", "s").await.unwrap();
481        store.write("L1/key.md", "k").await.unwrap();
482        let l0 = store.list(MemoryLevel::L0).await.unwrap();
483        assert_eq!(l0, vec!["L0/rules.md", "L0/sop.md"]);
484        let l1 = store.list(MemoryLevel::L1).await.unwrap();
485        assert_eq!(l1, vec!["L1/key.md"]);
486        let l2 = store.list(MemoryLevel::L2).await.unwrap();
487        assert!(l2.is_empty());
488    }
489
490    #[tokio::test]
491    async fn l1_validator_rejects_excess_lines() {
492        let dir = TempDir::new().unwrap();
493        let store = make_store(&dir);
494        let long = (0..31)
495            .map(|i| format!("line {i}"))
496            .collect::<Vec<_>>()
497            .join("\n");
498        let err = store.write("L1/key.md", &long).await.unwrap_err();
499        assert!(matches!(
500            err,
501            MemoryError::ValidationFailed {
502                level: MemoryLevel::L1,
503                ..
504            }
505        ));
506    }
507
508    #[tokio::test]
509    async fn l1_validator_accepts_at_limit() {
510        let dir = TempDir::new().unwrap();
511        let store = make_store(&dir);
512        let exact = (0..30)
513            .map(|i| format!("line {i}"))
514            .collect::<Vec<_>>()
515            .join("\n");
516        store.write("L1/key.md", &exact).await.unwrap();
517    }
518
519    #[tokio::test]
520    async fn l2_validator_rejects_missing_section() {
521        let dir = TempDir::new().unwrap();
522        let store = make_store(&dir);
523        let err = store
524            .write("L2/session.md", "# No findings here")
525            .await
526            .unwrap_err();
527        assert!(matches!(
528            err,
529            MemoryError::ValidationFailed {
530                level: MemoryLevel::L2,
531                ..
532            }
533        ));
534    }
535
536    #[tokio::test]
537    async fn l2_validator_accepts_with_section() {
538        let dir = TempDir::new().unwrap();
539        let store = make_store(&dir);
540        store
541            .write("L2/session.md", "# Findings\nsome insight")
542            .await
543            .unwrap();
544    }
545
546    #[test]
547    fn l1_max_lines_validator_direct() {
548        let v = L1MaxLines { max: 2 };
549        assert!(v.validate("a\nb").is_ok());
550        assert!(v.validate("a\nb\nc").is_err());
551    }
552
553    #[test]
554    fn l2_section_guard_validator_direct() {
555        let v = L2SectionGuard {
556            required: vec!["Summary".into()],
557        };
558        assert!(v.validate("# Summary\nhello").is_ok());
559        assert!(v.validate("## Summary\nhello").is_ok());
560        assert!(v.validate("# No summary here").is_err());
561    }
562
563    #[tokio::test]
564    async fn path_traversal_is_rejected() {
565        let dir = TempDir::new().unwrap();
566        let store = make_store(&dir);
567        let err = store.read("L1/../../etc/passwd").await.unwrap_err();
568        assert!(matches!(err, MemoryError::PathDenied(_)));
569        let err = store.write("L0/../secret", "data").await.unwrap_err();
570        assert!(matches!(err, MemoryError::PathDenied(_)));
571    }
572
573    #[tokio::test]
574    async fn absolute_subpath_is_rejected() {
575        let dir = TempDir::new().unwrap();
576        let store = make_store(&dir);
577        let err = store.read("L0//etc/passwd").await.unwrap_err();
578        // "L0//etc/passwd" → level "L0", rest "/etc/passwd" → absolute → PathDenied
579        assert!(matches!(err, MemoryError::PathDenied(_)));
580        let err = store.write("L0//tmp/evil", "x").await.unwrap_err();
581        assert!(matches!(err, MemoryError::PathDenied(_)));
582    }
583
584    #[cfg(unix)]
585    #[tokio::test]
586    async fn symlink_escape_is_rejected() {
587        // Create a memory base with an L0 directory that contains a
588        // symlink pointing outside the base. Reads via that symlink
589        // must be rejected.
590        let outside = TempDir::new().unwrap();
591        std::fs::write(outside.path().join("target.md"), "secret").unwrap();
592
593        let dir = TempDir::new().unwrap();
594        let l0 = dir.path().join("L0");
595        std::fs::create_dir_all(&l0).unwrap();
596        std::os::unix::fs::symlink(outside.path().join("target.md"), l0.join("link.md")).unwrap();
597
598        let store = make_store(&dir);
599        let err = store.read("L0/link.md").await.unwrap_err();
600        assert!(
601            matches!(err, MemoryError::PathDenied(_)),
602            "expected PathDenied, got {err:?}"
603        );
604    }
605
606    #[tokio::test]
607    async fn l3_crud_roundtrip() {
608        let dir = TempDir::new().unwrap();
609        let store = FileMemoryStore::new(dir.path());
610
611        store
612            .write("L3/debug_rust_build.md", "# SOP\n1. cargo check")
613            .await
614            .unwrap();
615        let content = store.read("L3/debug_rust_build.md").await.unwrap();
616        assert!(content.contains("cargo check"));
617
618        store
619            .patch("L3/debug_rust_build.md", "cargo check", "cargo clippy")
620            .await
621            .unwrap();
622        let patched = store.read("L3/debug_rust_build.md").await.unwrap();
623        assert!(patched.contains("cargo clippy"));
624
625        store.delete("L3/debug_rust_build.md").await.unwrap();
626        assert!(matches!(
627            store.read("L3/debug_rust_build.md").await.unwrap_err(),
628            MemoryError::NotFound(_)
629        ));
630    }
631
632    #[tokio::test]
633    async fn list_isolation_between_levels() {
634        let dir = TempDir::new().unwrap();
635        let store = FileMemoryStore::new(dir.path());
636
637        store.write("L0/rules.md", "r").await.unwrap();
638        store.write("L3/sop1.md", "s1").await.unwrap();
639        store.write("L3/sop2.md", "s2").await.unwrap();
640
641        let l0 = store.list(MemoryLevel::L0).await.unwrap();
642        let l3 = store.list(MemoryLevel::L3).await.unwrap();
643
644        assert_eq!(l0, vec!["L0/rules.md"]);
645        assert_eq!(l3, vec!["L3/sop1.md", "L3/sop2.md"]);
646    }
647
648    #[tokio::test]
649    async fn write_creates_subdirectories() {
650        let dir = TempDir::new().unwrap();
651        let store = FileMemoryStore::new(dir.path());
652
653        store
654            .write("L3/debugging/rust_build.md", "steps")
655            .await
656            .unwrap();
657
658        let files = store.list(MemoryLevel::L3).await.unwrap();
659        assert_eq!(files, vec!["L3/debugging/rust_build.md"]);
660
661        let content = store.read("L3/debugging/rust_build.md").await.unwrap();
662        assert_eq!(content, "steps");
663    }
664
665    #[tokio::test]
666    async fn read_root_rejects_traversal() {
667        let dir = TempDir::new().unwrap();
668        let store = FileMemoryStore::new(dir.path());
669
670        let err = store.read_root("../etc/passwd").await.unwrap_err();
671        assert!(matches!(err, MemoryError::PathDenied(_)));
672
673        let err = store.read_root("sub/file").await.unwrap_err();
674        assert!(matches!(err, MemoryError::PathDenied(_)));
675    }
676
677    #[tokio::test]
678    async fn read_root_roundtrip() {
679        let dir = TempDir::new().unwrap();
680        std::fs::write(dir.path().join("README.md"), "project info").unwrap();
681
682        let store = FileMemoryStore::new(dir.path());
683        let content = store.read_root("README.md").await.unwrap();
684        assert_eq!(content, "project info");
685    }
686}