feat(memory): lucid memory integration with optional backends (#285)
This commit is contained in:
parent
04bf94443f
commit
53844f7207
11 changed files with 1089 additions and 137 deletions
|
|
@ -699,9 +699,8 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
.default_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| "openrouter".into());
|
||||
|
||||
let provider: Arc<dyn Provider> = 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),
|
||||
|
|
|
|||
|
|
@ -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<Mutex<()>> = 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ enum Commands {
|
|||
#[arg(long)]
|
||||
provider: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
|
|
|||
145
src/memory/backend.rs
Normal file
145
src/memory/backend.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
601
src/memory/lucid.rs
Normal file
601
src/memory/lucid.rs
Normal file
|
|
@ -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<Option<Instant>>,
|
||||
}
|
||||
|
||||
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::<usize>().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::<usize>().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::<u64>().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<MemoryEntry>,
|
||||
secondary_results: Vec<MemoryEntry>,
|
||||
limit: usize,
|
||||
) -> Vec<MemoryEntry> {
|
||||
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<MemoryEntry> {
|
||||
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 == "<lucid-context>" {
|
||||
in_context_block = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "</lucid-context>" {
|
||||
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<String> {
|
||||
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<String> {
|
||||
Self::run_lucid_command_raw(&self.lucid_cmd, args, timeout_window).await
|
||||
}
|
||||
|
||||
fn build_store_args(&self, key: &str, content: &str, category: &MemoryCategory) -> Vec<String> {
|
||||
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<String> {
|
||||
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<Vec<MemoryEntry>> {
|
||||
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<Vec<MemoryEntry>> {
|
||||
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<Option<MemoryEntry>> {
|
||||
self.local.get(key).await
|
||||
}
|
||||
|
||||
async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
self.local.list(category).await
|
||||
}
|
||||
|
||||
async fn forget(&self, key: &str) -> anyhow::Result<bool> {
|
||||
self.local.forget(key).await
|
||||
}
|
||||
|
||||
async fn count(&self) -> anyhow::Result<usize> {
|
||||
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'
|
||||
<lucid-context>
|
||||
Auth context snapshot
|
||||
- [decision] Use token refresh middleware
|
||||
- [context] Working in src/auth.rs
|
||||
</lucid-context>
|
||||
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'
|
||||
<lucid-context>
|
||||
- [decision] should not be used when local hits are enough
|
||||
</lucid-context>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<F>(
|
||||
backend_name: &str,
|
||||
workspace_dir: &Path,
|
||||
mut sqlite_builder: F,
|
||||
unknown_context: &str,
|
||||
) -> anyhow::Result<Box<dyn Memory>>
|
||||
where
|
||||
F: FnMut() -> anyhow::Result<SqliteMemory>,
|
||||
{
|
||||
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<dyn embeddings::EmbeddingProvider> =
|
||||
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<SqliteMemory> {
|
||||
let embedder: Arc<dyn embeddings::EmbeddingProvider> =
|
||||
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<Box<dyn Memory>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
src/memory/none.rs
Normal file
74
src/memory/none.rs
Normal file
|
|
@ -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<Vec<MemoryEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list(&self, _category: Option<&MemoryCategory>) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn count(&self) -> anyhow::Result<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Box<dyn Memory>> {
|
||||
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<Option<PathBuf>> {
|
|||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Config> {
|
|||
// ── 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<MemoryConfig> {
|
|||
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<MemoryConfig> {
|
|||
.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<MemoryConfig> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
|||
"cloudflare" | "cloudflare-ai" => 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<Box<
|
|||
"bedrock" | "aws-bedrock" => 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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue