1use async_trait::async_trait;
2use std::fmt::Write;
3use zeroclaw_memory::{self, MEMORY_CONTEXT_CLOSE, MEMORY_CONTEXT_OPEN, Memory, decay};
4
5#[async_trait]
6pub trait MemoryLoader: Send + Sync {
7 async fn load_context(
8 &self,
9 memory: &dyn Memory,
10 user_message: &str,
11 session_id: Option<&str>,
12 ) -> anyhow::Result<String>;
13}
14
15pub struct DefaultMemoryLoader {
16 limit: usize,
17 min_relevance_score: f64,
18}
19
20impl Default for DefaultMemoryLoader {
21 fn default() -> Self {
22 Self {
23 limit: 5,
24 min_relevance_score: 0.4,
25 }
26 }
27}
28
29impl DefaultMemoryLoader {
30 pub fn new(limit: usize, min_relevance_score: f64) -> Self {
31 Self {
32 limit: limit.max(1),
33 min_relevance_score,
34 }
35 }
36}
37
38#[async_trait]
39impl MemoryLoader for DefaultMemoryLoader {
40 async fn load_context(
41 &self,
42 memory: &dyn Memory,
43 user_message: &str,
44 session_id: Option<&str>,
45 ) -> anyhow::Result<String> {
46 let mut entries = memory
47 .recall(user_message, self.limit, session_id, None, None)
48 .await?;
49 if entries.is_empty() {
50 return Ok(String::new());
51 }
52
53 decay::apply_time_decay(&mut entries, decay::DEFAULT_HALF_LIFE_DAYS);
55
56 let mut context = String::new();
57 let mut included = false;
58 for entry in entries {
59 if zeroclaw_memory::is_assistant_autosave_key(&entry.key) {
60 continue;
61 }
62 if zeroclaw_memory::is_user_autosave_key(&entry.key) {
63 continue;
64 }
65 if zeroclaw_memory::should_skip_autosave_content(&entry.content) {
66 continue;
67 }
68 if let Some(score) = entry.score
69 && score < self.min_relevance_score
70 {
71 continue;
72 }
73 if !included {
74 context.push_str(MEMORY_CONTEXT_OPEN);
75 context.push('\n');
76 included = true;
77 }
78 let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
79 }
80
81 if !included {
83 return Ok(String::new());
84 }
85
86 context.push_str(MEMORY_CONTEXT_CLOSE);
87 context.push_str("\n\n");
88 Ok(context)
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use std::sync::Arc;
96 use zeroclaw_memory::{
97 MEMORY_CONTEXT_CLOSE, MEMORY_CONTEXT_OPEN, Memory, MemoryCategory, MemoryEntry,
98 };
99
100 struct MockMemory;
101 struct MockMemoryWithEntries {
102 entries: Arc<Vec<MemoryEntry>>,
103 }
104
105 #[async_trait]
106 impl Memory for MockMemory {
107 async fn store(
108 &self,
109 _key: &str,
110 _content: &str,
111 _category: MemoryCategory,
112 _session_id: Option<&str>,
113 ) -> anyhow::Result<()> {
114 Ok(())
115 }
116
117 async fn recall(
118 &self,
119 _query: &str,
120 limit: usize,
121 _session_id: Option<&str>,
122 _since: Option<&str>,
123 _until: Option<&str>,
124 ) -> anyhow::Result<Vec<MemoryEntry>> {
125 if limit == 0 {
126 return Ok(vec![]);
127 }
128 Ok(vec![MemoryEntry {
129 id: "1".into(),
130 key: "k".into(),
131 content: "v".into(),
132 category: MemoryCategory::Conversation,
133 timestamp: "now".into(),
134 session_id: None,
135 score: None,
136 namespace: "default".into(),
137 importance: None,
138 superseded_by: None,
139 agent_alias: None,
140 agent_id: None,
141 }])
142 }
143
144 async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
145 Ok(None)
146 }
147
148 async fn list(
149 &self,
150 _category: Option<&MemoryCategory>,
151 _session_id: Option<&str>,
152 ) -> anyhow::Result<Vec<MemoryEntry>> {
153 Ok(vec![])
154 }
155
156 async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
157 Ok(true)
158 }
159
160 async fn forget_for_agent(&self, _key: &str, _agent_id: &str) -> anyhow::Result<bool> {
161 Ok(true)
162 }
163
164 async fn count(&self) -> anyhow::Result<usize> {
165 Ok(0)
166 }
167
168 async fn health_check(&self) -> bool {
169 true
170 }
171
172 fn name(&self) -> &str {
173 "mock"
174 }
175
176 async fn store_with_agent(
177 &self,
178 _key: &str,
179 _content: &str,
180 _category: MemoryCategory,
181 _session_id: Option<&str>,
182 _namespace: Option<&str>,
183 _importance: Option<f64>,
184 _agent_id: Option<&str>,
185 ) -> anyhow::Result<()> {
186 Ok(())
187 }
188
189 async fn recall_for_agents(
190 &self,
191 _allowed_agent_ids: &[&str],
192 query: &str,
193 limit: usize,
194 session_id: Option<&str>,
195 since: Option<&str>,
196 until: Option<&str>,
197 ) -> anyhow::Result<Vec<MemoryEntry>> {
198 self.recall(query, limit, session_id, since, until).await
199 }
200 }
201 impl ::zeroclaw_api::attribution::Attributable for MockMemory {
202 fn role(&self) -> ::zeroclaw_api::attribution::Role {
203 ::zeroclaw_api::attribution::Role::Memory(
204 ::zeroclaw_api::attribution::MemoryKind::InMemory,
205 )
206 }
207 fn alias(&self) -> &str {
208 "MockMemory"
209 }
210 }
211
212 #[async_trait]
213 impl Memory for MockMemoryWithEntries {
214 async fn store(
215 &self,
216 _key: &str,
217 _content: &str,
218 _category: MemoryCategory,
219 _session_id: Option<&str>,
220 ) -> anyhow::Result<()> {
221 Ok(())
222 }
223
224 async fn recall(
225 &self,
226 _query: &str,
227 _limit: usize,
228 _session_id: Option<&str>,
229 _since: Option<&str>,
230 _until: Option<&str>,
231 ) -> anyhow::Result<Vec<MemoryEntry>> {
232 Ok(self.entries.as_ref().clone())
233 }
234
235 async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
236 Ok(None)
237 }
238
239 async fn list(
240 &self,
241 _category: Option<&MemoryCategory>,
242 _session_id: Option<&str>,
243 ) -> anyhow::Result<Vec<MemoryEntry>> {
244 Ok(vec![])
245 }
246
247 async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
248 Ok(true)
249 }
250
251 async fn forget_for_agent(&self, _key: &str, _agent_id: &str) -> anyhow::Result<bool> {
252 Ok(true)
253 }
254
255 async fn count(&self) -> anyhow::Result<usize> {
256 Ok(self.entries.len())
257 }
258
259 async fn health_check(&self) -> bool {
260 true
261 }
262
263 fn name(&self) -> &str {
264 "mock-with-entries"
265 }
266
267 async fn store_with_agent(
268 &self,
269 _key: &str,
270 _content: &str,
271 _category: MemoryCategory,
272 _session_id: Option<&str>,
273 _namespace: Option<&str>,
274 _importance: Option<f64>,
275 _agent_id: Option<&str>,
276 ) -> anyhow::Result<()> {
277 Ok(())
278 }
279
280 async fn recall_for_agents(
281 &self,
282 _allowed_agent_ids: &[&str],
283 query: &str,
284 limit: usize,
285 session_id: Option<&str>,
286 since: Option<&str>,
287 until: Option<&str>,
288 ) -> anyhow::Result<Vec<MemoryEntry>> {
289 self.recall(query, limit, session_id, since, until).await
290 }
291 }
292 impl ::zeroclaw_api::attribution::Attributable for MockMemoryWithEntries {
293 fn role(&self) -> ::zeroclaw_api::attribution::Role {
294 ::zeroclaw_api::attribution::Role::Memory(
295 ::zeroclaw_api::attribution::MemoryKind::InMemory,
296 )
297 }
298 fn alias(&self) -> &str {
299 "MockMemoryWithEntries"
300 }
301 }
302
303 #[tokio::test]
304 async fn default_loader_formats_context() {
305 let loader = DefaultMemoryLoader::default();
306 let context = loader
307 .load_context(&MockMemory, "hello", None)
308 .await
309 .unwrap();
310 assert_eq!(
311 context,
312 format!("{MEMORY_CONTEXT_OPEN}\n- k: v\n{MEMORY_CONTEXT_CLOSE}\n\n")
313 );
314 }
315
316 #[tokio::test]
317 async fn default_loader_skips_legacy_assistant_autosave_entries() {
318 let loader = DefaultMemoryLoader::new(5, 0.0);
319 let memory = MockMemoryWithEntries {
320 entries: Arc::new(vec![
321 MemoryEntry {
322 id: "1".into(),
323 key: "assistant_resp_legacy".into(),
324 content: "fabricated detail".into(),
325 category: MemoryCategory::Daily,
326 timestamp: "now".into(),
327 session_id: None,
328 score: Some(0.95),
329 namespace: "default".into(),
330 importance: None,
331 superseded_by: None,
332 agent_alias: None,
333 agent_id: None,
334 },
335 MemoryEntry {
336 id: "2".into(),
337 key: "user_fact".into(),
338 content: "User prefers concise answers".into(),
339 category: MemoryCategory::Conversation,
340 timestamp: "now".into(),
341 session_id: None,
342 score: Some(0.9),
343 namespace: "default".into(),
344 importance: None,
345 superseded_by: None,
346 agent_alias: None,
347 agent_id: None,
348 },
349 ]),
350 };
351
352 let context = loader
353 .load_context(&memory, "answer style", None)
354 .await
355 .unwrap();
356 assert!(context.contains("user_fact"));
357 assert!(!context.contains("assistant_resp_legacy"));
358 assert!(!context.contains("fabricated detail"));
359 }
360
361 #[tokio::test]
362 async fn default_loader_skips_user_autosave_entries() {
363 let loader = DefaultMemoryLoader::new(5, 0.0);
364 let memory = MockMemoryWithEntries {
365 entries: Arc::new(vec![
366 MemoryEntry {
367 id: "1".into(),
368 key: "user_msg_e5f6g7h8".into(),
369 content: "User message embedding prior context verbatim".into(),
370 category: MemoryCategory::Conversation,
371 timestamp: "now".into(),
372 session_id: None,
373 score: Some(0.95),
374 namespace: "default".into(),
375 importance: None,
376 superseded_by: None,
377 agent_alias: None,
378 agent_id: None,
379 },
380 MemoryEntry {
381 id: "2".into(),
382 key: "user_fact".into(),
383 content: "User prefers concise answers".into(),
384 category: MemoryCategory::Conversation,
385 timestamp: "now".into(),
386 session_id: None,
387 score: Some(0.9),
388 namespace: "default".into(),
389 importance: None,
390 superseded_by: None,
391 agent_alias: None,
392 agent_id: None,
393 },
394 ]),
395 };
396
397 let context = loader
398 .load_context(&memory, "answer style", None)
399 .await
400 .unwrap();
401 assert!(context.contains("user_fact"));
402 assert!(!context.contains("user_msg_e5f6g7h8"));
403 assert!(!context.contains("embedding prior context"));
404 }
405}