diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bad00..64b10e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,7 +139,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agit commit` - Create a neural commit with linked context - `agit log` - View commit history with summaries - `agit show` - Show full context for a commit - - `agit server` - Start the MCP server + - `agit serve` - Start the MCP server for AI editors - MCP (Model Context Protocol) server: - `agit_log_step` - Log conversation steps from AI editors - `agit_read_roadmap` - Read project roadmap diff --git a/README.md b/README.md index 36a26cf..d19176f 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ your-project/ | `agit log` | View commit history with summaries | | `agit show [hash]` | Show full context for a commit | | `agit search ` | Search past reasoning | -| `agit server` | Start the MCP server | +| `agit serve` | Start the MCP server for AI editors | ## Philosophy diff --git a/src/cli/args.rs b/src/cli/args.rs index f3f82b1..ae15886 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -49,8 +49,8 @@ pub enum Commands { /// Migrate storage from V1 (file-based) to V2 (Git-native) Migrate(MigrateArgs), - /// Start the MCP server - Server(ServerArgs), + /// Start the MCP server for AI editors + Serve(ServeArgs), /// Search-related commands (rebuild index, query) Search(SearchArgs), @@ -185,9 +185,9 @@ pub struct CommitArgs { pub journal: bool, } -/// Arguments for the `server` command +/// Arguments for the `serve` command #[derive(Parser, Debug)] -pub struct ServerArgs { +pub struct ServeArgs { /// Port to listen on (for HTTP mode, not implemented yet) #[arg(short, long)] pub port: Option, diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index e4f9e8f..5c0afbd 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -35,20 +35,25 @@ pub fn execute(args: InitArgs) -> Result<()> { return Err(AgitError::NotGitRepository); } - // Handle --update mode: only update template files, don't reinitialize + // Handle --update mode: update template files and MCP configs if args.update { if !agit_dir.exists() { println!("⚠️ AGIT is not initialized. Run `agit init` first."); return Ok(()); } + // Update template files (CLAUDE.md, .cursorrules, etc.) let result = update_template_files(&cwd)?; - if result.updated_count > 0 { - println!("\nRestart your AI assistant to apply the updated AGIT memory protocol."); + + // Update MCP configs (server -> serve) + let mcp_updated = update_mcp_configs(&cwd)?; + + if result.updated_count > 0 || mcp_updated > 0 { + println!("\nRestart your AI assistant to apply the updates."); } else if result.up_to_date_count > 0 { println!("Already up to date."); } else { - println!("No template files were updated. Ensure CLAUDE.md or .cursorrules exists."); + println!("No files were updated."); } return Ok(()); } @@ -242,7 +247,8 @@ fn update_template_files(project_dir: &Path) -> Result { if let Some(version_start) = protocol_block.find("version=\"") { let version_str_start = version_start + 9; // len of 'version="' if let Some(version_end) = protocol_block[version_str_start..].find('"') { - let existing_version = &protocol_block[version_str_start..version_str_start + version_end]; + let existing_version = + &protocol_block[version_str_start..version_str_start + version_end]; if existing_version == AGIT_VERSION { result.up_to_date_count += 1; continue; @@ -284,7 +290,7 @@ fn generate_mcp_config(agit_path: &str) -> String { "mcpServers": {{ "agit": {{ "command": "{}", - "args": ["server"] + "args": ["serve"] }} }} }} @@ -303,7 +309,7 @@ fn generate_vscode_mcp_config(agit_path: &str) -> String { "servers": {{ "agit": {{ "command": "{}", - "args": ["server"] + "args": ["serve"] }} }} }} @@ -352,6 +358,33 @@ fn generate_mcp_configs(project_dir: &Path) -> Result<()> { Ok(()) } +/// Update existing MCP configuration files to use the new "serve" command. +/// Returns the number of files updated. +fn update_mcp_configs(project_dir: &Path) -> Result { + let mcp_paths = [ + project_dir.join(".mcp.json"), + project_dir.join(".cursor/mcp.json"), + project_dir.join(".vscode/mcp.json"), + ]; + + let mut updated_count = 0; + + for path in &mcp_paths { + if path.exists() { + let content = fs::read_to_string(path)?; + // Check if it has the old "server" command + if content.contains(r#""args": ["server"]"#) { + let new_content = content.replace(r#""args": ["server"]"#, r#""args": ["serve"]"#); + fs::write(path, new_content)?; + updated_count += 1; + println!(" - Updated {}", path.display()); + } + } + } + + Ok(updated_count) +} + /// Update .gitignore with AGIT entries. /// Uses V2 (Git-native) entries by default. fn update_gitignore(project_dir: &Path) -> Result<()> { @@ -474,7 +507,7 @@ mod tests { let mcp_content = fs::read_to_string(temp.path().join(".mcp.json")).unwrap(); assert!(mcp_content.contains("mcpServers")); assert!(mcp_content.contains("agit")); - assert!(mcp_content.contains("server")); + assert!(mcp_content.contains("serve")); // Verify VS Code uses "servers" key (not "mcpServers") let vscode_content = fs::read_to_string(temp.path().join(".vscode/mcp.json")).unwrap(); @@ -556,7 +589,10 @@ Additional custom content below. // Run the update let result = update_template_files(temp.path()).unwrap(); - assert!(result.updated_count > 0, "Should have updated at least one file"); + assert!( + result.updated_count > 0, + "Should have updated at least one file" + ); // Verify the result let new_content = fs::read_to_string(temp.path().join("CLAUDE.md")).unwrap(); @@ -617,8 +653,14 @@ Additional custom content below. // Run the update - should not update anything let result = update_template_files(temp.path()).unwrap(); - assert_eq!(result.updated_count, 0, "Should not update when no protocol block found"); - assert_eq!(result.up_to_date_count, 0, "Should not be up-to-date when no protocol block found"); + assert_eq!( + result.updated_count, 0, + "Should not update when no protocol block found" + ); + assert_eq!( + result.up_to_date_count, 0, + "Should not be up-to-date when no protocol block found" + ); // Content should remain unchanged let after_content = fs::read_to_string(temp.path().join("CLAUDE.md")).unwrap(); @@ -631,8 +673,14 @@ Additional custom content below. // No template files exist let result = update_template_files(temp.path()).unwrap(); - assert_eq!(result.updated_count, 0, "Should not update when no template files exist"); - assert_eq!(result.up_to_date_count, 0, "Should not be up-to-date when no template files exist"); + assert_eq!( + result.updated_count, 0, + "Should not update when no template files exist" + ); + assert_eq!( + result.up_to_date_count, 0, + "Should not be up-to-date when no template files exist" + ); } #[test] @@ -659,7 +707,10 @@ Additional custom content below. // Run the update - should detect as already up-to-date let result = update_template_files(temp.path()).unwrap(); - assert_eq!(result.updated_count, 0, "Should not update when already current version"); + assert_eq!( + result.updated_count, 0, + "Should not update when already current version" + ); assert_eq!(result.up_to_date_count, 1, "Should count as up-to-date"); // Content should remain unchanged diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 948b636..60f0cef 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -11,7 +11,7 @@ pub mod push; pub mod record; pub mod reset; pub mod search; -pub mod server; +pub mod serve; pub mod show; pub mod status; pub mod sync; diff --git a/src/cli/commands/server.rs b/src/cli/commands/serve.rs similarity index 86% rename from src/cli/commands/server.rs rename to src/cli/commands/serve.rs index 258d153..eb7ea9f 100644 --- a/src/cli/commands/server.rs +++ b/src/cli/commands/serve.rs @@ -1,14 +1,14 @@ -//! Implementation of the `agit server` command. +//! Implementation of the `agit serve` command. //! //! This starts the MCP (Model Context Protocol) server that AI assistants //! can connect to for logging thoughts and reading context. -use crate::cli::args::ServerArgs; +use crate::cli::args::ServeArgs; use crate::error::{AgitError, Result}; use crate::mcp::McpServer; -/// Execute the `server` command. -pub fn execute(args: ServerArgs) -> Result<()> { +/// Execute the `serve` command. +pub fn execute(args: ServeArgs) -> Result<()> { let cwd = std::env::current_dir()?; let agit_dir = cwd.join(".agit"); diff --git a/src/main.rs b/src/main.rs index bcb2dca..64a986f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ fn run() -> Result<()> { Commands::Push(args) => agit::cli::commands::push::execute(args), Commands::Pull(args) => agit::cli::commands::pull::execute(args), Commands::Migrate(args) => agit::cli::commands::migrate::execute(args), - Commands::Server(args) => agit::cli::commands::server::execute(args), + Commands::Serve(args) => agit::cli::commands::serve::execute(args), Commands::Search(args) => agit::cli::commands::search::execute(args), Commands::Reset(args) => agit::cli::commands::reset::execute(args), Commands::Sync(args) => agit::cli::commands::sync::execute(args), diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index a88875a..dbae770 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -8,6 +8,7 @@ //! The MCP server uses JSON-RPC 2.0 over stdio: //! - AI editors connect to the server via stdin/stdout //! - They can call tools like `agit_log_step` to record context +//! - They can read resources like `agit://history/recent` //! - The server responds with JSON-RPC responses //! //! # Available Tools @@ -15,8 +16,13 @@ //! - `agit_log_step` - Log a step (intent/reasoning) in the conversation //! - `agit_read_roadmap` - Read the project roadmap //! - `agit_get_context` - Get context for a specific git commit +//! +//! # Available Resources +//! +//! - `agit://history/recent` - Recent commit summaries pub mod protocol; +pub mod resources; pub mod server; pub mod tools; diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs index 61028ed..d8b5c4c 100644 --- a/src/mcp/protocol.rs +++ b/src/mcp/protocol.rs @@ -116,6 +116,8 @@ pub struct InitializeResult { #[derive(Debug, Serialize)] pub struct ServerCapabilities { pub tools: ToolsCapability, + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option, } /// Tools capability. @@ -193,6 +195,85 @@ impl ToolCallResult { } } +// ============================================================ +// RESOURCE TYPES +// ============================================================ + +/// Resources capability for server capabilities. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourcesCapability { + /// Whether the server emits list_changed notifications. + #[serde(skip_serializing_if = "Option::is_none")] + pub list_changed: Option, + /// Whether the server supports subscriptions. + #[serde(skip_serializing_if = "Option::is_none")] + pub subscribe: Option, +} + +/// Resource definition exposed by the server. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ResourceDefinition { + /// Unique URI identifying this resource (e.g., "agit://history/recent") + pub uri: String, + /// Human-readable name of the resource + pub name: String, + /// Optional description of what this resource provides + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional MIME type of the resource content + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, +} + +/// Response for resources/list request. +#[derive(Debug, Serialize)] +pub struct ResourcesListResult { + pub resources: Vec, +} + +/// Parameters for resources/read request. +#[derive(Debug, Deserialize)] +pub struct ResourceReadParams { + pub uri: String, +} + +/// Content item returned from resources/read. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceContent { + /// URI of the resource + pub uri: String, + /// MIME type of the content + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Text content (for text resources) + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + /// Base64-encoded binary content (for binary resources) + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, +} + +impl ResourceContent { + /// Create a text resource content. + pub fn text(uri: &str, content: &str, mime_type: Option<&str>) -> Self { + Self { + uri: uri.to_string(), + mime_type: mime_type.map(|s| s.to_string()), + text: Some(content.to_string()), + blob: None, + } + } +} + +/// Response for resources/read request. +#[derive(Debug, Serialize)] +pub struct ResourceReadResult { + pub contents: Vec, +} + // Tool-specific parameter types /// A code location for MCP input. diff --git a/src/mcp/resources/mod.rs b/src/mcp/resources/mod.rs new file mode 100644 index 0000000..ae3ece8 --- /dev/null +++ b/src/mcp/resources/mod.rs @@ -0,0 +1,6 @@ +//! MCP resource implementations. +//! +//! This module contains the implementations of MCP resources that +//! AI editors can read to get project context. + +pub mod recent_history; diff --git a/src/mcp/resources/recent_history.rs b/src/mcp/resources/recent_history.rs new file mode 100644 index 0000000..b87a38e --- /dev/null +++ b/src/mcp/resources/recent_history.rs @@ -0,0 +1,177 @@ +//! Implementation of the agit://history/recent resource. +//! +//! This resource provides recent commit summaries to AI editors +//! so they can understand what was done recently in the project. + +use std::path::Path; + +use git2::Repository; +use tracing::debug; + +use crate::core::{detect_version, StorageVersion}; +use crate::domain::WrappedNeuralCommit; +use crate::mcp::protocol::ResourceContent; +use crate::storage::{ + FileHeadStore, FileObjectStore, FileRefStore, GitObjectStore, GitRefStore, HeadStore, + ObjectStore, RefStore, +}; + +/// The URI for this resource. +pub const URI: &str = "agit://history/recent"; + +/// The human-readable name. +pub const NAME: &str = "Recent History"; + +/// Description of what this resource provides. +pub const DESCRIPTION: &str = + "Recent commit summaries showing what was done recently in the project. \ + Use this to understand recent changes before starting work."; + +/// MIME type for the content. +pub const MIME_TYPE: &str = "text/plain"; + +/// Default number of recent summaries to return. +const DEFAULT_COUNT: usize = 5; + +/// Read the recent history resource. +pub fn read(project_root: &Path, agit_dir: &Path) -> Result { + // Check if agit is initialized + if !agit_dir.exists() { + return Err("AGIT not initialized. Run 'agit init' first.".to_string()); + } + + // Get recent summaries (reuse logic from get_recent_summaries tool) + match get_recent_summaries(project_root, agit_dir, DEFAULT_COUNT) { + Ok(summaries) => Ok(ResourceContent::text(URI, &summaries, Some(MIME_TYPE))), + Err(e) => { + debug!("Failed to get recent summaries: {}", e); + // Return a helpful message instead of an error for empty history + let content = "No commits yet.\n\n\ + Recent summaries will appear here after you make commits with AGIT.\n\ + Use 'agit commit' to create commits that capture your reasoning."; + Ok(ResourceContent::text(URI, content, Some(MIME_TYPE))) + }, + } +} + +/// Get recent commit summaries from the neural commit history. +/// This is extracted from get_recent_summaries tool for reuse. +fn get_recent_summaries( + project_root: &Path, + agit_dir: &Path, + count: usize, +) -> Result { + let head_store = FileHeadStore::new(agit_dir); + + // Detect storage version + let is_v2 = match Repository::discover(project_root) { + Ok(repo) => matches!(detect_version(agit_dir, &repo), StorageVersion::V2GitNative), + Err(_) => false, + }; + + // Get current branch + let branch = head_store + .get() + .map_err(|e| format!("Failed to read HEAD: {}", e))? + .unwrap_or_else(|| "main".to_string()); + + // Start from the latest commit and walk back + let mut current_hash: Option = if is_v2 { + let ref_store = GitRefStore::new(project_root); + ref_store + .get(&branch) + .map_err(|e| format!("Failed to read ref: {}", e))? + } else { + let ref_store = FileRefStore::new(agit_dir); + ref_store + .get(&branch) + .map_err(|e| format!("Failed to read ref: {}", e))? + }; + + if current_hash.is_none() { + return Err("No commits yet".to_string()); + } + + let mut summaries = Vec::new(); + + while let Some(hash) = current_hash { + if summaries.len() >= count { + break; + } + + // Load the commit + let commit_data = if is_v2 { + let object_store = GitObjectStore::new(project_root); + object_store + .load(&hash) + .map_err(|e| format!("Failed to load commit: {}", e))? + } else { + let object_store = FileObjectStore::new(agit_dir); + object_store + .load(&hash) + .map_err(|e| format!("Failed to load commit: {}", e))? + }; + + let wrapped: WrappedNeuralCommit = serde_json::from_slice(&commit_data) + .map_err(|e| format!("Failed to parse commit: {}", e))?; + + let commit = &wrapped.data; + + // Format: ## [short_hash] date\nsummary + let date_str = commit.created_at.format("%Y-%m-%d").to_string(); + summaries.push(format!( + "## [{}] {}\n{}", + commit.short_hash(), + date_str, + commit.summary + )); + + current_hash = commit.first_parent().map(|s| s.to_string()); + } + + if summaries.is_empty() { + return Err("No commits found".to_string()); + } + + let mut output = String::from("# Recent Activity\n\n"); + output.push_str(&summaries.join("\n\n")); + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_read_not_initialized() { + let temp = TempDir::new().unwrap(); + let project_root = temp.path(); + let agit_dir = temp.path().join(".agit"); + + let result = read(project_root, &agit_dir); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not initialized")); + } + + #[test] + fn test_read_no_commits() { + let temp = TempDir::new().unwrap(); + let project_root = temp.path(); + let agit_dir = temp.path().join(".agit"); + + // Create basic structure without any commits + fs::create_dir_all(agit_dir.join("objects")).unwrap(); + fs::create_dir_all(agit_dir.join("refs/heads")).unwrap(); + fs::write(agit_dir.join("HEAD"), "main").unwrap(); + fs::write(agit_dir.join("index"), "").unwrap(); + + let result = read(project_root, &agit_dir); + // Should return OK with helpful message, not an error + assert!(result.is_ok()); + let content = result.unwrap(); + assert!(content.text.unwrap().contains("No commits yet")); + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4b4f705..71e79b8 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -11,6 +11,7 @@ use tracing::{debug, error, info}; use crate::error::Result; use crate::mcp::protocol::*; +use crate::mcp::resources; use crate::mcp::tools; /// MCP Server that handles JSON-RPC requests over stdio. @@ -123,6 +124,10 @@ impl McpServer { "tools/list" => self.handle_tools_list(), "tools/call" => self.handle_tools_call(request.params.as_ref()), + // MCP resource methods + "resources/list" => self.handle_resources_list(), + "resources/read" => self.handle_resources_read(request.params.as_ref()), + // Unknown method _ => { error!("Unknown method: {}", request.method); @@ -147,6 +152,10 @@ impl McpServer { tools: ToolsCapability { list_changed: false, }, + resources: Some(ResourcesCapability { + list_changed: Some(false), + subscribe: Some(false), + }), }, server_info: ServerInfo { name: "agit".to_string(), @@ -345,4 +354,46 @@ impl McpServer { serde_json::to_value(result).map_err(|e| (INTERNAL_ERROR, e.to_string())) } + + /// Handle the resources/list request. + fn handle_resources_list(&self) -> std::result::Result { + let resources = vec![ResourceDefinition { + uri: resources::recent_history::URI.to_string(), + name: resources::recent_history::NAME.to_string(), + description: Some(resources::recent_history::DESCRIPTION.to_string()), + mime_type: Some(resources::recent_history::MIME_TYPE.to_string()), + }]; + + let result = ResourcesListResult { resources }; + serde_json::to_value(result).map_err(|e| (INTERNAL_ERROR, e.to_string())) + } + + /// Handle the resources/read request. + fn handle_resources_read( + &self, + params: Option<&Value>, + ) -> std::result::Result { + let params = params.ok_or((INVALID_PARAMS, "Missing params".to_string()))?; + + let read_params: ResourceReadParams = serde_json::from_value(params.clone()) + .map_err(|e| (INVALID_PARAMS, format!("Invalid params: {}", e)))?; + + let content = match read_params.uri.as_str() { + uri if uri == resources::recent_history::URI => { + resources::recent_history::read(&self.project_root, &self.agit_dir) + .map_err(|e| (INTERNAL_ERROR, e))? + }, + _ => { + return Err(( + INVALID_PARAMS, + format!("Unknown resource URI: {}", read_params.uri), + )); + }, + }; + + let result = ResourceReadResult { + contents: vec![content], + }; + serde_json::to_value(result).map_err(|e| (INTERNAL_ERROR, e.to_string())) + } } diff --git a/src/mcp/tools/log_step.rs b/src/mcp/tools/log_step.rs index 35ea9fd..81745b0 100644 --- a/src/mcp/tools/log_step.rs +++ b/src/mcp/tools/log_step.rs @@ -224,7 +224,11 @@ fn execute_batch(agit_dir: &Path, entries: Vec) -> ToolCallResult { rejection_parts.push(format!( "⛔ {} {} rejected (all locations outside repository scope)", rejected_entries, - if rejected_entries == 1 { "entry" } else { "entries" } + if rejected_entries == 1 { + "entry" + } else { + "entries" + } )); } diff --git a/src/storage/memory.rs b/src/storage/memory.rs new file mode 100644 index 0000000..9eebcf6 --- /dev/null +++ b/src/storage/memory.rs @@ -0,0 +1,849 @@ +//! Merge-friendly immutable memory storage. +//! +//! This module provides a content-addressable storage system for memory nodes, +//! designed to be merge-friendly by storing each memory as a separate immutable +//! JSON file. This eliminates merge conflicts when multiple users generate +//! different memories on different branches. +//! +//! ## Storage Model +//! +//! Similar to Git's internal blob storage: +//! - Each memory is stored as a separate `.json` file +//! - Files are organized in sharded directories: `.agit/objects/memories/{hash[0..2]}/{hash[2..]}.json` +//! - Content is hashed with SHA-256 for deduplication and integrity +//! - Files are immutable once written (idempotent saves) +//! +//! ## Merge Behavior +//! +//! When two branches have different memories: +//! - Git merge keeps both files (union merge) +//! - No conflicts occur since files don't overlap +//! - `get_all_memories()` returns the union of all memories + +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::domain::{Category, IndexEntry, Location, Role}; +use crate::error::{AgitError, Result, StorageError}; +use crate::safety::atomic_write; + +// ============================================================================ +// MemoryNode - The Core Data Structure +// ============================================================================ + +/// A memory node representing a single thought, intent, or reasoning entry. +/// +/// Unlike `IndexEntry` which is transient (stored in staging area), `MemoryNode` +/// is the permanent, immutable representation of a memory linked to a commit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MemoryNode { + /// The git commit hash this memory is associated with. + /// Empty string for uncommitted memories. + #[serde(default)] + pub commit_hash: String, + + /// Who created this entry (user or AI). + pub role: Role, + + /// Category of the entry (intent, reasoning, error, note). + pub category: Category, + + /// The actual content/message. + pub content: String, + + /// When this memory was created. + #[serde(with = "chrono::serde::ts_seconds")] + pub timestamp: DateTime, + + /// Code locations this memory relates to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locations: Option>, + + /// Schema version for future migrations. + #[serde(default = "default_schema_version")] + pub schema_version: u32, +} + +fn default_schema_version() -> u32 { + 1 +} + +impl MemoryNode { + /// Create a new memory node with the current timestamp. + pub fn new( + commit_hash: impl Into, + role: Role, + category: Category, + content: impl Into, + ) -> Self { + Self { + commit_hash: commit_hash.into(), + role, + category, + content: content.into(), + timestamp: Utc::now(), + locations: None, + schema_version: 1, + } + } + + /// Create a memory node with locations. + pub fn with_locations( + commit_hash: impl Into, + role: Role, + category: Category, + content: impl Into, + locations: Vec, + ) -> Self { + Self { + commit_hash: commit_hash.into(), + role, + category, + content: content.into(), + timestamp: Utc::now(), + locations: if locations.is_empty() { + None + } else { + Some(locations) + }, + schema_version: 1, + } + } + + /// Convert from an IndexEntry, associating with a commit. + pub fn from_index_entry(entry: &IndexEntry, commit_hash: impl Into) -> Self { + Self { + commit_hash: commit_hash.into(), + role: entry.role, + category: entry.category, + content: entry.content.clone(), + timestamp: entry.timestamp, + locations: entry.locations.clone(), + schema_version: 1, + } + } + + /// Get all locations, returning empty vec if none. + pub fn get_locations(&self) -> Vec { + self.locations.clone().unwrap_or_default() + } + + /// Compute the SHA-256 hash of this memory's JSON representation. + pub fn compute_hash(&self) -> Result { + let json = serde_json::to_vec(self)?; + let mut hasher = Sha256::new(); + hasher.update(&json); + Ok(hex::encode(hasher.finalize())) + } +} + +// ============================================================================ +// MemoryStore Trait +// ============================================================================ + +/// Trait for memory storage operations. +/// +/// Provides a merge-friendly storage interface where each memory is stored +/// as a separate immutable file. +pub trait MemoryStore: Send + Sync { + /// Save a memory node to storage. + /// + /// Returns the content hash (SHA-256) of the stored memory. + /// If the memory already exists (same hash), this is a no-op. + fn save_memory(&self, memory: &MemoryNode) -> Result; + + /// Load a specific memory by its hash. + fn load_memory(&self, hash: &str) -> Result; + + /// Check if a memory exists by its hash. + fn exists(&self, hash: &str) -> Result; + + /// Get all memories in storage. + /// + /// Traverses the entire storage directory and deserializes all memory files. + fn get_all_memories(&self) -> Result>; + + /// Get all memories associated with a specific commit. + /// + /// More efficient than loading all memories when you only need + /// memories for a specific commit. + fn get_memories_by_commit(&self, commit_hash: &str) -> Result>; + + /// Get all memory hashes in storage. + /// + /// Useful for listing without loading full content. + fn list_hashes(&self) -> Result>; + + /// Delete a memory by its hash. + fn delete(&self, hash: &str) -> Result<()>; + + /// Get the count of memories in storage. + fn count(&self) -> Result; +} + +// ============================================================================ +// FileMemoryStore Implementation +// ============================================================================ + +/// File-system based memory store with SHA-256 sharded directories. +/// +/// Storage layout: +/// ```text +/// .agit/ +/// └── objects/ +/// └── memories/ +/// ├── a1/ +/// │ ├── b2c3d4e5...json +/// │ └── f6g7h8i9...json +/// ├── b2/ +/// │ └── ... +/// └── ... +/// ``` +pub struct FileMemoryStore { + /// Path to the memories directory (`.agit/objects/memories`). + memories_dir: PathBuf, +} + +impl FileMemoryStore { + /// Create a new file memory store. + /// + /// # Arguments + /// * `agit_dir` - Path to the `.agit` directory + pub fn new(agit_dir: &Path) -> Self { + Self { + memories_dir: agit_dir.join("objects").join("memories"), + } + } + + /// Compute the SHA-256 hash of content bytes. + pub fn hash_content(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(content); + hex::encode(hasher.finalize()) + } + + /// Get the path where a memory would be stored based on its hash. + /// + /// Uses 2-character prefix sharding like Git: + /// - Hash: `a1b2c3d4e5...` + /// - Path: `.agit/objects/memories/a1/b2c3d4e5...json` + fn memory_path(&self, hash: &str) -> Result { + if hash.len() < 4 { + return Err(AgitError::Storage(StorageError::InvalidHash( + hash.to_string(), + ))); + } + let (prefix, rest) = hash.split_at(2); + Ok(self + .memories_dir + .join(prefix) + .join(format!("{}.json", rest))) + } + + /// Ensure the parent directory exists for a given path. + fn ensure_parent_dir(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + Ok(()) + } + + /// Iterate over all memory files in storage. + /// + /// Yields tuples of (hash, file_path) for each memory file found. + fn iter_memory_files(&self) -> Result> { + let mut results = Vec::new(); + + // Check if memories directory exists + if !self.memories_dir.exists() { + return Ok(results); + } + + // Iterate over shard directories (00-ff) + for shard_entry in fs::read_dir(&self.memories_dir)? { + let shard_entry = shard_entry?; + let shard_path = shard_entry.path(); + + if !shard_path.is_dir() { + continue; + } + + let shard_name = shard_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Must be a 2-character hex prefix + if shard_name.len() != 2 { + continue; + } + + // Iterate over memory files in this shard + for file_entry in fs::read_dir(&shard_path)? { + let file_entry = file_entry?; + let file_path = file_entry.path(); + + if !file_path.is_file() { + continue; + } + + // Must be a .json file + let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if !file_name.ends_with(".json") { + continue; + } + + // Reconstruct the full hash from shard + filename + let rest = file_name.trim_end_matches(".json"); + let full_hash = format!("{}{}", shard_name, rest); + + results.push((full_hash, file_path)); + } + } + + Ok(results) + } +} + +impl MemoryStore for FileMemoryStore { + fn save_memory(&self, memory: &MemoryNode) -> Result { + // Serialize to JSON + let json = serde_json::to_vec_pretty(memory)?; + + // Compute hash + let hash = Self::hash_content(&json); + + // Get storage path + let path = self.memory_path(&hash)?; + + // Check if already exists (idempotent) + if path.exists() { + return Ok(hash); + } + + // Ensure parent directory exists + self.ensure_parent_dir(&path)?; + + // Write atomically + atomic_write(&path, &json)?; + + Ok(hash) + } + + fn load_memory(&self, hash: &str) -> Result { + let path = self.memory_path(hash)?; + + if !path.exists() { + return Err(AgitError::Storage(StorageError::NotFound { + hash: hash.to_string(), + })); + } + + let content = fs::read(&path).map_err(|e| { + AgitError::Storage(StorageError::ReadFailed(format!( + "Failed to read memory {}: {}", + hash, e + ))) + })?; + + let memory: MemoryNode = serde_json::from_slice(&content).map_err(|e| { + AgitError::Storage(StorageError::Corrupt { + hash: hash.to_string(), + reason: e.to_string(), + }) + })?; + + Ok(memory) + } + + fn exists(&self, hash: &str) -> Result { + let path = self.memory_path(hash)?; + Ok(path.exists()) + } + + fn get_all_memories(&self) -> Result> { + let files = self.iter_memory_files()?; + let mut memories = Vec::with_capacity(files.len()); + + for (hash, path) in files { + let content = fs::read(&path).map_err(|e| { + AgitError::Storage(StorageError::ReadFailed(format!( + "Failed to read memory {}: {}", + hash, e + ))) + })?; + + match serde_json::from_slice::(&content) { + Ok(memory) => memories.push(memory), + Err(e) => { + // Log warning but continue - don't fail on corrupt files + tracing::warn!("Skipping corrupt memory file {}: {}", hash, e); + }, + } + } + + // Sort by timestamp for consistent ordering + memories.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + + Ok(memories) + } + + fn get_memories_by_commit(&self, commit_hash: &str) -> Result> { + // For now, we load all memories and filter + // Future optimization: maintain an index file mapping commit_hash -> memory_hashes + let all_memories = self.get_all_memories()?; + + Ok(all_memories + .into_iter() + .filter(|m| m.commit_hash == commit_hash) + .collect()) + } + + fn list_hashes(&self) -> Result> { + let files = self.iter_memory_files()?; + Ok(files.into_iter().map(|(hash, _)| hash).collect()) + } + + fn delete(&self, hash: &str) -> Result<()> { + let path = self.memory_path(hash)?; + if path.exists() { + fs::remove_file(&path)?; + } + Ok(()) + } + + fn count(&self) -> Result { + let files = self.iter_memory_files()?; + Ok(files.len()) + } +} + +// ============================================================================ +// Migration from Legacy Storage +// ============================================================================ + +/// Migrates memories from legacy JSONL index to immutable object storage. +/// +/// This function handles the transition from the old single-file format +/// (`.agit/log.json` or `.agit/index`) to the new sharded object storage. +pub struct MemoryMigration { + memory_store: FileMemoryStore, + agit_dir: PathBuf, +} + +impl MemoryMigration { + /// Create a new migration handler. + pub fn new(agit_dir: &Path) -> Self { + Self { + memory_store: FileMemoryStore::new(agit_dir), + agit_dir: agit_dir.to_path_buf(), + } + } + + /// Check if legacy storage exists and needs migration. + pub fn needs_migration(&self) -> bool { + self.legacy_log_path().exists() + } + + /// Path to legacy log file. + fn legacy_log_path(&self) -> PathBuf { + self.agit_dir.join("log.json") + } + + /// Migrate from legacy `log.json` to new object storage. + /// + /// Returns the number of memories migrated. + pub fn migrate(&self) -> Result { + let legacy_path = self.legacy_log_path(); + + if !legacy_path.exists() { + return Ok(0); + } + + // Read legacy file + let content = fs::read_to_string(&legacy_path)?; + + // Try parsing as array of entries first + let entries: Vec = if content.trim().starts_with('[') { + // JSON array format + serde_json::from_str(&content)? + } else { + // JSONL format (one JSON object per line) + content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(serde_json::from_str) + .collect::, _>>()? + }; + + let count = entries.len(); + + // Convert and save each entry + for entry in entries { + let memory = entry.to_memory_node(); + self.memory_store.save_memory(&memory)?; + } + + // Remove legacy file after successful migration + fs::remove_file(&legacy_path)?; + + tracing::info!("Migrated {} memories from legacy storage", count); + + Ok(count) + } + + /// Migrate staged index entries to memory storage. + /// + /// This is called during commit to convert staging entries to permanent memories. + pub fn migrate_index_entries( + &self, + entries: &[IndexEntry], + commit_hash: &str, + ) -> Result> { + let mut hashes = Vec::with_capacity(entries.len()); + + for entry in entries { + let memory = MemoryNode::from_index_entry(entry, commit_hash); + let hash = self.memory_store.save_memory(&memory)?; + hashes.push(hash); + } + + Ok(hashes) + } +} + +/// Legacy log entry format for migration. +#[derive(Debug, Deserialize)] +struct LegacyLogEntry { + #[serde(default)] + commit_hash: Option, + role: Role, + category: Category, + content: String, + #[serde(default = "Utc::now", with = "chrono::serde::ts_seconds")] + timestamp: DateTime, + #[serde(default)] + locations: Option>, + #[serde(default)] + file_path: Option, + #[serde(default)] + line_number: Option, +} + +impl LegacyLogEntry { + fn to_memory_node(&self) -> MemoryNode { + // Normalize legacy file_path/line_number to locations + let locations = if self.locations.is_some() { + self.locations.clone() + } else if let Some(ref path) = self.file_path { + Some(vec![Location { + file: path.clone(), + start_line: self.line_number, + end_line: None, + }]) + } else { + None + }; + + MemoryNode { + commit_hash: self.commit_hash.clone().unwrap_or_default(), + role: self.role, + category: self.category, + content: self.content.clone(), + timestamp: self.timestamp, + locations, + schema_version: 1, + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn setup() -> (TempDir, FileMemoryStore) { + let temp = TempDir::new().unwrap(); + let agit_dir = temp.path().join(".agit"); + fs::create_dir_all(&agit_dir).unwrap(); + let store = FileMemoryStore::new(&agit_dir); + (temp, store) + } + + #[test] + fn test_memory_node_creation() { + let memory = MemoryNode::new("abc123", Role::User, Category::Intent, "Fix the auth bug"); + + assert_eq!(memory.commit_hash, "abc123"); + assert_eq!(memory.role, Role::User); + assert_eq!(memory.category, Category::Intent); + assert_eq!(memory.content, "Fix the auth bug"); + assert_eq!(memory.schema_version, 1); + } + + #[test] + fn test_memory_node_with_locations() { + let locations = vec![ + Location::file("src/auth.rs"), + Location::range("src/main.rs", 10, 20), + ]; + + let memory = MemoryNode::with_locations( + "abc123", + Role::Ai, + Category::Reasoning, + "Added error handling", + locations, + ); + + assert_eq!(memory.get_locations().len(), 2); + } + + #[test] + fn test_memory_node_serialization() { + let memory = MemoryNode::new("abc123", Role::User, Category::Intent, "Test content"); + + let json = serde_json::to_string(&memory).unwrap(); + assert!(json.contains("\"commit_hash\":\"abc123\"")); + assert!(json.contains("\"role\":\"user\"")); + assert!(json.contains("\"category\":\"intent\"")); + } + + #[test] + fn test_memory_node_hash() { + let memory = MemoryNode::new("abc123", Role::User, Category::Intent, "Test content"); + + let hash = memory.compute_hash().unwrap(); + assert_eq!(hash.len(), 64); // SHA-256 = 64 hex chars + } + + #[test] + fn test_save_and_load_memory() { + let (_temp, store) = setup(); + + let memory = MemoryNode::new("commit123", Role::User, Category::Intent, "Fix the bug"); + + let hash = store.save_memory(&memory).unwrap(); + assert!(store.exists(&hash).unwrap()); + + let loaded = store.load_memory(&hash).unwrap(); + assert_eq!(loaded.content, memory.content); + assert_eq!(loaded.commit_hash, memory.commit_hash); + } + + #[test] + fn test_save_is_idempotent() { + let (_temp, store) = setup(); + + let memory = MemoryNode::new("commit123", Role::User, Category::Intent, "Same content"); + + let hash1 = store.save_memory(&memory).unwrap(); + let hash2 = store.save_memory(&memory).unwrap(); + + assert_eq!(hash1, hash2); + assert_eq!(store.count().unwrap(), 1); + } + + #[test] + fn test_get_all_memories() { + let (_temp, store) = setup(); + + // Save multiple memories + for i in 0..5 { + let memory = MemoryNode::new( + format!("commit{}", i), + Role::User, + Category::Intent, + format!("Content {}", i), + ); + store.save_memory(&memory).unwrap(); + } + + let all = store.get_all_memories().unwrap(); + assert_eq!(all.len(), 5); + } + + #[test] + fn test_get_memories_by_commit() { + let (_temp, store) = setup(); + + // Save memories for different commits + let memory1 = MemoryNode::new("commit_a", Role::User, Category::Intent, "Intent A"); + let memory2 = MemoryNode::new("commit_a", Role::Ai, Category::Reasoning, "Reasoning A"); + let memory3 = MemoryNode::new("commit_b", Role::User, Category::Intent, "Intent B"); + + store.save_memory(&memory1).unwrap(); + store.save_memory(&memory2).unwrap(); + store.save_memory(&memory3).unwrap(); + + let commit_a_memories = store.get_memories_by_commit("commit_a").unwrap(); + assert_eq!(commit_a_memories.len(), 2); + + let commit_b_memories = store.get_memories_by_commit("commit_b").unwrap(); + assert_eq!(commit_b_memories.len(), 1); + } + + #[test] + fn test_list_hashes() { + let (_temp, store) = setup(); + + let memory = MemoryNode::new("commit1", Role::User, Category::Intent, "Test"); + let hash = store.save_memory(&memory).unwrap(); + + let hashes = store.list_hashes().unwrap(); + assert_eq!(hashes.len(), 1); + assert!(hashes.contains(&hash)); + } + + #[test] + fn test_delete_memory() { + let (_temp, store) = setup(); + + let memory = MemoryNode::new("commit1", Role::User, Category::Intent, "To delete"); + let hash = store.save_memory(&memory).unwrap(); + + assert!(store.exists(&hash).unwrap()); + store.delete(&hash).unwrap(); + assert!(!store.exists(&hash).unwrap()); + } + + #[test] + fn test_from_index_entry() { + let entry = IndexEntry::user_intent("Fix the authentication"); + let memory = MemoryNode::from_index_entry(&entry, "abc123"); + + assert_eq!(memory.commit_hash, "abc123"); + assert_eq!(memory.role, Role::User); + assert_eq!(memory.category, Category::Intent); + assert_eq!(memory.content, "Fix the authentication"); + } + + #[test] + fn test_empty_storage() { + let (_temp, store) = setup(); + + assert_eq!(store.count().unwrap(), 0); + assert!(store.get_all_memories().unwrap().is_empty()); + assert!(store.list_hashes().unwrap().is_empty()); + } + + #[test] + fn test_memory_path_sharding() { + let temp = TempDir::new().unwrap(); + let agit_dir = temp.path().join(".agit"); + let store = FileMemoryStore::new(&agit_dir); + + // Hash with known prefix + let hash = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234"; + let path = store.memory_path(hash).unwrap(); + + // Should be sharded: .agit/objects/memories/a1/b2c3d4...json + assert!(path.to_string_lossy().contains("a1")); + assert!(path.to_string_lossy().ends_with(".json")); + } + + #[test] + fn test_invalid_hash() { + let temp = TempDir::new().unwrap(); + let agit_dir = temp.path().join(".agit"); + let store = FileMemoryStore::new(&agit_dir); + + let result = store.memory_path("ab"); // Too short + assert!(matches!( + result, + Err(AgitError::Storage(StorageError::InvalidHash(_))) + )); + } + + #[test] + fn test_migration_needs_migration() { + let temp = TempDir::new().unwrap(); + let agit_dir = temp.path().join(".agit"); + fs::create_dir_all(&agit_dir).unwrap(); + + let migration = MemoryMigration::new(&agit_dir); + assert!(!migration.needs_migration()); + + // Create legacy file + fs::write(agit_dir.join("log.json"), "[]").unwrap(); + assert!(migration.needs_migration()); + } + + #[test] + fn test_migration_jsonl_format() { + let temp = TempDir::new().unwrap(); + let agit_dir = temp.path().join(".agit"); + fs::create_dir_all(&agit_dir).unwrap(); + + // Create legacy JSONL file + let legacy_content = r#"{"role":"user","category":"intent","content":"First entry","timestamp":1704812345} +{"role":"ai","category":"reasoning","content":"Second entry","timestamp":1704812346}"#; + + fs::write(agit_dir.join("log.json"), legacy_content).unwrap(); + + let migration = MemoryMigration::new(&agit_dir); + let count = migration.migrate().unwrap(); + + assert_eq!(count, 2); + assert!(!migration.needs_migration()); // Legacy file should be deleted + + // Verify memories were saved + assert_eq!(migration.memory_store.count().unwrap(), 2); + } + + #[test] + fn test_migration_json_array_format() { + let temp = TempDir::new().unwrap(); + let agit_dir = temp.path().join(".agit"); + fs::create_dir_all(&agit_dir).unwrap(); + + // Create legacy JSON array file + let legacy_content = r#"[ + {"role":"user","category":"intent","content":"First","timestamp":1704812345}, + {"role":"ai","category":"reasoning","content":"Second","timestamp":1704812346} + ]"#; + + fs::write(agit_dir.join("log.json"), legacy_content).unwrap(); + + let migration = MemoryMigration::new(&agit_dir); + let count = migration.migrate().unwrap(); + + assert_eq!(count, 2); + } + + #[test] + fn test_migrate_index_entries() { + let temp = TempDir::new().unwrap(); + let agit_dir = temp.path().join(".agit"); + fs::create_dir_all(&agit_dir).unwrap(); + + let migration = MemoryMigration::new(&agit_dir); + + let entries = vec![ + IndexEntry::user_intent("Fix bug"), + IndexEntry::ai_reasoning("Added try/catch"), + ]; + + let hashes = migration + .migrate_index_entries(&entries, "commit123") + .unwrap(); + + assert_eq!(hashes.len(), 2); + + // Verify memories are stored with commit_hash + let memories = migration + .memory_store + .get_memories_by_commit("commit123") + .unwrap(); + assert_eq!(memories.len(), 2); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 1f7edb6..e91a228 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -7,11 +7,17 @@ //! //! - **File-based (V1)**: Objects in `.agit/objects/`, refs in `.agit/refs/heads/` //! - **Git-native (V2)**: Objects in Git ODB, refs in `refs/agit/heads/` +//! +//! ## Merge-Friendly Memory Storage +//! +//! The `memory` module provides immutable object storage for memory nodes, +//! designed to eliminate merge conflicts by storing each memory as a separate file. mod cas; mod git_objects; mod git_refs; mod index; +mod memory; mod refs; mod traits; @@ -19,5 +25,6 @@ pub use cas::*; pub use git_objects::*; pub use git_refs::*; pub use index::*; +pub use memory::*; pub use refs::*; pub use traits::*; diff --git a/tests/mcp_tests.rs b/tests/mcp_tests.rs index e0bd7d8..87b4d94 100644 --- a/tests/mcp_tests.rs +++ b/tests/mcp_tests.rs @@ -49,7 +49,7 @@ fn setup_mcp_test() -> (TempDir, std::process::Child) { // Start the MCP server let child = Command::new(env!("CARGO_BIN_EXE_agit")) - .arg("server") + .arg("serve") .current_dir(temp.path()) .stdin(Stdio::piped()) .stdout(Stdio::piped())