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)]
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)]
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)]
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#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::constants::MAX_MEMORY_INDEX_BYTES;
507 use std::fs;
508 use std::sync::Mutex;
509
510 static FS_LOCK: Mutex<()> = Mutex::new(());
511
512 fn temp_dir(name: &str) -> PathBuf {
513 let p = std::env::temp_dir().join(format!("mermaid_memory_test_{name}"));
514 let _ = fs::remove_dir_all(&p);
515 fs::create_dir_all(&p).expect("create temp dir");
516 p
517 }
518
519 #[test]
520 fn slugify_makes_safe_stems() {
521 assert_eq!(slugify("Prefer ripgrep!"), "prefer-ripgrep");
522 assert_eq!(slugify(" use pnpm "), "use-pnpm");
523 assert_eq!(slugify("***"), "memory");
524 }
525
526 #[test]
527 fn parse_frontmatter_extracts_name_and_description() {
528 let raw =
529 "---\nname: prefer-rg\ndescription: Use ripgrep\ntags: [tooling]\n---\n\nThe body.\n";
530 let (fm, body) = parse_frontmatter(raw);
531 assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
532 assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
533 assert_eq!(body, "The body.");
534 }
535
536 #[test]
537 fn parse_frontmatter_without_fence_is_all_body() {
538 let (fm, body) = parse_frontmatter("just a note\nsecond line");
539 assert!(fm.name.is_none());
540 assert_eq!(body, "just a note\nsecond line");
541 }
542
543 #[test]
544 fn render_and_parse_round_trip() {
545 let content = render_file(
546 "prefer-rg",
547 "Use ripgrep",
548 MemoryScope::ProjectShared,
549 &["tooling".to_string()],
550 "Always reach for rg.",
551 );
552 assert!(content.contains("scope: project-shared"));
553 let (fm, body) = parse_frontmatter(&content);
554 assert_eq!(fm.name.as_deref(), Some("prefer-rg"));
555 assert_eq!(fm.description.as_deref(), Some("Use ripgrep"));
556 assert_eq!(body, "Always reach for rg.");
557 }
558
559 #[test]
560 fn write_and_load_root_round_trip() {
561 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
562 let dir = temp_dir("root");
563 write_to_dir(
564 &dir,
565 "Test Fact",
566 "A description",
567 MemoryScope::Global,
568 &[],
569 "body",
570 )
571 .unwrap();
572 let entries = load_root(&dir, MemoryScope::Global);
573 assert_eq!(entries.len(), 1);
574 assert_eq!(entries[0].name, "Test Fact");
575 assert_eq!(entries[0].description, "A description");
576 assert_eq!(entries[0].path.file_name().unwrap(), "test-fact.md");
577 let _ = fs::remove_dir_all(&dir);
578 }
579
580 #[test]
581 fn write_to_dir_redacts_name_and_tags() {
582 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
586 let dir = temp_dir("redact_name_tags");
587 let path = write_to_dir(
588 &dir,
589 "leaked key sk-ant-api03-abcdefghijklmnop",
590 "desc",
591 MemoryScope::Global,
592 &["env-OPENAI_API_KEY=sk-abcdefghijklmnop1234".to_string()],
593 "body",
594 )
595 .unwrap();
596 let raw = fs::read_to_string(&path).unwrap();
597 assert!(
598 !raw.contains("sk-ant-api03-abcdefghijklmnop"),
599 "name secret leaked: {raw}"
600 );
601 assert!(
602 !raw.contains("sk-abcdefghijklmnop1234"),
603 "tag secret leaked: {raw}"
604 );
605 assert!(
606 raw.contains("[REDACTED]"),
607 "expected redaction marker: {raw}"
608 );
609 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
611 assert!(
612 !stem.contains("abcdefghijklmnop"),
613 "secret leaked into filename: {stem}"
614 );
615 let _ = fs::remove_dir_all(&dir);
616 }
617
618 #[test]
619 fn load_root_falls_back_to_stem_and_first_line() {
620 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
621 let dir = temp_dir("fallback");
622 fs::write(dir.join("bare-note.md"), "first meaningful line\nmore").unwrap();
623 let entries = load_root(&dir, MemoryScope::Global);
624 assert_eq!(entries.len(), 1);
625 assert_eq!(entries[0].name, "bare-note");
626 assert_eq!(entries[0].description, "first meaningful line");
627 let _ = fs::remove_dir_all(&dir);
628 }
629
630 #[test]
631 fn render_index_groups_by_scope_and_excludes_body() {
632 let entries = vec![
633 MemoryEntry {
634 name: "g".into(),
635 description: "global fact".into(),
636 path: PathBuf::from("/g/g.md"),
637 scope: MemoryScope::Global,
638 mtime: UNIX_EPOCH,
639 },
640 MemoryEntry {
641 name: "p".into(),
642 description: "private fact".into(),
643 path: PathBuf::from("/p/p.md"),
644 scope: MemoryScope::ProjectPrivate,
645 mtime: UNIX_EPOCH,
646 },
647 ];
648 let (index, truncated) = render_index(&entries, MAX_MEMORY_INDEX_BYTES);
649 assert!(!truncated);
650 assert!(index.contains("# Memory"));
651 assert!(index.contains("## Global"));
652 assert!(index.contains("## Project (private)"));
653 assert!(index.contains("[g] global fact"));
654 assert!(index.contains("[p] private fact"));
655 assert!(index.find("global fact") < index.find("private fact"));
657 }
658
659 #[test]
660 fn render_index_truncates_when_oversized() {
661 let entries: Vec<MemoryEntry> = (0..200)
662 .map(|i| MemoryEntry {
663 name: format!("name-{i}"),
664 description: "a".repeat(80),
665 path: PathBuf::from(format!("/m/name-{i}.md")),
666 scope: MemoryScope::Global,
667 mtime: UNIX_EPOCH,
668 })
669 .collect();
670 let (index, truncated) = render_index(&entries, 1_000);
671 assert!(truncated);
672 assert!(index.ends_with(MEMORY_INDEX_TRUNCATION_MARKER));
673 }
674
675 #[test]
676 fn find_git_root_detects_dot_git() {
677 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
678 let root = temp_dir("gitroot");
679 fs::create_dir(root.join(".git")).unwrap();
680 let sub = root.join("a/b");
681 fs::create_dir_all(&sub).unwrap();
682 assert_eq!(find_git_root(&sub).as_deref(), Some(root.as_path()));
683 let _ = fs::remove_dir_all(&root);
684 }
685
686 #[test]
687 fn find_git_root_none_without_repo() {
688 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
689 let dir = temp_dir("norepo");
690 assert_ne!(find_git_root(&dir).as_deref(), Some(dir.as_path()));
693 let _ = fs::remove_dir_all(&dir);
694 }
695
696 #[test]
697 fn delete_in_dir_round_trip() {
698 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
699 let dir = temp_dir("delete");
700 let path = write_to_dir(&dir, "doomed", "x", MemoryScope::Global, &[], "bye").unwrap();
701 assert!(path.exists());
702 let entries = load_root(&dir, MemoryScope::Global);
704 assert_eq!(entries.len(), 1);
705 fs::remove_file(&entries[0].path).unwrap();
706 assert!(load_root(&dir, MemoryScope::Global).is_empty());
707 let _ = fs::remove_dir_all(&dir);
708 }
709}