use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app::MemoryConfig;
use crate::constants::MEMORY_INDEX_TRUNCATION_MARKER;
const MAX_WALK_DEPTH: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryScope {
Global,
ProjectPrivate,
ProjectShared,
}
impl MemoryScope {
pub fn as_str(self) -> &'static str {
match self {
MemoryScope::Global => "global",
MemoryScope::ProjectPrivate => "project-private",
MemoryScope::ProjectShared => "project-shared",
}
}
fn label(self) -> &'static str {
match self {
MemoryScope::Global => "Global (all projects)",
MemoryScope::ProjectPrivate => "Project (private)",
MemoryScope::ProjectShared => "Project (shared)",
}
}
}
#[derive(Debug, Clone)]
pub struct MemoryEntry {
pub name: String,
pub description: String,
pub path: PathBuf,
pub scope: MemoryScope,
pub mtime: SystemTime,
}
#[derive(Debug, Clone)]
pub struct LoadedMemory {
pub entries: Vec<MemoryEntry>,
pub index: String,
pub truncated: bool,
}
impl LoadedMemory {
pub fn approx_tokens(&self) -> usize {
self.index.len() / 4
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum MemoryReloadOutcome {
Unchanged,
Reloaded,
LoadedFirst,
Removed,
}
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
for _ in 0..MAX_WALK_DEPTH {
if current.join(".git").exists() {
return Some(current);
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => return None,
}
}
None
}
pub fn memory_roots(cwd: &Path) -> Vec<(PathBuf, MemoryScope)> {
let Ok(data) = crate::runtime::data_dir() else {
return Vec::new();
};
let mut roots = vec![(data.join("memory"), MemoryScope::Global)];
match find_git_root(cwd) {
Some(git_root) => {
roots.push((
data.join("projects")
.join(project_key(&git_root))
.join("memory"),
MemoryScope::ProjectPrivate,
));
roots.push((
git_root.join(".mermaid").join("memory"),
MemoryScope::ProjectShared,
));
},
None => {
roots.push((
data.join("projects").join(project_key(cwd)).join("memory"),
MemoryScope::ProjectPrivate,
));
},
}
roots
}
pub fn dir_for(scope: MemoryScope, cwd: &Path) -> Option<PathBuf> {
memory_roots(cwd)
.into_iter()
.find(|(_, s)| *s == scope)
.map(|(dir, _)| dir)
}
fn project_key(path: &Path) -> String {
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let slug: String = canonical
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.take(32)
.collect();
let slug = slug.trim_matches('-');
let mut hasher = std::collections::hash_map::DefaultHasher::new();
canonical.to_string_lossy().hash(&mut hasher);
let hash = hasher.finish() as u32;
let slug = if slug.is_empty() { "project" } else { slug };
format!("{slug}-{hash:08x}")
}
pub fn slugify(name: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let slug = out.trim_matches('-').to_string();
if slug.is_empty() {
"memory".to_string()
} else {
slug
}
}
#[derive(Debug, Default)]
struct Frontmatter {
name: Option<String>,
description: Option<String>,
}
fn parse_frontmatter(raw: &str) -> (Frontmatter, String) {
let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
let mut lines = raw.lines();
if lines.next().map(str::trim) != Some("---") {
return (Frontmatter::default(), raw.to_string());
}
let mut fm = Frontmatter::default();
let mut body_lines: Vec<&str> = Vec::new();
let mut in_fm = true;
for line in lines {
if in_fm {
if line.trim() == "---" {
in_fm = false;
continue;
}
if let Some((key, value)) = line.split_once(':') {
let value = value.trim().trim_matches('"').to_string();
match key.trim() {
"name" => fm.name = Some(value),
"description" => fm.description = Some(value),
_ => {},
}
}
} else {
body_lines.push(line);
}
}
if in_fm {
return (Frontmatter::default(), raw.to_string());
}
(fm, body_lines.join("\n").trim().to_string())
}
fn load_root(dir: &Path, scope: MemoryScope) -> Vec<MemoryEntry> {
let mut entries = Vec::new();
let Ok(read) = std::fs::read_dir(dir) else {
return entries;
};
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
let mtime = meta.modified().unwrap_or(UNIX_EPOCH);
let raw = std::fs::read_to_string(&path).unwrap_or_default();
let (fm, body) = parse_frontmatter(&raw);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("memory")
.to_string();
let name = fm.name.filter(|s| !s.is_empty()).unwrap_or(stem);
let description = fm.description.filter(|s| !s.is_empty()).unwrap_or_else(|| {
body.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.to_string()
});
entries.push(MemoryEntry {
name,
description,
path,
scope,
mtime,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
fn render_index(entries: &[MemoryEntry], cap: usize) -> (String, bool) {
if entries.is_empty() {
return (String::new(), false);
}
let mut out = String::from(
"# 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",
);
for scope in [
MemoryScope::Global,
MemoryScope::ProjectPrivate,
MemoryScope::ProjectShared,
] {
let mut first = true;
for entry in entries.iter().filter(|e| e.scope == scope) {
if first {
out.push_str(&format!("\n## {}\n", scope.label()));
first = false;
}
out.push_str(&format!(
"- [{}] {} — {}\n",
entry.name,
entry.description,
entry.path.display()
));
}
}
if out.len() > cap {
let cut = out.floor_char_boundary(cap);
let mut clipped = out[..cut].to_string();
clipped.push_str(MEMORY_INDEX_TRUNCATION_MARKER);
(clipped, true)
} else {
(out, false)
}
}
pub fn load(cwd: &Path, cfg: &MemoryConfig) -> Option<LoadedMemory> {
if !cfg.enabled {
return None;
}
let mut entries = Vec::new();
for (dir, scope) in memory_roots(cwd) {
entries.extend(load_root(&dir, scope));
}
if entries.is_empty() {
return None;
}
let (index, truncated) = render_index(&entries, cfg.index_cap_bytes);
Some(LoadedMemory {
entries,
index,
truncated,
})
}
pub fn refresh(
current: Option<LoadedMemory>,
cwd: &Path,
cfg: &MemoryConfig,
) -> (Option<LoadedMemory>, MemoryReloadOutcome) {
let fresh = load(cwd, cfg);
let outcome = match (¤t, &fresh) {
(None, None) => MemoryReloadOutcome::Unchanged,
(None, Some(_)) => MemoryReloadOutcome::LoadedFirst,
(Some(_), None) => MemoryReloadOutcome::Removed,
(Some(prev), Some(next)) => {
if same_entries(prev, next) {
MemoryReloadOutcome::Unchanged
} else {
MemoryReloadOutcome::Reloaded
}
},
};
(fresh, outcome)
}
fn same_entries(a: &LoadedMemory, b: &LoadedMemory) -> bool {
a.entries.len() == b.entries.len()
&& a.entries
.iter()
.zip(&b.entries)
.all(|(x, y)| x.path == y.path && x.mtime == y.mtime)
}
fn render_file(
name: &str,
description: &str,
scope: MemoryScope,
tags: &[String],
body: &str,
) -> String {
let created = chrono::Utc::now().to_rfc3339();
let tags = tags
.iter()
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(", ");
format!(
"---\nname: {name}\ndescription: {description}\nscope: {scope}\ncreated: {created}\ntags: [{tags}]\n---\n\n{body}\n",
scope = scope.as_str(),
body = body.trim_end(),
)
}
pub fn write_to_dir(
dir: &Path,
name: &str,
description: &str,
scope: MemoryScope,
tags: &[String],
body: &str,
) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{}.md", slugify(name)));
let description = crate::utils::redact_secrets(description);
let body = crate::utils::redact_secrets(body);
std::fs::write(&path, render_file(name, &description, scope, tags, &body))?;
Ok(path)
}
pub fn write_memory(
cwd: &Path,
scope: MemoryScope,
name: &str,
description: &str,
tags: &[String],
body: &str,
) -> std::io::Result<PathBuf> {
let dir = dir_for(scope, cwd).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not resolve a memory directory",
)
})?;
write_to_dir(&dir, name, description, scope, tags, body)
}
pub fn find(cwd: &Path, id_or_name: &str) -> Option<MemoryEntry> {
for (dir, scope) in memory_roots(cwd) {
for entry in load_root(&dir, scope) {
let stem = entry.path.file_stem().and_then(|s| s.to_str());
if entry.name == id_or_name || stem == Some(id_or_name) {
return Some(entry);
}
}
}
None
}
pub fn delete_memory(cwd: &Path, id_or_name: &str) -> std::io::Result<Option<PathBuf>> {
match find(cwd, id_or_name) {
Some(entry) => {
std::fs::remove_file(&entry.path)?;
Ok(Some(entry.path))
},
None => Ok(None),
}
}
pub fn entries_with_bodies(cwd: &Path) -> Vec<(MemoryEntry, String)> {
let mut out = Vec::new();
for (dir, scope) in memory_roots(cwd) {
for entry in load_root(&dir, scope) {
let raw = std::fs::read_to_string(&entry.path).unwrap_or_default();
let (_, body) = parse_frontmatter(&raw);
out.push((entry, body));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::MAX_MEMORY_INDEX_BYTES;
use std::fs;
use std::sync::Mutex;
static FS_LOCK: Mutex<()> = Mutex::new(());
fn temp_dir(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("mermaid_memory_test_{name}"));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).expect("create temp dir");
p
}
#[test]
fn slugify_makes_safe_stems() {
assert_eq!(slugify("Prefer ripgrep!"), "prefer-ripgrep");
assert_eq!(slugify(" use pnpm "), "use-pnpm");
assert_eq!(slugify("***"), "memory");
}
#[test]
fn parse_frontmatter_extracts_name_and_description() {
let raw =
"---\nname: prefer-rg\ndescription: Use ripgrep\ntags: [tooling]\n---\n\nThe body.\n";
let (fm, body) = parse_frontmatter(raw);
assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
assert_eq!(body, "The body.");
}
#[test]
fn parse_frontmatter_without_fence_is_all_body() {
let (fm, body) = parse_frontmatter("just a note\nsecond line");
assert!(fm.name.is_none());
assert_eq!(body, "just a note\nsecond line");
}
#[test]
fn render_and_parse_round_trip() {
let content = render_file(
"prefer-rg",
"Use ripgrep",
MemoryScope::ProjectShared,
&["tooling".to_string()],
"Always reach for rg.",
);
assert!(content.contains("scope: project-shared"));
let (fm, body) = parse_frontmatter(&content);
assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
assert_eq!(body, "Always reach for rg.");
}
#[test]
fn write_and_load_root_round_trip() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("root");
write_to_dir(
&dir,
"Test Fact",
"A description",
MemoryScope::Global,
&[],
"body",
)
.unwrap();
let entries = load_root(&dir, MemoryScope::Global);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "Test Fact");
assert_eq!(entries[0].description, "A description");
assert_eq!(entries[0].path.file_name().unwrap(), "test-fact.md");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_root_falls_back_to_stem_and_first_line() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("fallback");
fs::write(dir.join("bare-note.md"), "first meaningful line\nmore").unwrap();
let entries = load_root(&dir, MemoryScope::Global);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "bare-note");
assert_eq!(entries[0].description, "first meaningful line");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn render_index_groups_by_scope_and_excludes_body() {
let entries = vec![
MemoryEntry {
name: "g".into(),
description: "global fact".into(),
path: PathBuf::from("/g/g.md"),
scope: MemoryScope::Global,
mtime: UNIX_EPOCH,
},
MemoryEntry {
name: "p".into(),
description: "private fact".into(),
path: PathBuf::from("/p/p.md"),
scope: MemoryScope::ProjectPrivate,
mtime: UNIX_EPOCH,
},
];
let (index, truncated) = render_index(&entries, MAX_MEMORY_INDEX_BYTES);
assert!(!truncated);
assert!(index.contains("# Memory"));
assert!(index.contains("## Global"));
assert!(index.contains("## Project (private)"));
assert!(index.contains("[g] global fact"));
assert!(index.contains("[p] private fact"));
assert!(index.find("global fact") < index.find("private fact"));
}
#[test]
fn render_index_truncates_when_oversized() {
let entries: Vec<MemoryEntry> = (0..200)
.map(|i| MemoryEntry {
name: format!("name-{i}"),
description: "a".repeat(80),
path: PathBuf::from(format!("/m/name-{i}.md")),
scope: MemoryScope::Global,
mtime: UNIX_EPOCH,
})
.collect();
let (index, truncated) = render_index(&entries, 1_000);
assert!(truncated);
assert!(index.ends_with(MEMORY_INDEX_TRUNCATION_MARKER));
}
#[test]
fn find_git_root_detects_dot_git() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let root = temp_dir("gitroot");
fs::create_dir(root.join(".git")).unwrap();
let sub = root.join("a/b");
fs::create_dir_all(&sub).unwrap();
assert_eq!(find_git_root(&sub).as_deref(), Some(root.as_path()));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn find_git_root_none_without_repo() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("norepo");
assert_ne!(find_git_root(&dir).as_deref(), Some(dir.as_path()));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn delete_in_dir_round_trip() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("delete");
let path = write_to_dir(&dir, "doomed", "x", MemoryScope::Global, &[], "bye").unwrap();
assert!(path.exists());
let entries = load_root(&dir, MemoryScope::Global);
assert_eq!(entries.len(), 1);
fs::remove_file(&entries[0].path).unwrap();
assert!(load_root(&dir, MemoryScope::Global).is_empty());
let _ = fs::remove_dir_all(&dir);
}
}