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