Skip to main content

mermaid_cli/app/
memory.rs

1//! Durable semantic memory (v0.10.0).
2//!
3//! Plain-Markdown, agent-managed long-term memory: one fact per file with
4//! YAML frontmatter (`name`, `description`, `scope`, `created`, `tags`) and a
5//! body. Three scopes, all machine-local except shared:
6//!   - **global**     `<data_dir>/memory/`                       (all projects)
7//!   - **project-private** `<data_dir>/projects/<key>/memory/`   (default; not committed)
8//!   - **project-shared**  `<git-root>/.mermaid/memory/`         (opt-in; committed)
9//!
10//! Retrieval is an always-loaded auto-derived INDEX (name + description + path
11//! per file, grouped by scope) plus on-demand reads of the full files via the
12//! normal `read_file` tool. The index is generated from the files, so it can
13//! never drift from them. No database, no vectors, no embeddings.
14//!
15//! This module owns the on-disk format, scope resolution, index generation,
16//! load/refresh, and the write/delete primitives the memory tool and slash
17//! commands build on.
18
19use std::hash::{Hash, Hasher};
20use std::path::{Path, PathBuf};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use crate::app::MemoryConfig;
24use crate::constants::MEMORY_INDEX_TRUNCATION_MARKER;
25
26/// Hard cap on directory levels `find_git_root` walks up (symlink-loop guard).
27const MAX_WALK_DEPTH: usize = 32;
28
29/// Where a memory lives. The *directory* is authoritative; the frontmatter
30/// `scope` field is advisory/portable metadata so a hand-moved file is still
31/// classified by its location.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum MemoryScope {
34    Global,
35    ProjectPrivate,
36    ProjectShared,
37}
38
39impl MemoryScope {
40    /// Kebab token used in frontmatter and the `scope` tool argument.
41    pub fn as_str(self) -> &'static str {
42        match self {
43            MemoryScope::Global => "global",
44            MemoryScope::ProjectPrivate => "project-private",
45            MemoryScope::ProjectShared => "project-shared",
46        }
47    }
48
49    /// Human label for the index section header.
50    fn label(self) -> &'static str {
51        match self {
52            MemoryScope::Global => "Global (all projects)",
53            MemoryScope::ProjectPrivate => "Project (private)",
54            MemoryScope::ProjectShared => "Project (shared)",
55        }
56    }
57}
58
59/// One memory file's index entry. Deliberately holds no body — the full fact
60/// is read on demand via `read_file` on `path`, keeping the always-loaded
61/// snapshot small.
62#[derive(Debug, Clone)]
63pub struct MemoryEntry {
64    pub name: String,
65    pub description: String,
66    pub path: PathBuf,
67    pub scope: MemoryScope,
68    pub mtime: SystemTime,
69}
70
71/// Snapshot of all loaded memory across scopes plus the rendered index block.
72#[derive(Debug, Clone)]
73pub struct LoadedMemory {
74    pub entries: Vec<MemoryEntry>,
75    /// Pre-rendered `# Memory` block injected into the prompt (capped).
76    pub index: String,
77    /// True when the index exceeded the cap and was clipped.
78    pub truncated: bool,
79}
80
81impl LoadedMemory {
82    pub fn approx_tokens(&self) -> usize {
83        self.index.len() / 4
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.entries.is_empty()
88    }
89}
90
91/// Outcome of a per-turn `refresh()`, for optional status reporting.
92#[derive(Debug, PartialEq, Eq)]
93pub enum MemoryReloadOutcome {
94    Unchanged,
95    Reloaded,
96    LoadedFirst,
97    Removed,
98}
99
100/// Walk UP from `start` to the nearest directory containing a `.git` entry
101/// (file or dir, so worktrees resolve), or `None` if not inside a repo.
102pub fn find_git_root(start: &Path) -> Option<PathBuf> {
103    let mut current = start.to_path_buf();
104    for _ in 0..MAX_WALK_DEPTH {
105        if current.join(".git").exists() {
106            return Some(current);
107        }
108        match current.parent() {
109            Some(parent) if parent != current => current = parent.to_path_buf(),
110            _ => return None,
111        }
112    }
113    None
114}
115
116/// The memory roots for `cwd`, in injection order (global → private → shared).
117/// Shared is omitted when `cwd` isn't in a git repo. Returns an empty vec only
118/// if the machine data dir can't be resolved.
119pub fn memory_roots(cwd: &Path) -> Vec<(PathBuf, MemoryScope)> {
120    let Ok(data) = crate::runtime::data_dir() else {
121        return Vec::new();
122    };
123    let mut roots = vec![(data.join("memory"), MemoryScope::Global)];
124    match find_git_root(cwd) {
125        Some(git_root) => {
126            roots.push((
127                data.join("projects")
128                    .join(project_key(&git_root))
129                    .join("memory"),
130                MemoryScope::ProjectPrivate,
131            ));
132            roots.push((
133                git_root.join(".mermaid").join("memory"),
134                MemoryScope::ProjectShared,
135            ));
136        },
137        None => {
138            // Not a repo: key private memory off the canonical cwd; no shared.
139            roots.push((
140                data.join("projects").join(project_key(cwd)).join("memory"),
141                MemoryScope::ProjectPrivate,
142            ));
143        },
144    }
145    roots
146}
147
148/// Resolve the on-disk directory for a scope at `cwd`, if available.
149pub fn dir_for(scope: MemoryScope, cwd: &Path) -> Option<PathBuf> {
150    memory_roots(cwd)
151        .into_iter()
152        .find(|(_, s)| *s == scope)
153        .map(|(dir, _)| dir)
154}
155
156/// Stable machine-local key for a project path: `<slug>-<hash8>`. The slug is
157/// human-debuggable; the hash disambiguates same-named dirs. Only keys the
158/// machine-local private store, so cross-machine stability is irrelevant.
159fn project_key(path: &Path) -> String {
160    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
161    let slug: String = canonical
162        .file_name()
163        .and_then(|n| n.to_str())
164        .unwrap_or("project")
165        .chars()
166        .map(|c| {
167            if c.is_ascii_alphanumeric() {
168                c.to_ascii_lowercase()
169            } else {
170                '-'
171            }
172        })
173        .take(32)
174        .collect();
175    let slug = slug.trim_matches('-');
176    let mut hasher = std::collections::hash_map::DefaultHasher::new();
177    canonical.to_string_lossy().hash(&mut hasher);
178    let hash = hasher.finish() as u32;
179    let slug = if slug.is_empty() { "project" } else { slug };
180    format!("{slug}-{hash:08x}")
181}
182
183/// kebab-case slug for a memory name → filename stem.
184pub fn slugify(name: &str) -> String {
185    let mut out = String::new();
186    let mut prev_dash = false;
187    for ch in name.trim().chars() {
188        if ch.is_ascii_alphanumeric() {
189            out.push(ch.to_ascii_lowercase());
190            prev_dash = false;
191        } else if !prev_dash {
192            out.push('-');
193            prev_dash = true;
194        }
195    }
196    let slug = out.trim_matches('-').to_string();
197    if slug.is_empty() {
198        "memory".to_string()
199    } else {
200        slug
201    }
202}
203
204#[derive(Debug, Default)]
205struct Frontmatter {
206    name: Option<String>,
207    description: Option<String>,
208}
209
210/// Split a memory file into its (name, description) frontmatter and body. A
211/// file without a leading `---` fence is treated as all-body. Tolerant: a
212/// malformed/unclosed fence falls back to the whole content as body.
213fn parse_frontmatter(raw: &str) -> (Frontmatter, String) {
214    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
215    let mut lines = raw.lines();
216    if lines.next().map(str::trim) != Some("---") {
217        return (Frontmatter::default(), raw.to_string());
218    }
219    let mut fm = Frontmatter::default();
220    let mut body_lines: Vec<&str> = Vec::new();
221    let mut in_fm = true;
222    for line in lines {
223        if in_fm {
224            if line.trim() == "---" {
225                in_fm = false;
226                continue;
227            }
228            if let Some((key, value)) = line.split_once(':') {
229                let value = value.trim().trim_matches('"').to_string();
230                match key.trim() {
231                    "name" => fm.name = Some(value),
232                    "description" => fm.description = Some(value),
233                    _ => {},
234                }
235            }
236        } else {
237            body_lines.push(line);
238        }
239    }
240    if in_fm {
241        // Unclosed fence — not real frontmatter.
242        return (Frontmatter::default(), raw.to_string());
243    }
244    (fm, body_lines.join("\n").trim().to_string())
245}
246
247/// Load all `*.md` memories in `dir` (non-recursive) as index entries. Missing
248/// dir ⇒ empty. Sorted by name for a deterministic index.
249fn load_root(dir: &Path, scope: MemoryScope) -> Vec<MemoryEntry> {
250    let mut entries = Vec::new();
251    let Ok(read) = std::fs::read_dir(dir) else {
252        return entries;
253    };
254    for entry in read.flatten() {
255        let path = entry.path();
256        if path.extension().and_then(|e| e.to_str()) != Some("md") {
257            continue;
258        }
259        let Ok(meta) = entry.metadata() else { continue };
260        if !meta.is_file() {
261            continue;
262        }
263        let mtime = meta.modified().unwrap_or(UNIX_EPOCH);
264        let raw = std::fs::read_to_string(&path).unwrap_or_default();
265        let (fm, body) = parse_frontmatter(&raw);
266        let stem = path
267            .file_stem()
268            .and_then(|s| s.to_str())
269            .unwrap_or("memory")
270            .to_string();
271        let name = fm.name.filter(|s| !s.is_empty()).unwrap_or(stem);
272        let description = fm.description.filter(|s| !s.is_empty()).unwrap_or_else(|| {
273            body.lines()
274                .find(|l| !l.trim().is_empty())
275                .unwrap_or("")
276                .to_string()
277        });
278        entries.push(MemoryEntry {
279            name,
280            description,
281            path,
282            scope,
283            mtime,
284        });
285    }
286    entries.sort_by(|a, b| a.name.cmp(&b.name));
287    entries
288}
289
290/// Render the always-loaded index from entries, grouped global → private →
291/// shared, clipped to `cap` bytes with a marker if oversized.
292fn render_index(entries: &[MemoryEntry], cap: usize) -> (String, bool) {
293    if entries.is_empty() {
294        return (String::new(), false);
295    }
296    let mut out = String::from(
297        "# Memory\n\nDurable facts you have saved across sessions. Read a file with `read_file` when its description is relevant; change memory with the `memory` tool.\n",
298    );
299    for scope in [
300        MemoryScope::Global,
301        MemoryScope::ProjectPrivate,
302        MemoryScope::ProjectShared,
303    ] {
304        let mut first = true;
305        for entry in entries.iter().filter(|e| e.scope == scope) {
306            if first {
307                out.push_str(&format!("\n## {}\n", scope.label()));
308                first = false;
309            }
310            out.push_str(&format!(
311                "- [{}] {} — {}\n",
312                entry.name,
313                entry.description,
314                entry.path.display()
315            ));
316        }
317    }
318    if out.len() > cap {
319        let cut = out.floor_char_boundary(cap);
320        let mut clipped = out[..cut].to_string();
321        clipped.push_str(MEMORY_INDEX_TRUNCATION_MARKER);
322        (clipped, true)
323    } else {
324        (out, false)
325    }
326}
327
328/// Load all memory for `cwd` into a snapshot, or `None` when disabled or empty.
329pub fn load(cwd: &Path, cfg: &MemoryConfig) -> Option<LoadedMemory> {
330    if !cfg.enabled {
331        return None;
332    }
333    let mut entries = Vec::new();
334    for (dir, scope) in memory_roots(cwd) {
335        entries.extend(load_root(&dir, scope));
336    }
337    if entries.is_empty() {
338        return None;
339    }
340    let (index, truncated) = render_index(&entries, cfg.index_cap_bytes);
341    Some(LoadedMemory {
342        entries,
343        index,
344        truncated,
345    })
346}
347
348/// Per-turn refresh: re-scan the roots (cheap — a few `read_dir`s + stats) and
349/// report whether anything changed since `current`. Picks up the agent's own
350/// mid-session writes and hand edits with no filesystem watcher.
351pub fn refresh(
352    current: Option<LoadedMemory>,
353    cwd: &Path,
354    cfg: &MemoryConfig,
355) -> (Option<LoadedMemory>, MemoryReloadOutcome) {
356    let fresh = load(cwd, cfg);
357    let outcome = match (&current, &fresh) {
358        (None, None) => MemoryReloadOutcome::Unchanged,
359        (None, Some(_)) => MemoryReloadOutcome::LoadedFirst,
360        (Some(_), None) => MemoryReloadOutcome::Removed,
361        (Some(prev), Some(next)) => {
362            if same_entries(prev, next) {
363                MemoryReloadOutcome::Unchanged
364            } else {
365                MemoryReloadOutcome::Reloaded
366            }
367        },
368    };
369    (fresh, outcome)
370}
371
372fn same_entries(a: &LoadedMemory, b: &LoadedMemory) -> bool {
373    a.entries.len() == b.entries.len()
374        && a.entries
375            .iter()
376            .zip(&b.entries)
377            .all(|(x, y)| x.path == y.path && x.mtime == y.mtime)
378}
379
380/// Render a memory file's content (frontmatter + body).
381fn render_file(
382    name: &str,
383    description: &str,
384    scope: MemoryScope,
385    tags: &[String],
386    body: &str,
387) -> String {
388    let created = chrono::Utc::now().to_rfc3339();
389    let tags = tags
390        .iter()
391        .map(|t| t.trim())
392        .filter(|t| !t.is_empty())
393        .collect::<Vec<_>>()
394        .join(", ");
395    format!(
396        "---\nname: {name}\ndescription: {description}\nscope: {scope}\ncreated: {created}\ntags: [{tags}]\n---\n\n{body}\n",
397        scope = scope.as_str(),
398        body = body.trim_end(),
399    )
400}
401
402/// Write a memory into `dir` (created if needed). Returns the file path.
403/// Testable core of `write_memory`.
404pub fn write_to_dir(
405    dir: &Path,
406    name: &str,
407    description: &str,
408    scope: MemoryScope,
409    tags: &[String],
410    body: &str,
411) -> std::io::Result<PathBuf> {
412    std::fs::create_dir_all(dir)?;
413    let path = dir.join(format!("{}.md", slugify(name)));
414    std::fs::write(&path, render_file(name, description, scope, tags, body))?;
415    Ok(path)
416}
417
418/// Write a memory at the resolved directory for `scope`/`cwd`.
419pub fn write_memory(
420    cwd: &Path,
421    scope: MemoryScope,
422    name: &str,
423    description: &str,
424    tags: &[String],
425    body: &str,
426) -> std::io::Result<PathBuf> {
427    let dir = dir_for(scope, cwd).ok_or_else(|| {
428        std::io::Error::new(
429            std::io::ErrorKind::NotFound,
430            "could not resolve a memory directory",
431        )
432    })?;
433    write_to_dir(&dir, name, description, scope, tags, body)
434}
435
436/// Find a memory by name or file-stem id across all scopes.
437pub fn find(cwd: &Path, id_or_name: &str) -> Option<MemoryEntry> {
438    for (dir, scope) in memory_roots(cwd) {
439        for entry in load_root(&dir, scope) {
440            let stem = entry.path.file_stem().and_then(|s| s.to_str());
441            if entry.name == id_or_name || stem == Some(id_or_name) {
442                return Some(entry);
443            }
444        }
445    }
446    None
447}
448
449/// Delete a memory by name or file-stem id. Returns the deleted path, or
450/// `None` if no match.
451pub fn delete_memory(cwd: &Path, id_or_name: &str) -> std::io::Result<Option<PathBuf>> {
452    match find(cwd, id_or_name) {
453        Some(entry) => {
454            std::fs::remove_file(&entry.path)?;
455            Ok(Some(entry.path))
456        },
457        None => Ok(None),
458    }
459}
460
461/// Load every memory's index entry paired with its full body text, across all
462/// scopes. Consolidation needs the bodies to judge duplicates/staleness.
463pub fn entries_with_bodies(cwd: &Path) -> Vec<(MemoryEntry, String)> {
464    let mut out = Vec::new();
465    for (dir, scope) in memory_roots(cwd) {
466        for entry in load_root(&dir, scope) {
467            let raw = std::fs::read_to_string(&entry.path).unwrap_or_default();
468            let (_, body) = parse_frontmatter(&raw);
469            out.push((entry, body));
470        }
471    }
472    out
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::constants::MAX_MEMORY_INDEX_BYTES;
479    use std::fs;
480    use std::sync::Mutex;
481
482    static FS_LOCK: Mutex<()> = Mutex::new(());
483
484    fn temp_dir(name: &str) -> PathBuf {
485        let p = std::env::temp_dir().join(format!("mermaid_memory_test_{name}"));
486        let _ = fs::remove_dir_all(&p);
487        fs::create_dir_all(&p).expect("create temp dir");
488        p
489    }
490
491    #[test]
492    fn slugify_makes_safe_stems() {
493        assert_eq!(slugify("Prefer ripgrep!"), "prefer-ripgrep");
494        assert_eq!(slugify("  use   pnpm  "), "use-pnpm");
495        assert_eq!(slugify("***"), "memory");
496    }
497
498    #[test]
499    fn parse_frontmatter_extracts_name_and_description() {
500        let raw =
501            "---\nname: prefer-rg\ndescription: Use ripgrep\ntags: [tooling]\n---\n\nThe body.\n";
502        let (fm, body) = parse_frontmatter(raw);
503        assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
504        assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
505        assert_eq!(body, "The body.");
506    }
507
508    #[test]
509    fn parse_frontmatter_without_fence_is_all_body() {
510        let (fm, body) = parse_frontmatter("just a note\nsecond line");
511        assert!(fm.name.is_none());
512        assert_eq!(body, "just a note\nsecond line");
513    }
514
515    #[test]
516    fn render_and_parse_round_trip() {
517        let content = render_file(
518            "prefer-rg",
519            "Use ripgrep",
520            MemoryScope::ProjectShared,
521            &["tooling".to_string()],
522            "Always reach for rg.",
523        );
524        assert!(content.contains("scope: project-shared"));
525        let (fm, body) = parse_frontmatter(&content);
526        assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
527        assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
528        assert_eq!(body, "Always reach for rg.");
529    }
530
531    #[test]
532    fn write_and_load_root_round_trip() {
533        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
534        let dir = temp_dir("root");
535        write_to_dir(
536            &dir,
537            "Test Fact",
538            "A description",
539            MemoryScope::Global,
540            &[],
541            "body",
542        )
543        .unwrap();
544        let entries = load_root(&dir, MemoryScope::Global);
545        assert_eq!(entries.len(), 1);
546        assert_eq!(entries[0].name, "Test Fact");
547        assert_eq!(entries[0].description, "A description");
548        assert_eq!(entries[0].path.file_name().unwrap(), "test-fact.md");
549        let _ = fs::remove_dir_all(&dir);
550    }
551
552    #[test]
553    fn load_root_falls_back_to_stem_and_first_line() {
554        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
555        let dir = temp_dir("fallback");
556        fs::write(dir.join("bare-note.md"), "first meaningful line\nmore").unwrap();
557        let entries = load_root(&dir, MemoryScope::Global);
558        assert_eq!(entries.len(), 1);
559        assert_eq!(entries[0].name, "bare-note");
560        assert_eq!(entries[0].description, "first meaningful line");
561        let _ = fs::remove_dir_all(&dir);
562    }
563
564    #[test]
565    fn render_index_groups_by_scope_and_excludes_body() {
566        let entries = vec![
567            MemoryEntry {
568                name: "g".into(),
569                description: "global fact".into(),
570                path: PathBuf::from("/g/g.md"),
571                scope: MemoryScope::Global,
572                mtime: UNIX_EPOCH,
573            },
574            MemoryEntry {
575                name: "p".into(),
576                description: "private fact".into(),
577                path: PathBuf::from("/p/p.md"),
578                scope: MemoryScope::ProjectPrivate,
579                mtime: UNIX_EPOCH,
580            },
581        ];
582        let (index, truncated) = render_index(&entries, MAX_MEMORY_INDEX_BYTES);
583        assert!(!truncated);
584        assert!(index.contains("# Memory"));
585        assert!(index.contains("## Global"));
586        assert!(index.contains("## Project (private)"));
587        assert!(index.contains("[g] global fact"));
588        assert!(index.contains("[p] private fact"));
589        // Global section precedes private (most specific last).
590        assert!(index.find("global fact") < index.find("private fact"));
591    }
592
593    #[test]
594    fn render_index_truncates_when_oversized() {
595        let entries: Vec<MemoryEntry> = (0..200)
596            .map(|i| MemoryEntry {
597                name: format!("name-{i}"),
598                description: "a".repeat(80),
599                path: PathBuf::from(format!("/m/name-{i}.md")),
600                scope: MemoryScope::Global,
601                mtime: UNIX_EPOCH,
602            })
603            .collect();
604        let (index, truncated) = render_index(&entries, 1_000);
605        assert!(truncated);
606        assert!(index.ends_with(MEMORY_INDEX_TRUNCATION_MARKER));
607    }
608
609    #[test]
610    fn find_git_root_detects_dot_git() {
611        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
612        let root = temp_dir("gitroot");
613        fs::create_dir(root.join(".git")).unwrap();
614        let sub = root.join("a/b");
615        fs::create_dir_all(&sub).unwrap();
616        assert_eq!(find_git_root(&sub).as_deref(), Some(root.as_path()));
617        let _ = fs::remove_dir_all(&root);
618    }
619
620    #[test]
621    fn find_git_root_none_without_repo() {
622        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
623        let dir = temp_dir("norepo");
624        // No .git anywhere up to a sentinel; walk eventually returns None or a
625        // real ancestor repo. Assert it does not falsely claim `dir` is a root.
626        assert_ne!(find_git_root(&dir).as_deref(), Some(dir.as_path()));
627        let _ = fs::remove_dir_all(&dir);
628    }
629
630    #[test]
631    fn delete_in_dir_round_trip() {
632        let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
633        let dir = temp_dir("delete");
634        let path = write_to_dir(&dir, "doomed", "x", MemoryScope::Global, &[], "bye").unwrap();
635        assert!(path.exists());
636        // Mirror delete_memory's match-then-remove against the single root.
637        let entries = load_root(&dir, MemoryScope::Global);
638        assert_eq!(entries.len(), 1);
639        fs::remove_file(&entries[0].path).unwrap();
640        assert!(load_root(&dir, MemoryScope::Global).is_empty());
641        let _ = fs::remove_dir_all(&dir);
642    }
643}