1use super::markdown::MarkdownMemory;
19use super::traits::{Memory, MemoryCategory, MemoryEntry};
20use anyhow::Result;
21use async_trait::async_trait;
22
23pub struct MarkdownPeer {
26 pub alias: String,
27 pub memory: MarkdownMemory,
28}
29
30pub struct AgentScopedMarkdownMemory {
34 own_alias: String,
37 own: MarkdownMemory,
40 peers: Vec<MarkdownPeer>,
46}
47
48impl AgentScopedMarkdownMemory {
49 pub fn new(
50 own_alias: impl Into<String>,
51 own: MarkdownMemory,
52 peers: Vec<MarkdownPeer>,
53 ) -> Self {
54 Self {
55 own_alias: own_alias.into(),
56 own,
57 peers,
58 }
59 }
60
61 fn attribute(alias: &str, mut entries: Vec<MemoryEntry>) -> Vec<MemoryEntry> {
67 for entry in &mut entries {
68 entry.key = format!("[{alias}] {}", entry.key);
69 entry.agent_alias = Some(alias.to_string());
70 entry.agent_id = Some(alias.to_string());
71 }
72 entries
73 }
74
75 fn stamp_attribution(alias: &str, mut entries: Vec<MemoryEntry>) -> Vec<MemoryEntry> {
80 for entry in &mut entries {
81 entry.agent_alias = Some(alias.to_string());
82 entry.agent_id = Some(alias.to_string());
83 }
84 entries
85 }
86}
87
88#[async_trait]
89impl Memory for AgentScopedMarkdownMemory {
90 fn name(&self) -> &str {
91 self.own.name()
94 }
95
96 async fn health_check(&self) -> bool {
97 self.own.health_check().await
103 }
104
105 async fn store(
106 &self,
107 key: &str,
108 content: &str,
109 category: MemoryCategory,
110 session_id: Option<&str>,
111 ) -> Result<()> {
112 self.own.store(key, content, category, session_id).await
113 }
114
115 async fn store_with_metadata(
116 &self,
117 key: &str,
118 content: &str,
119 category: MemoryCategory,
120 session_id: Option<&str>,
121 namespace: Option<&str>,
122 importance: Option<f64>,
123 ) -> Result<()> {
124 self.own
125 .store_with_metadata(key, content, category, session_id, namespace, importance)
126 .await
127 }
128
129 async fn store_with_agent(
130 &self,
131 key: &str,
132 content: &str,
133 category: MemoryCategory,
134 session_id: Option<&str>,
135 namespace: Option<&str>,
136 importance: Option<f64>,
137 _agent_id: Option<&str>,
138 ) -> Result<()> {
139 self.own
143 .store_with_metadata(key, content, category, session_id, namespace, importance)
144 .await
145 }
146
147 async fn recall(
148 &self,
149 query: &str,
150 limit: usize,
151 session_id: Option<&str>,
152 since: Option<&str>,
153 until: Option<&str>,
154 ) -> Result<Vec<MemoryEntry>> {
155 let mut merged = Self::attribute(
156 &self.own_alias,
157 self.own
158 .recall(query, limit, session_id, since, until)
159 .await?,
160 );
161 for peer in &self.peers {
162 match peer
163 .memory
164 .recall(query, limit, session_id, since, until)
165 .await
166 {
167 Ok(rows) => merged.extend(Self::attribute(&peer.alias, rows)),
168 Err(error) => ::zeroclaw_log::record!(
169 WARN,
170 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
171 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
172 .with_attrs(
173 ::serde_json::json!({"peer": peer.alias, "error": format!("{}", error)})
174 ),
175 "AgentScopedMarkdownMemory peer recall failed; continuing with other peers"
176 ),
177 }
178 }
179 merged.truncate(limit);
180 Ok(merged)
181 }
182
183 async fn recall_for_agents(
184 &self,
185 allowed_agent_ids: &[&str],
186 query: &str,
187 limit: usize,
188 session_id: Option<&str>,
189 since: Option<&str>,
190 until: Option<&str>,
191 ) -> Result<Vec<MemoryEntry>> {
192 if allowed_agent_ids.is_empty() {
195 return self.recall(query, limit, session_id, since, until).await;
196 }
197
198 let mut merged = Vec::new();
203 if allowed_agent_ids.contains(&self.own_alias.as_str()) {
204 merged.extend(Self::attribute(
205 &self.own_alias,
206 self.own
207 .recall(query, limit, session_id, since, until)
208 .await?,
209 ));
210 }
211 for peer in &self.peers {
212 if !allowed_agent_ids.contains(&peer.alias.as_str()) {
213 continue;
214 }
215 match peer
216 .memory
217 .recall(query, limit, session_id, since, until)
218 .await
219 {
220 Ok(rows) => merged.extend(Self::attribute(&peer.alias, rows)),
221 Err(error) => ::zeroclaw_log::record!(
222 WARN,
223 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
224 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
225 .with_attrs(
226 ::serde_json::json!({"peer": peer.alias, "error": format!("{}", error)})
227 ),
228 "AgentScopedMarkdownMemory peer recall failed; continuing with other peers"
229 ),
230 }
231 }
232 merged.truncate(limit);
233 Ok(merged)
234 }
235
236 async fn get(&self, key: &str) -> Result<Option<MemoryEntry>> {
237 let entry = self.own.get(key).await?;
238 Ok(entry.map(|mut e| {
239 e.agent_alias = Some(self.own_alias.clone());
240 e.agent_id = Some(self.own_alias.clone());
241 e
242 }))
243 }
244
245 async fn list(
246 &self,
247 category: Option<&MemoryCategory>,
248 session_id: Option<&str>,
249 ) -> Result<Vec<MemoryEntry>> {
250 let entries = self.own.list(category, session_id).await?;
251 Ok(Self::stamp_attribution(&self.own_alias, entries))
252 }
253
254 async fn forget(&self, key: &str) -> Result<bool> {
255 self.own.forget(key).await
256 }
257
258 async fn forget_for_agent(&self, key: &str, agent_id: &str) -> Result<bool> {
259 self.own.forget_for_agent(key, agent_id).await
260 }
261
262 async fn count(&self) -> Result<usize> {
263 self.own.count().await
264 }
265}
266
267impl ::zeroclaw_api::attribution::Attributable for AgentScopedMarkdownMemory {
268 fn role(&self) -> ::zeroclaw_api::attribution::Role {
269 ::zeroclaw_api::attribution::Role::Memory(
270 ::zeroclaw_api::attribution::MemoryKind::AgentScopedMarkdown,
271 )
272 }
273 fn alias(&self) -> &str {
274 &self.own_alias
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use tempfile::TempDir;
282
283 fn make_md(name: &str) -> (TempDir, MarkdownMemory) {
284 let tmp = TempDir::new().unwrap();
285 let dir = tmp.path().join(name);
286 std::fs::create_dir_all(&dir).unwrap();
287 let mem = MarkdownMemory::new("markdown", &dir);
288 (tmp, mem)
289 }
290
291 #[tokio::test]
292 async fn store_writes_only_to_own_backend() {
293 let (_tmp_a, own) = make_md("alpha-ws");
294 let (_tmp_b, peer_mem) = make_md("beta-ws");
295 let scoped = AgentScopedMarkdownMemory::new(
296 "alpha",
297 own,
298 vec![MarkdownPeer {
299 alias: "beta".into(),
300 memory: peer_mem,
301 }],
302 );
303
304 scoped
305 .store("k1", "alpha-only", MemoryCategory::Core, None)
306 .await
307 .unwrap();
308
309 let hits = scoped
312 .recall("alpha-only", 10, None, None, None)
313 .await
314 .unwrap();
315 assert_eq!(hits.len(), 1);
316 assert!(
317 hits[0].key.starts_with("[alpha] "),
318 "own-backend rows must surface with [alpha] attribution"
319 );
320 }
321
322 #[tokio::test]
323 async fn recall_unions_own_and_peer_rows_with_attribution() {
324 let (_tmp_a, own) = make_md("alpha-ws");
325 let (_tmp_b, peer_mem) = make_md("beta-ws");
326
327 peer_mem
330 .store("shared", "beta-content", MemoryCategory::Core, None)
331 .await
332 .unwrap();
333
334 let scoped = AgentScopedMarkdownMemory::new(
335 "alpha",
336 own,
337 vec![MarkdownPeer {
338 alias: "beta".into(),
339 memory: peer_mem,
340 }],
341 );
342
343 scoped
345 .store("shared", "alpha-content", MemoryCategory::Core, None)
346 .await
347 .unwrap();
348
349 let hits = scoped.recall("shared", 10, None, None, None).await.unwrap();
350 let attribution_set: std::collections::HashSet<&str> =
351 hits.iter().map(|h| h.key.as_str()).collect();
352 assert!(
353 attribution_set.iter().any(|k| k.starts_with("[alpha] ")),
354 "merged recall must include alpha-attributed rows"
355 );
356 assert!(
357 attribution_set.iter().any(|k| k.starts_with("[beta] ")),
358 "merged recall must include beta-attributed rows"
359 );
360 }
361
362 #[tokio::test]
363 async fn recall_for_agents_filters_to_alias_intersection() {
364 let (_tmp_a, own) = make_md("alpha-ws");
365 let (_tmp_b, peer_mem) = make_md("beta-ws");
366
367 peer_mem
368 .store("peer-only", "beta-content", MemoryCategory::Core, None)
369 .await
370 .unwrap();
371
372 let scoped = AgentScopedMarkdownMemory::new(
373 "alpha",
374 own,
375 vec![MarkdownPeer {
376 alias: "beta".into(),
377 memory: peer_mem,
378 }],
379 );
380
381 let hits = scoped
383 .recall_for_agents(&["alpha"], "peer-only", 10, None, None, None)
384 .await
385 .unwrap();
386 assert!(
387 !hits.iter().any(|h| h.key.starts_with("[beta] ")),
388 "caller-restricted recall must drop unlisted peer rows"
389 );
390
391 let hits = scoped
393 .recall_for_agents(&["beta"], "peer-only", 10, None, None, None)
394 .await
395 .unwrap();
396 assert!(
397 !hits.iter().any(|h| h.key.starts_with("[alpha] ")),
398 "caller-restricted recall must drop own rows when own is not on the caller list"
399 );
400 assert!(
401 hits.iter().any(|h| h.key.starts_with("[beta] ")),
402 "caller-restricted recall must include the requested peer's rows"
403 );
404 }
405
406 #[tokio::test]
411 async fn list_and_get_stamp_agent_alias_for_dashboard_parity() {
412 let (_tmp, own) = make_md("alpha-ws");
413 let scoped = AgentScopedMarkdownMemory::new("alpha", own, vec![]);
414
415 scoped
416 .store("note", "preferences", MemoryCategory::Core, None)
417 .await
418 .unwrap();
419
420 let list_rows = scoped.list(None, None).await.unwrap();
421 assert!(!list_rows.is_empty(), "list must return the stored row");
422 for row in &list_rows {
423 assert_eq!(
424 row.agent_alias.as_deref(),
425 Some("alpha"),
426 "list rows must be stamped with the bound agent's alias"
427 );
428 assert_eq!(
429 row.agent_id.as_deref(),
430 Some("alpha"),
431 "agent_id mirrors agent_alias on Markdown (no UUID indirection)"
432 );
433 }
434
435 let key = &list_rows[0].key;
436 let got = scoped
437 .get(key)
438 .await
439 .unwrap()
440 .expect("get must find the row");
441 assert_eq!(got.agent_alias.as_deref(), Some("alpha"));
442 assert_eq!(got.agent_id.as_deref(), Some("alpha"));
443 }
444
445 #[tokio::test]
449 async fn recall_attribution_carries_through_both_key_prefix_and_alias_field() {
450 let (_tmp_a, own) = make_md("alpha-ws");
451 let (_tmp_b, peer_mem) = make_md("beta-ws");
452 peer_mem
453 .store("peer-note", "from beta", MemoryCategory::Core, None)
454 .await
455 .unwrap();
456 let scoped = AgentScopedMarkdownMemory::new(
457 "alpha",
458 own,
459 vec![MarkdownPeer {
460 alias: "beta".into(),
461 memory: peer_mem,
462 }],
463 );
464 scoped
465 .store("own-note", "from alpha", MemoryCategory::Core, None)
466 .await
467 .unwrap();
468
469 let hits = scoped.recall("from", 10, None, None, None).await.unwrap();
470 let alpha_hit = hits.iter().find(|h| h.key.starts_with("[alpha] ")).unwrap();
471 let beta_hit = hits.iter().find(|h| h.key.starts_with("[beta] ")).unwrap();
472 assert_eq!(alpha_hit.agent_alias.as_deref(), Some("alpha"));
473 assert_eq!(beta_hit.agent_alias.as_deref(), Some("beta"));
474 }
475}