Skip to main content

lc_memory/
file_memory.rs

1// lc-memory/src/file_memory.rs
2//! File-based memory (C1, v0.22.1 §S4): memories are real, inspectable files.
3//!
4//! Many agent memory systems model "memory" as an opaque serialized blob. C1 takes the
5//! opposite approach: each memory is a plain `NAME.md` file under a single root
6//! directory. This gives the model (and a human auditor) a first-class editing surface —
7//! view, create, append, str_replace, rename, delete — mirroring the model editing tools
8//! that frontier agents expose, but as a deterministic library primitive, not a
9//! tool-calling loop.
10//!
11//! # Path safety
12//!
13//! Because the memory name arrives from the model, every operation funnels through
14//! `validated_name`, which rejects traversal (`..`), absolute paths, separators
15//! (`/`, `\`), drive letters (`:`), leading/trailing whitespace, and Windows reserved
16//! device names. The store root is canonicalized at construction, so no name can
17//! escape it. The `.md` suffix is appended by the store, never by the caller.
18//!
19//! Decay / forgetting (TTL + importance) is layered on top in [`super::decay`].
20
21use std::fs;
22use std::path::{Path, PathBuf};
23
24/// Windows reserved device names that cannot be used as file names (case-insensitive,
25/// with or without an extension).
26const WINDOWS_RESERVED: [&str; 22] = [
27    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
28    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
29];
30
31/// A memory file on disk, ready for inspection.
32#[derive(Debug, Clone)]
33pub struct MemoryEntry {
34    /// Memory name (file stem, no `.md`).
35    pub name: String,
36    /// Last modification time of the file.
37    pub modified: std::time::SystemTime,
38}
39
40/// Errors from [`FileMemoryStore`] operations.
41#[derive(Debug, thiserror::Error)]
42pub enum FileMemoryError {
43    /// The memory name is unsafe (traversal, absolute path, separator, reserved name...).
44    #[error("unsafe memory name `{name}`: {reason}")]
45    UnsafeName {
46        /// The offending name.
47        name: String,
48        /// Why it was rejected.
49        reason: String,
50    },
51    /// A memory with that name already exists.
52    #[error("memory `{0}` already exists")]
53    AlreadyExists(String),
54    /// No memory with that name exists.
55    #[error("memory `{0}` not found")]
56    NotFound(String),
57    /// `str_replace` could not find the old text.
58    #[error("old text not found in memory `{0}`")]
59    OldTextNotFound(String),
60    /// Filesystem I/O failure.
61    #[error("I/O error while accessing `{path}`: {source}")]
62    Io {
63        /// The path involved.
64        path: String,
65        /// The underlying I/O error.
66        #[source]
67        source: std::io::Error,
68    },
69}
70
71impl From<std::io::Error> for FileMemoryError {
72    fn from(source: std::io::Error) -> Self {
73        FileMemoryError::Io {
74            path: "<unknown>".to_string(),
75            source,
76        }
77    }
78}
79
80/// Validates a caller-supplied memory name, forbidding anything that could escape the
81/// store root. Returns the validated bare name.
82fn validated_name(name: &str) -> Result<String, FileMemoryError> {
83    if name.is_empty() {
84        return Err(unsafe_name(name, "name is empty"));
85    }
86    if name == "." || name == ".." {
87        return Err(unsafe_name(name, "path traversal"));
88    }
89    if name.contains('/') || name.contains('\\') {
90        return Err(unsafe_name(name, "path separators are not allowed"));
91    }
92    if name.contains(':') {
93        return Err(unsafe_name(
94            name,
95            "drive/stream designators (`:`) are not allowed",
96        ));
97    }
98    if name.trim() != name {
99        return Err(unsafe_name(
100            name,
101            "leading/trailing whitespace is not allowed",
102        ));
103    }
104    if name.contains('\0') {
105        return Err(unsafe_name(name, "NUL byte is not allowed"));
106    }
107    if name.len() > 255 {
108        return Err(unsafe_name(name, "name too long"));
109    }
110    let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase();
111    if WINDOWS_RESERVED.contains(&stem.as_str()) {
112        return Err(unsafe_name(name, "Windows reserved device name"));
113    }
114    Ok(name.to_string())
115}
116
117/// Builds an [`FileMemoryError::UnsafeName`] for a rejected name.
118fn unsafe_name(name: &str, reason: &str) -> FileMemoryError {
119    FileMemoryError::UnsafeName {
120        name: name.to_string(),
121        reason: reason.to_string(),
122    }
123}
124
125/// A deterministic, path-sandboxed collection of memory files under one root directory.
126pub struct FileMemoryStore {
127    root: PathBuf,
128}
129
130impl std::fmt::Debug for FileMemoryStore {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("FileMemoryStore")
133            .field("root", &self.root)
134            .finish()
135    }
136}
137
138impl FileMemoryStore {
139    /// Opens (creating if needed) a memory root and canonicalizes it so every later
140    /// path check is against a single absolute base.
141    pub fn new(root: impl Into<PathBuf>) -> Result<Self, FileMemoryError> {
142        let root = root.into();
143        fs::create_dir_all(&root)?;
144        let root = root.canonicalize().map_err(|source| FileMemoryError::Io {
145            path: root.display().to_string(),
146            source,
147        })?;
148        Ok(Self { root })
149    }
150
151    /// The canonicalized root directory.
152    pub fn root(&self) -> &Path {
153        &self.root
154    }
155
156    /// Resolves a validated name to its `.md` path inside the root.
157    fn path_for(&self, name: &str) -> Result<PathBuf, FileMemoryError> {
158        let name = validated_name(name)?;
159        Ok(self.root.join(format!("{name}.md")))
160    }
161
162    /// Creates a new memory. Fails if a memory with the same name already exists.
163    pub fn create(&self, name: &str, content: &str) -> Result<(), FileMemoryError> {
164        let path = self.path_for(name)?;
165        if path.exists() {
166            return Err(FileMemoryError::AlreadyExists(name.to_string()));
167        }
168        fs::write(&path, content).map_err(map_io(&path))?;
169        Ok(())
170    }
171
172    /// Returns the full content of a memory.
173    pub fn view(&self, name: &str) -> Result<String, FileMemoryError> {
174        let path = self.path_for(name)?;
175        if !path.exists() {
176            return Err(FileMemoryError::NotFound(name.to_string()));
177        }
178        fs::read_to_string(&path).map_err(map_io(&path))
179    }
180
181    /// Overwrites a memory's content whether or not it exists.
182    ///
183    /// Unlike [`Self::create`] this never fails on an existing name; it is the
184    /// upsert primitive used by higher-level stacks (e.g. re-remembering).
185    pub fn write(&self, name: &str, content: &str) -> Result<(), FileMemoryError> {
186        let path = self.path_for(name)?;
187        fs::write(&path, content).map_err(map_io(&path))?;
188        Ok(())
189    }
190
191    /// Appends `content` to an existing memory.
192    pub fn append(&self, name: &str, content: &str) -> Result<(), FileMemoryError> {
193        let path = self.path_for(name)?;
194        if !path.exists() {
195            return Err(FileMemoryError::NotFound(name.to_string()));
196        }
197        let mut file = fs::OpenOptions::new()
198            .append(true)
199            .open(&path)
200            .map_err(map_io(&path))?;
201        std::io::Write::write_all(&mut file, content.as_bytes()).map_err(map_io(&path))?;
202        Ok(())
203    }
204
205    /// Replaces the first exact occurrence of `old` with `new` in a memory.
206    ///
207    /// Fails with [`FileMemoryError::OldTextNotFound`] if `old` is not present, rather
208    /// than silently corrupting the file — str_replace must be idempotent and observable.
209    pub fn str_replace(&self, name: &str, old: &str, new: &str) -> Result<(), FileMemoryError> {
210        let path = self.path_for(name)?;
211        if !path.exists() {
212            return Err(FileMemoryError::NotFound(name.to_string()));
213        }
214        let content = fs::read_to_string(&path).map_err(map_io(&path))?;
215        if !content.contains(old) {
216            return Err(FileMemoryError::OldTextNotFound(name.to_string()));
217        }
218        // one-shot replace (no `replace_all`): the caller observes exactly one edit.
219        let replaced = content.replacen(old, new, 1);
220        fs::write(&path, replaced).map_err(map_io(&path))?;
221        Ok(())
222    }
223
224    /// Renames a memory to a new validated name.
225    pub fn rename(&self, name: &str, new_name: &str) -> Result<(), FileMemoryError> {
226        let from = self.path_for(name)?;
227        let to = self.path_for(new_name)?;
228        if !from.exists() {
229            return Err(FileMemoryError::NotFound(name.to_string()));
230        }
231        if to.exists() {
232            return Err(FileMemoryError::AlreadyExists(new_name.to_string()));
233        }
234        fs::rename(&from, &to).map_err(map_io(&from))?;
235        Ok(())
236    }
237
238    /// Deletes a memory.
239    pub fn delete(&self, name: &str) -> Result<(), FileMemoryError> {
240        let path = self.path_for(name)?;
241        if !path.exists() {
242            return Err(FileMemoryError::NotFound(name.to_string()));
243        }
244        fs::remove_file(&path).map_err(map_io(&path))?;
245        Ok(())
246    }
247
248    /// Lists all memories, newest-modified first.
249    pub fn list(&self) -> Result<Vec<MemoryEntry>, FileMemoryError> {
250        let mut entries = Vec::new();
251        for entry in fs::read_dir(&self.root)? {
252            let entry = entry?;
253            let path = entry.path();
254            if path.extension().and_then(|e| e.to_str()) != Some("md") {
255                continue;
256            }
257            let name = path
258                .file_stem()
259                .and_then(|s| s.to_str())
260                .unwrap_or_default()
261                .to_string();
262            let modified = entry
263                .metadata()?
264                .modified()
265                .unwrap_or(std::time::UNIX_EPOCH);
266            entries.push(MemoryEntry { name, modified });
267        }
268        entries.sort_by(|a, b| b.modified.cmp(&a.modified));
269        Ok(entries)
270    }
271}
272
273/// Lifts an I/O error into `FileMemoryError::Io` carrying the path context.
274fn map_io(path: &Path) -> impl FnOnce(std::io::Error) -> FileMemoryError + '_ {
275    move |source| FileMemoryError::Io {
276        path: path.display().to_string(),
277        source,
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn store() -> (tempfile::TempDir, FileMemoryStore) {
286        let dir = tempfile::tempdir().unwrap();
287        let store = FileMemoryStore::new(dir.path()).unwrap();
288        (dir, store)
289    }
290
291    #[test]
292    fn create_view_roundtrip() {
293        let (_d, s) = store();
294        s.create("facts", "# Facts\n\n- Rust is fast\n").unwrap();
295        assert_eq!(s.view("facts").unwrap(), "# Facts\n\n- Rust is fast\n");
296    }
297
298    #[test]
299    fn create_rejects_duplicate() {
300        let (_d, s) = store();
301        s.create("a", "one").unwrap();
302        assert!(matches!(
303            s.create("a", "two"),
304            Err(FileMemoryError::AlreadyExists(_))
305        ));
306    }
307
308    #[test]
309    fn append_adds_to_existing() {
310        let (_d, s) = store();
311        s.create("a", "first\n").unwrap();
312        s.append("a", "second\n").unwrap();
313        assert_eq!(s.view("a").unwrap(), "first\nsecond\n");
314    }
315
316    #[test]
317    fn append_requires_existing() {
318        let (_d, s) = store();
319        assert!(matches!(
320            s.append("nope", "x"),
321            Err(FileMemoryError::NotFound(_))
322        ));
323    }
324
325    #[test]
326    fn str_replace_is_single_and_explicit() {
327        let (_d, s) = store();
328        s.create("a", "x x x").unwrap();
329        s.str_replace("a", "x", "y").unwrap();
330        assert_eq!(s.view("a").unwrap(), "y x x");
331        // missing old text is an explicit error, not silent no-op
332        assert!(matches!(
333            s.str_replace("a", "zzz", "q"),
334            Err(FileMemoryError::OldTextNotFound(_))
335        ));
336    }
337
338    #[test]
339    fn rename_moves_content() {
340        let (_d, s) = store();
341        s.create("a", "data").unwrap();
342        s.rename("a", "b").unwrap();
343        assert!(matches!(s.view("a"), Err(FileMemoryError::NotFound(_))));
344        assert_eq!(s.view("b").unwrap(), "data");
345    }
346
347    #[test]
348    fn rename_onto_existing_fails() {
349        let (_d, s) = store();
350        s.create("a", "x").unwrap();
351        s.create("b", "y").unwrap();
352        assert!(matches!(
353            s.rename("a", "b"),
354            Err(FileMemoryError::AlreadyExists(_))
355        ));
356    }
357
358    #[test]
359    fn delete_removes() {
360        let (_d, s) = store();
361        s.create("a", "x").unwrap();
362        s.delete("a").unwrap();
363        assert!(matches!(s.view("a"), Err(FileMemoryError::NotFound(_))));
364        assert!(s.list().unwrap().is_empty());
365    }
366
367    #[test]
368    fn list_newest_first() {
369        let (_d, s) = store();
370        s.create("old", "1").unwrap();
371        s.create("new", "2").unwrap();
372        let names: Vec<String> = s.list().unwrap().into_iter().map(|e| e.name).collect();
373        assert_eq!(names, vec!["new", "old"]);
374    }
375
376    // ---- path safety ----
377
378    #[test]
379    fn traversal_is_rejected() {
380        let (_d, s) = store();
381        for bad in [
382            "../evil",
383            "..",
384            ".",
385            "a/../evil",
386            "a\\..\\evil",
387            "C:\\evil",
388            "/etc/passwd",
389            "a:b",
390        ] {
391            assert!(
392                matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
393                "expected rejection for {bad:?}"
394            );
395        }
396    }
397
398    #[test]
399    fn reserved_windows_names_rejected() {
400        let (_d, s) = store();
401        for bad in ["CON", "con", "PRN", "NUL", "COM1", "LPT9", "CON.txt"] {
402            assert!(
403                matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
404                "expected rejection for {bad:?}"
405            );
406        }
407    }
408
409    #[test]
410    fn empty_and_padded_names_rejected() {
411        let (_d, s) = store();
412        for bad in ["", " closed"] {
413            assert!(
414                matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
415                "expected rejection for {bad:?}"
416            );
417        }
418    }
419}