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