1use mermaid_domain::{LoadedMemory, MemoryEntry, MemoryScope};
20use std::hash::{Hash, Hasher};
21use std::path::{Path, PathBuf};
22use std::time::UNIX_EPOCH;
23
24use mermaid_domain::MemoryConfig;
25use mermaid_model::constants::MEMORY_INDEX_TRUNCATION_MARKER;
26
27const MAX_WALK_DEPTH: usize = 32;
29
30const MAX_MEMORY_FILE_BYTES: usize = 64_000;
34
35#[derive(Debug, PartialEq, Eq)]
37pub enum MemoryReloadOutcome {
38 Unchanged,
39 Reloaded,
40 LoadedFirst,
41 Removed,
42}
43
44#[must_use]
47pub fn find_git_root(start: &Path) -> Option<PathBuf> {
48 let mut current = start.to_path_buf();
49 for _ in 0..MAX_WALK_DEPTH {
50 if current.join(".git").exists() {
51 return Some(current);
52 }
53 match current.parent() {
54 Some(parent) if parent != current => current = parent.to_path_buf(),
55 _ => return None,
56 }
57 }
58 None
59}
60
61#[must_use]
65pub fn memory_roots(cwd: &Path) -> Vec<(PathBuf, MemoryScope)> {
66 let Ok(data) = mermaid_runtime::data_dir() else {
67 return Vec::new();
68 };
69 let mut roots = vec![(data.join("memory"), MemoryScope::Global)];
70 match find_git_root(cwd) {
71 Some(git_root) => {
72 roots.push((
73 data.join("projects")
74 .join(project_key(&git_root))
75 .join("memory"),
76 MemoryScope::ProjectPrivate,
77 ));
78 roots.push((
79 git_root.join(".mermaid").join("memory"),
80 MemoryScope::ProjectShared,
81 ));
82 },
83 None => {
84 roots.push((
86 data.join("projects").join(project_key(cwd)).join("memory"),
87 MemoryScope::ProjectPrivate,
88 ));
89 },
90 }
91 roots
92}
93
94#[must_use]
96pub fn dir_for(scope: MemoryScope, cwd: &Path) -> Option<PathBuf> {
97 memory_roots(cwd)
98 .into_iter()
99 .find(|(_, s)| *s == scope)
100 .map(|(dir, _)| dir)
101}
102
103fn project_key(path: &Path) -> String {
107 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
108 let slug: String = canonical
109 .file_name()
110 .and_then(|n| n.to_str())
111 .unwrap_or("project")
112 .chars()
113 .map(|c| {
114 if c.is_ascii_alphanumeric() {
115 c.to_ascii_lowercase()
116 } else {
117 '-'
118 }
119 })
120 .take(32)
121 .collect();
122 let slug = slug.trim_matches('-');
123 let mut hasher = std::collections::hash_map::DefaultHasher::new();
124 canonical.to_string_lossy().hash(&mut hasher);
125 let hash = hasher.finish() as u32;
126 let slug = if slug.is_empty() { "project" } else { slug };
127 format!("{slug}-{hash:08x}")
128}
129
130#[must_use]
132pub fn slugify(name: &str) -> String {
133 let mut out = String::new();
134 let mut prev_dash = false;
135 for ch in name.trim().chars() {
136 if ch.is_ascii_alphanumeric() {
137 out.push(ch.to_ascii_lowercase());
138 prev_dash = false;
139 } else if !prev_dash {
140 out.push('-');
141 prev_dash = true;
142 }
143 }
144 let slug = out.trim_matches('-').to_string();
145 if slug.is_empty() {
146 "memory".to_string()
147 } else {
148 slug
149 }
150}
151
152#[derive(Debug, Default)]
153struct Frontmatter {
154 name: Option<String>,
155 description: Option<String>,
156}
157
158fn parse_frontmatter(raw: &str) -> (Frontmatter, String) {
162 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
163 let mut lines = raw.lines();
164 if lines.next().map(str::trim) != Some("---") {
165 return (Frontmatter::default(), raw.to_string());
166 }
167 let mut fm = Frontmatter::default();
168 let mut body_lines: Vec<&str> = Vec::new();
169 let mut in_fm = true;
170 for line in lines {
171 if in_fm {
172 if line.trim() == "---" {
173 in_fm = false;
174 continue;
175 }
176 if let Some((key, value)) = line.split_once(':') {
177 let value = value.trim().trim_matches('"').to_string();
178 match key.trim() {
179 "name" => fm.name = Some(value),
180 "description" => fm.description = Some(value),
181 _ => {},
182 }
183 }
184 } else {
185 body_lines.push(line);
186 }
187 }
188 if in_fm {
189 return (Frontmatter::default(), raw.to_string());
191 }
192 (fm, body_lines.join("\n").trim().to_string())
193}
194
195fn load_root(dir: &Path, scope: MemoryScope) -> Vec<MemoryEntry> {
198 let mut entries = Vec::new();
199 let Ok(read) = std::fs::read_dir(dir) else {
200 return entries;
201 };
202 for entry in read.flatten() {
203 let path = entry.path();
204 if path.extension().and_then(|e| e.to_str()) != Some("md") {
205 continue;
206 }
207 let Ok(meta) = entry.metadata() else { continue };
208 if !meta.is_file() {
209 continue;
210 }
211 let mtime = meta.modified().unwrap_or(UNIX_EPOCH);
212 let raw = match mermaid_model::utils::read_file_capped(&path, MAX_MEMORY_FILE_BYTES) {
216 Ok((bytes, _truncated)) => String::from_utf8_lossy(&bytes).into_owned(),
217 Err(e) => {
218 tracing::warn!(path = %path.display(), error = %e, "memory: skipping unreadable file");
219 continue;
220 },
221 };
222 let (fm, body) = parse_frontmatter(&raw);
223 let stem = path
224 .file_stem()
225 .and_then(|s| s.to_str())
226 .unwrap_or("memory")
227 .to_string();
228 let name = fm.name.filter(|s| !s.is_empty()).unwrap_or(stem);
229 let description = fm.description.filter(|s| !s.is_empty()).unwrap_or_else(|| {
230 body.lines()
231 .find(|l| !l.trim().is_empty())
232 .unwrap_or("")
233 .to_string()
234 });
235 entries.push(MemoryEntry {
236 name,
237 description,
238 path,
239 scope,
240 mtime,
241 });
242 }
243 entries.sort_by(|a, b| a.name.cmp(&b.name));
244 entries
245}
246
247fn render_index(entries: &[MemoryEntry], cap: usize) -> (String, bool) {
250 if entries.is_empty() {
251 return (String::new(), false);
252 }
253 let mut out = String::from(
254 "# 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",
255 );
256 for scope in [
257 MemoryScope::Global,
258 MemoryScope::ProjectPrivate,
259 MemoryScope::ProjectShared,
260 ] {
261 let mut first = true;
262 for entry in entries.iter().filter(|e| e.scope == scope) {
263 if first {
264 out.push_str(&format!("\n## {}\n", scope.label()));
265 first = false;
266 }
267 out.push_str(&format!(
268 "- [{}] {} — {}\n",
269 entry.name,
270 entry.description,
271 entry.path.display()
272 ));
273 }
274 }
275 if out.len() > cap {
276 let cut = out.floor_char_boundary(cap);
277 let mut clipped = out[..cut].to_string();
278 clipped.push_str(MEMORY_INDEX_TRUNCATION_MARKER);
279 (clipped, true)
280 } else {
281 (out, false)
282 }
283}
284
285#[must_use]
287pub fn load(cwd: &Path, cfg: &MemoryConfig) -> Option<LoadedMemory> {
288 if !cfg.enabled {
289 return None;
290 }
291 let mut entries = Vec::new();
292 for (dir, scope) in memory_roots(cwd) {
293 entries.extend(load_root(&dir, scope));
294 }
295 if entries.is_empty() {
296 return None;
297 }
298 let (index, truncated) = render_index(&entries, cfg.index_cap_bytes);
299 Some(LoadedMemory {
300 entries,
301 index,
302 truncated,
303 })
304}
305
306#[must_use]
310pub fn refresh(
311 current: Option<LoadedMemory>,
312 cwd: &Path,
313 cfg: &MemoryConfig,
314) -> (Option<LoadedMemory>, MemoryReloadOutcome) {
315 let fresh = load(cwd, cfg);
316 let outcome = match (¤t, &fresh) {
317 (None, None) => MemoryReloadOutcome::Unchanged,
318 (None, Some(_)) => MemoryReloadOutcome::LoadedFirst,
319 (Some(_), None) => MemoryReloadOutcome::Removed,
320 (Some(prev), Some(next)) => {
321 if same_entries(prev, next) {
322 MemoryReloadOutcome::Unchanged
323 } else {
324 MemoryReloadOutcome::Reloaded
325 }
326 },
327 };
328 (fresh, outcome)
329}
330
331fn same_entries(a: &LoadedMemory, b: &LoadedMemory) -> bool {
332 a.entries.len() == b.entries.len()
333 && a.entries
334 .iter()
335 .zip(&b.entries)
336 .all(|(x, y)| x.path == y.path && x.mtime == y.mtime)
337}
338
339fn render_file(
341 name: &str,
342 description: &str,
343 scope: MemoryScope,
344 tags: &[String],
345 body: &str,
346) -> String {
347 let created = chrono::Utc::now().to_rfc3339();
348 let tags = tags
349 .iter()
350 .map(|t| t.trim())
351 .filter(|t| !t.is_empty())
352 .collect::<Vec<_>>()
353 .join(", ");
354 format!(
355 "---\nname: {name}\ndescription: {description}\nscope: {scope}\ncreated: {created}\ntags: [{tags}]\n---\n\n{body}\n",
356 scope = scope.as_str(),
357 body = body.trim_end(),
358 )
359}
360
361pub fn write_to_dir(
370 dir: &Path,
371 name: &str,
372 description: &str,
373 scope: MemoryScope,
374 tags: &[String],
375 body: &str,
376) -> std::io::Result<PathBuf> {
377 std::fs::create_dir_all(dir)?;
378 let name = mermaid_model::utils::redact_secrets(name);
386 let description = mermaid_model::utils::redact_secrets(description);
387 let tags: Vec<String> = tags
388 .iter()
389 .map(|t| mermaid_model::utils::redact_secrets(t))
390 .collect();
391 let body = mermaid_model::utils::redact_secrets(body);
392 let path = dir.join(format!("{}.md", slugify(&name)));
393 std::fs::write(&path, render_file(&name, &description, scope, &tags, &body))?;
394 Ok(path)
395}
396
397pub fn write_memory(
404 cwd: &Path,
405 scope: MemoryScope,
406 name: &str,
407 description: &str,
408 tags: &[String],
409 body: &str,
410) -> std::io::Result<PathBuf> {
411 let dir = dir_for(scope, cwd).ok_or_else(|| {
412 std::io::Error::new(
413 std::io::ErrorKind::NotFound,
414 "could not resolve a memory directory",
415 )
416 })?;
417 write_to_dir(&dir, name, description, scope, tags, body)
418}
419
420#[must_use]
422pub fn find(cwd: &Path, id_or_name: &str) -> Option<MemoryEntry> {
423 for (dir, scope) in memory_roots(cwd) {
424 for entry in load_root(&dir, scope) {
425 let stem = entry.path.file_stem().and_then(|s| s.to_str());
426 if entry.name == id_or_name || stem == Some(id_or_name) {
427 return Some(entry);
428 }
429 }
430 }
431 None
432}
433
434pub fn delete_memory(cwd: &Path, id_or_name: &str) -> std::io::Result<Option<PathBuf>> {
442 match find(cwd, id_or_name) {
443 Some(entry) => {
444 std::fs::remove_file(&entry.path)?;
445 Ok(Some(entry.path))
446 },
447 None => Ok(None),
448 }
449}
450
451#[must_use]
454pub fn entries_with_bodies(cwd: &Path) -> Vec<(MemoryEntry, String)> {
455 let mut out = Vec::new();
456 for (dir, scope) in memory_roots(cwd) {
457 for entry in load_root(&dir, scope) {
458 let raw = std::fs::read_to_string(&entry.path).unwrap_or_default();
459 let (_, body) = parse_frontmatter(&raw);
460 out.push((entry, body));
461 }
462 }
463 out
464}
465
466#[derive(Debug, Clone)]
470pub struct MemorySearchHit {
471 pub entry: MemoryEntry,
472 pub snippet: String,
473}
474
475#[must_use]
481pub fn search(cwd: &Path, query: &str) -> Vec<MemorySearchHit> {
482 search_entries(entries_with_bodies(cwd), query)
483}
484
485fn search_entries(entries: Vec<(MemoryEntry, String)>, query: &str) -> Vec<MemorySearchHit> {
488 let needle = query.trim().to_lowercase();
489 if needle.is_empty() {
490 return Vec::new();
491 }
492 let mut out = Vec::new();
493 for (entry, body) in entries {
494 let body_line = body
495 .lines()
496 .find(|line| line.to_lowercase().contains(&needle));
497 let matches = entry.name.to_lowercase().contains(&needle)
498 || entry.description.to_lowercase().contains(&needle)
499 || body_line.is_some();
500 if !matches {
501 continue;
502 }
503 let raw_snippet = body_line
504 .map(str::trim)
505 .filter(|l| !l.is_empty())
506 .unwrap_or(entry.description.as_str());
507 let snippet = clip_chars(raw_snippet, 160);
508 out.push(MemorySearchHit { entry, snippet });
509 }
510 out
511}
512
513fn clip_chars(s: &str, max: usize) -> String {
516 if s.chars().count() <= max {
517 return s.to_string();
518 }
519 let clipped: String = s.chars().take(max).collect();
520 format!("{clipped}…")
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use mermaid_model::constants::MAX_MEMORY_INDEX_BYTES;
527 use std::fs;
528 use std::sync::Mutex;
529 use std::time::SystemTime;
530
531 static FS_LOCK: Mutex<()> = Mutex::new(());
532
533 fn temp_dir(name: &str) -> PathBuf {
534 let p = std::env::temp_dir().join(format!("mermaid_memory_test_{name}"));
535 let _ = fs::remove_dir_all(&p);
536 fs::create_dir_all(&p).expect("create temp dir");
537 p
538 }
539
540 #[test]
541 fn slugify_makes_safe_stems() {
542 assert_eq!(slugify("Prefer ripgrep!"), "prefer-ripgrep");
543 assert_eq!(slugify(" use pnpm "), "use-pnpm");
544 assert_eq!(slugify("***"), "memory");
545 }
546
547 #[test]
548 fn parse_frontmatter_extracts_name_and_description() {
549 let raw =
550 "---\nname: prefer-rg\ndescription: Use ripgrep\ntags: [tooling]\n---\n\nThe body.\n";
551 let (fm, body) = parse_frontmatter(raw);
552 assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
553 assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
554 assert_eq!(body, "The body.");
555 }
556
557 #[test]
558 fn parse_frontmatter_without_fence_is_all_body() {
559 let (fm, body) = parse_frontmatter("just a note\nsecond line");
560 assert!(fm.name.is_none());
561 assert_eq!(body, "just a note\nsecond line");
562 }
563
564 #[test]
565 fn render_and_parse_round_trip() {
566 let content = render_file(
567 "prefer-rg",
568 "Use ripgrep",
569 MemoryScope::ProjectShared,
570 &["tooling".to_string()],
571 "Always reach for rg.",
572 );
573 assert!(content.contains("scope: project-shared"));
574 let (fm, body) = parse_frontmatter(&content);
575 assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
576 assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
577 assert_eq!(body, "Always reach for rg.");
578 }
579
580 #[test]
581 fn write_and_load_root_round_trip() {
582 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
583 let dir = temp_dir("root");
584 write_to_dir(
585 &dir,
586 "Test Fact",
587 "A description",
588 MemoryScope::Global,
589 &[],
590 "body",
591 )
592 .unwrap();
593 let entries = load_root(&dir, MemoryScope::Global);
594 assert_eq!(entries.len(), 1);
595 assert_eq!(entries[0].name, "Test Fact");
596 assert_eq!(entries[0].description, "A description");
597 assert_eq!(entries[0].path.file_name().unwrap(), "test-fact.md");
598 let _ = fs::remove_dir_all(&dir);
599 }
600
601 #[test]
602 fn search_entries_matches_name_description_and_body() {
603 let mk = |name: &str, desc: &str| MemoryEntry {
604 name: name.to_string(),
605 description: desc.to_string(),
606 path: PathBuf::from(format!("/tmp/{name}.md")),
607 scope: MemoryScope::ProjectPrivate,
608 mtime: SystemTime::UNIX_EPOCH,
609 };
610 let entries = vec![
611 (
612 mk("prefer-ripgrep", "Use rg for search"),
613 "Always reach for ripgrep over grep.".to_string(),
614 ),
615 (
616 mk("editor-choice", "Editor preference"),
617 "The user likes neovim.".to_string(),
618 ),
619 (
620 mk("ci-flow", "CI conventions"),
621 "Run just check before every PR.".to_string(),
622 ),
623 ];
624
625 let hits = search_entries(entries.clone(), "neovim");
627 assert_eq!(hits.len(), 1);
628 assert_eq!(hits[0].entry.name, "editor-choice");
629 assert!(hits[0].snippet.contains("neovim"));
630
631 assert_eq!(search_entries(entries.clone(), "RIPGREP").len(), 1);
633
634 let desc_hits = search_entries(entries.clone(), "conventions");
636 assert_eq!(desc_hits.len(), 1);
637 assert_eq!(desc_hits[0].snippet, "CI conventions");
638
639 assert!(search_entries(entries.clone(), " ").is_empty());
641 assert!(search_entries(entries, "nonexistent-xyz").is_empty());
642 }
643
644 #[test]
645 fn clip_chars_truncates_on_char_boundary() {
646 assert_eq!(clip_chars("short", 10), "short");
647 let clipped = clip_chars(&"a".repeat(200), 160);
648 assert_eq!(clipped.chars().count(), 161); assert!(clipped.ends_with('…'));
650 }
651
652 #[test]
653 fn write_to_dir_redacts_name_and_tags() {
654 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
658 let dir = temp_dir("redact_name_tags");
659 let path = write_to_dir(
660 &dir,
661 "leaked key sk-ant-api03-abcdefghijklmnop",
662 "desc",
663 MemoryScope::Global,
664 &["env-OPENAI_API_KEY=sk-abcdefghijklmnop1234".to_string()],
665 "body",
666 )
667 .unwrap();
668 let raw = fs::read_to_string(&path).unwrap();
669 assert!(
670 !raw.contains("sk-ant-api03-abcdefghijklmnop"),
671 "name secret leaked: {raw}"
672 );
673 assert!(
674 !raw.contains("sk-abcdefghijklmnop1234"),
675 "tag secret leaked: {raw}"
676 );
677 assert!(
678 raw.contains("[REDACTED]"),
679 "expected redaction marker: {raw}"
680 );
681 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
683 assert!(
684 !stem.contains("abcdefghijklmnop"),
685 "secret leaked into filename: {stem}"
686 );
687 let _ = fs::remove_dir_all(&dir);
688 }
689
690 #[test]
691 fn load_root_falls_back_to_stem_and_first_line() {
692 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
693 let dir = temp_dir("fallback");
694 fs::write(dir.join("bare-note.md"), "first meaningful line\nmore").unwrap();
695 let entries = load_root(&dir, MemoryScope::Global);
696 assert_eq!(entries.len(), 1);
697 assert_eq!(entries[0].name, "bare-note");
698 assert_eq!(entries[0].description, "first meaningful line");
699 let _ = fs::remove_dir_all(&dir);
700 }
701
702 #[test]
703 fn render_index_groups_by_scope_and_excludes_body() {
704 let entries = vec![
705 MemoryEntry {
706 name: "g".into(),
707 description: "global fact".into(),
708 path: PathBuf::from("/g/g.md"),
709 scope: MemoryScope::Global,
710 mtime: UNIX_EPOCH,
711 },
712 MemoryEntry {
713 name: "p".into(),
714 description: "private fact".into(),
715 path: PathBuf::from("/p/p.md"),
716 scope: MemoryScope::ProjectPrivate,
717 mtime: UNIX_EPOCH,
718 },
719 ];
720 let (index, truncated) = render_index(&entries, MAX_MEMORY_INDEX_BYTES);
721 assert!(!truncated);
722 assert!(index.contains("# Memory"));
723 assert!(index.contains("## Global"));
724 assert!(index.contains("## Project (private)"));
725 assert!(index.contains("[g] global fact"));
726 assert!(index.contains("[p] private fact"));
727 assert!(index.find("global fact") < index.find("private fact"));
729 }
730
731 #[test]
732 fn render_index_truncates_when_oversized() {
733 let entries: Vec<MemoryEntry> = (0..200)
734 .map(|i| MemoryEntry {
735 name: format!("name-{i}"),
736 description: "a".repeat(80),
737 path: PathBuf::from(format!("/m/name-{i}.md")),
738 scope: MemoryScope::Global,
739 mtime: UNIX_EPOCH,
740 })
741 .collect();
742 let (index, truncated) = render_index(&entries, 1_000);
743 assert!(truncated);
744 assert!(index.ends_with(MEMORY_INDEX_TRUNCATION_MARKER));
745 }
746
747 #[test]
748 fn find_git_root_detects_dot_git() {
749 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
750 let root = temp_dir("gitroot");
751 fs::create_dir(root.join(".git")).unwrap();
752 let sub = root.join("a/b");
753 fs::create_dir_all(&sub).unwrap();
754 assert_eq!(find_git_root(&sub).as_deref(), Some(root.as_path()));
755 let _ = fs::remove_dir_all(&root);
756 }
757
758 #[test]
759 fn find_git_root_none_without_repo() {
760 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
761 let dir = temp_dir("norepo");
762 assert_ne!(find_git_root(&dir).as_deref(), Some(dir.as_path()));
765 let _ = fs::remove_dir_all(&dir);
766 }
767
768 #[test]
769 fn delete_in_dir_round_trip() {
770 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
771 let dir = temp_dir("delete");
772 let path = write_to_dir(&dir, "doomed", "x", MemoryScope::Global, &[], "bye").unwrap();
773 assert!(path.exists());
774 let entries = load_root(&dir, MemoryScope::Global);
776 assert_eq!(entries.len(), 1);
777 fs::remove_file(&entries[0].path).unwrap();
778 assert!(load_root(&dir, MemoryScope::Global).is_empty());
779 let _ = fs::remove_dir_all(&dir);
780 }
781}