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