Skip to main content

lore_cli/capture/
memory.rs

1//! Read-only mirror of a coding tool's per-project memory store.
2//!
3//! Coding tools such as Claude Code write per-project "memory" (running notes,
4//! next steps, corrections) into their own private stores that other tools
5//! cannot see. This module mirrors those files into Lore's `memories` table so
6//! any LLM can read them through Lore's MCP server.
7//!
8//! The mirror is strictly READ-ONLY: it never creates, modifies, or deletes
9//! files in the tool's memory folder. It only reflects the current folder
10//! state into the database, adding new memories, updating changed ones, and
11//! removing memories whose source file no longer exists.
12//!
13//! Claude Code stores per-project data under `~/.claude/projects/<slug>/` where
14//! `<slug>` is the project's absolute path with the path separator replaced by
15//! `-` (for example `/Users/me/proj` becomes `-Users-me-proj`). Sessions live
16//! in the `sessions/` folder; memory lives in the sibling `memory/` folder as a
17//! `MEMORY.md` index plus per-fact markdown files. Each fact file carries YAML
18//! frontmatter with `name`, `description`, and `metadata.type`, followed by the
19//! fact body.
20
21use std::collections::HashSet;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result};
26use chrono::{DateTime, Utc};
27use serde::Deserialize;
28use uuid::Uuid;
29
30use crate::storage::{Database, Memory};
31
32/// The source-tool identifier used for Claude Code memories.
33pub const CLAUDE_CODE_TOOL: &str = "claude-code";
34
35/// Frontmatter parsed from a memory markdown file.
36#[derive(Debug, Default, Deserialize)]
37struct Frontmatter {
38    /// Short name of the memory.
39    #[serde(default)]
40    name: Option<String>,
41
42    /// Human-readable description of the memory.
43    #[serde(default)]
44    description: Option<String>,
45
46    /// Nested metadata block (holds the memory type).
47    #[serde(default)]
48    metadata: Option<FrontmatterMetadata>,
49}
50
51/// The `metadata` block within a memory's frontmatter.
52#[derive(Debug, Default, Deserialize)]
53struct FrontmatterMetadata {
54    /// The memory type (e.g., user, feedback, project, reference).
55    #[serde(default, rename = "type")]
56    memory_type: Option<String>,
57}
58
59/// A memory parsed from a single markdown file on disk.
60#[derive(Debug, Clone, PartialEq)]
61pub struct ParsedMemory {
62    /// Short name (frontmatter `name` or the file stem).
63    pub name: String,
64
65    /// Optional description from frontmatter.
66    pub description: Option<String>,
67
68    /// Optional memory type from `metadata.type`.
69    pub memory_type: Option<String>,
70
71    /// The memory body following any frontmatter.
72    pub content: String,
73
74    /// Absolute path of the source file.
75    pub file_path: String,
76
77    /// Source file modification time.
78    pub updated_at: DateTime<Utc>,
79}
80
81/// Statistics describing what a single refresh changed.
82#[derive(Debug, Default, Clone, PartialEq, Eq)]
83pub struct MirrorStats {
84    /// Number of memories added or updated from the folder.
85    pub upserted: usize,
86
87    /// Number of memories removed because their source file was gone.
88    pub removed: usize,
89}
90
91/// Mirrors a coding tool's memory folder into Lore's database.
92///
93/// The base directory (the tool's per-project root) is injectable so tests can
94/// point the mirror at a temporary folder instead of the real `~/.claude`.
95pub struct MemoryMirror {
96    /// The tool's per-project storage root (e.g., `~/.claude/projects`).
97    base_dir: PathBuf,
98
99    /// The source-tool identifier stored on mirrored memories.
100    source_tool: String,
101}
102
103impl MemoryMirror {
104    /// Creates a mirror for Claude Code, reading from `~/.claude/projects`.
105    pub fn claude() -> Self {
106        Self {
107            base_dir: claude_projects_dir(),
108            source_tool: CLAUDE_CODE_TOOL.to_string(),
109        }
110    }
111
112    /// Creates a mirror with an explicit base directory and source tool.
113    ///
114    /// Intended for tests that point the mirror at a temporary folder instead
115    /// of the real `~/.claude`, so tests never touch a developer's real memory
116    /// store.
117    #[cfg(test)]
118    pub fn with_base_dir(base_dir: impl Into<PathBuf>, source_tool: impl Into<String>) -> Self {
119        Self {
120            base_dir: base_dir.into(),
121            source_tool: source_tool.into(),
122        }
123    }
124
125    /// Resolves the memory folder for a project.
126    ///
127    /// This is `<base_dir>/<slug>/memory` where `<slug>` is the project's
128    /// absolute path with separators replaced by `-`.
129    pub fn memory_dir(&self, project_path: &Path) -> PathBuf {
130        self.base_dir
131            .join(project_slug(project_path))
132            .join("memory")
133    }
134
135    /// Refreshes the mirror for a project to match the current folder state.
136    ///
137    /// Adds new memories, updates changed ones, and removes memories whose
138    /// source file no longer exists. If the memory folder does not exist the
139    /// project simply has no memories and any previously mirrored ones are
140    /// removed; this is not an error.
141    ///
142    /// This is a read-only operation with respect to the tool's memory folder:
143    /// it only reads the folder and writes to Lore's database.
144    pub fn refresh(&self, db: &Database, project_path: &Path) -> Result<MirrorStats> {
145        let project_key = normalized_project_key(project_path);
146        let memory_dir = self.memory_dir(project_path);
147
148        let parsed = parse_memory_dir(&memory_dir)?;
149        let current_paths: HashSet<String> = parsed.iter().map(|m| m.file_path.clone()).collect();
150
151        let existing = db.get_memories(&project_key, &self.source_tool)?;
152
153        let mut stats = MirrorStats::default();
154
155        // Remove memories whose source file no longer exists.
156        for memory in &existing {
157            if !current_paths.contains(&memory.file_path) && db.delete_memory(&memory.id)? {
158                stats.removed += 1;
159            }
160        }
161
162        // Add or update the memories reflected on disk.
163        for parsed_memory in &parsed {
164            let memory = Memory {
165                id: Uuid::new_v4(),
166                project_path: project_key.clone(),
167                source_tool: self.source_tool.clone(),
168                name: parsed_memory.name.clone(),
169                description: parsed_memory.description.clone(),
170                memory_type: parsed_memory.memory_type.clone(),
171                content: parsed_memory.content.clone(),
172                file_path: parsed_memory.file_path.clone(),
173                updated_at: parsed_memory.updated_at,
174            };
175            db.upsert_memory(&memory)?;
176            stats.upserted += 1;
177        }
178
179        Ok(stats)
180    }
181}
182
183/// Returns the path to the Claude Code projects directory (`~/.claude/projects`).
184fn claude_projects_dir() -> PathBuf {
185    dirs::home_dir()
186        .unwrap_or_else(|| PathBuf::from("."))
187        .join(".claude")
188        .join("projects")
189}
190
191/// Computes the Claude-style project slug for a path.
192///
193/// Claude stores per-project data under a directory whose name is the project's
194/// absolute path with path separators replaced by `-`. The project key is
195/// normalized (trailing separators trimmed) first so a path reported with a
196/// trailing slash yields the same slug as one without.
197fn project_slug(project_path: &Path) -> String {
198    let normalized = normalized_project_key(project_path);
199    normalized.replace(['/', '\\'], "-")
200}
201
202/// Returns a stable string key for a project path.
203///
204/// Trailing path separators are trimmed so the same repository always maps to
205/// the same key regardless of whether callers include a trailing slash (git
206/// working directories, for example, are reported with one).
207fn normalized_project_key(project_path: &Path) -> String {
208    let raw = project_path.to_string_lossy();
209    let trimmed = raw.trim_end_matches(['/', '\\']);
210    if trimmed.is_empty() {
211        raw.to_string()
212    } else {
213        trimmed.to_string()
214    }
215}
216
217/// Resolves the project path to scope memories to.
218///
219/// When `explicit` is provided it is used directly; otherwise the current
220/// working directory is used. The path is then resolved to its git top-level
221/// (working directory) when inside a repository, so memories are consistently
222/// scoped to the repository root. Trailing separators are trimmed.
223pub fn resolve_project_path(explicit: Option<&str>) -> Result<PathBuf> {
224    let base = match explicit {
225        Some(p) => PathBuf::from(p),
226        None => std::env::current_dir().context("Failed to determine current directory")?,
227    };
228
229    // Prefer the git top-level so memories are scoped to the repository root.
230    let resolved = match crate::git::repo_info(&base) {
231        Ok(info) if !info.path.is_empty() => PathBuf::from(info.path),
232        _ => base,
233    };
234
235    Ok(PathBuf::from(normalized_project_key(&resolved)))
236}
237
238/// Parses all memory markdown files in a folder.
239///
240/// Returns an empty vector when the folder does not exist. `MEMORY.md` is
241/// captured as an index entry alongside the per-fact files. Files that cannot
242/// be read are skipped with a debug log rather than failing the whole refresh.
243pub fn parse_memory_dir(memory_dir: &Path) -> Result<Vec<ParsedMemory>> {
244    if !memory_dir.exists() {
245        return Ok(Vec::new());
246    }
247
248    let mut memories = Vec::new();
249
250    for entry in fs::read_dir(memory_dir)
251        .with_context(|| format!("Failed to read memory directory {}", memory_dir.display()))?
252    {
253        let entry = entry?;
254        let path = entry.path();
255
256        if !path.is_file() {
257            continue;
258        }
259
260        match path.extension().and_then(|e| e.to_str()) {
261            Some("md") => {}
262            _ => continue,
263        }
264
265        match parse_memory_file(&path) {
266            Ok(memory) => memories.push(memory),
267            Err(e) => {
268                tracing::debug!("Skipping unreadable memory file {}: {}", path.display(), e);
269            }
270        }
271    }
272
273    // Sort for deterministic ordering across platforms.
274    memories.sort_by(|a, b| a.file_path.cmp(&b.file_path));
275
276    Ok(memories)
277}
278
279/// Parses a single memory markdown file into a [`ParsedMemory`].
280fn parse_memory_file(path: &Path) -> Result<ParsedMemory> {
281    let raw = fs::read_to_string(path)
282        .with_context(|| format!("Failed to read memory file {}", path.display()))?;
283
284    let (frontmatter, body) = split_frontmatter(&raw);
285
286    let file_stem = path
287        .file_stem()
288        .and_then(|s| s.to_str())
289        .unwrap_or("memory")
290        .to_string();
291
292    let is_index = path
293        .file_name()
294        .and_then(|n| n.to_str())
295        .map(|n| n.eq_ignore_ascii_case("MEMORY.md"))
296        .unwrap_or(false);
297
298    let name = frontmatter
299        .as_ref()
300        .and_then(|f| f.name.clone())
301        .unwrap_or(file_stem);
302
303    let description = frontmatter.as_ref().and_then(|f| f.description.clone());
304
305    let memory_type = frontmatter
306        .as_ref()
307        .and_then(|f| f.metadata.as_ref())
308        .and_then(|m| m.memory_type.clone())
309        .or_else(|| is_index.then(|| "index".to_string()));
310
311    let updated_at = file_modified_time(path);
312
313    Ok(ParsedMemory {
314        name,
315        description,
316        memory_type,
317        content: body,
318        file_path: path.to_string_lossy().to_string(),
319        updated_at,
320    })
321}
322
323/// Returns a file's modification time as a UTC timestamp.
324///
325/// Falls back to the current time when the metadata is unavailable.
326fn file_modified_time(path: &Path) -> DateTime<Utc> {
327    fs::metadata(path)
328        .and_then(|m| m.modified())
329        .map(DateTime::<Utc>::from)
330        .unwrap_or_else(|_| Utc::now())
331}
332
333/// Splits a markdown document into optional YAML frontmatter and its body.
334///
335/// Frontmatter is a leading block delimited by lines containing only `---`.
336/// Returns the parsed frontmatter (when present and valid) and the trimmed
337/// body. When there is no frontmatter, or the closing delimiter is missing,
338/// the entire document is treated as the body.
339fn split_frontmatter(raw: &str) -> (Option<Frontmatter>, String) {
340    let raw = raw.trim_start_matches('\u{feff}');
341
342    let mut lines = raw.lines();
343    if lines.next().map(str::trim_end) != Some("---") {
344        return (None, raw.trim().to_string());
345    }
346
347    let mut yaml = String::new();
348    let mut body_lines: Vec<&str> = Vec::new();
349    let mut found_close = false;
350
351    for line in lines {
352        if !found_close && line.trim_end() == "---" {
353            found_close = true;
354            continue;
355        }
356        if found_close {
357            body_lines.push(line);
358        } else {
359            yaml.push_str(line);
360            yaml.push('\n');
361        }
362    }
363
364    if !found_close {
365        // No closing delimiter: treat the whole document as body.
366        return (None, raw.trim().to_string());
367    }
368
369    let frontmatter = serde_saphyr::from_str::<Frontmatter>(&yaml).ok();
370    (frontmatter, body_lines.join("\n").trim().to_string())
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::io::Write;
377    use tempfile::tempdir;
378
379    /// Writes a file with the given content, creating parent directories.
380    fn write_file(path: &Path, content: &str) {
381        if let Some(parent) = path.parent() {
382            fs::create_dir_all(parent).expect("Failed to create parent dirs");
383        }
384        let mut file = fs::File::create(path).expect("Failed to create file");
385        file.write_all(content.as_bytes())
386            .expect("Failed to write file");
387    }
388
389    /// Creates an in-memory-style test database in a temp directory.
390    fn create_test_db() -> (Database, tempfile::TempDir) {
391        let dir = tempdir().expect("Failed to create temp dir");
392        let db_path = dir.path().join("test.db");
393        let db = Database::open(&db_path).expect("Failed to open test database");
394        (db, dir)
395    }
396
397    #[test]
398    fn test_project_slug_replaces_separators() {
399        let slug = project_slug(Path::new("/Users/me/projects/lore"));
400        assert_eq!(slug, "-Users-me-projects-lore");
401    }
402
403    #[test]
404    fn test_project_slug_trims_trailing_slash() {
405        let with_slash = project_slug(Path::new("/Users/me/lore/"));
406        let without_slash = project_slug(Path::new("/Users/me/lore"));
407        assert_eq!(with_slash, without_slash);
408    }
409
410    #[test]
411    fn test_split_frontmatter_parses_fields() {
412        let raw = "---\nname: Prefer tabs\ndescription: Use tabs not spaces\nmetadata:\n  type: user\n---\nThe user prefers tabs.\n";
413        let (fm, body) = split_frontmatter(raw);
414        let fm = fm.expect("Should parse frontmatter");
415        assert_eq!(fm.name.as_deref(), Some("Prefer tabs"));
416        assert_eq!(fm.description.as_deref(), Some("Use tabs not spaces"));
417        assert_eq!(
418            fm.metadata.and_then(|m| m.memory_type).as_deref(),
419            Some("user")
420        );
421        assert_eq!(body, "The user prefers tabs.");
422    }
423
424    #[test]
425    fn test_split_frontmatter_no_frontmatter() {
426        let raw = "Just some notes without frontmatter.";
427        let (fm, body) = split_frontmatter(raw);
428        assert!(fm.is_none());
429        assert_eq!(body, "Just some notes without frontmatter.");
430    }
431
432    #[test]
433    fn test_split_frontmatter_missing_close_is_body() {
434        let raw = "---\nname: broken\nno closing delimiter here";
435        let (fm, body) = split_frontmatter(raw);
436        assert!(fm.is_none());
437        assert!(body.contains("no closing delimiter"));
438    }
439
440    #[test]
441    fn test_parse_memory_dir_missing_folder_is_empty() {
442        let dir = tempdir().unwrap();
443        let missing = dir.path().join("does-not-exist");
444        let memories = parse_memory_dir(&missing).expect("Should not error");
445        assert!(memories.is_empty());
446    }
447
448    #[test]
449    fn test_parse_memory_dir_reads_facts_and_index() {
450        let dir = tempdir().unwrap();
451        let mem_dir = dir.path().join("memory");
452        write_file(&mem_dir.join("MEMORY.md"), "# Index\n- fact-1\n");
453        write_file(
454            &mem_dir.join("fact-1.md"),
455            "---\nname: API base URL\ndescription: Where the API lives\nmetadata:\n  type: reference\n---\nThe API base URL is https://example.com.\n",
456        );
457
458        let memories = parse_memory_dir(&mem_dir).expect("Should parse");
459        assert_eq!(memories.len(), 2);
460
461        let fact = memories
462            .iter()
463            .find(|m| m.name == "API base URL")
464            .expect("Should find fact");
465        assert_eq!(fact.description.as_deref(), Some("Where the API lives"));
466        assert_eq!(fact.memory_type.as_deref(), Some("reference"));
467        assert!(fact.content.contains("https://example.com"));
468
469        let index = memories
470            .iter()
471            .find(|m| m.name == "MEMORY")
472            .expect("Should capture index");
473        assert_eq!(index.memory_type.as_deref(), Some("index"));
474    }
475
476    #[test]
477    fn test_refresh_captures_memories_scoped_to_project() {
478        let (db, _db_dir) = create_test_db();
479        let base = tempdir().unwrap();
480        let project = Path::new("/tmp/example-project");
481
482        let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
483        let mem_dir = mirror.memory_dir(project);
484        write_file(
485            &mem_dir.join("fact-1.md"),
486            "---\nname: Fact one\ndescription: First fact\nmetadata:\n  type: project\n---\nBody one.\n",
487        );
488
489        let stats = mirror
490            .refresh(&db, project)
491            .expect("Refresh should succeed");
492        assert_eq!(stats.upserted, 1);
493        assert_eq!(stats.removed, 0);
494
495        let memories = db
496            .get_memories("/tmp/example-project", CLAUDE_CODE_TOOL)
497            .expect("Should list memories");
498        assert_eq!(memories.len(), 1);
499        assert_eq!(memories[0].name, "Fact one");
500        assert_eq!(memories[0].memory_type.as_deref(), Some("project"));
501        assert_eq!(memories[0].project_path, "/tmp/example-project");
502
503        // Memories are scoped to the project: a different project sees none.
504        let other = db
505            .get_memories("/tmp/other-project", CLAUDE_CODE_TOOL)
506            .expect("Should query");
507        assert!(other.is_empty());
508    }
509
510    #[test]
511    fn test_refresh_removes_deleted_files() {
512        let (db, _db_dir) = create_test_db();
513        let base = tempdir().unwrap();
514        let project = Path::new("/tmp/mirror-remove");
515
516        let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
517        let mem_dir = mirror.memory_dir(project);
518        let fact_a = mem_dir.join("a.md");
519        let fact_b = mem_dir.join("b.md");
520        write_file(&fact_a, "---\nname: A\n---\nBody A.\n");
521        write_file(&fact_b, "---\nname: B\n---\nBody B.\n");
522
523        mirror.refresh(&db, project).expect("Initial refresh");
524        assert_eq!(
525            db.get_memories("/tmp/mirror-remove", CLAUDE_CODE_TOOL)
526                .unwrap()
527                .len(),
528            2
529        );
530
531        // Remove one source file; the mirror should drop it on refresh.
532        fs::remove_file(&fact_b).expect("Failed to remove file");
533        let stats = mirror.refresh(&db, project).expect("Second refresh");
534        assert_eq!(stats.removed, 1);
535
536        let remaining = db
537            .get_memories("/tmp/mirror-remove", CLAUDE_CODE_TOOL)
538            .unwrap();
539        assert_eq!(remaining.len(), 1);
540        assert_eq!(remaining[0].name, "A");
541    }
542
543    #[test]
544    fn test_refresh_updates_changed_files() {
545        let (db, _db_dir) = create_test_db();
546        let base = tempdir().unwrap();
547        let project = Path::new("/tmp/mirror-update");
548
549        let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
550        let mem_dir = mirror.memory_dir(project);
551        let fact = mem_dir.join("fact.md");
552        write_file(&fact, "---\nname: Original\n---\nOriginal body.\n");
553
554        mirror.refresh(&db, project).expect("Initial refresh");
555        let first = db
556            .get_memories("/tmp/mirror-update", CLAUDE_CODE_TOOL)
557            .unwrap();
558        assert_eq!(first.len(), 1);
559        let original_id = first[0].id;
560
561        // Rewrite the same file with new content.
562        write_file(&fact, "---\nname: Updated\n---\nUpdated body.\n");
563        mirror.refresh(&db, project).expect("Second refresh");
564
565        let updated = db
566            .get_memories("/tmp/mirror-update", CLAUDE_CODE_TOOL)
567            .unwrap();
568        assert_eq!(updated.len(), 1);
569        assert_eq!(updated[0].name, "Updated");
570        assert!(updated[0].content.contains("Updated body"));
571        // The id is preserved across updates for the same source file.
572        assert_eq!(updated[0].id, original_id);
573    }
574
575    #[test]
576    fn test_refresh_adds_new_files() {
577        let (db, _db_dir) = create_test_db();
578        let base = tempdir().unwrap();
579        let project = Path::new("/tmp/mirror-add");
580
581        let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
582        let mem_dir = mirror.memory_dir(project);
583        write_file(&mem_dir.join("a.md"), "---\nname: A\n---\nBody A.\n");
584        mirror.refresh(&db, project).expect("Initial refresh");
585
586        write_file(&mem_dir.join("b.md"), "---\nname: B\n---\nBody B.\n");
587        let stats = mirror.refresh(&db, project).expect("Second refresh");
588        assert_eq!(stats.upserted, 2);
589
590        let memories = db
591            .get_memories("/tmp/mirror-add", CLAUDE_CODE_TOOL)
592            .unwrap();
593        assert_eq!(memories.len(), 2);
594    }
595
596    #[test]
597    fn test_refresh_missing_folder_yields_no_memories() {
598        let (db, _db_dir) = create_test_db();
599        let base = tempdir().unwrap();
600        let project = Path::new("/tmp/mirror-empty");
601
602        let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
603        let stats = mirror
604            .refresh(&db, project)
605            .expect("Refresh should not error on missing folder");
606        assert_eq!(stats.upserted, 0);
607        assert_eq!(stats.removed, 0);
608        assert!(db
609            .get_memories("/tmp/mirror-empty", CLAUDE_CODE_TOOL)
610            .unwrap()
611            .is_empty());
612    }
613
614    #[test]
615    fn test_search_memories_returns_matches() {
616        let (db, _db_dir) = create_test_db();
617        let base = tempdir().unwrap();
618        let project = Path::new("/tmp/mirror-search");
619
620        let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL);
621        let mem_dir = mirror.memory_dir(project);
622        write_file(
623            &mem_dir.join("auth.md"),
624            "---\nname: Auth flow\n---\nUse OAuth with PKCE for authentication.\n",
625        );
626        write_file(
627            &mem_dir.join("db.md"),
628            "---\nname: Database\n---\nThe project uses SQLite for storage.\n",
629        );
630        mirror.refresh(&db, project).expect("Refresh");
631
632        let results = db
633            .search_memories("/tmp/mirror-search", CLAUDE_CODE_TOOL, "OAuth", 10)
634            .expect("Search should succeed");
635        assert_eq!(results.len(), 1);
636        assert_eq!(results[0].name, "Auth flow");
637
638        let none = db
639            .search_memories("/tmp/mirror-search", CLAUDE_CODE_TOOL, "kubernetes", 10)
640            .expect("Search should succeed");
641        assert!(none.is_empty());
642    }
643}