diff --git a/memora-cli/Cargo.toml b/memora-cli/Cargo.toml index 48229e1d..7351c55e 100644 --- a/memora-cli/Cargo.toml +++ b/memora-cli/Cargo.toml @@ -38,6 +38,7 @@ thiserror = "1.0" # Utilities chrono = "0.4" walkdir = "2.5" +dirs = "5.0" [profile.release] opt-level = "z" diff --git a/memora-cli/src/config.rs b/memora-cli/src/config.rs index f66e2d18..ca83625c 100644 --- a/memora-cli/src/config.rs +++ b/memora-cli/src/config.rs @@ -1,29 +1,153 @@ use anyhow::{Context, Result}; use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::PathBuf; + +const DEFAULT_API_URL: &str = "http://localhost:8080"; +const CONFIG_FILE_NAME: &str = "config"; +const CONFIG_DIR_NAME: &str = ".memora"; pub struct Config { pub api_url: String, + pub source: ConfigSource, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConfigSource { + LocalFile, + Environment, + Default, +} + +impl std::fmt::Display for ConfigSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigSource::LocalFile => write!(f, "config file"), + ConfigSource::Environment => write!(f, "environment variable"), + ConfigSource::Default => write!(f, "default"), + } + } } impl Config { - pub fn from_env() -> Result { - let api_url = env::var("MEMORA_API_URL") - .unwrap_or_else(|_| "http://localhost:8080".to_string()); + /// Load configuration with the following priority: + /// 1. Environment variable (MEMORA_API_URL) - highest priority, for overrides + /// 2. Local config file (~/.memora/config.toml) + /// 3. Default (http://localhost:8080) + pub fn load() -> Result { + // 1. Environment variable takes highest priority (for overrides) + if let Ok(api_url) = env::var("MEMORA_API_URL") { + return Self::validate_and_create(api_url, ConfigSource::Environment); + } - // Validate URL format + // 2. Try local config file + if let Some(api_url) = Self::load_from_file()? { + return Self::validate_and_create(api_url, ConfigSource::LocalFile); + } + + // 3. Fall back to default + Self::validate_and_create(DEFAULT_API_URL.to_string(), ConfigSource::Default) + } + + /// Legacy method for backwards compatibility + pub fn from_env() -> Result { + Self::load() + } + + fn validate_and_create(api_url: String, source: ConfigSource) -> Result { if !api_url.starts_with("http://") && !api_url.starts_with("https://") { anyhow::bail!( "Invalid API URL: {}. Must start with http:// or https://", api_url ); } + Ok(Config { api_url, source }) + } - Ok(Config { api_url }) + fn config_dir() -> Option { + dirs::home_dir().map(|home| home.join(CONFIG_DIR_NAME)) + } + + fn config_file_path() -> Option { + Self::config_dir().map(|dir| dir.join(CONFIG_FILE_NAME)) + } + + fn load_from_file() -> Result> { + let config_path = match Self::config_file_path() { + Some(path) => path, + None => return Ok(None), + }; + + if !config_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; + + // Simple TOML parsing for api_url + for line in content.lines() { + let line = line.trim(); + if line.starts_with("api_url") { + if let Some(value) = line.split('=').nth(1) { + let value = value.trim().trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Ok(Some(value.to_string())); + } + } + } + } + + Ok(None) + } + + pub fn save_api_url(api_url: &str) -> Result { + let config_dir = Self::config_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + + // Create config directory if it doesn't exist + if !config_dir.exists() { + fs::create_dir_all(&config_dir) + .with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?; + } + + let config_path = config_dir.join(CONFIG_FILE_NAME); + let content = format!("api_url = \"{}\"\n", api_url); + + fs::write(&config_path, content) + .with_context(|| format!("Failed to write config file: {}", config_path.display()))?; + + Ok(config_path) } pub fn api_url(&self) -> &str { &self.api_url } + + pub fn config_file_path_display() -> String { + Self::config_file_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "~/.memora/config.toml".to_string()) + } +} + +/// Prompt user for API URL interactively +pub fn prompt_api_url(current_url: Option<&str>) -> Result { + let default = current_url.unwrap_or(DEFAULT_API_URL); + + print!("Enter API URL [{}]: ", default); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + let input = input.trim(); + if input.is_empty() { + Ok(default.to_string()) + } else { + Ok(input.to_string()) + } } pub fn generate_doc_id() -> String { diff --git a/memora-cli/src/errors.rs b/memora-cli/src/errors.rs index df74c58c..0d2b9ed4 100644 --- a/memora-cli/src/errors.rs +++ b/memora-cli/src/errors.rs @@ -172,9 +172,15 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String { pub fn print_config_help() { println!("\n{}", "Configuration:".bright_cyan().bold()); - println!(" Set the API URL using an environment variable:"); - println!(" {}", "export MEMORA_API_URL=http://localhost:8080".bright_white()); - println!("\n Add to your shell profile to make it permanent:"); - println!(" {}", "echo 'export MEMORA_API_URL=http://localhost:8080' >> ~/.zshrc".bright_black()); + println!(" Run the configure command to set the API URL:"); + println!(" {}", "memora configure".bright_white()); + println!(); + println!(" Or set it directly:"); + println!(" {}", "memora configure --api-url http://your-api:8080".bright_white()); + println!(); + println!(" {}", "Configuration priority:".bright_yellow()); + println!(" 1. Environment variable (MEMORA_API_URL) - highest priority"); + println!(" 2. Config file (~/.memora/config)"); + println!(" 3. Default (http://localhost:8080)"); println!(); } diff --git a/memora-cli/src/main.rs b/memora-cli/src/main.rs index 5cab85a1..44b87136 100644 --- a/memora-cli/src/main.rs +++ b/memora-cli/src/main.rs @@ -34,6 +34,7 @@ impl From for OutputFormat { #[command(name = "memora")] #[command(about = "Memora CLI - Semantic memory system", long_about = None)] #[command(version)] +#[command(after_help = get_after_help())] struct Cli { /// Output format (pretty, json, yaml) #[arg(short = 'o', long, global = true, default_value = "pretty")] @@ -47,6 +48,18 @@ struct Cli { command: Commands, } +fn get_after_help() -> String { + let config = config::Config::load().ok(); + let (api_url, source) = match &config { + Some(c) => (c.api_url.as_str(), c.source.to_string()), + None => ("http://localhost:8080", "default".to_string()), + }; + format!( + "Current API URL: {} (from {})\n\nRun 'memora configure' to change the API URL.", + api_url, source + ) +} + #[derive(Subcommand)] enum Commands { /// Manage agents (list, profile, stats) @@ -64,6 +77,14 @@ enum Commands { /// Manage async operations (list, cancel) #[command(subcommand)] Operation(OperationCommands), + + /// Configure the CLI (API URL, etc.) + #[command(after_help = "Configuration priority:\n 1. Environment variable (MEMORA_API_URL) - highest priority\n 2. Config file (~/.memora/config)\n 3. Default (http://localhost:8080)")] + Configure { + /// API URL to connect to (interactive prompt if not provided) + #[arg(long)] + api_url: Option, + }, } #[derive(Subcommand)] @@ -285,6 +306,11 @@ fn run() -> Result<()> { let output_format: OutputFormat = cli.output.into(); let verbose = cli.verbose; + // Handle configure command before loading full config (it doesn't need API client) + if let Commands::Configure { api_url } = cli.command { + return handle_configure(api_url, output_format); + } + // Load configuration let config = Config::from_env().unwrap_or_else(|e| { ui::print_error(&format!("Configuration error: {}", e)); @@ -301,6 +327,7 @@ fn run() -> Result<()> { // Execute command and handle errors let result: Result<()> = match cli.command { + Commands::Configure { .. } => unreachable!(), // Handled above Commands::Agent(agent_cmd) => match agent_cmd { AgentCommands::List => { let spinner = if output_format == OutputFormat::Pretty { @@ -1075,3 +1102,58 @@ fn run() -> Result<()> { Ok(()) } + +fn handle_configure(api_url: Option, output_format: OutputFormat) -> Result<()> { + // Load current config to show current state + let current_config = Config::load().ok(); + + if output_format == OutputFormat::Pretty { + ui::print_info("Memora CLI Configuration"); + println!(); + + // Show current configuration + if let Some(ref config) = current_config { + println!(" Current API URL: {}", config.api_url); + println!(" Source: {}", config.source); + println!(); + } + } + + // Get the new API URL (from argument or prompt) + let new_api_url = match api_url { + Some(url) => url, + None => { + // Interactive prompt + let current = current_config.as_ref().map(|c| c.api_url.as_str()); + config::prompt_api_url(current)? + } + }; + + // Validate the URL + if !new_api_url.starts_with("http://") && !new_api_url.starts_with("https://") { + ui::print_error(&format!( + "Invalid API URL: {}. Must start with http:// or https://", + new_api_url + )); + return Ok(()); + } + + // Save to config file + let config_path = Config::save_api_url(&new_api_url)?; + + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Configuration saved to {}", config_path.display())); + println!(); + println!(" API URL: {}", new_api_url); + println!(); + println!("Note: Environment variable MEMORA_API_URL will override this setting."); + } else { + let result = serde_json::json!({ + "api_url": new_api_url, + "config_path": config_path.display().to_string(), + }); + output::print_output(&result, output_format)?; + } + + Ok(()) +} diff --git a/memora/memora/api/mcp.py b/memora/memora/api/mcp.py index bb14f2d6..c4c7ba94 100644 --- a/memora/memora/api/mcp.py +++ b/memora/memora/api/mcp.py @@ -28,6 +28,13 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP: """ **CRITICAL: Store important user information to long-term memory.** + **⚠️ PER-USER TOOL - REQUIRES USER IDENTIFICATION:** + - This tool is STRICTLY per-user. Each user MUST have a unique `agent_id`. + - ONLY use this tool if you have a valid user identifier (user ID, email, session ID, etc.) to map to `agent_id`. + - DO NOT use this tool if you cannot identify the specific user. + - DO NOT share memories between different users - each user's memories are isolated by their `agent_id`. + - If you don't have a user identifier, DO NOT use this tool at all. + Use this tool PROACTIVELY whenever the user shares: - Personal facts, preferences, or interests (e.g., "I love hiking", "I'm a vegetarian") - Important events or milestones (e.g., "I got promoted", "My birthday is June 15") @@ -44,7 +51,9 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP: "career_goals", "project_details", etc. This helps organize and retrieve related memories later. Args: - agent_id: The unique identifier for the agent/user storing the memory + agent_id: **REQUIRED** - The unique, persistent identifier for this specific user (e.g., user_id, email, session_id). + This MUST be consistent across all interactions with the same user. + Example: "user_12345", "alice@example.com", "session_abc123" content: The fact/memory to store (be specific and include relevant details) context: Categorize the memory (e.g., 'personal_preferences', 'work_history', 'hobbies', 'family') explanation: Optional explanation for why this memory is being stored @@ -69,6 +78,13 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP: """ **CRITICAL: Search user's memory to provide personalized, context-aware responses.** + **⚠️ PER-USER TOOL - REQUIRES USER IDENTIFICATION:** + - This tool is STRICTLY per-user. Each user MUST have a unique `agent_id`. + - ONLY use this tool if you have a valid user identifier (user ID, email, session ID, etc.) to map to `agent_id`. + - DO NOT use this tool if you cannot identify the specific user. + - DO NOT search across multiple users - each user's memories are isolated by their `agent_id`. + - If you don't have a user identifier, DO NOT use this tool at all. + Use this tool PROACTIVELY at the start of conversations or when making recommendations to: - Check user's preferences before making suggestions (e.g., "what foods does the user like?") - Recall user's history to provide continuity (e.g., "what projects has the user worked on?") @@ -87,7 +103,9 @@ def create_mcp_server(memory: TemporalSemanticMemory) -> FastMCP: "user's work experience", "user's dietary restrictions", "what does the user know about X?" Args: - agent_id: The unique identifier for the agent/user whose memories to search + agent_id: **REQUIRED** - The unique, persistent identifier for this specific user (e.g., user_id, email, session_id). + This MUST be consistent across all interactions with the same user. + Example: "user_12345", "alice@example.com", "session_abc123" query: Natural language search query to find relevant memories max_tokens: Maximum tokens for search context (default: 4096) explanation: Optional explanation for why this search is being performed