1pub mod backend;
2pub mod chunker;
3pub mod cli;
4pub mod decay;
5pub mod embeddings;
6pub mod none;
7pub mod response_cache;
8pub mod traits;
9pub mod vector;
10
11#[cfg(test)]
12pub mod test_memory;
13
14#[allow(unused_imports)]
15pub use backend::{
16 MemoryBackendKind, MemoryBackendProfile, classify_memory_backend, default_memory_backend_key,
17 memory_backend_profile, selectable_memory_backends,
18};
19pub use none::NoneMemory;
20pub use response_cache::ResponseCache;
21pub use traits::Memory;
22#[allow(unused_imports)]
23pub use traits::{ExportFilter, MemoryCategory, MemoryEntry, ProceduralMessage};
24
25use crate::config::{EmbeddingRouteConfig, MemoryConfig, StorageProviderConfig};
26use std::path::Path;
27
28pub fn effective_memory_backend_name(
29 memory_backend: &str,
30 storage_provider: Option<&StorageProviderConfig>,
31) -> String {
32 if let Some(override_provider) = storage_provider
33 .map(|cfg| cfg.provider.trim())
34 .filter(|provider| !provider.is_empty())
35 {
36 return override_provider.to_ascii_lowercase();
37 }
38
39 memory_backend.trim().to_ascii_lowercase()
40}
41
42pub fn is_assistant_autosave_key(key: &str) -> bool {
45 let normalized = key.trim().to_ascii_lowercase();
46 normalized == "assistant_resp" || normalized.starts_with("assistant_resp_")
47}
48
49pub fn should_skip_autosave_content(content: &str) -> bool {
52 let normalized = content.trim();
53 if normalized.is_empty() {
54 return true;
55 }
56
57 let lowered = normalized.to_ascii_lowercase();
58 lowered.starts_with("[cron:")
59 || lowered.starts_with("[heartbeat task")
60 || lowered.starts_with("[distilled_")
61 || lowered.contains("distilled_index_sig:")
62}
63
64pub fn create_memory(
71 config: &MemoryConfig,
72 workspace_dir: &Path,
73 api_key: Option<&str>,
74) -> anyhow::Result<Box<dyn Memory>> {
75 create_memory_with_storage_and_routes(config, &[], None, workspace_dir, api_key)
76}
77
78pub fn create_memory_with_storage(
80 config: &MemoryConfig,
81 storage_provider: Option<&StorageProviderConfig>,
82 workspace_dir: &Path,
83 api_key: Option<&str>,
84) -> anyhow::Result<Box<dyn Memory>> {
85 create_memory_with_storage_and_routes(config, &[], storage_provider, workspace_dir, api_key)
86}
87
88pub fn create_memory_with_storage_and_routes(
90 config: &MemoryConfig,
91 _embedding_routes: &[EmbeddingRouteConfig],
92 storage_provider: Option<&StorageProviderConfig>,
93 _workspace_dir: &Path,
94 _api_key: Option<&str>,
95) -> anyhow::Result<Box<dyn Memory>> {
96 let backend_name = effective_memory_backend_name(&config.backend, storage_provider);
97
98 match classify_memory_backend(&backend_name) {
99 MemoryBackendKind::Kumiho | MemoryBackendKind::None => Ok(Box::new(NoneMemory::new())),
100 MemoryBackendKind::Unknown => {
101 anyhow::bail!(
102 "Memory backend '{backend_name}' is not supported in Construct. \
103 Use 'kumiho' (default) for persistent memory via Kumiho MCP, or 'none' \
104 to disable persistence."
105 )
106 }
107 }
108}
109
110pub fn create_response_cache(config: &MemoryConfig, workspace_dir: &Path) -> Option<ResponseCache> {
112 if !config.response_cache_enabled {
113 return None;
114 }
115
116 match ResponseCache::new(
117 workspace_dir,
118 config.response_cache_ttl_minutes,
119 config.response_cache_max_entries,
120 ) {
121 Ok(cache) => {
122 tracing::info!(
123 "💾 Response cache enabled (TTL: {}min, max: {} entries)",
124 config.response_cache_ttl_minutes,
125 config.response_cache_max_entries
126 );
127 Some(cache)
128 }
129 Err(e) => {
130 tracing::warn!("Response cache disabled due to error: {e}");
131 None
132 }
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::config::StorageProviderConfig;
140 use tempfile::TempDir;
141
142 #[test]
143 fn assistant_autosave_key_detection_matches_legacy_patterns() {
144 assert!(is_assistant_autosave_key("assistant_resp"));
145 assert!(is_assistant_autosave_key("assistant_resp_1234"));
146 assert!(is_assistant_autosave_key("ASSISTANT_RESP_abcd"));
147 assert!(!is_assistant_autosave_key("assistant_response"));
148 assert!(!is_assistant_autosave_key("user_msg_1234"));
149 }
150
151 #[test]
152 fn autosave_content_filter_drops_cron_and_distilled_noise() {
153 assert!(should_skip_autosave_content("[cron:auto] patrol check"));
154 assert!(should_skip_autosave_content(
155 "[DISTILLED_MEMORY_CHUNK 1/2] DISTILLED_INDEX_SIG:abc123"
156 ));
157 assert!(should_skip_autosave_content(
158 "[Heartbeat Task | decision] Should I run tasks?"
159 ));
160 assert!(should_skip_autosave_content(
161 "[Heartbeat Task | high] Execute scheduled patrol"
162 ));
163 assert!(!should_skip_autosave_content(
164 "User prefers concise answers."
165 ));
166 }
167
168 #[test]
169 fn factory_kumiho_uses_noop_memory() {
170 let tmp = TempDir::new().unwrap();
171 let cfg = MemoryConfig {
172 backend: "kumiho".into(),
173 ..MemoryConfig::default()
174 };
175 let mem = create_memory(&cfg, tmp.path(), None).unwrap();
176 assert_eq!(mem.name(), "none");
177 }
178
179 #[test]
180 fn factory_none_uses_noop_memory() {
181 let tmp = TempDir::new().unwrap();
182 let cfg = MemoryConfig {
183 backend: "none".into(),
184 ..MemoryConfig::default()
185 };
186 let mem = create_memory(&cfg, tmp.path(), None).unwrap();
187 assert_eq!(mem.name(), "none");
188 }
189
190 #[test]
191 fn factory_removed_backends_are_rejected() {
192 let tmp = TempDir::new().unwrap();
193 for name in ["sqlite", "lucid", "markdown", "qdrant", "redis"] {
194 let cfg = MemoryConfig {
195 backend: name.into(),
196 ..MemoryConfig::default()
197 };
198 assert!(
199 create_memory(&cfg, tmp.path(), None).is_err(),
200 "backend '{name}' must be rejected in Construct"
201 );
202 }
203 }
204
205 #[test]
206 fn effective_backend_name_prefers_storage_override() {
207 let storage = StorageProviderConfig {
208 provider: "custom".into(),
209 ..StorageProviderConfig::default()
210 };
211
212 assert_eq!(
213 effective_memory_backend_name("kumiho", Some(&storage)),
214 "custom"
215 );
216 }
217}