Skip to main content

merlion_memory/
store.rs

1//! On-disk store implementation for [`MemoryStore`].
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use chrono::Utc;
8use tracing::debug;
9
10use crate::parse::{
11    parse_index, parse_memory, remove_index_line, render_memory, upsert_index_line, validate_slug,
12};
13use crate::{Memory, MemoryRow, MemoryStore};
14
15const INDEX_FILE: &str = "MEMORY.md";
16const INDEX_HEADER: &str = "# Memory\n";
17
18impl MemoryStore {
19    /// Open a store rooted at `dir`. Creates the directory and a default
20    /// `MEMORY.md` index if either is missing.
21    pub fn open(dir: impl Into<PathBuf>) -> Result<Self> {
22        let dir = dir.into();
23        if !dir.exists() {
24            fs::create_dir_all(&dir)
25                .with_context(|| format!("failed to create memory dir {}", dir.display()))?;
26        }
27        let index = dir.join(INDEX_FILE);
28        if !index.exists() {
29            fs::write(&index, INDEX_HEADER)
30                .with_context(|| format!("failed to create {}", index.display()))?;
31        }
32        Ok(Self { dir })
33    }
34
35    /// Open the default store. Uses `$MERLION_HOME/memory` if set, else
36    /// `~/.merlion/memory` via [`dirs::home_dir`].
37    pub fn open_default() -> Result<Self> {
38        let dir = if let Some(home) = std::env::var_os("MERLION_HOME") {
39            PathBuf::from(home).join("memory")
40        } else {
41            let home = dirs::home_dir()
42                .context("could not resolve home directory for default memory store")?;
43            home.join(".merlion").join("memory")
44        };
45        Self::open(dir)
46    }
47
48    /// Path to the on-disk directory.
49    pub fn dir(&self) -> &Path {
50        &self.dir
51    }
52
53    fn index_path(&self) -> PathBuf {
54        self.dir.join(INDEX_FILE)
55    }
56
57    fn memory_path(&self, name: &str) -> PathBuf {
58        self.dir.join(format!("{}.md", name))
59    }
60
61    fn read_index(&self) -> Result<String> {
62        let path = self.index_path();
63        if !path.exists() {
64            return Ok(INDEX_HEADER.to_string());
65        }
66        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))
67    }
68
69    fn write_index(&self, text: &str) -> Result<()> {
70        let path = self.index_path();
71        fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))
72    }
73
74    /// List memories declared in `MEMORY.md`.
75    pub fn list(&self) -> Result<Vec<MemoryRow>> {
76        let text = self.read_index()?;
77        Ok(parse_index(&text))
78    }
79
80    /// Read a single memory by slug. Fails if the file is missing or malformed.
81    pub fn read(&self, name: &str) -> Result<Memory> {
82        validate_slug(name)?;
83        let path = self.memory_path(name);
84        let raw = fs::read_to_string(&path)
85            .with_context(|| format!("failed to read memory `{}` at {}", name, path.display()))?;
86        parse_memory(&raw).with_context(|| format!("failed to parse memory `{}`", name))
87    }
88
89    /// Write a memory to disk, creating or overwriting `<name>.md` and
90    /// updating the index line in `MEMORY.md`.
91    ///
92    /// - `created_at` is preserved if the file already exists.
93    /// - `updated_at` is always set to `Utc::now()`.
94    pub fn write(&self, m: &Memory) -> Result<()> {
95        validate_slug(&m.name)?;
96        let path = self.memory_path(&m.name);
97
98        let now = Utc::now();
99        let created_at = if path.exists() {
100            // Preserve the existing created_at, if we can parse it.
101            match fs::read_to_string(&path)
102                .ok()
103                .and_then(|raw| parse_memory(&raw).ok())
104            {
105                Some(existing) => existing.created_at,
106                None => m.created_at,
107            }
108        } else {
109            // For new files honor whatever the caller passed (defaults to now in practice).
110            m.created_at
111        };
112
113        let to_write = Memory {
114            name: m.name.clone(),
115            description: m.description.clone(),
116            kind: m.kind.clone(),
117            body: m.body.clone(),
118            created_at,
119            updated_at: now,
120        };
121
122        let rendered = render_memory(&to_write)?;
123        fs::write(&path, rendered).with_context(|| {
124            format!("failed to write memory `{}` at {}", m.name, path.display())
125        })?;
126
127        // Update the index. Use the slug itself as the "title" for now —
128        // hand-edited titles in MEMORY.md are preserved by upsert_index_line
129        // only when the file matches; since we replace the whole line on
130        // match, we conservatively use the slug. Hooks come from description.
131        let title = to_write.name.clone();
132        let index_text = self.read_index()?;
133        let new_index =
134            upsert_index_line(&index_text, &to_write.name, &title, &to_write.description);
135        self.write_index(&new_index)?;
136
137        debug!(name = %to_write.name, "wrote memory");
138        Ok(())
139    }
140
141    /// Delete a memory. Idempotent: missing files are not an error. The
142    /// matching index line, if present, is stripped.
143    pub fn delete(&self, name: &str) -> Result<()> {
144        validate_slug(name)?;
145        let path = self.memory_path(name);
146        if path.exists() {
147            fs::remove_file(&path).with_context(|| {
148                format!("failed to remove memory `{}` at {}", name, path.display())
149            })?;
150        }
151        let index_text = self.read_index()?;
152        let new_index = remove_index_line(&index_text, name);
153        if new_index != index_text {
154            self.write_index(&new_index)?;
155        }
156        debug!(name = %name, "deleted memory");
157        Ok(())
158    }
159
160    /// Build a context block suitable for system-prompt injection. Returns
161    /// an empty string when there are no memories. Stops adding lines once
162    /// the cumulative size would exceed `max_chars`.
163    pub fn render_context_block(&self, max_chars: usize) -> Result<String> {
164        let rows = self.list()?;
165        if rows.is_empty() {
166            return Ok(String::new());
167        }
168        let header = format!("# Persistent memory ({} entries)\n", rows.len());
169        let mut out = String::new();
170        if header.len() > max_chars {
171            // Even the header doesn't fit — return empty rather than a half-built block.
172            return Ok(String::new());
173        }
174        out.push_str(&header);
175        for row in rows {
176            let line = format!("- [{}] {}\n", row.name, row.hook);
177            if out.len() + line.len() > max_chars {
178                break;
179            }
180            out.push_str(&line);
181        }
182        Ok(out)
183    }
184}