Skip to main content

cersei_memory/
memdir.rs

1//! Memory directory: persistent file-based memory system.
2//!
3//! Scans .md files with YAML frontmatter, sorts by recency, caps at 200 files.
4
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::{Deserialize, Serialize};
9
10// ─── Types ───────────────────────────────────────────────────────────────────
11
12/// Memory types for categorized memory entries.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum MemoryType {
16    User,
17    Feedback,
18    Project,
19    Reference,
20}
21
22impl MemoryType {
23    pub fn from_str(s: &str) -> Option<Self> {
24        match s.to_lowercase().as_str() {
25            "user" => Some(Self::User),
26            "feedback" => Some(Self::Feedback),
27            "project" => Some(Self::Project),
28            "reference" => Some(Self::Reference),
29            _ => None,
30        }
31    }
32}
33
34/// Metadata for a memory file (without loading full content).
35#[derive(Debug, Clone)]
36pub struct MemoryFileMeta {
37    pub filename: String,
38    pub path: PathBuf,
39    pub name: Option<String>,
40    pub description: Option<String>,
41    pub memory_type: Option<MemoryType>,
42    pub modified_secs: u64,
43}
44
45/// A memory file with its full content.
46#[derive(Debug, Clone)]
47pub struct MemoryFile {
48    pub meta: MemoryFileMeta,
49    pub content: String,
50}
51
52/// Result of loading MEMORY.md with truncation info.
53pub struct MemoryIndex {
54    pub content: String,
55    pub truncated: bool,
56    pub total_lines: usize,
57}
58
59// ─── Constants ───────────────────────────────────────────────────────────────
60
61const MAX_MEMORY_FILES: usize = 200;
62const MAX_INDEX_LINES: usize = 200;
63const MAX_INDEX_BYTES: usize = 25_000;
64
65// ─── Path resolution ─────────────────────────────────────────────────────────
66
67/// Sanitize a path component for use as a directory name.
68/// Keeps alphanumeric, `-`, `_`, `.`. Replaces everything else with `_`.
69pub fn sanitize_path_component(path: &str) -> String {
70    path.chars()
71        .map(|c| {
72            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
73                c
74            } else {
75                '_'
76            }
77        })
78        .collect()
79}
80
81/// Resolve the memory directory for a project.
82/// Default: `~/.claude/projects/<sanitized-root>/memory/`
83pub fn auto_memory_path(project_root: &Path) -> PathBuf {
84    // Check override env var first
85    if let Ok(override_path) = std::env::var("CERSEI_MEMORY_PATH_OVERRIDE") {
86        return PathBuf::from(override_path);
87    }
88
89    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
90    let sanitized = sanitize_path_component(&project_root.display().to_string());
91    home.join(".claude")
92        .join("projects")
93        .join(sanitized)
94        .join("memory")
95}
96
97/// Create the memory directory if it doesn't exist.
98pub fn ensure_memory_dir_exists(dir: &Path) {
99    if let Err(e) = std::fs::create_dir_all(dir) {
100        tracing::debug!("Failed to create memory dir {}: {}", dir.display(), e);
101    }
102}
103
104// ─── Scanning ────────────────────────────────────────────────────────────────
105
106/// Parse YAML frontmatter from the first ~30 lines of a file.
107/// Returns (name, description, memory_type).
108fn parse_frontmatter_quick(content: &str) -> (Option<String>, Option<String>, Option<MemoryType>) {
109    let mut name = None;
110    let mut description = None;
111    let mut memory_type = None;
112
113    if !content.starts_with("---") {
114        return (name, description, memory_type);
115    }
116
117    let mut in_frontmatter = false;
118    for (i, line) in content.lines().enumerate() {
119        if i > 30 {
120            break;
121        }
122        if i == 0 && line.trim() == "---" {
123            in_frontmatter = true;
124            continue;
125        }
126        if in_frontmatter && line.trim() == "---" {
127            break;
128        }
129        if !in_frontmatter {
130            continue;
131        }
132
133        if let Some(colon) = line.find(':') {
134            let key = line[..colon].trim().to_lowercase();
135            let value = line[colon + 1..].trim().to_string();
136            match key.as_str() {
137                "name" => name = Some(value),
138                "description" => description = Some(value),
139                "type" => memory_type = MemoryType::from_str(&value),
140                _ => {}
141            }
142        }
143    }
144
145    (name, description, memory_type)
146}
147
148/// Scan a memory directory for .md files.
149/// Returns metadata sorted newest-first, capped at 200 files.
150/// Excludes MEMORY.md (the index file).
151pub fn scan_memory_dir(dir: &Path) -> Vec<MemoryFileMeta> {
152    let mut results: Vec<MemoryFileMeta> = Vec::new();
153
154    let _walker = match std::fs::read_dir(dir) {
155        Ok(w) => w,
156        Err(_) => return results,
157    };
158
159    // Recursive scan
160    scan_dir_recursive(dir, dir, &mut results);
161
162    // Sort newest-first
163    results.sort_by(|a, b| b.modified_secs.cmp(&a.modified_secs));
164
165    // Cap
166    results.truncate(MAX_MEMORY_FILES);
167    results
168}
169
170fn scan_dir_recursive(base: &Path, dir: &Path, results: &mut Vec<MemoryFileMeta>) {
171    let entries = match std::fs::read_dir(dir) {
172        Ok(e) => e,
173        Err(_) => return,
174    };
175
176    for entry in entries.flatten() {
177        let path = entry.path();
178
179        if path.is_dir() {
180            scan_dir_recursive(base, &path, results);
181            continue;
182        }
183
184        if path.extension().and_then(|e| e.to_str()) != Some("md") {
185            continue;
186        }
187
188        // Skip MEMORY.md (index file)
189        if path.file_name().and_then(|n| n.to_str()) == Some("MEMORY.md") {
190            continue;
191        }
192
193        let filename = path
194            .strip_prefix(base)
195            .unwrap_or(&path)
196            .display()
197            .to_string();
198
199        let modified_secs = std::fs::metadata(&path)
200            .and_then(|m| m.modified())
201            .ok()
202            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
203            .map(|d| d.as_secs())
204            .unwrap_or(0);
205
206        // Quick frontmatter parse
207        let content = std::fs::read_to_string(&path).unwrap_or_default();
208        let (name, description, memory_type) = parse_frontmatter_quick(&content);
209
210        results.push(MemoryFileMeta {
211            filename,
212            path,
213            name,
214            description,
215            memory_type,
216            modified_secs,
217        });
218    }
219}
220
221// ─── Index loading ───────────────────────────────────────────────────────────
222
223/// Load the MEMORY.md index file with truncation.
224/// Returns None if the file doesn't exist or is empty.
225pub fn load_memory_index(memory_dir: &Path) -> Option<MemoryIndex> {
226    let index_path = memory_dir.join("MEMORY.md");
227    let content = std::fs::read_to_string(&index_path).ok()?;
228
229    if content.trim().is_empty() {
230        return None;
231    }
232
233    let lines: Vec<&str> = content.lines().collect();
234    let total_lines = lines.len();
235    let truncated = total_lines > MAX_INDEX_LINES || content.len() > MAX_INDEX_BYTES;
236
237    let output = if truncated {
238        let mut result: String = lines[..MAX_INDEX_LINES.min(total_lines)].join("\n");
239        if result.len() > MAX_INDEX_BYTES {
240            result.truncate(MAX_INDEX_BYTES);
241        }
242        result.push_str(&format!(
243            "\n\n<!-- MEMORY.md truncated: {} total lines, showing {} -->",
244            total_lines,
245            MAX_INDEX_LINES.min(total_lines)
246        ));
247        result
248    } else {
249        content
250    };
251
252    Some(MemoryIndex {
253        content: output,
254        truncated,
255        total_lines,
256    })
257}
258
259/// Build the complete memory prompt content for the system prompt.
260pub fn build_memory_prompt_content(memory_dir: &Path) -> String {
261    let index = load_memory_index(memory_dir);
262    match index {
263        Some(idx) => idx.content,
264        None => String::new(),
265    }
266}
267
268// ─── Staleness ───────────────────────────────────────────────────────────────
269
270/// How many days ago a memory was modified.
271pub fn memory_age_days(modified_secs: u64) -> u64 {
272    let now = SystemTime::now()
273        .duration_since(UNIX_EPOCH)
274        .map(|d| d.as_secs())
275        .unwrap_or(0);
276    if now > modified_secs {
277        (now - modified_secs) / 86400
278    } else {
279        0
280    }
281}
282
283/// Human-readable age string.
284pub fn memory_age_text(modified_secs: u64) -> String {
285    let days = memory_age_days(modified_secs);
286    match days {
287        0 => "today".to_string(),
288        1 => "yesterday".to_string(),
289        d => format!("{} days ago", d),
290    }
291}
292
293/// Freshness warning for stale memories (>1 day old).
294pub fn memory_freshness_text(modified_secs: u64) -> Option<String> {
295    let days = memory_age_days(modified_secs);
296    if days > 1 {
297        Some(format!(
298            "This memory was last updated {} — verify it's still current before acting on it.",
299            memory_age_text(modified_secs)
300        ))
301    } else {
302        None
303    }
304}
305
306// ─── Load full file ──────────────────────────────────────────────────────────
307
308/// Load a memory file with its full content, stripping frontmatter.
309pub fn load_memory_file(path: &Path) -> Option<MemoryFile> {
310    let content = std::fs::read_to_string(path).ok()?;
311    let (name, description, memory_type) = parse_frontmatter_quick(&content);
312
313    let modified_secs = std::fs::metadata(path)
314        .and_then(|m| m.modified())
315        .ok()
316        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
317        .map(|d| d.as_secs())
318        .unwrap_or(0);
319
320    // Strip frontmatter from content
321    let body = crate::strip_frontmatter(&content);
322
323    Some(MemoryFile {
324        meta: MemoryFileMeta {
325            filename: path.file_name()?.to_str()?.to_string(),
326            path: path.to_path_buf(),
327            name,
328            description,
329            memory_type,
330            modified_secs,
331        },
332        content: body,
333    })
334}
335
336// ─── Tests ───────────────────────────────────────────────────────────────────
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn create_memory_file(dir: &Path, name: &str, content: &str) {
343        std::fs::write(dir.join(name), content).unwrap();
344    }
345
346    #[test]
347    fn test_scan_memory_dir() {
348        let tmp = tempfile::tempdir().unwrap();
349        let mem_dir = tmp.path();
350
351        create_memory_file(mem_dir, "user_role.md", "---\nname: User Role\ndescription: Developer preferences\ntype: user\n---\n\nI prefer Rust.");
352        create_memory_file(
353            mem_dir,
354            "project_arch.md",
355            "---\nname: Architecture\ntype: project\n---\n\nMicroservices.",
356        );
357        create_memory_file(
358            mem_dir,
359            "feedback_style.md",
360            "---\ntype: feedback\n---\n\nBe concise.",
361        );
362        create_memory_file(
363            mem_dir,
364            "MEMORY.md",
365            "- [User Role](user_role.md)\n- [Architecture](project_arch.md)",
366        );
367        create_memory_file(mem_dir, "no_frontmatter.md", "Just plain content.");
368
369        let metas = scan_memory_dir(mem_dir);
370        assert_eq!(metas.len(), 4); // excludes MEMORY.md
371        assert!(metas.iter().all(|m| m.filename != "MEMORY.md"));
372
373        // Check frontmatter parsing
374        let user = metas.iter().find(|m| m.filename == "user_role.md").unwrap();
375        assert_eq!(user.name.as_deref(), Some("User Role"));
376        assert_eq!(user.description.as_deref(), Some("Developer preferences"));
377        assert_eq!(user.memory_type, Some(MemoryType::User));
378
379        // No frontmatter still scanned
380        let plain = metas
381            .iter()
382            .find(|m| m.filename == "no_frontmatter.md")
383            .unwrap();
384        assert!(plain.name.is_none());
385    }
386
387    #[test]
388    fn test_load_memory_index() {
389        let tmp = tempfile::tempdir().unwrap();
390        std::fs::write(
391            tmp.path().join("MEMORY.md"),
392            "- [Test](test.md) — hook\n".repeat(10),
393        )
394        .unwrap();
395
396        let index = load_memory_index(tmp.path());
397        assert!(index.is_some());
398        let index = index.unwrap();
399        assert!(!index.truncated);
400        assert_eq!(index.total_lines, 10);
401    }
402
403    #[test]
404    fn test_load_memory_index_truncation() {
405        let tmp = tempfile::tempdir().unwrap();
406        let content = "- line\n".repeat(300);
407        std::fs::write(tmp.path().join("MEMORY.md"), content).unwrap();
408
409        let index = load_memory_index(tmp.path());
410        assert!(index.is_some());
411        let index = index.unwrap();
412        assert!(index.truncated);
413        assert!(index.content.contains("truncated"));
414    }
415
416    #[test]
417    fn test_load_memory_index_missing() {
418        let tmp = tempfile::tempdir().unwrap();
419        assert!(load_memory_index(tmp.path()).is_none());
420    }
421
422    #[test]
423    fn test_sanitize_path_component() {
424        assert_eq!(
425            sanitize_path_component("/Users/foo/project"),
426            "_Users_foo_project"
427        );
428        assert_eq!(sanitize_path_component("simple-name"), "simple-name");
429        assert_eq!(sanitize_path_component("a/b:c"), "a_b_c");
430    }
431
432    #[test]
433    fn test_memory_age() {
434        let now = SystemTime::now()
435            .duration_since(UNIX_EPOCH)
436            .unwrap()
437            .as_secs();
438        assert_eq!(memory_age_days(now), 0);
439        assert_eq!(memory_age_text(now), "today");
440        assert_eq!(memory_age_text(now - 86400), "yesterday");
441        assert_eq!(memory_age_text(now - 86400 * 5), "5 days ago");
442    }
443
444    #[test]
445    fn test_freshness_warning() {
446        let now = SystemTime::now()
447            .duration_since(UNIX_EPOCH)
448            .unwrap()
449            .as_secs();
450        assert!(memory_freshness_text(now).is_none()); // today = fresh
451        assert!(memory_freshness_text(now - 86400 * 3).is_some()); // 3 days = stale
452    }
453
454    #[test]
455    fn test_auto_memory_path() {
456        let path = auto_memory_path(Path::new("/Users/test/myproject"));
457        assert!(path.to_str().unwrap().contains("memory"));
458        assert!(path.to_str().unwrap().contains(".claude"));
459    }
460
461    #[test]
462    fn test_load_memory_file() {
463        let tmp = tempfile::tempdir().unwrap();
464        create_memory_file(
465            tmp.path(),
466            "test.md",
467            "---\nname: Test\ntype: user\n---\n\nContent here.",
468        );
469
470        let file = load_memory_file(&tmp.path().join("test.md"));
471        assert!(file.is_some());
472        let file = file.unwrap();
473        assert_eq!(file.meta.name.as_deref(), Some("Test"));
474        assert!(file.content.contains("Content here"));
475        assert!(!file.content.contains("name: Test")); // frontmatter stripped
476    }
477
478    #[test]
479    fn test_build_memory_prompt() {
480        let tmp = tempfile::tempdir().unwrap();
481        std::fs::write(
482            tmp.path().join("MEMORY.md"),
483            "- [Role](role.md) — my role\n- [Project](proj.md) — the project",
484        )
485        .unwrap();
486
487        let content = build_memory_prompt_content(tmp.path());
488        assert!(content.contains("Role"));
489        assert!(content.contains("Project"));
490    }
491}