agent_code_lib/memory/
mod.rs1pub 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)]
42pub struct MemoryContext {
43 pub project_context: Option<String>,
45 pub user_memory: Option<String>,
47 pub memory_files: Vec<MemoryFile>,
49 pub surfaced: HashSet<PathBuf>,
51}
52
53#[derive(Debug, Clone)]
55pub struct MemoryFile {
56 pub path: PathBuf,
58 pub name: String,
60 pub content: String,
62 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
134fn load_project_context(project_root: &Path) -> Option<String> {
145 let mut sections = Vec::new();
146
147 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 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 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 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
289pub fn project_memory_dir(project_root: &Path) -> PathBuf {
291 project_root.join(".agent")
292}
293
294pub 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}