Skip to main content

agent_code_lib/memory/
mod.rs

1//! Memory system — 3-layer architecture.
2//!
3//! **Layer 1 — Index (always loaded):**
4//! MEMORY.md contains one-line pointers to topic files. Capped at
5//! 200 lines / 25KB. Always in the system prompt.
6//!
7//! **Layer 2 — Topic files (on-demand):**
8//! Individual .md files with YAML frontmatter. Loaded selectively
9//! based on relevance to the current conversation.
10//!
11//! **Layer 3 — Transcripts (never loaded, only grepped):**
12//! Past session logs. Not loaded into context.
13//!
14//! # Write discipline
15//!
16//! 1. Write the memory file with frontmatter
17//! 2. Update MEMORY.md index with a one-line pointer
18//!
19//! Never dump content into the index.
20
21pub mod consolidation;
22pub mod extraction;
23pub mod scanner;
24pub mod session_notes;
25pub mod types;
26pub mod writer;
27
28use std::collections::HashSet;
29use std::path::{Path, PathBuf};
30
31use tracing::debug;
32
33const MAX_INDEX_LINES: usize = 200;
34const MAX_MEMORY_FILE_BYTES: usize = 25_000;
35
36#[derive(Debug, Clone, Default)]
37pub struct MemoryContext {
38    pub project_context: Option<String>,
39    pub user_memory: Option<String>,
40    pub memory_files: Vec<MemoryFile>,
41    pub surfaced: HashSet<PathBuf>,
42}
43
44#[derive(Debug, Clone)]
45pub struct MemoryFile {
46    pub path: PathBuf,
47    pub name: String,
48    pub content: String,
49    pub staleness: Option<String>,
50}
51
52impl MemoryContext {
53    pub fn load(project_root: Option<&Path>) -> Self {
54        let mut ctx = Self::default();
55        if let Some(root) = project_root {
56            ctx.project_context = load_project_context(root);
57        }
58        if let Some(memory_dir) = user_memory_dir() {
59            let index_path = memory_dir.join("MEMORY.md");
60            if index_path.exists() {
61                ctx.user_memory = load_truncated_file(&index_path);
62            }
63            if let Some(ref index) = ctx.user_memory {
64                ctx.memory_files = load_referenced_files(index, &memory_dir);
65            }
66        }
67        ctx
68    }
69
70    pub fn load_relevant(&mut self, recent_text: &str) {
71        let Some(memory_dir) = user_memory_dir() else {
72            return;
73        };
74        let headers = scanner::scan_memory_files(&memory_dir);
75        let relevant = scanner::select_relevant(&headers, recent_text, &self.surfaced);
76        for path in relevant {
77            if let Some(file) = load_memory_file_with_staleness(&path) {
78                self.surfaced.insert(path);
79                self.memory_files.push(file);
80            }
81        }
82    }
83
84    pub fn to_system_prompt_section(&self) -> String {
85        let mut section = String::new();
86        if let Some(ref project) = self.project_context
87            && !project.is_empty()
88        {
89            section.push_str("# Project Context\n\n");
90            section.push_str(project);
91            section.push_str("\n\n");
92        }
93        if let Some(ref memory) = self.user_memory
94            && !memory.is_empty()
95        {
96            section.push_str("# Memory Index\n\n");
97            section.push_str(memory);
98            section.push_str("\n\n");
99            section.push_str(
100                "_Memory is a hint, not truth. Verify against current state \
101                     before acting on remembered facts._\n\n",
102            );
103        }
104        for file in &self.memory_files {
105            section.push_str(&format!("## Memory: {}\n\n", file.name));
106            if let Some(ref warning) = file.staleness {
107                section.push_str(&format!("_{warning}_\n\n"));
108            }
109            section.push_str(&file.content);
110            section.push_str("\n\n");
111        }
112        section
113    }
114
115    pub fn is_empty(&self) -> bool {
116        self.project_context.is_none() && self.user_memory.is_none() && self.memory_files.is_empty()
117    }
118}
119
120/// Load project context by traversing the directory hierarchy.
121///
122/// Checks (in priority order, lowest to highest):
123/// 1. User global: ~/.config/agent-code/AGENTS.md
124/// 2. Project root: AGENTS.md, .agent/AGENTS.md (+ CLAUDE.md compat)
125/// 3. Project rules: .agent/rules/*.md AND .claude/rules/*.md
126/// 4. Project local: AGENTS.local.md / CLAUDE.local.md (gitignored)
127///
128/// CLAUDE.md is supported for compatibility with existing projects.
129/// If both AGENTS.md and CLAUDE.md exist, both are loaded (AGENTS.md first).
130fn load_project_context(project_root: &Path) -> Option<String> {
131    let mut sections = Vec::new();
132
133    // Layer 1: User global context.
134    for name in &["AGENTS.md", "CLAUDE.md"] {
135        if let Some(global_path) = dirs::config_dir().map(|d| d.join("agent-code").join(name))
136            && let Some(content) = load_truncated_file(&global_path)
137        {
138            debug!("Loaded global context from {}", global_path.display());
139            sections.push(content);
140        }
141    }
142
143    // Layer 2: Project root context (AGENTS.md primary, CLAUDE.md compat).
144    for path in &[
145        project_root.join("AGENTS.md"),
146        project_root.join(".agent").join("AGENTS.md"),
147        project_root.join("CLAUDE.md"),
148        project_root.join(".claude").join("CLAUDE.md"),
149    ] {
150        if let Some(content) = load_truncated_file(path) {
151            debug!("Loaded project context from {}", path.display());
152            sections.push(content);
153        }
154    }
155
156    // Layer 3: Rules directories (both .agent/ and .claude/ for compat).
157    for rules_dir in &[
158        project_root.join(".agent").join("rules"),
159        project_root.join(".claude").join("rules"),
160    ] {
161        if rules_dir.is_dir()
162            && let Ok(entries) = std::fs::read_dir(rules_dir)
163        {
164            let mut rule_files: Vec<_> = entries
165                .flatten()
166                .filter(|e| {
167                    e.path().extension().is_some_and(|ext| ext == "md") && e.path().is_file()
168                })
169                .collect();
170            rule_files.sort_by_key(|e| e.file_name());
171
172            for entry in rule_files {
173                if let Some(content) = load_truncated_file(&entry.path()) {
174                    debug!("Loaded rule from {}", entry.path().display());
175                    sections.push(content);
176                }
177            }
178        }
179    }
180
181    // Layer 4: Local overrides (gitignored).
182    for name in &["AGENTS.local.md", "CLAUDE.local.md"] {
183        let local_path = project_root.join(name);
184        if let Some(content) = load_truncated_file(&local_path) {
185            debug!("Loaded local context from {}", local_path.display());
186            sections.push(content);
187        }
188    }
189
190    if sections.is_empty() {
191        None
192    } else {
193        Some(sections.join("\n\n"))
194    }
195}
196
197fn load_truncated_file(path: &Path) -> Option<String> {
198    let content = std::fs::read_to_string(path).ok()?;
199    if content.is_empty() {
200        return None;
201    }
202
203    let mut result = content.clone();
204    let mut was_byte_truncated = false;
205
206    if result.len() > MAX_MEMORY_FILE_BYTES {
207        if let Some(pos) = result[..MAX_MEMORY_FILE_BYTES].rfind('\n') {
208            result.truncate(pos);
209        } else {
210            result.truncate(MAX_MEMORY_FILE_BYTES);
211        }
212        was_byte_truncated = true;
213    }
214
215    let lines: Vec<&str> = result.lines().collect();
216    let was_line_truncated = lines.len() > MAX_INDEX_LINES;
217    if was_line_truncated {
218        result = lines[..MAX_INDEX_LINES].join("\n");
219    }
220
221    if was_byte_truncated || was_line_truncated {
222        result.push_str("\n\n(truncated)");
223    }
224
225    Some(result)
226}
227
228fn load_memory_file_with_staleness(path: &Path) -> Option<MemoryFile> {
229    let content = load_truncated_file(path)?;
230    let name = path
231        .file_stem()
232        .and_then(|s| s.to_str())
233        .unwrap_or("unknown")
234        .to_string();
235
236    let staleness = std::fs::metadata(path)
237        .ok()
238        .and_then(|m| m.modified().ok())
239        .and_then(|modified| {
240            let age = std::time::SystemTime::now().duration_since(modified).ok()?;
241            types::staleness_caveat(age.as_secs())
242        });
243
244    Some(MemoryFile {
245        path: path.to_path_buf(),
246        name,
247        content,
248        staleness,
249    })
250}
251
252fn load_referenced_files(index: &str, base_dir: &Path) -> Vec<MemoryFile> {
253    let mut files = Vec::new();
254    let link_re = regex::Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap();
255
256    for captures in link_re.captures_iter(index) {
257        let name = captures.get(1).map(|m| m.as_str()).unwrap_or("");
258        let filename = captures.get(2).map(|m| m.as_str()).unwrap_or("");
259        if filename.is_empty() || !filename.ends_with(".md") {
260            continue;
261        }
262        let path = base_dir.join(filename);
263        if let Some(mut file) = load_memory_file_with_staleness(&path) {
264            file.name = name.to_string();
265            files.push(file);
266        }
267    }
268    files
269}
270
271fn user_memory_dir() -> Option<PathBuf> {
272    dirs::config_dir().map(|d| d.join("agent-code").join("memory"))
273}
274
275pub fn project_memory_dir(project_root: &Path) -> PathBuf {
276    project_root.join(".agent")
277}
278
279pub fn ensure_memory_dir() -> Option<PathBuf> {
280    let dir = user_memory_dir()?;
281    let _ = std::fs::create_dir_all(&dir);
282    Some(dir)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_load_truncated_file() {
291        let dir = tempfile::tempdir().unwrap();
292        let path = dir.path().join("test.md");
293        std::fs::write(&path, "a\n".repeat(300)).unwrap();
294        let loaded = load_truncated_file(&path).unwrap();
295        assert!(loaded.contains("truncated"));
296    }
297
298    #[test]
299    fn test_load_referenced_files() {
300        let dir = tempfile::tempdir().unwrap();
301        std::fs::write(dir.path().join("prefs.md"), "I prefer Rust").unwrap();
302        let index = "- [Preferences](prefs.md) — prefs\n- [Missing](gone.md) — gone";
303        let files = load_referenced_files(index, dir.path());
304        assert_eq!(files.len(), 1);
305        assert_eq!(files[0].name, "Preferences");
306    }
307}