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

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