Skip to main content

zeroclaw_memory/
lib.rs

1#![allow(clippy::to_string_in_format_args)]
2//! Memory subsystem: backends, embeddings, consolidation, retrieval.
3//!
4//! ## Reserved Key Prefixes
5//!
6//! The following key prefixes are reserved for the auto-save system. Any memory
7//! stored under these keys will be **excluded from context assembly** by all
8//! three context-building paths (`build_context`, `DefaultMemoryLoader`, and
9//! `should_skip_memory_context_entry`). Do not use these prefixes for semantic
10//! memories that should surface in agent context.
11//!
12//! | Prefix | Purpose | Detection function |
13//! |---|---|---|
14//! | `assistant_resp` / `assistant_resp_*` | Model-authored assistant summaries (untrusted context) | [`is_assistant_autosave_key`] |
15//! | `user_msg` / `user_msg_*` | Raw per-turn user messages (consolidation queue) | [`is_user_autosave_key`] |
16//!
17//! Channel-scoped variants (e.g. `telegram_user_msg_*`, `discord_*`) are
18//! **not** filtered — they use different prefixes and are handled separately.
19
20/// Opening delimiter for recalled memory injected into provider context.
21pub const MEMORY_CONTEXT_OPEN: &str = "[Memory context]";
22/// Closing delimiter for recalled memory injected into provider context.
23pub const MEMORY_CONTEXT_CLOSE: &str = "[/Memory context]";
24
25pub mod agent_scoped;
26pub mod agent_scoped_markdown;
27pub mod audit;
28pub mod backend;
29pub mod chunker;
30pub mod conflict;
31pub mod consolidation;
32pub mod decay;
33pub mod embeddings;
34pub mod hygiene;
35pub mod importance;
36pub mod knowledge_graph;
37#[cfg(feature = "memory-postgres")]
38pub mod knowledge_graph_pg;
39pub mod lucid;
40pub mod markdown;
41pub mod none;
42pub mod policy;
43#[cfg(feature = "memory-postgres")]
44pub mod postgres;
45pub mod qdrant;
46pub mod response_cache;
47pub mod retrieval;
48pub mod snapshot;
49pub mod sqlite;
50pub mod traits;
51pub mod vector;
52
53pub use agent_scoped::AgentScopedMemory;
54pub use agent_scoped_markdown::{AgentScopedMarkdownMemory, MarkdownPeer};
55#[allow(unused_imports)]
56pub use audit::AuditedMemory;
57#[allow(unused_imports)]
58pub use backend::{
59    MemoryBackendKind, MemoryBackendProfile, classify_memory_backend, default_memory_backend_key,
60    memory_backend_profile, selectable_memory_backends,
61};
62pub use lucid::LucidMemory;
63pub use markdown::MarkdownMemory;
64pub use none::NoneMemory;
65#[allow(unused_imports)]
66pub use policy::PolicyEnforcer;
67#[cfg(feature = "memory-postgres")]
68#[allow(unused_imports)]
69pub use postgres::PostgresMemory;
70pub use qdrant::QdrantMemory;
71pub use response_cache::ResponseCache;
72#[allow(unused_imports)]
73pub use retrieval::{RetrievalConfig, RetrievalPipeline};
74pub use sqlite::SqliteMemory;
75pub use traits::Memory;
76#[allow(unused_imports)]
77pub use traits::{
78    ExportFilter, MemoryCategory, MemoryEntry, ProceduralMessage, is_recent_recall_query,
79    normalize_recent_recall_query,
80};
81
82use anyhow::Context;
83use std::path::Path;
84use std::sync::Arc;
85use zeroclaw_config::schema::{
86    ActiveStorage, EmbeddingRouteConfig, MemoryConfig, PostgresStorageConfig,
87};
88
89#[cfg(feature = "memory-postgres")]
90fn build_postgres_memory(storage: &PostgresStorageConfig) -> anyhow::Result<Box<dyn Memory>> {
91    use postgres::PostgresMemory;
92    let db_url = storage
93        .db_url
94        .as_deref()
95        .context("memory backend 'postgres' requires [storage.postgres.<alias>].db_url")?;
96    let memory = PostgresMemory::new(
97        "postgres",
98        db_url,
99        &storage.schema,
100        &storage.table,
101        storage.connect_timeout_secs,
102        Some(storage.vector_enabled),
103        Some(storage.vector_dimensions),
104    )?;
105    Ok(Box::new(memory))
106}
107
108#[cfg(not(feature = "memory-postgres"))]
109fn build_postgres_memory(_storage: &PostgresStorageConfig) -> anyhow::Result<Box<dyn Memory>> {
110    anyhow::bail!(
111        "memory backend 'postgres' requested but this build was compiled without \
112         `memory-postgres`; rebuild with `--features memory-postgres`"
113    )
114}
115
116fn create_memory_with_builders<F>(
117    backend_name: &str,
118    workspace_dir: &Path,
119    mut sqlite_builder: F,
120    unknown_context: &str,
121) -> anyhow::Result<Box<dyn Memory>>
122where
123    F: FnMut() -> anyhow::Result<SqliteMemory>,
124{
125    match classify_memory_backend(backend_name) {
126        MemoryBackendKind::Sqlite => Ok(Box::new(sqlite_builder()?)),
127        MemoryBackendKind::Lucid => {
128            let local = sqlite_builder()?;
129            Ok(Box::new(LucidMemory::new("lucid", workspace_dir, local)))
130        }
131        MemoryBackendKind::Postgres => {
132            // Postgres requires a typed `[storage.postgres.<alias>]` config, which this
133            // builder-only entry point does not receive. All supported call paths go
134            // through `create_memory_with_storage_and_routes`, which handles postgres via
135            // an early return. Fail loudly if a caller ever reaches this arm, rather than
136            // pretending to work with default configs that can never connect.
137            anyhow::bail!(
138                "postgres backend requires storage config; \
139                 call create_memory_with_storage_and_routes instead of create_memory_with_builders"
140            )
141        }
142        MemoryBackendKind::Qdrant | MemoryBackendKind::Markdown => {
143            Ok(Box::new(MarkdownMemory::new("markdown", workspace_dir)))
144        }
145        MemoryBackendKind::None => Ok(Box::new(NoneMemory::new("none"))),
146        MemoryBackendKind::Unknown => {
147            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"backend_name": backend_name, "unknown_context": unknown_context})), "Unknown memory backend '', falling back to markdown");
148            Ok(Box::new(MarkdownMemory::new("markdown", workspace_dir)))
149        }
150    }
151}
152
153/// Extract the backend kind from a V3 dotted reference (`<kind>.<alias>`).
154/// Bare names (`"sqlite"`) are returned as-is. Returned lowercase.
155pub fn backend_kind_from_dotted(memory_backend: &str) -> String {
156    memory_backend
157        .trim()
158        .split_once('.')
159        .map_or(memory_backend.trim(), |(kind, _)| kind)
160        .to_ascii_lowercase()
161}
162
163/// Legacy auto-save key used for model-authored assistant summaries.
164/// These entries are treated as untrusted context and should not be re-injected.
165pub fn is_assistant_autosave_key(key: &str) -> bool {
166    let normalized = key.trim().to_ascii_lowercase();
167    normalized == "assistant_resp" || normalized.starts_with("assistant_resp_")
168}
169
170/// Auto-save key used for raw user messages captured per-turn.
171/// Re-injecting these into build_context causes exponential bloat: each recalled
172/// entry contains prior generations' context verbatim, growing unboundedly.
173/// Consolidated knowledge is already promoted to Core/Daily entries.
174pub fn is_user_autosave_key(key: &str) -> bool {
175    let normalized = key.trim().to_ascii_lowercase();
176    normalized == "user_msg" || normalized.starts_with("user_msg_")
177}
178
179/// Filter known synthetic autosave noise patterns that should not be
180/// persisted as user conversation memories.
181pub fn should_skip_autosave_content(content: &str) -> bool {
182    let normalized = content.trim();
183    if normalized.is_empty() {
184        return true;
185    }
186
187    let lowered = normalized.to_ascii_lowercase();
188    lowered.starts_with("[cron:")
189        || lowered.starts_with("[heartbeat task")
190        || lowered.starts_with("[distilled_")
191        || starts_with_ignore_ascii_case(normalized, MEMORY_CONTEXT_OPEN)
192        || lowered.contains("distilled_index_sig:")
193}
194
195fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
196    value
197        .get(..prefix.len())
198        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
199}
200
201#[derive(Clone, PartialEq, Eq)]
202struct ResolvedEmbeddingConfig {
203    model_provider: String,
204    model: String,
205    dimensions: usize,
206    api_key: Option<String>,
207}
208
209impl std::fmt::Debug for ResolvedEmbeddingConfig {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("ResolvedEmbeddingConfig")
212            .field("model_provider", &self.model_provider)
213            .field("model", &self.model)
214            .field("dimensions", &self.dimensions)
215            .finish_non_exhaustive()
216    }
217}
218
219fn resolve_embedding_config(
220    config: &MemoryConfig,
221    embedding_routes: &[EmbeddingRouteConfig],
222    api_key: Option<&str>,
223) -> ResolvedEmbeddingConfig {
224    let fallback_api_key = api_key
225        .map(str::trim)
226        .filter(|value| !value.is_empty())
227        .map(str::to_string);
228    let fallback = ResolvedEmbeddingConfig {
229        model_provider: config.embedding_provider.trim().to_string(),
230        model: config.embedding_model.trim().to_string(),
231        dimensions: config.embedding_dimensions,
232        api_key: fallback_api_key.clone(),
233    };
234
235    let Some(hint) = config
236        .embedding_model
237        .strip_prefix("hint:")
238        .map(str::trim)
239        .filter(|value| !value.is_empty())
240    else {
241        return fallback;
242    };
243
244    let Some(route) = embedding_routes
245        .iter()
246        .find(|route| route.hint.trim() == hint)
247    else {
248        ::zeroclaw_log::record!(
249            WARN,
250            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
251                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
252                .with_attrs(::serde_json::json!({"hint": hint})),
253            "Unknown embedding route hint; falling back to [memory] embedding settings"
254        );
255        return fallback;
256    };
257
258    let model_provider = route.model_provider.trim();
259    let model = route.model.trim();
260    let dimensions = route.dimensions.unwrap_or(config.embedding_dimensions);
261    if model_provider.is_empty() || model.is_empty() || dimensions == 0 {
262        ::zeroclaw_log::record!(
263            WARN,
264            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
265                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
266                .with_attrs(::serde_json::json!({"hint": hint})),
267            "Invalid embedding route configuration; falling back to [memory] embedding settings"
268        );
269        return fallback;
270    }
271
272    let routed_api_key = route
273        .api_key
274        .as_deref()
275        .map(str::trim)
276        .filter(|value: &&str| !value.is_empty())
277        .map(|value| value.to_string());
278
279    ResolvedEmbeddingConfig {
280        model_provider: model_provider.to_string(),
281        model: model.to_string(),
282        dimensions,
283        api_key: routed_api_key.or(fallback_api_key),
284    }
285}
286
287/// Factory: create the right memory backend from config
288pub fn create_memory(
289    config: &MemoryConfig,
290    workspace_dir: &Path,
291    api_key: Option<&str>,
292) -> anyhow::Result<Box<dyn Memory>> {
293    create_memory_with_storage_and_routes(config, &[], ActiveStorage::None, workspace_dir, api_key)
294}
295
296/// Factory: create memory with a resolved active storage backend and embedding routes.
297///
298/// Pass [`ActiveStorage::None`] when no typed storage config is needed (sqlite,
299/// markdown, lucid, none — all infer settings from the workspace). Postgres and
300/// Qdrant require their typed variants and will error if the wrong variant is
301/// supplied.
302pub fn create_memory_with_storage_and_routes(
303    config: &MemoryConfig,
304    embedding_routes: &[EmbeddingRouteConfig],
305    active_storage: ActiveStorage<'_>,
306    workspace_dir: &Path,
307    api_key: Option<&str>,
308) -> anyhow::Result<Box<dyn Memory>> {
309    let backend_name = backend_kind_from_dotted(&config.backend);
310    let backend_kind = classify_memory_backend(&backend_name);
311    let resolved_embedding = resolve_embedding_config(config, embedding_routes, api_key);
312
313    // Best-effort memory hygiene/retention pass (throttled by state file).
314    if let Err(e) = hygiene::run_if_due(config, workspace_dir) {
315        ::zeroclaw_log::record!(
316            WARN,
317            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
318                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
319                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
320            "memory hygiene skipped"
321        );
322    }
323
324    // If snapshot_on_hygiene is enabled, export core memories during hygiene.
325    if config.snapshot_enabled
326        && config.snapshot_on_hygiene
327        && matches!(
328            backend_kind,
329            MemoryBackendKind::Sqlite | MemoryBackendKind::Lucid
330        )
331        && let Err(e) = snapshot::export_snapshot(workspace_dir)
332    {
333        ::zeroclaw_log::record!(
334            WARN,
335            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
336                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
337                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
338            "memory snapshot skipped"
339        );
340    }
341
342    // Auto-hydration: if brain.db is missing but MEMORY_SNAPSHOT.md exists,
343    // restore the "soul" from the snapshot before creating the backend.
344    if config.auto_hydrate
345        && matches!(
346            backend_kind,
347            MemoryBackendKind::Sqlite | MemoryBackendKind::Lucid
348        )
349        && snapshot::should_hydrate(workspace_dir)
350    {
351        ::zeroclaw_log::record!(
352            INFO,
353            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
354            "cold boot detected; hydrating from MEMORY_SNAPSHOT.md"
355        );
356        match snapshot::hydrate_from_snapshot(workspace_dir) {
357            Ok(count) => {
358                if count > 0 {
359                    ::zeroclaw_log::record!(
360                        INFO,
361                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
362                            .with_attrs(::serde_json::json!({"count": count})),
363                        "hydrated core memories from snapshot"
364                    );
365                }
366            }
367            Err(e) => {
368                ::zeroclaw_log::record!(
369                    WARN,
370                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
371                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
372                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
373                    "memory hydration failed"
374                );
375            }
376        }
377    }
378
379    fn build_sqlite_memory(
380        config: &MemoryConfig,
381        sqlite_open_timeout_secs: Option<u64>,
382        workspace_dir: &Path,
383        resolved_embedding: &ResolvedEmbeddingConfig,
384    ) -> anyhow::Result<SqliteMemory> {
385        let embedder: Arc<dyn embeddings::EmbeddingProvider> =
386            Arc::from(embeddings::create_embedding_provider(
387                &resolved_embedding.model_provider,
388                resolved_embedding.api_key.as_deref(),
389                &resolved_embedding.model,
390                resolved_embedding.dimensions,
391            ));
392
393        #[allow(clippy::cast_possible_truncation)]
394        let mem = SqliteMemory::with_embedder(
395            "sqlite",
396            workspace_dir,
397            embedder,
398            config.vector_weight as f32,
399            config.keyword_weight as f32,
400            config.embedding_cache_size,
401            sqlite_open_timeout_secs,
402            config.search_mode.clone(),
403        )?;
404        Ok(mem)
405    }
406
407    // Per-backend SQLite open-timeout override comes from the active storage
408    // alias (V3); when no typed entry resolves, sqlite waits indefinitely.
409    let sqlite_open_timeout_secs = match active_storage {
410        ActiveStorage::Sqlite(sq) => sq.open_timeout_secs,
411        _ => None,
412    };
413
414    if matches!(backend_kind, MemoryBackendKind::Qdrant) {
415        let qdrant_cfg = match active_storage {
416            ActiveStorage::Qdrant(q) => q,
417            _ => anyhow::bail!(
418                "memory backend 'qdrant' requires a `[storage.qdrant.<alias>]` entry \
419                 referenced by `memory.backend = \"qdrant.<alias>\"`"
420            ),
421        };
422        let url = qdrant_cfg
423            .url
424            .clone()
425            .filter(|s| !s.trim().is_empty())
426            .context("Qdrant memory backend requires `url` in [storage.qdrant.<alias>]")?;
427        let collection = qdrant_cfg.collection.clone();
428        let qdrant_api_key = qdrant_cfg.api_key.clone().filter(|s| !s.trim().is_empty());
429        let embedder: Arc<dyn embeddings::EmbeddingProvider> =
430            Arc::from(embeddings::create_embedding_provider(
431                &resolved_embedding.model_provider,
432                resolved_embedding.api_key.as_deref(),
433                &resolved_embedding.model,
434                resolved_embedding.dimensions,
435            ));
436        ::zeroclaw_log::record!(
437            INFO,
438            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
439            &format!(
440                "📦 Qdrant memory backend configured (url: {}, collection: {})",
441                url, collection
442            )
443        );
444        return Ok(Box::new(QdrantMemory::new_lazy(
445            "qdrant",
446            &url,
447            &collection,
448            qdrant_api_key,
449            embedder,
450        )));
451    }
452
453    if matches!(backend_kind, MemoryBackendKind::Postgres) {
454        let pg_cfg = match active_storage {
455            ActiveStorage::Postgres(p) => p,
456            _ => anyhow::bail!(
457                "memory backend 'postgres' requires a `[storage.postgres.<alias>]` entry \
458                 referenced by `memory.backend = \"postgres.<alias>\"`"
459            ),
460        };
461        return build_postgres_memory(pg_cfg);
462    }
463
464    create_memory_with_builders(
465        &backend_name,
466        workspace_dir,
467        || {
468            build_sqlite_memory(
469                config,
470                sqlite_open_timeout_secs,
471                workspace_dir,
472                &resolved_embedding,
473            )
474        },
475        "",
476    )
477}
478
479pub fn create_memory_for_migration(
480    backend: &str,
481    workspace_dir: &Path,
482) -> anyhow::Result<Box<dyn Memory>> {
483    if matches!(classify_memory_backend(backend), MemoryBackendKind::None) {
484        anyhow::bail!(
485            "memory backend 'none' disables persistence; choose sqlite, lucid, or markdown before migration"
486        );
487    }
488
489    create_memory_with_builders(
490        backend,
491        workspace_dir,
492        || SqliteMemory::new("sqlite", workspace_dir),
493        " during migration",
494    )
495}
496
497/// Build the per-agent memory wrapper for `agent_alias`.
498///
499/// Wraps the appropriate inner backend with `AgentScopedMemory` (for
500/// SQL- and Qdrant-backed agents — single shared backend, agent_id
501/// column distinguishes rows) or `AgentScopedMarkdownMemory` (for
502/// Markdown-backed agents — per-agent dirs, peer set composed from
503/// the resolved `read_memory_from` allowlist). `NoneMemory` agents
504/// pass through unwrapped.
505///
506/// Cross-backend allowlist entries are rejected at config load, so by
507/// the time we get here every entry on
508/// `agents.<alias>.workspace.read_memory_from` is guaranteed to point
509/// at a sibling on the same backend kind.
510pub async fn create_memory_for_agent(
511    config: &zeroclaw_config::schema::Config,
512    agent_alias: &str,
513    api_key: Option<&str>,
514) -> anyhow::Result<Arc<dyn Memory>> {
515    use zeroclaw_config::multi_agent::MemoryBackendKind as ConfigBackend;
516    let agent_cfg = config
517        .agents
518        .get(agent_alias)
519        .with_context(|| format!("agents.{agent_alias} is not configured"))?;
520    let backend_kind = agent_cfg.memory.backend;
521
522    // Markdown branch: the wrapper composes per-agent dirs, not a
523    // shared backend. Skip the inner-backend factory entirely.
524    if matches!(backend_kind, ConfigBackend::Markdown) {
525        let own_workspace = config.agent_workspace_dir(agent_alias);
526        let own = MarkdownMemory::new("markdown", &own_workspace);
527        let mut peers: Vec<agent_scoped_markdown::MarkdownPeer> = Vec::new();
528        for peer in &agent_cfg.workspace.read_memory_from {
529            let peer_alias = peer.as_str();
530            let peer_workspace = config.agent_workspace_dir(peer_alias);
531            peers.push(agent_scoped_markdown::MarkdownPeer {
532                alias: peer_alias.to_string(),
533                memory: MarkdownMemory::new("markdown", &peer_workspace),
534            });
535        }
536        let scoped = AgentScopedMarkdownMemory::new(agent_alias, own, peers);
537        return Ok(Arc::new(scoped));
538    }
539
540    // None branch: nothing to scope, no agents-table lookup needed.
541    if matches!(backend_kind, ConfigBackend::None) {
542        return Ok(Arc::new(NoneMemory::new("none")));
543    }
544
545    // SQL / Qdrant / Lucid: single install-wide backend; the
546    // agent_id column (or payload field) carries the per-agent
547    // attribution. We synthesize the inner backend from the existing
548    // install-wide factory using the install workspace_dir, then wrap
549    // with AgentScopedMemory holding the agent's UUID + resolved
550    // allowlist UUIDs.
551    let inner = create_memory_with_storage_and_routes(
552        &config.memory,
553        &config.embedding_routes,
554        config.resolve_active_storage(),
555        &config.data_dir,
556        api_key,
557    )?;
558    let inner_arc: Arc<dyn Memory> = Arc::from(inner);
559
560    // Resolve the bound agent's identifier + the allowlist
561    // identifiers via the trait method `ensure_agent_uuid`. SQL
562    // backends override to look up agents-table UUIDs; Markdown,
563    // Qdrant, None use the trait default that returns the alias
564    // verbatim (alias-keyed; no UUID indirection at the storage
565    // layer). The factory is therefore backend-agnostic past the
566    // Markdown branch above.
567    let bound_id = inner_arc.ensure_agent_uuid(agent_alias).await?;
568    let mut allowlist_ids = Vec::with_capacity(agent_cfg.workspace.read_memory_from.len());
569    for peer in &agent_cfg.workspace.read_memory_from {
570        let uuid = inner_arc.ensure_agent_uuid(peer.as_str()).await?;
571        allowlist_ids.push(uuid);
572    }
573
574    let scoped = AgentScopedMemory::new(inner_arc, bound_id, allowlist_ids);
575    Ok(Arc::new(scoped))
576}
577
578/// Factory: create an optional response cache from config.
579pub fn create_response_cache(config: &MemoryConfig, workspace_dir: &Path) -> Option<ResponseCache> {
580    if !config.response_cache_enabled {
581        return None;
582    }
583
584    match ResponseCache::new(
585        workspace_dir,
586        config.response_cache_ttl_minutes,
587        config.response_cache_max_entries,
588    ) {
589        Ok(cache) => {
590            ::zeroclaw_log::record!(
591                INFO,
592                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
593                &format!(
594                    "💾 Response cache enabled (TTL: {}min, max: {} entries)",
595                    config.response_cache_ttl_minutes, config.response_cache_max_entries
596                )
597            );
598            Some(cache)
599        }
600        Err(e) => {
601            ::zeroclaw_log::record!(
602                WARN,
603                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
604                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
605                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
606                "Response cache disabled due to error"
607            );
608            None
609        }
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use tempfile::TempDir;
617    use zeroclaw_config::schema::EmbeddingRouteConfig;
618
619    #[test]
620    fn factory_sqlite() {
621        let tmp = TempDir::new().unwrap();
622        let cfg = MemoryConfig {
623            backend: "sqlite".into(),
624            ..MemoryConfig::default()
625        };
626        let mem = create_memory(&cfg, tmp.path(), None).unwrap();
627        assert_eq!(mem.name(), "sqlite");
628    }
629
630    #[test]
631    fn assistant_autosave_key_detection_matches_legacy_patterns() {
632        assert!(is_assistant_autosave_key("assistant_resp"));
633        assert!(is_assistant_autosave_key("assistant_resp_1234"));
634        assert!(is_assistant_autosave_key("ASSISTANT_RESP_abcd"));
635        assert!(!is_assistant_autosave_key("assistant_response"));
636        assert!(!is_assistant_autosave_key("user_msg_1234"));
637    }
638
639    #[test]
640    fn user_autosave_key_detection_matches_per_turn_patterns() {
641        assert!(is_user_autosave_key("user_msg"));
642        assert!(is_user_autosave_key("user_msg_1234"));
643        assert!(is_user_autosave_key("USER_MSG_abcd"));
644        assert!(!is_user_autosave_key("user_message"));
645        assert!(!is_user_autosave_key("assistant_resp_1234"));
646    }
647
648    #[test]
649    fn autosave_content_filter_drops_cron_and_distilled_noise() {
650        assert!(should_skip_autosave_content("[cron:auto] patrol check"));
651        assert!(should_skip_autosave_content(
652            "[DISTILLED_MEMORY_CHUNK 1/2] DISTILLED_INDEX_SIG:abc123"
653        ));
654        assert!(should_skip_autosave_content(
655            "[Heartbeat Task | decision] Should I run tasks?"
656        ));
657        assert!(should_skip_autosave_content(
658            "[Heartbeat Task | high] Execute scheduled patrol"
659        ));
660        assert!(should_skip_autosave_content(&format!(
661            "{MEMORY_CONTEXT_OPEN}\n- user_msg_abc: some recalled memory\n{MEMORY_CONTEXT_CLOSE}\n\n[cron:uuid job] prompt"
662        )));
663        assert!(!should_skip_autosave_content(
664            "User prefers concise answers."
665        ));
666    }
667
668    #[test]
669    fn factory_markdown() {
670        let tmp = TempDir::new().unwrap();
671        let cfg = MemoryConfig {
672            backend: "markdown".into(),
673            ..MemoryConfig::default()
674        };
675        let mem = create_memory(&cfg, tmp.path(), None).unwrap();
676        assert_eq!(mem.name(), "markdown");
677    }
678
679    #[test]
680    fn factory_lucid() {
681        let tmp = TempDir::new().unwrap();
682        let cfg = MemoryConfig {
683            backend: "lucid".into(),
684            ..MemoryConfig::default()
685        };
686        let mem = create_memory(&cfg, tmp.path(), None).unwrap();
687        assert_eq!(mem.name(), "lucid");
688    }
689
690    #[test]
691    fn factory_none_uses_noop_memory() {
692        let tmp = TempDir::new().unwrap();
693        let cfg = MemoryConfig {
694            backend: "none".into(),
695            ..MemoryConfig::default()
696        };
697        let mem = create_memory(&cfg, tmp.path(), None).unwrap();
698        assert_eq!(mem.name(), "none");
699    }
700
701    #[cfg(not(feature = "memory-postgres"))]
702    #[test]
703    fn factory_postgres_without_feature_gives_clear_error() {
704        use zeroclaw_config::schema::PostgresStorageConfig;
705        let tmp = TempDir::new().unwrap();
706        let cfg = MemoryConfig {
707            backend: "postgres.default".into(),
708            ..MemoryConfig::default()
709        };
710        let storage = PostgresStorageConfig {
711            db_url: Some("postgres://placeholder".into()),
712            ..PostgresStorageConfig::default()
713        };
714        let error = create_memory_with_storage_and_routes(
715            &cfg,
716            &[],
717            ActiveStorage::Postgres(&storage),
718            tmp.path(),
719            None,
720        )
721        .err()
722        .expect("backend=postgres without memory-postgres feature should fail");
723        assert!(
724            error.to_string().contains("memory-postgres"),
725            "error should mention the feature flag: {error}"
726        );
727    }
728
729    #[test]
730    fn factory_postgres_without_storage_alias_errors() {
731        let tmp = TempDir::new().unwrap();
732        let cfg = MemoryConfig {
733            backend: "postgres.default".into(),
734            ..MemoryConfig::default()
735        };
736        let error = create_memory(&cfg, tmp.path(), None)
737            .err()
738            .expect("backend=postgres requires a [storage.postgres.<alias>] entry");
739        assert!(
740            error.to_string().contains("storage.postgres"),
741            "error should reference storage.postgres alias: {error}"
742        );
743    }
744
745    #[test]
746    fn factory_qdrant_without_storage_alias_errors() {
747        let tmp = TempDir::new().unwrap();
748        let cfg = MemoryConfig {
749            backend: "qdrant.default".into(),
750            ..MemoryConfig::default()
751        };
752        let error = create_memory(&cfg, tmp.path(), None)
753            .err()
754            .expect("backend=qdrant requires a [storage.qdrant.<alias>] entry");
755        assert!(
756            error.to_string().contains("storage.qdrant"),
757            "error should reference storage.qdrant alias: {error}"
758        );
759    }
760
761    #[test]
762    fn backend_kind_extraction_strips_alias_suffix() {
763        assert_eq!(backend_kind_from_dotted("sqlite"), "sqlite");
764        assert_eq!(backend_kind_from_dotted("sqlite.default"), "sqlite");
765        assert_eq!(backend_kind_from_dotted("postgres.work"), "postgres");
766        assert_eq!(backend_kind_from_dotted("  Qdrant.Prod  "), "qdrant");
767    }
768
769    #[test]
770    fn factory_unknown_falls_back_to_markdown() {
771        let tmp = TempDir::new().unwrap();
772        let cfg = MemoryConfig {
773            backend: "redis".into(),
774            ..MemoryConfig::default()
775        };
776        let mem = create_memory(&cfg, tmp.path(), None).unwrap();
777        assert_eq!(mem.name(), "markdown");
778    }
779
780    #[test]
781    fn migration_factory_lucid() {
782        let tmp = TempDir::new().unwrap();
783        let mem = create_memory_for_migration("lucid", tmp.path()).unwrap();
784        assert_eq!(mem.name(), "lucid");
785    }
786
787    #[test]
788    fn migration_factory_none_is_rejected() {
789        let tmp = TempDir::new().unwrap();
790        let error = create_memory_for_migration("none", tmp.path())
791            .err()
792            .expect("backend=none should be rejected for migration");
793        assert!(error.to_string().contains("disables persistence"));
794    }
795
796    #[test]
797    fn resolve_embedding_config_uses_base_config_when_model_is_not_hint() {
798        let cfg = MemoryConfig {
799            embedding_provider: "openai".into(),
800            embedding_model: "text-embedding-3-small".into(),
801            embedding_dimensions: 1536,
802            ..MemoryConfig::default()
803        };
804
805        let resolved = resolve_embedding_config(&cfg, &[], Some("base-key"));
806        assert_eq!(
807            resolved,
808            ResolvedEmbeddingConfig {
809                model_provider: "openai".into(),
810                model: "text-embedding-3-small".into(),
811                dimensions: 1536,
812                api_key: Some("base-key".into()),
813            }
814        );
815    }
816
817    #[test]
818    fn resolve_embedding_config_uses_matching_route_with_api_key_override() {
819        let cfg = MemoryConfig {
820            embedding_provider: "none".into(),
821            embedding_model: "hint:semantic".into(),
822            embedding_dimensions: 1536,
823            ..MemoryConfig::default()
824        };
825        let routes = vec![EmbeddingRouteConfig {
826            hint: "semantic".into(),
827            model_provider: "custom:https://api.example.com/v1".into(),
828            model: "custom-embed-v2".into(),
829            dimensions: Some(1024),
830            api_key: Some("route-key".into()),
831        }];
832
833        let resolved = resolve_embedding_config(&cfg, &routes, Some("base-key"));
834        assert_eq!(
835            resolved,
836            ResolvedEmbeddingConfig {
837                model_provider: "custom:https://api.example.com/v1".into(),
838                model: "custom-embed-v2".into(),
839                dimensions: 1024,
840                api_key: Some("route-key".into()),
841            }
842        );
843    }
844
845    #[test]
846    fn resolve_embedding_config_falls_back_when_hint_is_missing() {
847        let cfg = MemoryConfig {
848            embedding_provider: "openai".into(),
849            embedding_model: "hint:semantic".into(),
850            embedding_dimensions: 1536,
851            ..MemoryConfig::default()
852        };
853
854        let resolved = resolve_embedding_config(&cfg, &[], Some("base-key"));
855        assert_eq!(
856            resolved,
857            ResolvedEmbeddingConfig {
858                model_provider: "openai".into(),
859                model: "hint:semantic".into(),
860                dimensions: 1536,
861                api_key: Some("base-key".into()),
862            }
863        );
864    }
865
866    #[test]
867    fn resolve_embedding_config_falls_back_when_route_is_invalid() {
868        let cfg = MemoryConfig {
869            embedding_provider: "openai".into(),
870            embedding_model: "hint:semantic".into(),
871            embedding_dimensions: 1536,
872            ..MemoryConfig::default()
873        };
874        let routes = vec![EmbeddingRouteConfig {
875            hint: "semantic".into(),
876            model_provider: String::new(),
877            model: "text-embedding-3-small".into(),
878            dimensions: Some(0),
879            api_key: None,
880        }];
881
882        let resolved = resolve_embedding_config(&cfg, &routes, Some("base-key"));
883        assert_eq!(
884            resolved,
885            ResolvedEmbeddingConfig {
886                model_provider: "openai".into(),
887                model: "hint:semantic".into(),
888                dimensions: 1536,
889                api_key: Some("base-key".into()),
890            }
891        );
892    }
893
894    #[test]
895    fn resolve_embedding_config_uses_caller_api_key_when_no_route_override() {
896        let cfg = MemoryConfig {
897            embedding_provider: "cohere".into(),
898            embedding_model: "embed-english-v3.0".into(),
899            embedding_dimensions: 1024,
900            ..MemoryConfig::default()
901        };
902
903        let resolved = resolve_embedding_config(&cfg, &[], Some("caller-supplied-key"));
904
905        assert_eq!(resolved.api_key.as_deref(), Some("caller-supplied-key"));
906    }
907}