1#![allow(clippy::to_string_in_format_args)]
2pub const MEMORY_CONTEXT_OPEN: &str = "[Memory context]";
22pub 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 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
153pub 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
163pub 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
170pub 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
179pub 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
287pub 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
296pub 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 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 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 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 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
497pub 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 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 if matches!(backend_kind, ConfigBackend::None) {
542 return Ok(Arc::new(NoneMemory::new("none")));
543 }
544
545 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 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
578pub 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}