Skip to main content

claude_wrapper/
memory.rs

1//! Read-side access to Claude Code's per-project **memory**
2//! directories.
3//!
4//! Claude Code's auto-memory persists facts per project under
5//! `~/.claude/projects/<slug>/memory/`: a `MEMORY.md` index (one
6//! line per memory, loaded into context each session) plus one fact
7//! per `<stem>.md` file with YAML frontmatter. A typical fact file:
8//!
9//! ```text
10//! ---
11//! name: cluster-72-canonical-slot-crash
12//! description: one-line summary used for recall relevance
13//! metadata:
14//!   type: project
15//! ---
16//!
17//! The fact body, with [[wiki-style]] links to other memories.
18//! ```
19//!
20//! This module is read-only on purpose, like the other
21//! introspection modules. The layout is undocumented Claude Code
22//! internal state (observed against CLI 2.1.219) and can change
23//! across CLI versions, so parsing is permissive: `name`,
24//! `description`, and the `type` under `metadata:` are typed
25//! (as plain strings); every other frontmatter key lands in
26//! [`Memory::extra`] verbatim.
27//!
28//! Three levels of granularity:
29//!
30//! - [`MemoryRoot::list_projects_with_memory`] -- which projects
31//!   have a memory directory at all.
32//! - [`MemoryRoot::list`] -- summaries of one project's memory
33//!   files.
34//! - [`MemoryRoot::get`] -- one memory's full record including the
35//!   body; [`MemoryRoot::index`] -- the raw `MEMORY.md`.
36//!
37//! # Example
38//!
39//! ```no_run
40//! use claude_wrapper::memory::MemoryRoot;
41//!
42//! # fn example() -> claude_wrapper::Result<()> {
43//! let root = MemoryRoot::home()?;
44//! for project in root.list_projects_with_memory()? {
45//!     println!("{}: {} memories", project.slug, project.entry_count);
46//!     for m in root.list(&project.slug)? {
47//!         println!("  {}: {}", m.name, m.description.as_deref().unwrap_or(""));
48//!     }
49//! }
50//! # Ok(()) }
51//! ```
52
53use std::collections::BTreeMap;
54use std::fs;
55use std::path::{Path, PathBuf};
56
57use serde::Serialize;
58
59use crate::artifacts::split_frontmatter;
60use crate::error::{Error, Result};
61
62/// Root directory of Claude Code's per-project state. Defaults to
63/// `~/.claude/projects` (memory directories live under each project
64/// slug); override with [`MemoryRoot::at`] for tests or non-default
65/// installs.
66#[derive(Debug, Clone)]
67pub struct MemoryRoot {
68    path: PathBuf,
69}
70
71impl MemoryRoot {
72    /// Resolve the default `~/.claude/projects`. Errors if `$HOME`
73    /// (or the platform-specific user home) cannot be determined.
74    pub fn home() -> Result<Self> {
75        let home = home_dir().ok_or_else(|| Error::Artifacts {
76            message: "could not determine user home directory".to_string(),
77        })?;
78        Ok(Self {
79            path: home.join(".claude").join("projects"),
80        })
81    }
82
83    /// Use a specific path as the projects root. Useful for tests
84    /// (point at a tempdir) and for non-default installs.
85    pub fn at(path: impl Into<PathBuf>) -> Self {
86        Self { path: path.into() }
87    }
88
89    /// The configured root directory.
90    pub fn path(&self) -> &Path {
91        &self.path
92    }
93
94    /// List every project slug that has a `memory/` directory,
95    /// sorted by slug. Projects without one are omitted; a missing
96    /// root returns an empty vec.
97    pub fn list_projects_with_memory(&self) -> Result<Vec<ProjectMemorySummary>> {
98        let entries = match fs::read_dir(&self.path) {
99            Ok(it) => it,
100            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
101            Err(e) => return Err(e.into()),
102        };
103        let mut out = Vec::new();
104        for entry in entries.flatten() {
105            let project_dir = entry.path();
106            if !project_dir.is_dir() {
107                continue;
108            }
109            let Some(slug) = project_dir.file_name().and_then(|s| s.to_str()) else {
110                continue;
111            };
112            let memory_dir = project_dir.join("memory");
113            if !memory_dir.is_dir() {
114                continue;
115            }
116            let entry_count = memory_files(&memory_dir).len();
117            let has_index = memory_dir.join("MEMORY.md").is_file();
118            out.push(ProjectMemorySummary {
119                slug: slug.to_string(),
120                memory_dir,
121                entry_count,
122                has_index,
123            });
124        }
125        out.sort_by(|a, b| a.slug.cmp(&b.slug));
126        Ok(out)
127    }
128
129    /// List one project's memory files, sorted by file stem.
130    /// `MEMORY.md` (the index) is excluded; read it with
131    /// [`Self::index`]. A project without a memory directory (or an
132    /// unknown slug) returns an empty vec. Files that fail to read
133    /// contribute a tracing warning and are skipped.
134    pub fn list(&self, slug: &str) -> Result<Vec<MemorySummary>> {
135        let memory_dir = self.path.join(slug).join("memory");
136        let mut out = Vec::new();
137        for path in memory_files(&memory_dir) {
138            match parse_memory_file(&path) {
139                Ok(memory) => out.push(MemorySummary::from_memory(&memory)),
140                Err(e) => tracing::warn!(?path, "skipping memory file: {e}"),
141            }
142        }
143        out.sort_by(|a, b| a.file_stem.cmp(&b.file_stem));
144        Ok(out)
145    }
146
147    /// Read one memory by file stem (the basename of `<stem>.md`
148    /// under the project's memory directory). Errors if no such
149    /// file exists.
150    pub fn get(&self, slug: &str, file_stem: &str) -> Result<Memory> {
151        let path = self
152            .path
153            .join(slug)
154            .join("memory")
155            .join(format!("{file_stem}.md"));
156        if !path.is_file() {
157            return Err(Error::Artifacts {
158                message: format!("no memory at {}", path.display()),
159            });
160        }
161        parse_memory_file(&path)
162    }
163
164    /// The raw `MEMORY.md` index content for one project, or `None`
165    /// when the project has no memory directory or no index file.
166    pub fn index(&self, slug: &str) -> Result<Option<String>> {
167        let path = self.path.join(slug).join("memory").join("MEMORY.md");
168        if !path.is_file() {
169            return Ok(None);
170        }
171        Ok(Some(fs::read_to_string(&path)?))
172    }
173}
174
175/// One project that has a memory directory, returned by
176/// [`MemoryRoot::list_projects_with_memory`].
177#[derive(Debug, Clone, Serialize)]
178pub struct ProjectMemorySummary {
179    /// Project slug (the encoded-path directory name).
180    pub slug: String,
181    /// Absolute path of the `memory/` directory.
182    pub memory_dir: PathBuf,
183    /// Number of memory files (excluding `MEMORY.md`).
184    pub entry_count: usize,
185    /// Whether a `MEMORY.md` index is present.
186    pub has_index: bool,
187}
188
189/// Lightweight metadata for one memory file, returned by
190/// [`MemoryRoot::list`]. Strips the body to keep listings cheap.
191#[derive(Debug, Clone, Serialize)]
192pub struct MemorySummary {
193    /// File stem (the basename of `<stem>.md`). The canonical
194    /// handle for [`MemoryRoot::get`].
195    pub file_stem: String,
196    /// Frontmatter `name` if present; falls back to `file_stem`.
197    pub name: String,
198    /// Frontmatter `description` if present.
199    pub description: Option<String>,
200    /// The `type` recorded under `metadata:` (`user`, `feedback`,
201    /// `project`, `reference`, or anything future), carried as a
202    /// plain string.
203    pub memory_type: Option<String>,
204    /// Absolute path to the source `.md`.
205    pub file_path: PathBuf,
206    /// File size in bytes.
207    pub size_bytes: u64,
208}
209
210impl MemorySummary {
211    fn from_memory(m: &Memory) -> Self {
212        let size_bytes = fs::metadata(&m.file_path)
213            .map(|meta| meta.len())
214            .unwrap_or_default();
215        Self {
216            file_stem: m.file_stem.clone(),
217            name: m.name.clone(),
218            description: m.description.clone(),
219            memory_type: m.memory_type.clone(),
220            file_path: m.file_path.clone(),
221            size_bytes,
222        }
223    }
224}
225
226/// Full memory record returned by [`MemoryRoot::get`].
227#[derive(Debug, Clone, Serialize)]
228pub struct Memory {
229    /// File stem (the basename of `<stem>.md`). The canonical
230    /// handle for lookup.
231    pub file_stem: String,
232    /// Frontmatter `name` if present; falls back to `file_stem`.
233    pub name: String,
234    /// Frontmatter `description` if present.
235    pub description: Option<String>,
236    /// The `type` recorded under `metadata:`, as a plain string.
237    pub memory_type: Option<String>,
238    /// Absolute path to the source `.md`.
239    pub file_path: PathBuf,
240    /// Markdown body after the frontmatter block (trimmed of
241    /// leading/trailing blank lines). `[[wiki-style]]` links are
242    /// left verbatim.
243    pub body: String,
244    /// Frontmatter keys other than the typed ones, flattened line
245    /// by line (nested YAML keys appear under their own names).
246    /// Preserves unknown future fields verbatim as raw strings.
247    pub extra: BTreeMap<String, String>,
248}
249
250/// Memory fact files in a directory: direct children matching
251/// `*.md`, excluding the `MEMORY.md` index. Missing or unreadable
252/// directories yield an empty list.
253fn memory_files(dir: &Path) -> Vec<PathBuf> {
254    let mut out = Vec::new();
255    if let Ok(entries) = fs::read_dir(dir) {
256        for entry in entries.flatten() {
257            let path = entry.path();
258            if !path.is_file() {
259                continue;
260            }
261            if path.extension().and_then(|s| s.to_str()) != Some("md") {
262                continue;
263            }
264            if path.file_name().and_then(|s| s.to_str()) == Some("MEMORY.md") {
265                continue;
266            }
267            out.push(path);
268        }
269    }
270    out
271}
272
273fn parse_memory_file(file_path: &Path) -> Result<Memory> {
274    let file_stem = file_path
275        .file_stem()
276        .and_then(|s| s.to_str())
277        .unwrap_or_default()
278        .to_string();
279    let raw = fs::read_to_string(file_path)?;
280    let (frontmatter, body) = split_frontmatter(&raw);
281
282    let mut name = file_stem.clone();
283    let mut description = None;
284    let mut memory_type = None;
285    let mut extra = BTreeMap::new();
286
287    if let Some(fm) = frontmatter {
288        for line in fm.lines() {
289            let trimmed = line.trim();
290            if trimmed.is_empty() {
291                continue;
292            }
293            let Some((k, v)) = trimmed.split_once(':') else {
294                continue;
295            };
296            let key = k.trim();
297            let value = unquote(v.trim()).to_string();
298            match key {
299                "name" if !value.is_empty() => name = value,
300                "description" if !value.is_empty() => description = Some(value),
301                // The line-based parse flattens the nested
302                // `metadata:` block, so its `type:` arrives as a
303                // bare key.
304                "type" if !value.is_empty() => memory_type = Some(value),
305                _ if !key.is_empty() && !value.is_empty() => {
306                    extra.insert(key.to_string(), value);
307                }
308                _ => {}
309            }
310        }
311    }
312
313    Ok(Memory {
314        file_stem,
315        name,
316        description,
317        memory_type,
318        file_path: file_path.to_path_buf(),
319        body: body.trim().to_string(),
320        extra,
321    })
322}
323
324/// Strip one pair of matching surrounding double quotes, if
325/// present. Frontmatter values are sometimes written quoted (e.g.
326/// descriptions containing punctuation).
327fn unquote(value: &str) -> &str {
328    value
329        .strip_prefix('"')
330        .and_then(|v| v.strip_suffix('"'))
331        .unwrap_or(value)
332}
333
334fn home_dir() -> Option<PathBuf> {
335    if let Ok(h) = std::env::var("HOME")
336        && !h.is_empty()
337    {
338        return Some(PathBuf::from(h));
339    }
340    if let Ok(h) = std::env::var("USERPROFILE")
341        && !h.is_empty()
342    {
343        return Some(PathBuf::from(h));
344    }
345    None
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use std::io::Write;
352
353    fn write_memory(root: &Path, slug: &str, stem: &str, contents: &str) -> PathBuf {
354        let dir = root.join(slug).join("memory");
355        fs::create_dir_all(&dir).expect("create memory dir");
356        let path = dir.join(format!("{stem}.md"));
357        let mut f = fs::File::create(&path).expect("create memory file");
358        f.write_all(contents.as_bytes()).expect("write memory file");
359        path
360    }
361
362    fn fixture_root() -> tempfile::TempDir {
363        let tmp = tempfile::tempdir().expect("tempdir");
364        write_memory(
365            tmp.path(),
366            "-Users-me-Code-projA",
367            "user-name",
368            "---\nname: user-name\ndescription: \"preferred name - quoted\"\nmetadata:\n  type: user\n---\n\nThe user goes by Zed. See [[other-memory]].\n",
369        );
370        write_memory(
371            tmp.path(),
372            "-Users-me-Code-projA",
373            "no-frontmatter",
374            "Just a body.\n",
375        );
376        fs::write(
377            tmp.path()
378                .join("-Users-me-Code-projA")
379                .join("memory")
380                .join("MEMORY.md"),
381            "# Memory index\n\n- [User name](user-name.md)\n",
382        )
383        .unwrap();
384        // A project without a memory directory.
385        fs::create_dir_all(tmp.path().join("-Users-me-Code-projB")).unwrap();
386        tmp
387    }
388
389    #[test]
390    fn list_projects_with_memory_omits_projects_without() {
391        let tmp = fixture_root();
392        let root = MemoryRoot::at(tmp.path());
393        let projects = root.list_projects_with_memory().expect("list");
394        assert_eq!(projects.len(), 1);
395        assert_eq!(projects[0].slug, "-Users-me-Code-projA");
396        assert_eq!(projects[0].entry_count, 2);
397        assert!(projects[0].has_index);
398    }
399
400    #[test]
401    fn list_projects_missing_root_returns_empty() {
402        let tmp = tempfile::tempdir().unwrap();
403        let root = MemoryRoot::at(tmp.path().join("does-not-exist"));
404        assert!(root.list_projects_with_memory().expect("ok").is_empty());
405    }
406
407    #[test]
408    fn list_excludes_index_and_parses_metadata() {
409        let tmp = fixture_root();
410        let root = MemoryRoot::at(tmp.path());
411        let memories = root.list("-Users-me-Code-projA").expect("list");
412        let stems: Vec<&str> = memories.iter().map(|m| m.file_stem.as_str()).collect();
413        assert_eq!(stems, ["no-frontmatter", "user-name"]);
414        let m = memories
415            .iter()
416            .find(|m| m.file_stem == "user-name")
417            .unwrap();
418        assert_eq!(m.name, "user-name");
419        assert_eq!(m.description.as_deref(), Some("preferred name - quoted"));
420        assert_eq!(m.memory_type.as_deref(), Some("user"));
421        assert!(m.size_bytes > 0);
422    }
423
424    #[test]
425    fn list_unknown_slug_returns_empty() {
426        let tmp = fixture_root();
427        let root = MemoryRoot::at(tmp.path());
428        assert!(root.list("nope").expect("ok").is_empty());
429        assert!(root.list("-Users-me-Code-projB").expect("ok").is_empty());
430    }
431
432    #[test]
433    fn get_returns_body_and_falls_back_to_stem() {
434        let tmp = fixture_root();
435        let root = MemoryRoot::at(tmp.path());
436        let m = root.get("-Users-me-Code-projA", "user-name").expect("get");
437        assert!(m.body.contains("[[other-memory]]"));
438        let nf = root
439            .get("-Users-me-Code-projA", "no-frontmatter")
440            .expect("get");
441        assert_eq!(nf.name, "no-frontmatter");
442        assert_eq!(nf.memory_type, None);
443        assert_eq!(nf.body, "Just a body.");
444    }
445
446    #[test]
447    fn get_unknown_stem_errors() {
448        let tmp = fixture_root();
449        let root = MemoryRoot::at(tmp.path());
450        let err = root.get("-Users-me-Code-projA", "nope").unwrap_err();
451        assert!(err.to_string().contains("no memory at"));
452    }
453
454    #[test]
455    fn index_reads_memory_md_or_none() {
456        let tmp = fixture_root();
457        let root = MemoryRoot::at(tmp.path());
458        let idx = root.index("-Users-me-Code-projA").expect("ok");
459        assert!(idx.expect("present").contains("# Memory index"));
460        assert!(root.index("-Users-me-Code-projB").expect("ok").is_none());
461        assert!(root.index("nope").expect("ok").is_none());
462    }
463
464    #[test]
465    fn unknown_frontmatter_keys_land_in_extra() {
466        let tmp = tempfile::tempdir().unwrap();
467        write_memory(
468            tmp.path(),
469            "-slug",
470            "weird",
471            "---\nname: weird\nmetadata:\n  type: reference\n  originSessionId: abc\ncustom: kept\n---\nbody\n",
472        );
473        let root = MemoryRoot::at(tmp.path());
474        let m = root.get("-slug", "weird").expect("get");
475        assert_eq!(m.memory_type.as_deref(), Some("reference"));
476        assert_eq!(
477            m.extra.get("originSessionId").map(String::as_str),
478            Some("abc")
479        );
480        assert_eq!(m.extra.get("custom").map(String::as_str), Some("kept"));
481        // The bare `metadata:` container line has no value; dropped.
482        assert!(!m.extra.contains_key("metadata"));
483    }
484}