From 3a994e2920a7abb08b08b8cee3bde0625950f146 Mon Sep 17 00:00:00 2001 From: franzer Date: Wed, 1 Jul 2026 17:03:20 -0600 Subject: [PATCH 1/2] feat: mirror Claude memory and expose it via MCP (cross-tool memory) --- src/capture/memory.rs | 643 +++++++++++++++++++++++++++++++++++ src/capture/mod.rs | 3 + src/cli/commands/mcp.rs | 4 +- src/cli/commands/memories.rs | 91 +++++ src/cli/commands/mod.rs | 3 + src/main.rs | 12 + src/mcp/mod.rs | 2 + src/mcp/server.rs | 188 +++++++++- src/storage/db.rs | 188 +++++++++- src/storage/mod.rs | 2 +- src/storage/models.rs | 42 +++ 11 files changed, 1173 insertions(+), 5 deletions(-) create mode 100644 src/capture/memory.rs create mode 100644 src/cli/commands/memories.rs diff --git a/src/capture/memory.rs b/src/capture/memory.rs new file mode 100644 index 0000000..b2389e5 --- /dev/null +++ b/src/capture/memory.rs @@ -0,0 +1,643 @@ +//! Read-only mirror of a coding tool's per-project memory store. +//! +//! Coding tools such as Claude Code write per-project "memory" (running notes, +//! next steps, corrections) into their own private stores that other tools +//! cannot see. This module mirrors those files into Lore's `memories` table so +//! any LLM can read them through Lore's MCP server. +//! +//! The mirror is strictly READ-ONLY: it never creates, modifies, or deletes +//! files in the tool's memory folder. It only reflects the current folder +//! state into the database, adding new memories, updating changed ones, and +//! removing memories whose source file no longer exists. +//! +//! Claude Code stores per-project data under `~/.claude/projects//` where +//! `` is the project's absolute path with the path separator replaced by +//! `-` (for example `/Users/me/proj` becomes `-Users-me-proj`). Sessions live +//! in the `sessions/` folder; memory lives in the sibling `memory/` folder as a +//! `MEMORY.md` index plus per-fact markdown files. Each fact file carries YAML +//! frontmatter with `name`, `description`, and `metadata.type`, followed by the +//! fact body. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::storage::{Database, Memory}; + +/// The source-tool identifier used for Claude Code memories. +pub const CLAUDE_CODE_TOOL: &str = "claude-code"; + +/// Frontmatter parsed from a memory markdown file. +#[derive(Debug, Default, Deserialize)] +struct Frontmatter { + /// Short name of the memory. + #[serde(default)] + name: Option, + + /// Human-readable description of the memory. + #[serde(default)] + description: Option, + + /// Nested metadata block (holds the memory type). + #[serde(default)] + metadata: Option, +} + +/// The `metadata` block within a memory's frontmatter. +#[derive(Debug, Default, Deserialize)] +struct FrontmatterMetadata { + /// The memory type (e.g., user, feedback, project, reference). + #[serde(default, rename = "type")] + memory_type: Option, +} + +/// A memory parsed from a single markdown file on disk. +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedMemory { + /// Short name (frontmatter `name` or the file stem). + pub name: String, + + /// Optional description from frontmatter. + pub description: Option, + + /// Optional memory type from `metadata.type`. + pub memory_type: Option, + + /// The memory body following any frontmatter. + pub content: String, + + /// Absolute path of the source file. + pub file_path: String, + + /// Source file modification time. + pub updated_at: DateTime, +} + +/// Statistics describing what a single refresh changed. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MirrorStats { + /// Number of memories added or updated from the folder. + pub upserted: usize, + + /// Number of memories removed because their source file was gone. + pub removed: usize, +} + +/// Mirrors a coding tool's memory folder into Lore's database. +/// +/// The base directory (the tool's per-project root) is injectable so tests can +/// point the mirror at a temporary folder instead of the real `~/.claude`. +pub struct MemoryMirror { + /// The tool's per-project storage root (e.g., `~/.claude/projects`). + base_dir: PathBuf, + + /// The source-tool identifier stored on mirrored memories. + source_tool: String, +} + +impl MemoryMirror { + /// Creates a mirror for Claude Code, reading from `~/.claude/projects`. + pub fn claude() -> Self { + Self { + base_dir: claude_projects_dir(), + source_tool: CLAUDE_CODE_TOOL.to_string(), + } + } + + /// Creates a mirror with an explicit base directory and source tool. + /// + /// Intended for tests that point the mirror at a temporary folder instead + /// of the real `~/.claude`, so tests never touch a developer's real memory + /// store. + #[cfg(test)] + pub fn with_base_dir(base_dir: impl Into, source_tool: impl Into) -> Self { + Self { + base_dir: base_dir.into(), + source_tool: source_tool.into(), + } + } + + /// Resolves the memory folder for a project. + /// + /// This is `//memory` where `` is the project's + /// absolute path with separators replaced by `-`. + pub fn memory_dir(&self, project_path: &Path) -> PathBuf { + self.base_dir + .join(project_slug(project_path)) + .join("memory") + } + + /// Refreshes the mirror for a project to match the current folder state. + /// + /// Adds new memories, updates changed ones, and removes memories whose + /// source file no longer exists. If the memory folder does not exist the + /// project simply has no memories and any previously mirrored ones are + /// removed; this is not an error. + /// + /// This is a read-only operation with respect to the tool's memory folder: + /// it only reads the folder and writes to Lore's database. + pub fn refresh(&self, db: &Database, project_path: &Path) -> Result { + let project_key = normalized_project_key(project_path); + let memory_dir = self.memory_dir(project_path); + + let parsed = parse_memory_dir(&memory_dir)?; + let current_paths: HashSet = parsed.iter().map(|m| m.file_path.clone()).collect(); + + let existing = db.get_memories(&project_key, &self.source_tool)?; + + let mut stats = MirrorStats::default(); + + // Remove memories whose source file no longer exists. + for memory in &existing { + if !current_paths.contains(&memory.file_path) && db.delete_memory(&memory.id)? { + stats.removed += 1; + } + } + + // Add or update the memories reflected on disk. + for parsed_memory in &parsed { + let memory = Memory { + id: Uuid::new_v4(), + project_path: project_key.clone(), + source_tool: self.source_tool.clone(), + name: parsed_memory.name.clone(), + description: parsed_memory.description.clone(), + memory_type: parsed_memory.memory_type.clone(), + content: parsed_memory.content.clone(), + file_path: parsed_memory.file_path.clone(), + updated_at: parsed_memory.updated_at, + }; + db.upsert_memory(&memory)?; + stats.upserted += 1; + } + + Ok(stats) + } +} + +/// Returns the path to the Claude Code projects directory (`~/.claude/projects`). +fn claude_projects_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude") + .join("projects") +} + +/// Computes the Claude-style project slug for a path. +/// +/// Claude stores per-project data under a directory whose name is the project's +/// absolute path with path separators replaced by `-`. The project key is +/// normalized (trailing separators trimmed) first so a path reported with a +/// trailing slash yields the same slug as one without. +fn project_slug(project_path: &Path) -> String { + let normalized = normalized_project_key(project_path); + normalized.replace(['/', '\\'], "-") +} + +/// Returns a stable string key for a project path. +/// +/// Trailing path separators are trimmed so the same repository always maps to +/// the same key regardless of whether callers include a trailing slash (git +/// working directories, for example, are reported with one). +fn normalized_project_key(project_path: &Path) -> String { + let raw = project_path.to_string_lossy(); + let trimmed = raw.trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + raw.to_string() + } else { + trimmed.to_string() + } +} + +/// Resolves the project path to scope memories to. +/// +/// When `explicit` is provided it is used directly; otherwise the current +/// working directory is used. The path is then resolved to its git top-level +/// (working directory) when inside a repository, so memories are consistently +/// scoped to the repository root. Trailing separators are trimmed. +pub fn resolve_project_path(explicit: Option<&str>) -> Result { + let base = match explicit { + Some(p) => PathBuf::from(p), + None => std::env::current_dir().context("Failed to determine current directory")?, + }; + + // Prefer the git top-level so memories are scoped to the repository root. + let resolved = match crate::git::repo_info(&base) { + Ok(info) if !info.path.is_empty() => PathBuf::from(info.path), + _ => base, + }; + + Ok(PathBuf::from(normalized_project_key(&resolved))) +} + +/// Parses all memory markdown files in a folder. +/// +/// Returns an empty vector when the folder does not exist. `MEMORY.md` is +/// captured as an index entry alongside the per-fact files. Files that cannot +/// be read are skipped with a debug log rather than failing the whole refresh. +pub fn parse_memory_dir(memory_dir: &Path) -> Result> { + if !memory_dir.exists() { + return Ok(Vec::new()); + } + + let mut memories = Vec::new(); + + for entry in fs::read_dir(memory_dir) + .with_context(|| format!("Failed to read memory directory {}", memory_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + + if !path.is_file() { + continue; + } + + match path.extension().and_then(|e| e.to_str()) { + Some("md") => {} + _ => continue, + } + + match parse_memory_file(&path) { + Ok(memory) => memories.push(memory), + Err(e) => { + tracing::debug!("Skipping unreadable memory file {}: {}", path.display(), e); + } + } + } + + // Sort for deterministic ordering across platforms. + memories.sort_by(|a, b| a.file_path.cmp(&b.file_path)); + + Ok(memories) +} + +/// Parses a single memory markdown file into a [`ParsedMemory`]. +fn parse_memory_file(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("Failed to read memory file {}", path.display()))?; + + let (frontmatter, body) = split_frontmatter(&raw); + + let file_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("memory") + .to_string(); + + let is_index = path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.eq_ignore_ascii_case("MEMORY.md")) + .unwrap_or(false); + + let name = frontmatter + .as_ref() + .and_then(|f| f.name.clone()) + .unwrap_or(file_stem); + + let description = frontmatter.as_ref().and_then(|f| f.description.clone()); + + let memory_type = frontmatter + .as_ref() + .and_then(|f| f.metadata.as_ref()) + .and_then(|m| m.memory_type.clone()) + .or_else(|| is_index.then(|| "index".to_string())); + + let updated_at = file_modified_time(path); + + Ok(ParsedMemory { + name, + description, + memory_type, + content: body, + file_path: path.to_string_lossy().to_string(), + updated_at, + }) +} + +/// Returns a file's modification time as a UTC timestamp. +/// +/// Falls back to the current time when the metadata is unavailable. +fn file_modified_time(path: &Path) -> DateTime { + fs::metadata(path) + .and_then(|m| m.modified()) + .map(DateTime::::from) + .unwrap_or_else(|_| Utc::now()) +} + +/// Splits a markdown document into optional YAML frontmatter and its body. +/// +/// Frontmatter is a leading block delimited by lines containing only `---`. +/// Returns the parsed frontmatter (when present and valid) and the trimmed +/// body. When there is no frontmatter, or the closing delimiter is missing, +/// the entire document is treated as the body. +fn split_frontmatter(raw: &str) -> (Option, String) { + let raw = raw.trim_start_matches('\u{feff}'); + + let mut lines = raw.lines(); + if lines.next().map(str::trim_end) != Some("---") { + return (None, raw.trim().to_string()); + } + + let mut yaml = String::new(); + let mut body_lines: Vec<&str> = Vec::new(); + let mut found_close = false; + + for line in lines { + if !found_close && line.trim_end() == "---" { + found_close = true; + continue; + } + if found_close { + body_lines.push(line); + } else { + yaml.push_str(line); + yaml.push('\n'); + } + } + + if !found_close { + // No closing delimiter: treat the whole document as body. + return (None, raw.trim().to_string()); + } + + let frontmatter = serde_saphyr::from_str::(&yaml).ok(); + (frontmatter, body_lines.join("\n").trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + /// Writes a file with the given content, creating parent directories. + fn write_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent dirs"); + } + let mut file = fs::File::create(path).expect("Failed to create file"); + file.write_all(content.as_bytes()) + .expect("Failed to write file"); + } + + /// Creates an in-memory-style test database in a temp directory. + fn create_test_db() -> (Database, tempfile::TempDir) { + let dir = tempdir().expect("Failed to create temp dir"); + let db_path = dir.path().join("test.db"); + let db = Database::open(&db_path).expect("Failed to open test database"); + (db, dir) + } + + #[test] + fn test_project_slug_replaces_separators() { + let slug = project_slug(Path::new("/Users/me/projects/lore")); + assert_eq!(slug, "-Users-me-projects-lore"); + } + + #[test] + fn test_project_slug_trims_trailing_slash() { + let with_slash = project_slug(Path::new("/Users/me/lore/")); + let without_slash = project_slug(Path::new("/Users/me/lore")); + assert_eq!(with_slash, without_slash); + } + + #[test] + fn test_split_frontmatter_parses_fields() { + let raw = "---\nname: Prefer tabs\ndescription: Use tabs not spaces\nmetadata:\n type: user\n---\nThe user prefers tabs.\n"; + let (fm, body) = split_frontmatter(raw); + let fm = fm.expect("Should parse frontmatter"); + assert_eq!(fm.name.as_deref(), Some("Prefer tabs")); + assert_eq!(fm.description.as_deref(), Some("Use tabs not spaces")); + assert_eq!( + fm.metadata.and_then(|m| m.memory_type).as_deref(), + Some("user") + ); + assert_eq!(body, "The user prefers tabs."); + } + + #[test] + fn test_split_frontmatter_no_frontmatter() { + let raw = "Just some notes without frontmatter."; + let (fm, body) = split_frontmatter(raw); + assert!(fm.is_none()); + assert_eq!(body, "Just some notes without frontmatter."); + } + + #[test] + fn test_split_frontmatter_missing_close_is_body() { + let raw = "---\nname: broken\nno closing delimiter here"; + let (fm, body) = split_frontmatter(raw); + assert!(fm.is_none()); + assert!(body.contains("no closing delimiter")); + } + + #[test] + fn test_parse_memory_dir_missing_folder_is_empty() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("does-not-exist"); + let memories = parse_memory_dir(&missing).expect("Should not error"); + assert!(memories.is_empty()); + } + + #[test] + fn test_parse_memory_dir_reads_facts_and_index() { + let dir = tempdir().unwrap(); + let mem_dir = dir.path().join("memory"); + write_file(&mem_dir.join("MEMORY.md"), "# Index\n- fact-1\n"); + write_file( + &mem_dir.join("fact-1.md"), + "---\nname: API base URL\ndescription: Where the API lives\nmetadata:\n type: reference\n---\nThe API base URL is https://example.com.\n", + ); + + let memories = parse_memory_dir(&mem_dir).expect("Should parse"); + assert_eq!(memories.len(), 2); + + let fact = memories + .iter() + .find(|m| m.name == "API base URL") + .expect("Should find fact"); + assert_eq!(fact.description.as_deref(), Some("Where the API lives")); + assert_eq!(fact.memory_type.as_deref(), Some("reference")); + assert!(fact.content.contains("https://example.com")); + + let index = memories + .iter() + .find(|m| m.name == "MEMORY") + .expect("Should capture index"); + assert_eq!(index.memory_type.as_deref(), Some("index")); + } + + #[test] + fn test_refresh_captures_memories_scoped_to_project() { + let (db, _db_dir) = create_test_db(); + let base = tempdir().unwrap(); + let project = Path::new("/tmp/example-project"); + + let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL); + let mem_dir = mirror.memory_dir(project); + write_file( + &mem_dir.join("fact-1.md"), + "---\nname: Fact one\ndescription: First fact\nmetadata:\n type: project\n---\nBody one.\n", + ); + + let stats = mirror + .refresh(&db, project) + .expect("Refresh should succeed"); + assert_eq!(stats.upserted, 1); + assert_eq!(stats.removed, 0); + + let memories = db + .get_memories("/tmp/example-project", CLAUDE_CODE_TOOL) + .expect("Should list memories"); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0].name, "Fact one"); + assert_eq!(memories[0].memory_type.as_deref(), Some("project")); + assert_eq!(memories[0].project_path, "/tmp/example-project"); + + // Memories are scoped to the project: a different project sees none. + let other = db + .get_memories("/tmp/other-project", CLAUDE_CODE_TOOL) + .expect("Should query"); + assert!(other.is_empty()); + } + + #[test] + fn test_refresh_removes_deleted_files() { + let (db, _db_dir) = create_test_db(); + let base = tempdir().unwrap(); + let project = Path::new("/tmp/mirror-remove"); + + let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL); + let mem_dir = mirror.memory_dir(project); + let fact_a = mem_dir.join("a.md"); + let fact_b = mem_dir.join("b.md"); + write_file(&fact_a, "---\nname: A\n---\nBody A.\n"); + write_file(&fact_b, "---\nname: B\n---\nBody B.\n"); + + mirror.refresh(&db, project).expect("Initial refresh"); + assert_eq!( + db.get_memories("/tmp/mirror-remove", CLAUDE_CODE_TOOL) + .unwrap() + .len(), + 2 + ); + + // Remove one source file; the mirror should drop it on refresh. + fs::remove_file(&fact_b).expect("Failed to remove file"); + let stats = mirror.refresh(&db, project).expect("Second refresh"); + assert_eq!(stats.removed, 1); + + let remaining = db + .get_memories("/tmp/mirror-remove", CLAUDE_CODE_TOOL) + .unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].name, "A"); + } + + #[test] + fn test_refresh_updates_changed_files() { + let (db, _db_dir) = create_test_db(); + let base = tempdir().unwrap(); + let project = Path::new("/tmp/mirror-update"); + + let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL); + let mem_dir = mirror.memory_dir(project); + let fact = mem_dir.join("fact.md"); + write_file(&fact, "---\nname: Original\n---\nOriginal body.\n"); + + mirror.refresh(&db, project).expect("Initial refresh"); + let first = db + .get_memories("/tmp/mirror-update", CLAUDE_CODE_TOOL) + .unwrap(); + assert_eq!(first.len(), 1); + let original_id = first[0].id; + + // Rewrite the same file with new content. + write_file(&fact, "---\nname: Updated\n---\nUpdated body.\n"); + mirror.refresh(&db, project).expect("Second refresh"); + + let updated = db + .get_memories("/tmp/mirror-update", CLAUDE_CODE_TOOL) + .unwrap(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].name, "Updated"); + assert!(updated[0].content.contains("Updated body")); + // The id is preserved across updates for the same source file. + assert_eq!(updated[0].id, original_id); + } + + #[test] + fn test_refresh_adds_new_files() { + let (db, _db_dir) = create_test_db(); + let base = tempdir().unwrap(); + let project = Path::new("/tmp/mirror-add"); + + let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL); + let mem_dir = mirror.memory_dir(project); + write_file(&mem_dir.join("a.md"), "---\nname: A\n---\nBody A.\n"); + mirror.refresh(&db, project).expect("Initial refresh"); + + write_file(&mem_dir.join("b.md"), "---\nname: B\n---\nBody B.\n"); + let stats = mirror.refresh(&db, project).expect("Second refresh"); + assert_eq!(stats.upserted, 2); + + let memories = db + .get_memories("/tmp/mirror-add", CLAUDE_CODE_TOOL) + .unwrap(); + assert_eq!(memories.len(), 2); + } + + #[test] + fn test_refresh_missing_folder_yields_no_memories() { + let (db, _db_dir) = create_test_db(); + let base = tempdir().unwrap(); + let project = Path::new("/tmp/mirror-empty"); + + let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL); + let stats = mirror + .refresh(&db, project) + .expect("Refresh should not error on missing folder"); + assert_eq!(stats.upserted, 0); + assert_eq!(stats.removed, 0); + assert!(db + .get_memories("/tmp/mirror-empty", CLAUDE_CODE_TOOL) + .unwrap() + .is_empty()); + } + + #[test] + fn test_search_memories_returns_matches() { + let (db, _db_dir) = create_test_db(); + let base = tempdir().unwrap(); + let project = Path::new("/tmp/mirror-search"); + + let mirror = MemoryMirror::with_base_dir(base.path(), CLAUDE_CODE_TOOL); + let mem_dir = mirror.memory_dir(project); + write_file( + &mem_dir.join("auth.md"), + "---\nname: Auth flow\n---\nUse OAuth with PKCE for authentication.\n", + ); + write_file( + &mem_dir.join("db.md"), + "---\nname: Database\n---\nThe project uses SQLite for storage.\n", + ); + mirror.refresh(&db, project).expect("Refresh"); + + let results = db + .search_memories("/tmp/mirror-search", CLAUDE_CODE_TOOL, "OAuth", 10) + .expect("Search should succeed"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "Auth flow"); + + let none = db + .search_memories("/tmp/mirror-search", CLAUDE_CODE_TOOL, "kubernetes", 10) + .expect("Search should succeed"); + assert!(none.is_empty()); + } +} diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 976908b..13b48ec 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -12,5 +12,8 @@ //! //! - GitHub Copilot - Will parse from Copilot's logs +/// Read-only mirror of a coding tool's per-project memory store. +pub mod memory; + /// Tool-specific session parsers. pub mod watchers; diff --git a/src/cli/commands/mcp.rs b/src/cli/commands/mcp.rs index 7ed1133..5598147 100644 --- a/src/cli/commands/mcp.rs +++ b/src/cli/commands/mcp.rs @@ -26,7 +26,9 @@ pub enum McpCommand { - lore_get_session: Get full session details\n \ - lore_list_sessions: List recent sessions\n \ - lore_get_context: Get repository context\n \ - - lore_get_linked_sessions: Get sessions linked to a commit" + - lore_get_linked_sessions: Get sessions linked to a commit\n \ + - lore_get_memories: Get a project's mirrored memories\n \ + - lore_search_memories: Search a project's mirrored memories" )] Serve, } diff --git a/src/cli/commands/memories.rs b/src/cli/commands/memories.rs new file mode 100644 index 0000000..e2f5fe1 --- /dev/null +++ b/src/cli/commands/memories.rs @@ -0,0 +1,91 @@ +//! Memories command - list a project's mirrored memories. +//! +//! Refreshes the read-only mirror of a coding tool's per-project memory store +//! (currently Claude Code) and lists the current memories for the repository. +//! Lore never writes back to the tool's memory folder; this only reflects it. + +use anyhow::Result; +use colored::Colorize; + +use crate::capture::memory::{resolve_project_path, MemoryMirror, CLAUDE_CODE_TOOL}; +use crate::cli::OutputFormat; +use crate::storage::Database; + +/// Arguments for the memories command. +#[derive(clap::Args)] +#[command(after_help = "EXAMPLES:\n \ + lore memories List memories for the current repository\n \ + lore memories --project /path List memories for a specific repository\n \ + lore memories --format json Output as JSON")] +pub struct Args { + /// Repository path to read memories for (defaults to the current repo) + #[arg(short, long, value_name = "PATH")] + #[arg( + long_help = "Read memories for this repository path. Defaults to the\n\ + current directory resolved to its git top-level." + )] + pub project: Option, + + /// Output format: text (default), json + #[arg(short, long, value_enum, default_value = "text")] + pub format: OutputFormat, +} + +/// Executes the memories command. +/// +/// Resolves the target repository, refreshes the memory mirror from the tool's +/// memory folder, and lists the current memories. +pub fn run(args: Args) -> Result<()> { + let db = Database::open_default()?; + let project = resolve_project_path(args.project.as_deref())?; + + // Refresh-on-read so results reflect the current folder state. + let mirror = MemoryMirror::claude(); + mirror.refresh(&db, &project)?; + + let project_key = project.to_string_lossy().to_string(); + let memories = db.get_memories(&project_key, CLAUDE_CODE_TOOL)?; + + match args.format { + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&memories)?; + println!("{json}"); + } + OutputFormat::Text | OutputFormat::Markdown => { + if memories.is_empty() { + println!("{}", "No memories found for this project.".dimmed()); + println!(); + println!( + "Memories are mirrored read-only from Claude Code's memory folder\n\ + for this repository. None were found at:" + ); + println!(" {}", mirror.memory_dir(&project).display()); + return Ok(()); + } + + println!( + "{}", + format!("Memories for {project_key} (source: {CLAUDE_CODE_TOOL})").bold() + ); + println!(); + + for memory in &memories { + let type_label = memory + .memory_type + .as_deref() + .map(|t| format!(" [{t}]")) + .unwrap_or_default(); + println!("{}{}", memory.name.cyan().bold(), type_label.yellow()); + if let Some(ref desc) = memory.description { + println!(" {}", desc.dimmed()); + } + for line in memory.content.lines() { + println!(" {line}"); + } + println!(); + } + } + } + + Ok(()) +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 7be2ffb..ebf3358 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -54,6 +54,9 @@ pub mod link; /// MCP (Model Context Protocol) server. pub mod mcp; +/// List a project's mirrored memories. +pub mod memories; + /// Search session content using FTS5 full-text search. pub mod search; diff --git a/src/main.rs b/src/main.rs index b746d22..25aa95c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -292,6 +292,16 @@ enum Commands { )] Mcp(commands::mcp::Args), + /// List a project's memories mirrored from a coding tool + #[command( + long_about = "Lists the current repository's memories, mirrored read-only from a\n\ + coding tool's per-project memory store (currently Claude Code). The\n\ + mirror is refreshed from the tool's memory folder before listing, so\n\ + results always reflect the current state. Lore never writes back to\n\ + the tool's memory folder." + )] + Memories(commands::memories::Args), + /// Generate shell completions #[command( long_about = "Generates shell completion scripts for various shells.\n\ @@ -444,6 +454,7 @@ fn command_name(command: &Commands) -> &'static str { Commands::Sync(_) => "sync", Commands::Doctor(_) => "doctor", Commands::Mcp(_) => "mcp", + Commands::Memories(_) => "memories", Commands::Completions(_) => "completions", } } @@ -529,6 +540,7 @@ fn main() -> Result<()> { Commands::Sync(args) => commands::sync::run(args), Commands::Doctor(args) => commands::doctor::run(args), Commands::Mcp(args) => commands::mcp::run(args), + Commands::Memories(args) => commands::memories::run(args), Commands::Completions(args) => { let mut cmd = Cli::command(); commands::completions::run(args, &mut cmd) diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 8a80b11..928d11d 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -10,6 +10,8 @@ //! - `lore_list_sessions`: List recent sessions with optional filters //! - `lore_get_context`: Get recent session context for a repository //! - `lore_get_linked_sessions`: Get sessions linked to a commit +//! - `lore_get_memories`: Get a project's memories mirrored from a coding tool +//! - `lore_search_memories`: Full-text search a project's mirrored memories mod server; mod tools; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 8c4f0b8..cac015c 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -18,7 +18,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::borrow::Cow; -use crate::storage::models::{Message, SearchOptions, Session}; +use crate::capture::memory::{resolve_project_path, MemoryMirror, CLAUDE_CODE_TOOL}; +use crate::storage::models::{Memory, Message, SearchOptions, Session}; use crate::storage::Database; // ============== Tool Parameter Types ============== @@ -91,6 +92,34 @@ pub struct GetLinkedSessionsParams { pub commit_sha: String, } +/// Parameters for the lore_get_memories tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetMemoriesParams { + /// Project path to read memories for. + #[schemars( + description = "Repository path (defaults to the current directory's git top-level)" + )] + pub project_path: Option, +} + +/// Parameters for the lore_search_memories tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SearchMemoriesParams { + /// The search query text. + #[schemars(description = "Text to search for in the project's mirrored memories")] + pub query: String, + + /// Project path to search memories within. + #[schemars( + description = "Repository path (defaults to the current directory's git top-level)" + )] + pub project_path: Option, + + /// Maximum number of results to return. + #[schemars(description = "Maximum number of results (default: 20)")] + pub limit: Option, +} + // ============== Result Types ============== /// A session in search results. @@ -157,6 +186,36 @@ pub struct LinkedSessionsResponse { pub sessions: Vec, } +/// A single mirrored memory in a response. +#[derive(Debug, Serialize)] +pub struct MemoryInfo { + pub name: String, + pub description: Option, + pub memory_type: Option, + pub content: String, + pub file_path: String, + pub updated_at: String, +} + +/// Response for the lore_get_memories tool. +#[derive(Debug, Serialize)] +pub struct MemoriesResponse { + pub project_path: String, + pub source_tool: String, + pub total: usize, + pub memories: Vec, +} + +/// Response for the lore_search_memories tool. +#[derive(Debug, Serialize)] +pub struct SearchMemoriesResponse { + pub query: String, + pub project_path: String, + pub source_tool: String, + pub total: usize, + pub memories: Vec, +} + // ============== Server Implementation ============== /// The Lore MCP server. @@ -294,6 +353,49 @@ impl LoreServer { Err(e) => Err(mcp_error(&format!("Get linked sessions failed: {e}"))), } } + + /// Get the mirrored memories for a project. + /// + /// Reflects the coding tool's per-project memory store (currently Claude + /// Code) and returns the current memories. The mirror is refreshed from the + /// tool's memory folder before returning so results are always current. + #[tool(description = "Get a project's memories mirrored from a coding tool's memory store")] + async fn lore_get_memories( + &self, + params: Parameters, + ) -> Result { + let params = params.0; + let result = get_memories_impl(params); + match result { + Ok(response) => { + let json = serde_json::to_string_pretty(&response) + .unwrap_or_else(|e| format!("Error serializing response: {e}")); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + Err(e) => Err(mcp_error(&format!("Get memories failed: {e}"))), + } + } + + /// Search a project's mirrored memories by text. + /// + /// Refreshes the mirror from the tool's memory folder, then full-text + /// searches the project's memories. + #[tool(description = "Full-text search a project's mirrored memories")] + async fn lore_search_memories( + &self, + params: Parameters, + ) -> Result { + let params = params.0; + let result = search_memories_impl(params); + match result { + Ok(response) => { + let json = serde_json::to_string_pretty(&response) + .unwrap_or_else(|e| format!("Error serializing response: {e}")); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + Err(e) => Err(mcp_error(&format!("Search memories failed: {e}"))), + } + } } #[tool_handler] @@ -528,6 +630,65 @@ fn get_linked_sessions_impl( }) } +/// Converts a Memory to MemoryInfo. +fn memory_to_info(memory: &Memory) -> MemoryInfo { + MemoryInfo { + name: memory.name.clone(), + description: memory.description.clone(), + memory_type: memory.memory_type.clone(), + content: memory.content.clone(), + file_path: memory.file_path.clone(), + updated_at: memory.updated_at.to_rfc3339(), + } +} + +/// Implementation of the get_memories tool. +/// +/// Refreshes the read-only mirror of the tool's memory folder for the resolved +/// project, then returns the current memories scoped to that project. +fn get_memories_impl(params: GetMemoriesParams) -> anyhow::Result { + let db = Database::open_default()?; + let project = resolve_project_path(params.project_path.as_deref())?; + + // Refresh-on-read so results are current without needing the daemon. + let mirror = MemoryMirror::claude(); + mirror.refresh(&db, &project)?; + + let project_key = project.to_string_lossy().to_string(); + let memories = db.get_memories(&project_key, CLAUDE_CODE_TOOL)?; + let infos: Vec = memories.iter().map(memory_to_info).collect(); + + Ok(MemoriesResponse { + project_path: project_key, + source_tool: CLAUDE_CODE_TOOL.to_string(), + total: infos.len(), + memories: infos, + }) +} + +/// Implementation of the search_memories tool. +fn search_memories_impl(params: SearchMemoriesParams) -> anyhow::Result { + let db = Database::open_default()?; + let project = resolve_project_path(params.project_path.as_deref())?; + + // Refresh-on-read so results are current without needing the daemon. + let mirror = MemoryMirror::claude(); + mirror.refresh(&db, &project)?; + + let project_key = project.to_string_lossy().to_string(); + let limit = params.limit.unwrap_or(20); + let memories = db.search_memories(&project_key, CLAUDE_CODE_TOOL, ¶ms.query, limit)?; + let infos: Vec = memories.iter().map(memory_to_info).collect(); + + Ok(SearchMemoriesResponse { + query: params.query, + project_path: project_key, + source_tool: CLAUDE_CODE_TOOL.to_string(), + total: infos.len(), + memories: infos, + }) +} + /// Resolves a session ID prefix to a full UUID. fn resolve_session_id(db: &Database, id_prefix: &str) -> anyhow::Result { // Use the efficient database method that searches all sessions @@ -635,4 +796,29 @@ mod tests { assert_eq!(info.role, "user"); assert_eq!(info.content, "Hello, world!"); } + + #[test] + fn test_memory_to_info() { + use chrono::Utc; + use uuid::Uuid; + + let memory = Memory { + id: Uuid::new_v4(), + project_path: "/home/user/project".to_string(), + source_tool: "claude-code".to_string(), + name: "API base URL".to_string(), + description: Some("Where the API lives".to_string()), + memory_type: Some("reference".to_string()), + content: "The API base URL is https://example.com.".to_string(), + file_path: "/home/user/.claude/projects/slug/memory/fact-1.md".to_string(), + updated_at: Utc::now(), + }; + + let info = memory_to_info(&memory); + assert_eq!(info.name, "API base URL"); + assert_eq!(info.description.as_deref(), Some("Where the API lives")); + assert_eq!(info.memory_type.as_deref(), Some("reference")); + assert!(info.content.contains("https://example.com")); + assert!(info.file_path.ends_with("fact-1.md")); + } } diff --git a/src/storage/db.rs b/src/storage/db.rs index d38cd3d..21b5cda 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -12,8 +12,8 @@ use std::path::{Path, PathBuf}; use uuid::Uuid; use super::models::{ - Annotation, Machine, Message, MessageContent, MessageRole, SearchResult, Session, SessionLink, - Summary, Tag, Tombstone, + Annotation, Machine, Memory, Message, MessageContent, MessageRole, SearchResult, Session, + SessionLink, Summary, Tag, Tombstone, }; /// Tombstone kind for a deleted session-to-commit link. @@ -325,6 +325,25 @@ impl Database { created_at TEXT NOT NULL ); + -- Read-only mirror of a coding tool's per-project memory store. + -- Each row is one markdown memory file mirrored from the tool's + -- memory folder. Scoped by (project_path, source_tool). The + -- (project_path, source_tool, file_path) triple is unique so a + -- refresh can upsert by source file. Lore never writes back to the + -- tool's own memory folder; this table is a read-only reflection. + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + project_path TEXT NOT NULL, + source_tool TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + memory_type TEXT, + content TEXT NOT NULL, + file_path TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(project_path, source_tool, file_path) + ); + -- Tombstones record locally deleted child records so a deletion on -- one machine propagates through the sync store instead of being -- resurrected by the additive child merge. Keyed by (child_id, kind) @@ -347,6 +366,7 @@ impl Database { CREATE INDEX IF NOT EXISTS idx_tags_session_id ON tags(session_id); CREATE INDEX IF NOT EXISTS idx_tags_label ON tags(label); CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at); + CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_path, source_tool); "#, )?; @@ -378,6 +398,21 @@ impl Database { "#, )?; + // Create FTS5 virtual table for full-text search over mirrored memories. + // The memory_id column stores the UUID string for joining back to the + // memories table. + self.conn.execute_batch( + r#" + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + memory_id, + name, + description, + content, + tokenize='porter unicode61' + ); + "#, + )?; + // Migration: Add machine_id column to existing sessions table if not present. // This handles upgrades from databases created before machine_id was added. self.migrate_add_machine_id()?; @@ -2003,6 +2038,155 @@ impl Database { || (session_count > 0 && session_fts_count == 0)) } + // ==================== Memories ==================== + + /// Maps a database row to a [`Memory`]. + fn row_to_memory(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Memory { + id: parse_uuid(&row.get::<_, String>(0)?)?, + project_path: row.get(1)?, + source_tool: row.get(2)?, + name: row.get(3)?, + description: row.get(4)?, + memory_type: row.get(5)?, + content: row.get(6)?, + file_path: row.get(7)?, + updated_at: parse_datetime(&row.get::<_, String>(8)?)?, + }) + } + + /// Inserts a mirrored memory or updates the existing one for the same source + /// file. + /// + /// Memories are uniquely identified by their + /// `(project_path, source_tool, file_path)` triple. If a memory already + /// exists for that source file its existing id is preserved and its fields + /// are updated in place. The `memories_fts` index is kept in sync so search + /// reflects the latest content. Returns the id of the stored memory. + pub fn upsert_memory(&self, memory: &Memory) -> Result { + // Preserve the existing id when the source file is already mirrored so + // that the FTS row and any external references stay stable. + let existing_id: Option = self + .conn + .query_row( + "SELECT id FROM memories WHERE project_path = ?1 AND source_tool = ?2 AND file_path = ?3", + params![memory.project_path, memory.source_tool, memory.file_path], + |row| row.get(0), + ) + .optional()?; + + let id = match existing_id { + Some(s) => Uuid::parse_str(&s).unwrap_or(memory.id), + None => memory.id, + }; + + self.conn.execute( + r#" + INSERT INTO memories (id, project_path, source_tool, name, description, memory_type, content, file_path, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(project_path, source_tool, file_path) DO UPDATE SET + name = excluded.name, + description = excluded.description, + memory_type = excluded.memory_type, + content = excluded.content, + updated_at = excluded.updated_at + "#, + params![ + id.to_string(), + memory.project_path, + memory.source_tool, + memory.name, + memory.description, + memory.memory_type, + memory.content, + memory.file_path, + memory.updated_at.to_rfc3339(), + ], + )?; + + // Keep the FTS index in sync: replace any prior row for this memory. + self.conn.execute( + "DELETE FROM memories_fts WHERE memory_id = ?1", + params![id.to_string()], + )?; + self.conn.execute( + "INSERT INTO memories_fts (memory_id, name, description, content) VALUES (?1, ?2, ?3, ?4)", + params![ + id.to_string(), + memory.name, + memory.description.as_deref().unwrap_or(""), + memory.content, + ], + )?; + + Ok(id) + } + + /// Deletes a mirrored memory by id, also removing it from the FTS index. + /// + /// Returns true if a memory was deleted. + pub fn delete_memory(&self, id: &Uuid) -> Result { + self.conn.execute( + "DELETE FROM memories_fts WHERE memory_id = ?1", + params![id.to_string()], + )?; + let rows = self.conn.execute( + "DELETE FROM memories WHERE id = ?1", + params![id.to_string()], + )?; + Ok(rows > 0) + } + + /// Returns all mirrored memories for a project and source tool. + /// + /// Results are ordered by name for stable display. + pub fn get_memories(&self, project_path: &str, source_tool: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, project_path, source_tool, name, description, memory_type, content, file_path, updated_at + FROM memories + WHERE project_path = ?1 AND source_tool = ?2 + ORDER BY name", + )?; + + let memories = stmt + .query_map(params![project_path, source_tool], Self::row_to_memory)? + .collect::>>()?; + + Ok(memories) + } + + /// Full-text searches mirrored memories for a project and source tool. + /// + /// Matches against memory name, description, and content using FTS5 and + /// returns results ordered by relevance, limited to `limit` rows. + pub fn search_memories( + &self, + project_path: &str, + source_tool: &str, + query: &str, + limit: usize, + ) -> Result> { + let escaped_query = escape_fts5_query(query); + + let mut stmt = self.conn.prepare( + "SELECT m.id, m.project_path, m.source_tool, m.name, m.description, m.memory_type, m.content, m.file_path, m.updated_at + FROM memories_fts fts + JOIN memories m ON fts.memory_id = m.id + WHERE memories_fts MATCH ?1 AND m.project_path = ?2 AND m.source_tool = ?3 + ORDER BY rank + LIMIT ?4", + )?; + + let memories = stmt + .query_map( + params![escaped_query, project_path, source_tool, limit as i64], + Self::row_to_memory, + )? + .collect::>>()?; + + Ok(memories) + } + // ==================== Sync ==================== /// Returns sessions that have not been synced. diff --git a/src/storage/mod.rs b/src/storage/mod.rs index d31eca7..a55de16 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -20,7 +20,7 @@ pub mod models; pub use db::Database; // DatabaseStats is also available at crate::storage::db::DatabaseStats if needed pub use models::{ - extract_session_files, Annotation, ContentBlock, LinkCreator, LinkType, Machine, + extract_session_files, Annotation, ContentBlock, LinkCreator, LinkType, Machine, Memory, MessageContent, MessageRole, SessionLink, Summary, Tag, }; diff --git a/src/storage/models.rs b/src/storage/models.rs index db0f7ec..76ddf7f 100644 --- a/src/storage/models.rs +++ b/src/storage/models.rs @@ -450,6 +450,48 @@ pub struct Summary { pub generated_at: DateTime, } +/// A per-project memory mirrored from a coding tool's memory store. +/// +/// Tools such as Claude Code write per-project "memory" (running notes, next +/// steps, corrections) into their own private stores that other tools cannot +/// see. Lore mirrors those files into this read-only representation so any LLM +/// can read them through Lore's MCP server. Lore never writes back to the +/// tool's memory folder. +/// +/// Each memory corresponds to a single markdown file in the tool's memory +/// folder. The memory is scoped to the project it belongs to (`project_path`) +/// and the tool that authored it (`source_tool`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Memory { + /// Unique identifier for this memory. + pub id: Uuid, + + /// Absolute path of the repository this memory belongs to. + pub project_path: String, + + /// The tool that authored this memory (e.g., "claude-code"). + pub source_tool: String, + + /// Short name of the memory (from frontmatter `name`, or the file stem). + pub name: String, + + /// Optional human-readable description (from frontmatter `description`). + pub description: Option, + + /// Optional memory type (from frontmatter `metadata.type`, e.g. user, + /// feedback, project, reference). + pub memory_type: Option, + + /// The memory body (the markdown content following any frontmatter). + pub content: String, + + /// Absolute path of the source file this memory was mirrored from. + pub file_path: String, + + /// When the source file was last modified, used to detect changes. + pub updated_at: DateTime, +} + /// Represents a machine that has captured sessions. /// /// Used for sync to map machine UUIDs to friendly names. Each machine From 18ee2ba13292dbb92cf13de0a8db401e27007305 Mon Sep 17 00:00:00 2001 From: franzer Date: Wed, 1 Jul 2026 20:18:12 -0600 Subject: [PATCH 2/2] docs: document cross-tool memory in README --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index dd4182f..a2514bd 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Linked sessions (1): |---------|-------------| | **Blame** | Trace any line of code to the AI session that produced it | | **Git-ref Sync** | Sync reasoning through your existing git remotes, encrypted; share by passphrase | +| **Cross-tool Memory** | Mirror one tool's project memory so any LLM can read it over MCP | | **Session Capture** | One history across 10+ AI coding tools | | **Git Linking** | Connect sessions to commits | | **Full-text Search** | Find any past conversation | @@ -120,6 +121,16 @@ Lore syncs reasoning history through your existing git remotes, with no server a See the [Sync Guide](https://lore.varalys.com/guides/sync/) for details. +## Memory + +Coding tools write their own per-project memory (running notes, next steps, corrections) that other tools cannot see. Lore mirrors that memory and exposes it over MCP, so switching tools keeps the context. Point Codex, or any MCP client, at `lore mcp serve` and it can read the memory another tool wrote for the same repo. + +- Read-only: Lore mirrors the tool's memory files, it never modifies them. +- Scoped to the current repo. +- `lore memories` lists what has been mirrored. + +Currently mirrors Claude Code memory, with more tools to follow. See the [Memory Guide](https://lore.varalys.com/guides/memory/) for details. + ## Supported Tools Claude Code, Codex CLI, Gemini CLI, Amp, Aider, Continue.dev, Cline, Roo Code, Kilo Code, OpenCode