Skip to main content

ai_agent/memdir/
memory_scan.rs

1//! Memory directory scanning primitives.
2
3use std::fs;
4use std::path::Path;
5
6use crate::memdir::memory_types::{MemoryType, parse_frontmatter};
7
8/// Maximum number of memory files to scan
9pub const MAX_MEMORY_FILES: usize = 200;
10/// Maximum lines to read for frontmatter parsing
11const FRONTMATTER_MAX_LINES: usize = 30;
12
13/// Header information for a memory file
14#[derive(Debug, Clone)]
15pub struct MemoryHeader {
16    pub filename: String,
17    pub file_path: String,
18    pub mtime_ms: u64,
19    pub description: Option<String>,
20    pub memory_type: Option<MemoryType>,
21}
22
23/// Scan a memory directory for .md files, read their frontmatter,
24/// and return a header list sorted newest-first (capped at MAX_MEMORY_FILES).
25pub async fn scan_memory_files(memory_dir: &str) -> Vec<MemoryHeader> {
26    let path = Path::new(memory_dir);
27
28    if !path.exists() {
29        return Vec::new();
30    }
31
32    let mut md_files = Vec::new();
33
34    if let Ok(entries) = fs::read_dir(path) {
35        for entry in entries.flatten() {
36            let file_path = entry.path();
37            if file_path.is_file() {
38                if let Some(ext) = file_path.extension() {
39                    if ext == "md" {
40                        if let Some(name) = file_path.file_name() {
41                            let name_str = name.to_string_lossy();
42                            // Exclude MEMORY.md (already loaded in system prompt)
43                            if name_str != "MEMORY.md" {
44                                md_files.push(file_path);
45                            }
46                        }
47                    }
48                }
49            }
50        }
51    }
52
53    let mut headers = Vec::new();
54
55    for file_path in md_files {
56        if let Some(filename) = file_path.file_name() {
57            let filename_str = filename.to_string_lossy().to_string();
58            let file_path_str = file_path.to_string_lossy().to_string();
59
60            // Get modification time
61            let mtime_ms = file_path
62                .metadata()
63                .ok()
64                .and_then(|m| m.modified().ok())
65                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
66                .map(|d| d.as_millis() as u64)
67                .unwrap_or(0);
68
69            // Read frontmatter
70            let (description, memory_type) = if let Ok(content) = read_frontmatter_lines(&file_path)
71            {
72                if let Some(fm) = parse_frontmatter(&content) {
73                    (Some(fm.description), Some(fm.memory_type))
74                } else {
75                    (None, None)
76                }
77            } else {
78                (None, None)
79            };
80
81            headers.push(MemoryHeader {
82                filename: filename_str,
83                file_path: file_path_str,
84                mtime_ms,
85                description,
86                memory_type,
87            });
88        }
89    }
90
91    // Sort by mtime newest-first
92    headers.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
93
94    // Cap at MAX_MEMORY_FILES
95    headers.truncate(MAX_MEMORY_FILES);
96
97    headers
98}
99
100/// Read only the first N lines of a file (for frontmatter parsing)
101fn read_frontmatter_lines(path: &Path) -> std::io::Result<String> {
102    use std::io::{BufRead, BufReader};
103
104    let file = fs::File::open(path)?;
105    let reader = BufReader::new(file);
106
107    let mut lines = Vec::new();
108    for (i, line) in reader.lines().enumerate() {
109        if i >= FRONTMATTER_MAX_LINES {
110            break;
111        }
112        if let Ok(l) = line {
113            lines.push(l);
114        }
115    }
116
117    Ok(lines.join("\n"))
118}
119
120/// Format memory headers as a text manifest:
121/// one line per file with [type] filename (timestamp): description
122pub fn format_memory_manifest(memories: &[MemoryHeader]) -> String {
123    memories
124        .iter()
125        .map(|m| {
126            let tag = m
127                .memory_type
128                .as_ref()
129                .map(|t| format!("[{}] ", t.as_str()))
130                .unwrap_or_default();
131
132            let ts = chrono::DateTime::from_timestamp_millis(m.mtime_ms as i64)
133                .map(|dt| dt.to_rfc3339())
134                .unwrap_or_else(|| "unknown".to_string());
135
136            match &m.description {
137                Some(desc) => format!("- {}{} ({}): {}", tag, m.filename, ts, desc),
138                None => format!("- {}{} ({})", tag, m.filename, ts),
139            }
140        })
141        .collect::<Vec<_>>()
142        .join("\n")
143}
144
145#[cfg(test)]
146#[allow(unused_imports)]
147mod tests {
148    use super::*;
149    use tempfile::TempDir;
150
151    #[test]
152    fn test_format_memory_manifest() {
153        let headers = vec![
154            MemoryHeader {
155                filename: "user_test.md".to_string(),
156                file_path: "/tmp/memory/user_test.md".to_string(),
157                mtime_ms: 1700000000000,
158                description: Some("Test description".to_string()),
159                memory_type: Some(MemoryType::User),
160            },
161            MemoryHeader {
162                filename: "feedback_test.md".to_string(),
163                file_path: "/tmp/memory/feedback_test.md".to_string(),
164                mtime_ms: 1700000000000,
165                description: None,
166                memory_type: Some(MemoryType::Feedback),
167            },
168        ];
169
170        let manifest = format_memory_manifest(&headers);
171        assert!(manifest.contains("user_test.md"));
172        assert!(manifest.contains("Test description"));
173        assert!(manifest.contains("[user]"));
174    }
175
176    #[tokio::test]
177    async fn test_scan_empty_directory() {
178        let temp_dir = TempDir::new().unwrap();
179        let result = scan_memory_files(temp_dir.path().to_str().unwrap()).await;
180        assert!(result.is_empty());
181    }
182
183    #[tokio::test]
184    async fn test_scan_nonexistent_directory() {
185        let result = scan_memory_files("/nonexistent/path").await;
186        assert!(result.is_empty());
187    }
188}