From 53844f7207b2e5533feae57bfc1257aec4151e12 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:31:50 +0800 Subject: [PATCH] feat(memory): lucid memory integration with optional backends (#285) --- README.md | 18 +- src/channels/mod.rs | 7 +- src/config/schema.rs | 42 ++- src/main.rs | 2 +- src/memory/backend.rs | 145 ++++++++++ src/memory/lucid.rs | 601 ++++++++++++++++++++++++++++++++++++++++++ src/memory/mod.rs | 137 ++++++++-- src/memory/none.rs | 74 ++++++ src/migration.rs | 26 +- src/onboard/wizard.rs | 164 +++++++----- src/providers/mod.rs | 10 +- 11 files changed, 1089 insertions(+), 137 deletions(-) create mode 100644 src/memory/backend.rs create mode 100644 src/memory/lucid.rs create mode 100644 src/memory/none.rs diff --git a/README.md b/README.md index 97619ea..40dfc6a 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze |-----------|-------|------------|--------| | **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API | | **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API | -| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend | +| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend | | **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability | | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | | **Runtime** | `RuntimeAdapter` | Native, Docker (sandboxed) | WASM (planned; unsupported kinds fail fast) | @@ -164,11 +164,21 @@ The agent automatically recalls, saves, and manages memory via tools. ```toml [memory] -backend = "sqlite" # "sqlite", "markdown", "none" +backend = "sqlite" # "sqlite", "lucid", "markdown", "none" auto_save = true embedding_provider = "openai" vector_weight = 0.7 keyword_weight = 0.3 + +# backend = "none" uses an explicit no-op memory backend (no persistence) + +# Optional for backend = "lucid" +# ZEROCLAW_LUCID_CMD=/usr/local/bin/lucid # default: lucid +# ZEROCLAW_LUCID_BUDGET=200 # default: 200 +# ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD=3 # local hit count to skip external recall +# ZEROCLAW_LUCID_RECALL_TIMEOUT_MS=120 # low-latency budget for lucid context recall +# ZEROCLAW_LUCID_STORE_TIMEOUT_MS=800 # async sync timeout for lucid store +# ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS=15000 # cooldown after lucid failure to avoid repeated slow attempts ``` ## Security @@ -264,12 +274,14 @@ default_model = "anthropic/claude-sonnet-4-20250514" default_temperature = 0.7 [memory] -backend = "sqlite" # "sqlite", "markdown", "none" +backend = "sqlite" # "sqlite", "lucid", "markdown", "none" auto_save = true embedding_provider = "openai" # "openai", "noop" vector_weight = 0.7 keyword_weight = 0.3 +# backend = "none" disables persistent memory via no-op backend + [gateway] require_pairing = true # require pairing code on first connect allow_public_bind = false # refuse 0.0.0.0 without tunnel diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 81fa704..be012fc 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -699,9 +699,8 @@ pub async fn start_channels(config: Config) -> Result<()> { .default_provider .clone() .unwrap_or_else(|| "openrouter".into()); - let provider: Arc = Arc::from(providers::create_resilient_provider( - provider_name.as_str(), + &provider_name, config.api_key.as_deref(), &config.reliability, )?); @@ -1163,7 +1162,7 @@ mod tests { let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), - provider_name: Arc::new("test-provider".to_string()), + provider_name: Arc::new("openrouter".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![Box::new(MockPriceTool)]), observer: Arc::new(NoopObserver), @@ -1254,7 +1253,7 @@ mod tests { provider: Arc::new(SlowProvider { delay: Duration::from_millis(250), }), - provider_name: Arc::new("test-provider".to_string()), + provider_name: Arc::new("openrouter".to_string()), memory: Arc::new(NoopMemory), tools_registry: Arc::new(vec![]), observer: Arc::new(NoopObserver), diff --git a/src/config/schema.rs b/src/config/schema.rs index 622e12d..0e58c8f 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -547,7 +547,7 @@ fn default_http_timeout_secs() -> u64 { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryConfig { - /// "sqlite" | "markdown" | "none" + /// "sqlite" | "lucid" | "markdown" | "none" (`none` = explicit no-op memory) pub backend: String, /// Auto-save conversation context to memory pub auto_save: bool, @@ -1618,7 +1618,6 @@ fn sync_directory(_path: &Path) -> Result<()> { mod tests { use super::*; use std::path::PathBuf; - use std::sync::{Mutex, OnceLock}; use tempfile::TempDir; // ── Defaults ───────────────────────────────────────────── @@ -2449,19 +2448,18 @@ default_temperature = 0.7 assert!(parsed.browser.allowed_domains.is_empty()); } - fn env_override_lock() -> std::sync::MutexGuard<'static, ()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK - .get_or_init(|| Mutex::new(())) + // ── Environment variable overrides (Docker support) ───────── + + fn env_override_test_guard() -> std::sync::MutexGuard<'static, ()> { + static ENV_OVERRIDE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + ENV_OVERRIDE_TEST_LOCK .lock() .expect("env override test lock poisoned") } - // ── Environment variable overrides (Docker support) ───────── - #[test] fn env_override_api_key() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert!(config.api_key.is_none()); @@ -2474,7 +2472,7 @@ default_temperature = 0.7 #[test] fn env_override_api_key_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_API_KEY"); @@ -2487,7 +2485,7 @@ default_temperature = 0.7 #[test] fn env_override_provider() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_PROVIDER", "anthropic"); @@ -2499,7 +2497,7 @@ default_temperature = 0.7 #[test] fn env_override_provider_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_PROVIDER"); @@ -2512,7 +2510,7 @@ default_temperature = 0.7 #[test] fn env_override_model() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_MODEL", "gpt-4o"); @@ -2524,7 +2522,7 @@ default_temperature = 0.7 #[test] fn env_override_workspace() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_WORKSPACE", "/custom/workspace"); @@ -2536,7 +2534,7 @@ default_temperature = 0.7 #[test] fn env_override_empty_values_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); let original_provider = config.default_provider.clone(); @@ -2549,7 +2547,7 @@ default_temperature = 0.7 #[test] fn env_override_gateway_port() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert_eq!(config.gateway.port, 3000); @@ -2562,7 +2560,7 @@ default_temperature = 0.7 #[test] fn env_override_port_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); @@ -2575,7 +2573,7 @@ default_temperature = 0.7 #[test] fn env_override_gateway_host() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); assert_eq!(config.gateway.host, "127.0.0.1"); @@ -2588,7 +2586,7 @@ default_temperature = 0.7 #[test] fn env_override_host_fallback() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); @@ -2601,7 +2599,7 @@ default_temperature = 0.7 #[test] fn env_override_temperature() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5"); @@ -2613,7 +2611,7 @@ default_temperature = 0.7 #[test] fn env_override_temperature_out_of_range_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); // Clean up any leftover env vars from other tests std::env::remove_var("ZEROCLAW_TEMPERATURE"); @@ -2633,7 +2631,7 @@ default_temperature = 0.7 #[test] fn env_override_invalid_port_ignored() { - let _guard = env_override_lock(); + let _env_guard = env_override_test_guard(); let mut config = Config::default(); let original_port = config.gateway.port; diff --git a/src/main.rs b/src/main.rs index 3253594..478ce41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,7 +110,7 @@ enum Commands { #[arg(long)] provider: Option, - /// Memory backend (sqlite, markdown, none) - used in quick mode, default: sqlite + /// Memory backend (sqlite, lucid, markdown, none) - used in quick mode, default: sqlite #[arg(long)] memory: Option, }, diff --git a/src/memory/backend.rs b/src/memory/backend.rs new file mode 100644 index 0000000..4de636a --- /dev/null +++ b/src/memory/backend.rs @@ -0,0 +1,145 @@ +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum MemoryBackendKind { + Sqlite, + Lucid, + Markdown, + None, + Unknown, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct MemoryBackendProfile { + pub key: &'static str, + pub label: &'static str, + pub auto_save_default: bool, + pub uses_sqlite_hygiene: bool, + pub sqlite_based: bool, + pub optional_dependency: bool, +} + +const SQLITE_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "sqlite", + label: "SQLite with Vector Search (recommended) — fast, hybrid search, embeddings", + auto_save_default: true, + uses_sqlite_hygiene: true, + sqlite_based: true, + optional_dependency: false, +}; + +const LUCID_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "lucid", + label: "Lucid Memory bridge — sync with local lucid-memory CLI, keep SQLite fallback", + auto_save_default: true, + uses_sqlite_hygiene: true, + sqlite_based: true, + optional_dependency: true, +}; + +const MARKDOWN_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "markdown", + label: "Markdown Files — simple, human-readable, no dependencies", + auto_save_default: true, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const NONE_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "none", + label: "None — disable persistent memory", + auto_save_default: false, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const CUSTOM_PROFILE: MemoryBackendProfile = MemoryBackendProfile { + key: "custom", + label: "Custom backend — extension point", + auto_save_default: true, + uses_sqlite_hygiene: false, + sqlite_based: false, + optional_dependency: false, +}; + +const SELECTABLE_MEMORY_BACKENDS: [MemoryBackendProfile; 4] = [ + SQLITE_PROFILE, + LUCID_PROFILE, + MARKDOWN_PROFILE, + NONE_PROFILE, +]; + +pub fn selectable_memory_backends() -> &'static [MemoryBackendProfile] { + &SELECTABLE_MEMORY_BACKENDS +} + +pub fn default_memory_backend_key() -> &'static str { + SQLITE_PROFILE.key +} + +pub fn classify_memory_backend(backend: &str) -> MemoryBackendKind { + match backend { + "sqlite" => MemoryBackendKind::Sqlite, + "lucid" => MemoryBackendKind::Lucid, + "markdown" => MemoryBackendKind::Markdown, + "none" => MemoryBackendKind::None, + _ => MemoryBackendKind::Unknown, + } +} + +pub fn memory_backend_profile(backend: &str) -> MemoryBackendProfile { + match classify_memory_backend(backend) { + MemoryBackendKind::Sqlite => SQLITE_PROFILE, + MemoryBackendKind::Lucid => LUCID_PROFILE, + MemoryBackendKind::Markdown => MARKDOWN_PROFILE, + MemoryBackendKind::None => NONE_PROFILE, + MemoryBackendKind::Unknown => CUSTOM_PROFILE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_known_backends() { + assert_eq!(classify_memory_backend("sqlite"), MemoryBackendKind::Sqlite); + assert_eq!(classify_memory_backend("lucid"), MemoryBackendKind::Lucid); + assert_eq!( + classify_memory_backend("markdown"), + MemoryBackendKind::Markdown + ); + assert_eq!(classify_memory_backend("none"), MemoryBackendKind::None); + } + + #[test] + fn classify_unknown_backend() { + assert_eq!(classify_memory_backend("redis"), MemoryBackendKind::Unknown); + } + + #[test] + fn selectable_backends_are_ordered_for_onboarding() { + let backends = selectable_memory_backends(); + assert_eq!(backends.len(), 4); + assert_eq!(backends[0].key, "sqlite"); + assert_eq!(backends[1].key, "lucid"); + assert_eq!(backends[2].key, "markdown"); + assert_eq!(backends[3].key, "none"); + } + + #[test] + fn lucid_profile_is_sqlite_based_optional_backend() { + let profile = memory_backend_profile("lucid"); + assert!(profile.sqlite_based); + assert!(profile.optional_dependency); + assert!(profile.uses_sqlite_hygiene); + } + + #[test] + fn unknown_profile_preserves_extensibility_defaults() { + let profile = memory_backend_profile("custom-memory"); + assert_eq!(profile.key, "custom"); + assert!(profile.auto_save_default); + assert!(!profile.uses_sqlite_hygiene); + } +} diff --git a/src/memory/lucid.rs b/src/memory/lucid.rs new file mode 100644 index 0000000..00e03f6 --- /dev/null +++ b/src/memory/lucid.rs @@ -0,0 +1,601 @@ +use super::sqlite::SqliteMemory; +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; +use chrono::Local; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tokio::process::Command; +use tokio::time::timeout; + +pub struct LucidMemory { + local: SqliteMemory, + lucid_cmd: String, + token_budget: usize, + workspace_dir: PathBuf, + recall_timeout: Duration, + store_timeout: Duration, + local_hit_threshold: usize, + failure_cooldown: Duration, + last_failure_at: Mutex>, +} + +impl LucidMemory { + const DEFAULT_LUCID_CMD: &'static str = "lucid"; + const DEFAULT_TOKEN_BUDGET: usize = 200; + const DEFAULT_RECALL_TIMEOUT_MS: u64 = 120; + const DEFAULT_STORE_TIMEOUT_MS: u64 = 800; + const DEFAULT_LOCAL_HIT_THRESHOLD: usize = 3; + const DEFAULT_FAILURE_COOLDOWN_MS: u64 = 15_000; + + pub fn new(workspace_dir: &Path, local: SqliteMemory) -> Self { + let lucid_cmd = std::env::var("ZEROCLAW_LUCID_CMD") + .unwrap_or_else(|_| Self::DEFAULT_LUCID_CMD.to_string()); + + let token_budget = std::env::var("ZEROCLAW_LUCID_BUDGET") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(Self::DEFAULT_TOKEN_BUDGET); + + let recall_timeout = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_RECALL_TIMEOUT_MS", + Self::DEFAULT_RECALL_TIMEOUT_MS, + 20, + ); + let store_timeout = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_STORE_TIMEOUT_MS", + Self::DEFAULT_STORE_TIMEOUT_MS, + 50, + ); + let local_hit_threshold = Self::read_env_usize( + "ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD", + Self::DEFAULT_LOCAL_HIT_THRESHOLD, + 1, + ); + let failure_cooldown = Self::read_env_duration_ms( + "ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS", + Self::DEFAULT_FAILURE_COOLDOWN_MS, + 100, + ); + + Self { + local, + lucid_cmd, + token_budget, + workspace_dir: workspace_dir.to_path_buf(), + recall_timeout, + store_timeout, + local_hit_threshold, + failure_cooldown, + last_failure_at: Mutex::new(None), + } + } + + #[cfg(test)] + fn with_options( + workspace_dir: &Path, + local: SqliteMemory, + lucid_cmd: String, + token_budget: usize, + local_hit_threshold: usize, + recall_timeout: Duration, + store_timeout: Duration, + failure_cooldown: Duration, + ) -> Self { + Self { + local, + lucid_cmd, + token_budget, + workspace_dir: workspace_dir.to_path_buf(), + recall_timeout, + store_timeout, + local_hit_threshold: local_hit_threshold.max(1), + failure_cooldown, + last_failure_at: Mutex::new(None), + } + } + + fn read_env_usize(name: &str, default: usize, min: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(default, |v| v.max(min)) + } + + fn read_env_duration_ms(name: &str, default_ms: u64, min_ms: u64) -> Duration { + let millis = std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .map_or(default_ms, |v| v.max(min_ms)); + Duration::from_millis(millis) + } + + fn in_failure_cooldown(&self) -> bool { + let Ok(guard) = self.last_failure_at.lock() else { + return false; + }; + + guard + .as_ref() + .is_some_and(|last| last.elapsed() < self.failure_cooldown) + } + + fn mark_failure_now(&self) { + if let Ok(mut guard) = self.last_failure_at.lock() { + *guard = Some(Instant::now()); + } + } + + fn clear_failure(&self) { + if let Ok(mut guard) = self.last_failure_at.lock() { + *guard = None; + } + } + + fn to_lucid_type(category: &MemoryCategory) -> &'static str { + match category { + MemoryCategory::Core => "decision", + MemoryCategory::Daily => "context", + MemoryCategory::Conversation => "conversation", + MemoryCategory::Custom(_) => "learning", + } + } + + fn to_memory_category(label: &str) -> MemoryCategory { + let normalized = label.to_lowercase(); + if normalized.contains("visual") { + return MemoryCategory::Custom("visual".to_string()); + } + + match normalized.as_str() { + "decision" | "learning" | "solution" => MemoryCategory::Core, + "context" | "conversation" => MemoryCategory::Conversation, + "bug" => MemoryCategory::Daily, + other => MemoryCategory::Custom(other.to_string()), + } + } + + fn merge_results( + primary_results: Vec, + secondary_results: Vec, + limit: usize, + ) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let mut merged = Vec::new(); + let mut seen = HashSet::new(); + + for entry in primary_results.into_iter().chain(secondary_results) { + let signature = format!( + "{}\u{0}{}", + entry.key.to_lowercase(), + entry.content.to_lowercase() + ); + + if seen.insert(signature) { + merged.push(entry); + if merged.len() >= limit { + break; + } + } + } + + merged + } + + fn parse_lucid_context(raw: &str) -> Vec { + let mut in_context_block = false; + let mut entries = Vec::new(); + let now = Local::now().to_rfc3339(); + + for line in raw.lines().map(str::trim) { + if line == "" { + in_context_block = true; + continue; + } + + if line == "" { + break; + } + + if !in_context_block || line.is_empty() { + continue; + } + + let Some(rest) = line.strip_prefix("- [") else { + continue; + }; + + let Some((label, content_part)) = rest.split_once(']') else { + continue; + }; + + let content = content_part.trim(); + if content.is_empty() { + continue; + } + + let rank = entries.len(); + entries.push(MemoryEntry { + id: format!("lucid:{rank}"), + key: format!("lucid_{rank}"), + content: content.to_string(), + category: Self::to_memory_category(label.trim()), + timestamp: now.clone(), + session_id: None, + score: Some((1.0 - rank as f64 * 0.05).max(0.1)), + }); + } + + entries + } + + async fn run_lucid_command_raw( + lucid_cmd: &str, + args: &[String], + timeout_window: Duration, + ) -> anyhow::Result { + let mut cmd = Command::new(lucid_cmd); + cmd.args(args); + + let output = timeout(timeout_window, cmd.output()).await.map_err(|_| { + anyhow::anyhow!( + "lucid command timed out after {}ms", + timeout_window.as_millis() + ) + })??; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("lucid command failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + async fn run_lucid_command( + &self, + args: &[String], + timeout_window: Duration, + ) -> anyhow::Result { + Self::run_lucid_command_raw(&self.lucid_cmd, args, timeout_window).await + } + + fn build_store_args(&self, key: &str, content: &str, category: &MemoryCategory) -> Vec { + let payload = format!("{key}: {content}"); + vec![ + "store".to_string(), + payload, + format!("--type={}", Self::to_lucid_type(category)), + format!("--project={}", self.workspace_dir.display()), + ] + } + + fn build_recall_args(&self, query: &str) -> Vec { + vec![ + "context".to_string(), + query.to_string(), + format!("--budget={}", self.token_budget), + format!("--project={}", self.workspace_dir.display()), + ] + } + + async fn sync_to_lucid_async(&self, key: &str, content: &str, category: &MemoryCategory) { + let args = self.build_store_args(key, content, category); + if let Err(error) = self.run_lucid_command(&args, self.store_timeout).await { + tracing::debug!( + command = %self.lucid_cmd, + error = %error, + "Lucid store sync failed; sqlite remains authoritative" + ); + } + } + + async fn recall_from_lucid(&self, query: &str) -> anyhow::Result> { + let args = self.build_recall_args(query); + let output = self.run_lucid_command(&args, self.recall_timeout).await?; + Ok(Self::parse_lucid_context(&output)) + } +} + +#[async_trait] +impl Memory for LucidMemory { + fn name(&self) -> &str { + "lucid" + } + + async fn store( + &self, + key: &str, + content: &str, + category: MemoryCategory, + ) -> anyhow::Result<()> { + self.local.store(key, content, category.clone()).await?; + self.sync_to_lucid_async(key, content, &category).await; + Ok(()) + } + + async fn recall(&self, query: &str, limit: usize) -> anyhow::Result> { + let local_results = self.local.recall(query, limit).await?; + if limit == 0 + || local_results.len() >= limit + || local_results.len() >= self.local_hit_threshold + { + return Ok(local_results); + } + + if self.in_failure_cooldown() { + return Ok(local_results); + } + + match self.recall_from_lucid(query).await { + Ok(lucid_results) if !lucid_results.is_empty() => { + self.clear_failure(); + Ok(Self::merge_results(local_results, lucid_results, limit)) + } + Ok(_) => { + self.clear_failure(); + Ok(local_results) + } + Err(error) => { + self.mark_failure_now(); + tracing::debug!( + command = %self.lucid_cmd, + error = %error, + "Lucid context unavailable; using local sqlite results" + ); + Ok(local_results) + } + } + } + + async fn get(&self, key: &str) -> anyhow::Result> { + self.local.get(key).await + } + + async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result> { + self.local.list(category).await + } + + async fn forget(&self, key: &str) -> anyhow::Result { + self.local.forget(key).await + } + + async fn count(&self) -> anyhow::Result { + self.local.count().await + } + + async fn health_check(&self) -> bool { + self.local.health_check().await + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + fn write_fake_lucid_script(dir: &Path) -> String { + let script_path = dir.join("fake-lucid.sh"); + let script = r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "store" ]]; then + echo '{"success":true,"id":"mem_1"}' + exit 0 +fi + +if [[ "${1:-}" == "context" ]]; then + cat <<'EOF' + +Auth context snapshot +- [decision] Use token refresh middleware +- [context] Working in src/auth.rs + +EOF + exit 0 +fi + +echo "unsupported command" >&2 +exit 1 +"#; + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + fn write_probe_lucid_script(dir: &Path, marker_path: &Path) -> String { + let script_path = dir.join("probe-lucid.sh"); + let marker = marker_path.display().to_string(); + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${{1:-}}" == "store" ]]; then + echo '{{"success":true,"id":"mem_store"}}' + exit 0 +fi + +if [[ "${{1:-}}" == "context" ]]; then + printf 'context\n' >> "{marker}" + cat <<'EOF' + +- [decision] should not be used when local hits are enough + +EOF + exit 0 +fi + +echo "unsupported command" >&2 +exit 1 +"# + ); + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + fn test_memory(workspace: &Path, cmd: String) -> LucidMemory { + let sqlite = SqliteMemory::new(workspace).unwrap(); + LucidMemory::with_options( + workspace, + sqlite, + cmd, + 200, + 3, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(2), + ) + } + + #[tokio::test] + async fn lucid_name() { + let tmp = TempDir::new().unwrap(); + let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string()); + assert_eq!(memory.name(), "lucid"); + } + + #[tokio::test] + async fn store_succeeds_when_lucid_missing() { + let tmp = TempDir::new().unwrap(); + let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string()); + + memory + .store("lang", "User prefers Rust", MemoryCategory::Core) + .await + .unwrap(); + + let entry = memory.get("lang").await.unwrap(); + assert!(entry.is_some()); + assert_eq!(entry.unwrap().content, "User prefers Rust"); + } + + #[tokio::test] + async fn recall_merges_lucid_and_local_results() { + let tmp = TempDir::new().unwrap(); + let fake_cmd = write_fake_lucid_script(tmp.path()); + let memory = test_memory(tmp.path(), fake_cmd); + + memory + .store( + "local_note", + "Local sqlite auth fallback note", + MemoryCategory::Core, + ) + .await + .unwrap(); + + let entries = memory.recall("auth", 5).await.unwrap(); + + assert!(entries + .iter() + .any(|e| e.content.contains("Local sqlite auth fallback note"))); + assert!(entries.iter().any(|e| e.content.contains("token refresh"))); + } + + #[tokio::test] + async fn recall_skips_lucid_when_local_hits_are_enough() { + let tmp = TempDir::new().unwrap(); + let marker = tmp.path().join("context_calls.log"); + let probe_cmd = write_probe_lucid_script(tmp.path(), &marker); + + let sqlite = SqliteMemory::new(tmp.path()).unwrap(); + let memory = LucidMemory::with_options( + tmp.path(), + sqlite, + probe_cmd, + 200, + 1, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(2), + ); + + memory + .store("pref", "Rust should stay local-first", MemoryCategory::Core) + .await + .unwrap(); + + let entries = memory.recall("rust", 5).await.unwrap(); + assert!(entries + .iter() + .any(|e| e.content.contains("Rust should stay local-first"))); + + let context_calls = fs::read_to_string(&marker).unwrap_or_default(); + assert!( + context_calls.trim().is_empty(), + "Expected local-hit short-circuit; got calls: {context_calls}" + ); + } + + fn write_failing_lucid_script(dir: &Path, marker_path: &Path) -> String { + let script_path = dir.join("failing-lucid.sh"); + let marker = marker_path.display().to_string(); + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +if [[ "${{1:-}}" == "store" ]]; then + echo '{{"success":true,"id":"mem_store"}}' + exit 0 +fi + +if [[ "${{1:-}}" == "context" ]]; then + printf 'context\n' >> "{marker}" + echo "simulated lucid failure" >&2 + exit 1 +fi + +echo "unsupported command" >&2 +exit 1 +"# + ); + + fs::write(&script_path, script).unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + script_path.display().to_string() + } + + #[tokio::test] + async fn failure_cooldown_avoids_repeated_lucid_calls() { + let tmp = TempDir::new().unwrap(); + let marker = tmp.path().join("failing_context_calls.log"); + let failing_cmd = write_failing_lucid_script(tmp.path(), &marker); + + let sqlite = SqliteMemory::new(tmp.path()).unwrap(); + let memory = LucidMemory::with_options( + tmp.path(), + sqlite, + failing_cmd, + 200, + 99, + Duration::from_millis(120), + Duration::from_millis(400), + Duration::from_secs(5), + ); + + let first = memory.recall("auth", 5).await.unwrap(); + let second = memory.recall("auth", 5).await.unwrap(); + + assert!(first.is_empty()); + assert!(second.is_empty()); + + let calls = fs::read_to_string(&marker).unwrap_or_default(); + assert_eq!(calls.lines().count(), 1); + } +} diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 66912ca..b04e0df 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -1,12 +1,22 @@ +pub mod backend; pub mod chunker; pub mod embeddings; pub mod hygiene; +pub mod lucid; pub mod markdown; +pub mod none; pub mod sqlite; pub mod traits; pub mod vector; +#[allow(unused_imports)] +pub use backend::{ + classify_memory_backend, default_memory_backend_key, memory_backend_profile, + selectable_memory_backends, MemoryBackendKind, MemoryBackendProfile, +}; +pub use lucid::LucidMemory; pub use markdown::MarkdownMemory; +pub use none::NoneMemory; pub use sqlite::SqliteMemory; pub use traits::Memory; #[allow(unused_imports)] @@ -16,6 +26,32 @@ use crate::config::MemoryConfig; use std::path::Path; use std::sync::Arc; +fn create_memory_with_sqlite_builder( + backend_name: &str, + workspace_dir: &Path, + mut sqlite_builder: F, + unknown_context: &str, +) -> anyhow::Result> +where + F: FnMut() -> anyhow::Result, +{ + match classify_memory_backend(backend_name) { + MemoryBackendKind::Sqlite => Ok(Box::new(sqlite_builder()?)), + MemoryBackendKind::Lucid => { + let local = sqlite_builder()?; + Ok(Box::new(LucidMemory::new(workspace_dir, local))) + } + MemoryBackendKind::Markdown => Ok(Box::new(MarkdownMemory::new(workspace_dir))), + MemoryBackendKind::None => Ok(Box::new(NoneMemory::new())), + MemoryBackendKind::Unknown => { + tracing::warn!( + "Unknown memory backend '{backend_name}'{unknown_context}, falling back to markdown" + ); + Ok(Box::new(MarkdownMemory::new(workspace_dir))) + } + } +} + /// Factory: create the right memory backend from config pub fn create_memory( config: &MemoryConfig, @@ -27,32 +63,54 @@ pub fn create_memory( tracing::warn!("memory hygiene skipped: {e}"); } - match config.backend.as_str() { - "sqlite" => { - let embedder: Arc = - Arc::from(embeddings::create_embedding_provider( - &config.embedding_provider, - api_key, - &config.embedding_model, - config.embedding_dimensions, - )); + fn build_sqlite_memory( + config: &MemoryConfig, + workspace_dir: &Path, + api_key: Option<&str>, + ) -> anyhow::Result { + let embedder: Arc = + Arc::from(embeddings::create_embedding_provider( + &config.embedding_provider, + api_key, + &config.embedding_model, + config.embedding_dimensions, + )); - #[allow(clippy::cast_possible_truncation)] - let mem = SqliteMemory::with_embedder( - workspace_dir, - embedder, - config.vector_weight as f32, - config.keyword_weight as f32, - config.embedding_cache_size, - )?; - Ok(Box::new(mem)) - } - "markdown" | "none" => Ok(Box::new(MarkdownMemory::new(workspace_dir))), - other => { - tracing::warn!("Unknown memory backend '{other}', falling back to markdown"); - Ok(Box::new(MarkdownMemory::new(workspace_dir))) - } + #[allow(clippy::cast_possible_truncation)] + let mem = SqliteMemory::with_embedder( + workspace_dir, + embedder, + config.vector_weight as f32, + config.keyword_weight as f32, + config.embedding_cache_size, + )?; + Ok(mem) } + + create_memory_with_sqlite_builder( + &config.backend, + workspace_dir, + || build_sqlite_memory(config, workspace_dir, api_key), + "", + ) +} + +pub fn create_memory_for_migration( + backend: &str, + workspace_dir: &Path, +) -> anyhow::Result> { + if matches!(classify_memory_backend(backend), MemoryBackendKind::None) { + anyhow::bail!( + "memory backend 'none' disables persistence; choose sqlite, lucid, or markdown before migration" + ); + } + + create_memory_with_sqlite_builder( + backend, + workspace_dir, + || SqliteMemory::new(workspace_dir), + " during migration", + ) } #[cfg(test)] @@ -83,14 +141,25 @@ mod tests { } #[test] - fn factory_none_falls_back_to_markdown() { + fn factory_lucid() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig { + backend: "lucid".into(), + ..MemoryConfig::default() + }; + let mem = create_memory(&cfg, tmp.path(), None).unwrap(); + assert_eq!(mem.name(), "lucid"); + } + + #[test] + fn factory_none_uses_noop_memory() { let tmp = TempDir::new().unwrap(); let cfg = MemoryConfig { backend: "none".into(), ..MemoryConfig::default() }; let mem = create_memory(&cfg, tmp.path(), None).unwrap(); - assert_eq!(mem.name(), "markdown"); + assert_eq!(mem.name(), "none"); } #[test] @@ -103,4 +172,20 @@ mod tests { let mem = create_memory(&cfg, tmp.path(), None).unwrap(); assert_eq!(mem.name(), "markdown"); } + + #[test] + fn migration_factory_lucid() { + let tmp = TempDir::new().unwrap(); + let mem = create_memory_for_migration("lucid", tmp.path()).unwrap(); + assert_eq!(mem.name(), "lucid"); + } + + #[test] + fn migration_factory_none_is_rejected() { + let tmp = TempDir::new().unwrap(); + let error = create_memory_for_migration("none", tmp.path()) + .err() + .expect("backend=none should be rejected for migration"); + assert!(error.to_string().contains("disables persistence")); + } } diff --git a/src/memory/none.rs b/src/memory/none.rs new file mode 100644 index 0000000..6057ad0 --- /dev/null +++ b/src/memory/none.rs @@ -0,0 +1,74 @@ +use super::traits::{Memory, MemoryCategory, MemoryEntry}; +use async_trait::async_trait; + +/// Explicit no-op memory backend. +/// +/// This backend is used when `memory.backend = "none"` to disable persistence +/// while keeping the runtime wiring stable. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoneMemory; + +impl NoneMemory { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Memory for NoneMemory { + fn name(&self) -> &str { + "none" + } + + async fn store( + &self, + _key: &str, + _content: &str, + _category: MemoryCategory, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn recall(&self, _query: &str, _limit: usize) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn get(&self, _key: &str) -> anyhow::Result> { + Ok(None) + } + + async fn list(&self, _category: Option<&MemoryCategory>) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn forget(&self, _key: &str) -> anyhow::Result { + Ok(false) + } + + async fn count(&self) -> anyhow::Result { + Ok(0) + } + + async fn health_check(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn none_memory_is_noop() { + let memory = NoneMemory::new(); + + memory.store("k", "v", MemoryCategory::Core).await.unwrap(); + + assert!(memory.get("k").await.unwrap().is_none()); + assert!(memory.recall("k", 10).await.unwrap().is_empty()); + assert!(memory.list(None).await.unwrap().is_empty()); + assert!(!memory.forget("k").await.unwrap()); + assert_eq!(memory.count().await.unwrap(), 0); + assert!(memory.health_check().await); + } +} diff --git a/src/migration.rs b/src/migration.rs index 04fa458..f217030 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::memory::{MarkdownMemory, Memory, MemoryCategory, SqliteMemory}; +use crate::memory::{self, Memory, MemoryCategory}; use anyhow::{bail, Context, Result}; use directories::UserDirs; use rusqlite::{Connection, OpenFlags, OptionalExtension}; @@ -112,16 +112,7 @@ async fn migrate_openclaw_memory( } fn target_memory_backend(config: &Config) -> Result> { - match config.memory.backend.as_str() { - "sqlite" => Ok(Box::new(SqliteMemory::new(&config.workspace_dir)?)), - "markdown" | "none" => Ok(Box::new(MarkdownMemory::new(&config.workspace_dir))), - other => { - tracing::warn!( - "Unknown memory backend '{other}' during migration, defaulting to markdown" - ); - Ok(Box::new(MarkdownMemory::new(&config.workspace_dir))) - } - } + memory::create_memory_for_migration(&config.memory.backend, &config.workspace_dir) } fn collect_source_entries( @@ -431,6 +422,7 @@ fn backup_target_memory(workspace_dir: &Path) -> Result> { mod tests { use super::*; use crate::config::{Config, MemoryConfig}; + use crate::memory::SqliteMemory; use rusqlite::params; use tempfile::TempDir; @@ -550,4 +542,16 @@ mod tests { let target_mem = SqliteMemory::new(target.path()).unwrap(); assert_eq!(target_mem.count().await.unwrap(), 0); } + + #[test] + fn migration_target_rejects_none_backend() { + let target = TempDir::new().unwrap(); + let mut config = test_config(target.path()); + config.memory.backend = "none".to_string(); + + let err = target_memory_backend(&config) + .err() + .expect("backend=none should be rejected for migration target"); + assert!(err.to_string().contains("disables persistence")); + } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 0bf285b..8714089 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -5,6 +5,9 @@ use crate::config::{ RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig, }; use crate::hardware::{self, HardwareConfig}; +use crate::memory::{ + default_memory_backend_key, memory_backend_profile, selectable_memory_backends, +}; use anyhow::{Context, Result}; use console::style; use dialoguer::{Confirm, Input, Select}; @@ -237,8 +240,38 @@ pub fn run_channels_repair_wizard() -> Result { // ── Quick setup (zero prompts) ─────────────────────────────────── /// Non-interactive setup: generates a sensible default config instantly. -/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite`. +/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`. /// Use `zeroclaw onboard --interactive` for the full wizard. +fn backend_key_from_choice(choice: usize) -> &'static str { + selectable_memory_backends() + .get(choice) + .map_or(default_memory_backend_key(), |backend| backend.key) +} + +fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig { + let profile = memory_backend_profile(backend); + + MemoryConfig { + backend: backend.to_string(), + auto_save: profile.auto_save_default, + hygiene_enabled: profile.uses_sqlite_hygiene, + archive_after_days: if profile.uses_sqlite_hygiene { 7 } else { 0 }, + purge_after_days: if profile.uses_sqlite_hygiene { 30 } else { 0 }, + conversation_retention_days: 30, + embedding_provider: "none".to_string(), + embedding_model: "text-embedding-3-small".to_string(), + embedding_dimensions: 1536, + vector_weight: 0.7, + keyword_weight: 0.3, + embedding_cache_size: if profile.uses_sqlite_hygiene { + 10000 + } else { + 0 + }, + chunk_max_tokens: 512, + } +} + #[allow(clippy::too_many_lines)] pub fn run_quick_setup( api_key: Option<&str>, @@ -265,36 +298,12 @@ pub fn run_quick_setup( let provider_name = provider.unwrap_or("openrouter").to_string(); let model = default_model_for_provider(&provider_name); - let memory_backend_name = memory_backend.unwrap_or("sqlite").to_string(); + let memory_backend_name = memory_backend + .unwrap_or(default_memory_backend_key()) + .to_string(); // Create memory config based on backend choice - let memory_config = MemoryConfig { - backend: memory_backend_name.clone(), - auto_save: memory_backend_name != "none", - hygiene_enabled: memory_backend_name == "sqlite", - archive_after_days: if memory_backend_name == "sqlite" { - 7 - } else { - 0 - }, - purge_after_days: if memory_backend_name == "sqlite" { - 30 - } else { - 0 - }, - conversation_retention_days: 30, - embedding_provider: "none".to_string(), - embedding_model: "text-embedding-3-small".to_string(), - embedding_dimensions: 1536, - vector_weight: 0.7, - keyword_weight: 0.3, - embedding_cache_size: if memory_backend_name == "sqlite" { - 10000 - } else { - 0 - }, - chunk_max_tokens: 512, - }; + let memory_config = memory_config_defaults_for_backend(&memory_backend_name); let config = Config { workspace_dir: workspace_dir.clone(), @@ -2164,11 +2173,10 @@ fn setup_memory() -> Result { print_bullet("You can always change this later in config.toml."); println!(); - let options = vec![ - "SQLite with Vector Search (recommended) — fast, hybrid search, embeddings", - "Markdown Files — simple, human-readable, no dependencies", - "None — disable persistent memory", - ]; + let options: Vec<&str> = selectable_memory_backends() + .iter() + .map(|backend| backend.label) + .collect(); let choice = Select::new() .with_prompt(" Select memory backend") @@ -2176,20 +2184,16 @@ fn setup_memory() -> Result { .default(0) .interact()?; - let backend = match choice { - 1 => "markdown", - 2 => "none", - _ => "sqlite", // 0 and any unexpected value defaults to sqlite - }; + let backend = backend_key_from_choice(choice); + let profile = memory_backend_profile(backend); - let auto_save = if backend == "none" { + let auto_save = if !profile.auto_save_default { false } else { - let save = Confirm::new() + Confirm::new() .with_prompt(" Auto-save conversations to memory?") .default(true) - .interact()?; - save + .interact()? }; println!( @@ -2199,21 +2203,9 @@ fn setup_memory() -> Result { if auto_save { "on" } else { "off" } ); - Ok(MemoryConfig { - backend: backend.to_string(), - auto_save, - hygiene_enabled: backend == "sqlite", // Only enable hygiene for SQLite - archive_after_days: if backend == "sqlite" { 7 } else { 0 }, - purge_after_days: if backend == "sqlite" { 30 } else { 0 }, - conversation_retention_days: 30, - embedding_provider: "none".to_string(), - embedding_model: "text-embedding-3-small".to_string(), - embedding_dimensions: 1536, - vector_weight: 0.7, - keyword_weight: 0.3, - embedding_cache_size: if backend == "sqlite" { 10000 } else { 0 }, - chunk_max_tokens: 512, - }) + let mut config = memory_config_defaults_for_backend(backend); + config.auto_save = auto_save; + Ok(config) } // ── Step 3: Channels ──────────────────────────────────────────── @@ -4343,18 +4335,54 @@ mod tests { } #[test] - fn default_model_for_minimax_is_m2_5() { - assert_eq!(default_model_for_provider("minimax"), "MiniMax-M2.5"); + fn backend_key_from_choice_maps_supported_backends() { + assert_eq!(backend_key_from_choice(0), "sqlite"); + assert_eq!(backend_key_from_choice(1), "lucid"); + assert_eq!(backend_key_from_choice(2), "markdown"); + assert_eq!(backend_key_from_choice(3), "none"); + assert_eq!(backend_key_from_choice(999), "sqlite"); } #[test] - fn minimax_onboard_models_include_m2_variants() { - let model_names: Vec<&str> = MINIMAX_ONBOARD_MODELS - .iter() - .map(|(name, _)| *name) - .collect(); - assert_eq!(model_names.first().copied(), Some("MiniMax-M2.5")); - assert!(model_names.contains(&"MiniMax-M2.1")); - assert!(model_names.contains(&"MiniMax-M2.1-highspeed")); + fn memory_backend_profile_marks_lucid_as_optional_sqlite_backed() { + let lucid = memory_backend_profile("lucid"); + assert!(lucid.auto_save_default); + assert!(lucid.uses_sqlite_hygiene); + assert!(lucid.sqlite_based); + assert!(lucid.optional_dependency); + + let markdown = memory_backend_profile("markdown"); + assert!(markdown.auto_save_default); + assert!(!markdown.uses_sqlite_hygiene); + + let none = memory_backend_profile("none"); + assert!(!none.auto_save_default); + assert!(!none.uses_sqlite_hygiene); + + let custom = memory_backend_profile("custom-memory"); + assert!(custom.auto_save_default); + assert!(!custom.uses_sqlite_hygiene); + } + + #[test] + fn memory_config_defaults_for_lucid_enable_sqlite_hygiene() { + let config = memory_config_defaults_for_backend("lucid"); + assert_eq!(config.backend, "lucid"); + assert!(config.auto_save); + assert!(config.hygiene_enabled); + assert_eq!(config.archive_after_days, 7); + assert_eq!(config.purge_after_days, 30); + assert_eq!(config.embedding_cache_size, 10000); + } + + #[test] + fn memory_config_defaults_for_none_disable_sqlite_hygiene() { + let config = memory_config_defaults_for_backend("none"); + assert_eq!(config.backend, "none"); + assert!(!config.auto_save); + assert!(!config.hygiene_enabled); + assert_eq!(config.archive_after_days, 0); + assert_eq!(config.purge_after_days, 0); + assert_eq!(config.embedding_cache_size, 0); } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index b342675..1808499 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -202,7 +202,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Cloudflare AI Gateway", "https://gateway.ai.cloudflare.com/v1", - api_key, + key, AuthStyle::Bearer, ))), "moonshot" | "kimi" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -229,7 +229,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Amazon Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", - api_key, + key, AuthStyle::Bearer, ))), "qianfan" | "baidu" => Ok(Box::new(OpenAiCompatibleProvider::new( @@ -421,6 +421,12 @@ pub fn create_routed_provider( mod tests { use super::*; + #[test] + fn resolve_api_key_prefers_explicit_argument() { + let resolved = resolve_api_key("openrouter", Some(" explicit-key ")); + assert_eq!(resolved.as_deref(), Some("explicit-key")); + } + // ── Primary providers ──────────────────────────────────── #[test]