Skip to main content

batuta/agent/
auto_memory.rs

1//! Per-project auto-memory loader (PMAT-CODE-MEMORY-AUTO-001).
2//!
3//! Mirrors Claude Code's auto-memory mechanism: every project has an
4//! associated directory under `~/.config/apr/projects/<slug>/memory/`
5//! containing freely-named markdown files. When `apr code` starts
6//! against a given working directory, every `*.md` in that directory
7//! is concatenated into the system prompt under a `## Auto-memory`
8//! section. This lets the user (or apr itself) accumulate
9//! per-project notes — facts about the user, project state,
10//! feedback, references — without ever editing a single CLAUDE.md.
11//!
12//! ## Path slug
13//!
14//! Claude Code uses a hyphenated path slug like
15//! `-home-noah-src-aprender` (leading dash + every `/` → `-`). We
16//! keep the same slug for cross-tool compatibility: a user moving
17//! from Claude Code to apr keeps their auto-memory by symlinking
18//! `~/.claude/projects/` ↔ `~/.config/apr/projects/`.
19//!
20//! ## File ordering
21//!
22//! Files are loaded in lexicographic order so `MEMORY.md` lands first
23//! (uppercase `M` < lowercase `f`/`p`/`r`/`u` ASCII) and the rest
24//! sort by name. A file named `MEMORY.md` is therefore the canonical
25//! "top-of-memory index" matching Claude's convention.
26//!
27//! ## Pure-function design
28//!
29//! No terminal I/O; warnings are returned via `&mut Vec<String>` for
30//! the caller to route. No filesystem mutation — read-only loader.
31
32use std::path::{Path, PathBuf};
33
34/// Path-slug Claude Code uses for the project directory under
35/// `~/.config/apr/projects/`. Format: leading `-`, then every path
36/// separator replaced with `-`. Non-absolute paths get prefixed with
37/// the host's HOME (Claude's behavior) so a relative `cwd` still
38/// produces a stable slug.
39pub fn project_slug(cwd: &Path) -> String {
40    let abs = if cwd.is_absolute() {
41        cwd.to_path_buf()
42    } else {
43        // Best-effort canonicalization; fall back to verbatim if cwd
44        // can't be resolved (e.g. the path doesn't exist).
45        std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())
46    };
47    let s = abs.to_string_lossy();
48    // Each `/` (or `\` on Windows) → `-`. For absolute paths, the
49    // leading `/` becomes the leading `-` automatically; relative
50    // paths get an explicit leading `-` prefix so the slug always
51    // starts with `-` (matches Claude Code's emit). We avoid
52    // double-dashing for absolute paths.
53    let mut out = String::with_capacity(s.len() + 1);
54    let starts_with_sep = s.starts_with('/') || s.starts_with('\\');
55    if !starts_with_sep {
56        out.push('-');
57    }
58    for ch in s.chars() {
59        if ch == '/' || ch == '\\' {
60            out.push('-');
61        } else {
62            out.push(ch);
63        }
64    }
65    out
66}
67
68/// Auto-memory root directory: `$APR_CONFIG/projects` if the env var
69/// is set (override; exclusive — same Poka-Yoke as the settings
70/// ladder), otherwise `~/.config/apr/projects`.
71pub fn auto_memory_root() -> Option<PathBuf> {
72    if let Ok(custom) = std::env::var("APR_CONFIG") {
73        if !custom.is_empty() {
74            return Some(PathBuf::from(custom).join("projects"));
75        }
76    }
77    dirs::config_dir().map(|d| d.join("apr").join("projects"))
78}
79
80/// Per-project memory directory: `<root>/<slug>/memory/`.
81pub fn project_memory_dir(cwd: &Path) -> Option<PathBuf> {
82    auto_memory_root().map(|r| r.join(project_slug(cwd)).join("memory"))
83}
84
85/// Load every `*.md` file in the project's memory directory in
86/// lexicographic order. Returns `None` when the directory doesn't
87/// exist or has no markdown files (so the caller can omit the
88/// `## Auto-memory` heading entirely instead of emitting an empty
89/// section).
90///
91/// Per-file read errors emit a warning and are skipped; one bad
92/// file doesn't take down the whole load.
93pub fn load_auto_memory(cwd: &Path, warnings: &mut Vec<String>) -> Option<String> {
94    let dir = project_memory_dir(cwd)?;
95    if !dir.is_dir() {
96        return None;
97    }
98    let mut entries: Vec<PathBuf> = match std::fs::read_dir(&dir) {
99        Ok(rd) => rd
100            .flatten()
101            .map(|e| e.path())
102            .filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "md"))
103            .collect(),
104        Err(e) => {
105            warnings.push(format!("auto-memory: read_dir({}) failed: {e}", dir.display()));
106            return None;
107        }
108    };
109    entries.sort();
110    if entries.is_empty() {
111        return None;
112    }
113    let mut out = String::new();
114    for path in &entries {
115        match std::fs::read_to_string(path) {
116            Ok(body) => {
117                let name =
118                    path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
119                if !out.is_empty() && !out.ends_with("\n\n") {
120                    out.push('\n');
121                }
122                out.push_str(&format!("### {name}\n\n"));
123                out.push_str(&body);
124                if !out.ends_with('\n') {
125                    out.push('\n');
126                }
127            }
128            Err(e) => {
129                warnings.push(format!("auto-memory: read({}) failed: {e}", path.display()));
130            }
131        }
132    }
133    if out.is_empty() {
134        None
135    } else {
136        Some(out)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::fs;
144    use std::path::Path;
145
146    fn write(path: &Path, body: &str) {
147        if let Some(p) = path.parent() {
148            fs::create_dir_all(p).expect("mkdir");
149        }
150        fs::write(path, body).expect("write");
151    }
152
153    // CI flake prevention: tests below mutate the process-wide
154    // `APR_CONFIG` env var. Same Mutex pattern as
155    // agent::settings::tests + agent::instructions::tests.
156    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
157        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
158        LOCK.lock().unwrap_or_else(|e| e.into_inner())
159    }
160
161    // ── project_slug ───────────────────────────────────────────────
162
163    #[test]
164    fn slug_for_absolute_path() {
165        let s = project_slug(Path::new("/home/noah/src/aprender"));
166        assert_eq!(s, "-home-noah-src-aprender");
167    }
168
169    #[test]
170    fn slug_for_root() {
171        let s = project_slug(Path::new("/"));
172        // "/" → "-" (single slash)
173        assert_eq!(s, "-");
174    }
175
176    #[test]
177    fn slug_with_dots_preserved() {
178        // Claude doesn't sanitize dots; only `/` becomes `-`.
179        let s = project_slug(Path::new("/tmp/a.b.c"));
180        assert_eq!(s, "-tmp-a.b.c");
181    }
182
183    #[test]
184    fn slug_strips_trailing_slash() {
185        // canonicalize won't add it, but if cwd ends in `/`, the
186        // string conversion preserves it. We accept the `-` at the
187        // tail since matching Claude's exact emit is more important
188        // than aesthetics.
189        let s = project_slug(Path::new("/tmp/x/"));
190        assert!(s == "-tmp-x-" || s == "-tmp-x", "got {s:?}");
191    }
192
193    // ── auto_memory_root ───────────────────────────────────────────
194
195    #[test]
196    fn root_honors_apr_config_env() {
197        let _guard = env_lock();
198        let dir = tempfile::tempdir().expect("tempdir");
199        std::env::set_var("APR_CONFIG", dir.path());
200        let r = auto_memory_root().expect("root resolved");
201        std::env::remove_var("APR_CONFIG");
202        assert_eq!(r, dir.path().join("projects"));
203    }
204
205    #[test]
206    fn root_uses_config_dir_when_env_unset() {
207        let _guard = env_lock();
208        std::env::remove_var("APR_CONFIG");
209        let r = auto_memory_root().expect("root resolved on supported platform");
210        // Should end in apr/projects regardless of host home.
211        assert!(r.ends_with("apr/projects"), "unexpected root: {r:?}");
212    }
213
214    // ── project_memory_dir ─────────────────────────────────────────
215
216    #[test]
217    fn project_memory_dir_layout() {
218        let _guard = env_lock();
219        let cfg = tempfile::tempdir().expect("cfg");
220        std::env::set_var("APR_CONFIG", cfg.path());
221        let dir = project_memory_dir(Path::new("/tmp/myproj")).expect("dir");
222        std::env::remove_var("APR_CONFIG");
223        assert_eq!(dir, cfg.path().join("projects").join("-tmp-myproj").join("memory"));
224    }
225
226    // ── load_auto_memory ───────────────────────────────────────────
227
228    #[test]
229    fn load_returns_none_when_no_dir() {
230        let _guard = env_lock();
231        let cfg = tempfile::tempdir().expect("cfg");
232        std::env::set_var("APR_CONFIG", cfg.path());
233        let mut warns = Vec::new();
234        // Project memory dir doesn't exist (no `projects/<slug>/memory/`).
235        let out = load_auto_memory(Path::new("/tmp/never"), &mut warns);
236        std::env::remove_var("APR_CONFIG");
237        assert!(out.is_none());
238        assert!(warns.is_empty());
239    }
240
241    #[test]
242    fn load_returns_none_when_dir_empty() {
243        let _guard = env_lock();
244        let cfg = tempfile::tempdir().expect("cfg");
245        std::env::set_var("APR_CONFIG", cfg.path());
246        let mem_dir = cfg.path().join("projects").join("-tmp-x").join("memory");
247        fs::create_dir_all(&mem_dir).expect("mkdir");
248        let mut warns = Vec::new();
249        let out = load_auto_memory(Path::new("/tmp/x"), &mut warns);
250        std::env::remove_var("APR_CONFIG");
251        assert!(out.is_none(), "empty memory dir → None, got: {out:?}");
252        assert!(warns.is_empty());
253    }
254
255    #[test]
256    fn load_concatenates_md_files_in_lex_order() {
257        let _guard = env_lock();
258        let cfg = tempfile::tempdir().expect("cfg");
259        let mem_dir = cfg.path().join("projects").join("-tmp-y").join("memory");
260        write(&mem_dir.join("MEMORY.md"), "# Top-of-memory index\n");
261        write(&mem_dir.join("zzz_user.md"), "User notes\n");
262        write(&mem_dir.join("feedback_x.md"), "Feedback X\n");
263        std::env::set_var("APR_CONFIG", cfg.path());
264        let mut warns = Vec::new();
265        let out = load_auto_memory(Path::new("/tmp/y"), &mut warns).expect("loaded");
266        std::env::remove_var("APR_CONFIG");
267        assert!(warns.is_empty());
268        // MEMORY.md (uppercase M) sorts before lowercase f/z.
269        let memory_idx = out.find("Top-of-memory index").expect("MEMORY present");
270        let feedback_idx = out.find("Feedback X").expect("feedback present");
271        let user_idx = out.find("User notes").expect("user present");
272        assert!(memory_idx < feedback_idx, "MEMORY.md must come first");
273        assert!(feedback_idx < user_idx, "feedback < user lexicographically");
274        // Each file gets a `### <name>` heading.
275        assert!(out.contains("### MEMORY.md"));
276        assert!(out.contains("### feedback_x.md"));
277        assert!(out.contains("### zzz_user.md"));
278    }
279
280    #[test]
281    fn load_skips_non_md_files() {
282        let _guard = env_lock();
283        let cfg = tempfile::tempdir().expect("cfg");
284        let mem_dir = cfg.path().join("projects").join("-tmp-skip").join("memory");
285        write(&mem_dir.join("note.md"), "kept\n");
286        write(&mem_dir.join("note.txt"), "skipped\n");
287        write(&mem_dir.join("note.json"), "skipped\n");
288        std::env::set_var("APR_CONFIG", cfg.path());
289        let mut warns = Vec::new();
290        let out = load_auto_memory(Path::new("/tmp/skip"), &mut warns).expect("loaded");
291        std::env::remove_var("APR_CONFIG");
292        assert!(out.contains("kept"));
293        assert!(!out.contains("skipped"), "non-md files must NOT be loaded");
294    }
295
296    #[test]
297    fn load_skips_subdirectories() {
298        let _guard = env_lock();
299        let cfg = tempfile::tempdir().expect("cfg");
300        let mem_dir = cfg.path().join("projects").join("-tmp-sub").join("memory");
301        write(&mem_dir.join("ok.md"), "ok-content\n");
302        // Subdirectory under memory/ — must not recurse.
303        fs::create_dir_all(mem_dir.join("nested")).expect("mkdir nested");
304        write(&mem_dir.join("nested").join("hidden.md"), "hidden-content\n");
305        std::env::set_var("APR_CONFIG", cfg.path());
306        let mut warns = Vec::new();
307        let out = load_auto_memory(Path::new("/tmp/sub"), &mut warns).expect("loaded");
308        std::env::remove_var("APR_CONFIG");
309        assert!(out.contains("ok-content"));
310        assert!(!out.contains("hidden-content"), "must not recurse into subdirs");
311    }
312}