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)]
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
120fn load_project_context(project_root: &Path) -> Option<String> {
131 let mut sections = Vec::new();
132
133 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 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 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 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}