Skip to main content

construct/memory/
mod.rs

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
42/// Legacy auto-save key used for model-authored assistant summaries.
43/// These entries are treated as untrusted context and should not be re-injected.
44pub 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
49/// Filter known synthetic autosave noise patterns that should not be
50/// persisted as user conversation memories.
51pub 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
64/// Factory: create the right memory backend from config.
65///
66/// Persistent memory in Construct is handled exclusively by Kumiho MCP (injected
67/// at the agent level). The runtime `Memory` trait binding is therefore always
68/// `NoneMemory` — in-session, non-persistent. Any non-Kumiho backend name is
69/// rejected with an error directing users to Kumiho.
70pub 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
78/// Factory: create memory with optional storage-provider override.
79pub 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
88/// Factory: create memory with optional storage-provider override and embedding routes.
89pub 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
110/// Factory: create an optional response cache from config.
111pub 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}