feat(memory): lucid memory integration with optional backends (#285)

This commit is contained in:
Chummy 2026-02-17 00:31:50 +08:00 committed by GitHub
parent 04bf94443f
commit 53844f7207
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1089 additions and 137 deletions

View file

@ -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

View file

@ -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),

View file

@ -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;

View file

@ -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
View 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
View 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);
}
}

View file

@ -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,8 +63,11 @@ pub fn create_memory(
tracing::warn!("memory hygiene skipped: {e}");
}
match config.backend.as_str() {
"sqlite" => {
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,
@ -45,14 +84,33 @@ pub fn create_memory(
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)))
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
View 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);
}
}

View file

@ -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"));
}
}

View file

@ -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);
}
}

View file

@ -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]