1use async_trait::async_trait;
2use std::collections::HashMap;
3use std::fmt;
4use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
5use std::sync::Arc;
6
7use tracing::{info, warn};
8
9use crate::internal::storage::db::{call_blocking, Database, Memory};
10use crate::mcp::{McpManager, McpServer, McpToolInfo};
11use microclaw_core::error::MicroClawError;
12use microclaw_core::text::floor_char_boundary;
13
14#[derive(Clone)]
15pub struct MemoryMcpClient {
16 server: Arc<McpServer>,
17 query_tool: String,
18 upsert_tool: String,
19}
20
21impl MemoryMcpClient {
22 pub fn discover(manager: &McpManager) -> Option<Self> {
23 let tools = manager.all_tools();
24 let mut grouped: HashMap<String, (Option<Arc<McpServer>>, bool, bool)> = HashMap::new();
25 for (server, tool) in tools {
26 let entry =
27 grouped
28 .entry(tool.server_name.clone())
29 .or_insert((Some(server), false, false));
30 if tool.name == "memory_query" {
31 entry.1 = true;
32 }
33 if tool.name == "memory_upsert" {
34 entry.2 = true;
35 }
36 }
37
38 for (name, (server_opt, has_query, has_upsert)) in grouped {
39 if has_query && has_upsert {
40 if let Some(server) = server_opt {
41 info!("Memory MCP backend enabled via server '{name}'");
42 return Some(Self {
43 server,
44 query_tool: "memory_query".to_string(),
45 upsert_tool: "memory_upsert".to_string(),
46 });
47 }
48 }
49 }
50 None
51 }
52
53 async fn call_query(&self, payload: serde_json::Value) -> Result<serde_json::Value, String> {
54 let text = self.server.call_tool(&self.query_tool, payload).await?;
55 parse_json_loose(&text)
56 }
57
58 async fn call_upsert(&self, payload: serde_json::Value) -> Result<serde_json::Value, String> {
59 let text = self.server.call_tool(&self.upsert_tool, payload).await?;
60 parse_json_loose(&text)
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65enum MemoryProviderErrorKind {
66 InvalidPayload,
67 Timeout,
68 Transport,
69 UnsupportedOperation,
70}
71
72impl MemoryProviderErrorKind {
73 fn as_str(self) -> &'static str {
74 match self {
75 Self::InvalidPayload => "invalid_payload",
76 Self::Timeout => "timeout",
77 Self::Transport => "transport",
78 Self::UnsupportedOperation => "unsupported_operation",
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
84struct MemoryProviderFailure {
85 op: String,
86 kind: MemoryProviderErrorKind,
87 detail: String,
88}
89
90impl MemoryProviderFailure {
91 fn timeout(op: impl Into<String>, detail: impl Into<String>) -> Self {
92 Self {
93 op: op.into(),
94 kind: MemoryProviderErrorKind::Timeout,
95 detail: detail.into(),
96 }
97 }
98
99 fn invalid_payload(op: impl Into<String>, detail: impl Into<String>) -> Self {
100 Self {
101 op: op.into(),
102 kind: MemoryProviderErrorKind::InvalidPayload,
103 detail: detail.into(),
104 }
105 }
106
107 fn transport(op: impl Into<String>, detail: impl Into<String>) -> Self {
108 Self {
109 op: op.into(),
110 kind: MemoryProviderErrorKind::Transport,
111 detail: detail.into(),
112 }
113 }
114
115 fn unsupported_operation(op: impl Into<String>, detail: impl Into<String>) -> Self {
116 Self {
117 op: op.into(),
118 kind: MemoryProviderErrorKind::UnsupportedOperation,
119 detail: detail.into(),
120 }
121 }
122
123 fn classify(op: impl Into<String>, detail: impl Into<String>) -> Self {
124 let op = op.into();
125 let detail = detail.into();
126 let lower = detail.to_ascii_lowercase();
127 if lower.contains("tool not found")
128 || lower.contains("unknown tool")
129 || lower.contains("not implemented")
130 || lower.contains("unsupported op")
131 || lower.contains("unsupported operation")
132 {
133 return Self::unsupported_operation(op, detail);
134 }
135 if lower.contains("timed out")
136 || lower.contains("timeout")
137 || lower.contains("deadline exceeded")
138 {
139 return Self::timeout(op, detail);
140 }
141 Self::transport(op, detail)
142 }
143
144 fn fallback_reason(&self) -> String {
145 format!("{}:{}", self.op, self.kind.as_str())
146 }
147
148 fn into_error(self) -> MicroClawError {
149 MicroClawError::ToolExecution(self.to_string())
150 }
151}
152
153impl fmt::Display for MemoryProviderFailure {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(f, "{} [{}]: {}", self.op, self.kind.as_str(), self.detail)
156 }
157}
158
159fn classify_memory_error(op: impl Into<String>, err: String) -> MicroClawError {
160 MemoryProviderFailure::classify(op, err).into_error()
161}
162
163fn invalid_memory_payload(op: impl Into<String>, detail: impl Into<String>) -> MicroClawError {
164 MemoryProviderFailure::invalid_payload(op, detail).into_error()
165}
166
167fn audit_memory_write(data_dir: &str, entry: serde_json::Value) {
170 let data_dir = data_dir.to_string();
171 tokio::spawn(async move {
172 let result = tokio::task::spawn_blocking(move || {
173 use std::io::Write;
174 let wal_dir = std::path::Path::new(&data_dir).join("runtime").join("wal");
175 std::fs::create_dir_all(&wal_dir)?;
176 let path = wal_dir.join("memory_writes.jsonl");
177 let mut file = std::fs::OpenOptions::new()
178 .create(true)
179 .append(true)
180 .open(path)?;
181 let mut line = serde_json::to_string(&entry).unwrap_or_default();
182 line.push('\n');
183 file.write_all(line.as_bytes())
184 })
185 .await;
186 if let Err(e) = result {
187 tracing::debug!("Memory WAL write failed (non-critical): {e}");
188 }
189 });
190}
191
192pub struct MemoryBackend {
193 provider: Arc<dyn MemoryProvider>,
194 stats: Arc<MemoryBackendStats>,
195 primary_provider: Option<Arc<dyn MemoryProvider>>,
196 primary_provider_name: Option<String>,
197 data_dir: String,
198}
199
200type ProviderBundle = (
201 Arc<dyn MemoryProvider>,
202 Option<Arc<dyn MemoryProvider>>,
203 Option<String>,
204);
205
206impl MemoryBackend {
207 pub fn new(db: Arc<Database>, mcp: Option<MemoryMcpClient>, data_dir: &str) -> Self {
208 let stats = Arc::new(MemoryBackendStats::new());
209 let sqlite: Arc<dyn MemoryProvider> = Arc::new(SqliteMemoryProvider::new(db.clone()));
210 let (provider, primary_provider, primary_provider_name): ProviderBundle = match mcp {
211 Some(mcp_client) => {
212 let primary: Arc<dyn MemoryProvider> = Arc::new(McpMemoryProvider::new(mcp_client));
213 (
214 Arc::new(FallbackMemoryProvider::new(
215 primary.clone(),
216 sqlite,
217 stats.clone(),
218 "mcp".to_string(),
219 )),
220 Some(primary),
221 Some("mcp".to_string()),
222 )
223 }
224 None => (sqlite, None, None),
225 };
226 Self {
227 provider,
228 stats,
229 primary_provider,
230 primary_provider_name,
231 data_dir: data_dir.to_string(),
232 }
233 }
234
235 pub fn local_only(db: Arc<Database>) -> Self {
236 Self {
237 provider: Arc::new(SqliteMemoryProvider::new(db)),
238 stats: Arc::new(MemoryBackendStats::new()),
239 primary_provider: None,
240 primary_provider_name: None,
241 data_dir: String::new(),
242 }
243 }
244
245 #[cfg(test)]
246 fn from_provider(provider: Arc<dyn MemoryProvider>) -> Self {
247 Self {
248 provider,
249 stats: Arc::new(MemoryBackendStats::new()),
250 primary_provider: None,
251 primary_provider_name: None,
252 data_dir: String::new(),
253 }
254 }
255
256 #[cfg(test)]
257 fn from_provider_with_stats(
258 provider: Arc<dyn MemoryProvider>,
259 stats: Arc<MemoryBackendStats>,
260 primary_provider_name: Option<String>,
261 ) -> Self {
262 Self {
263 provider,
264 stats,
265 primary_provider: None,
266 primary_provider_name,
267 data_dir: String::new(),
268 }
269 }
270
271 pub fn supports_local_semantic_ranking(&self) -> bool {
272 self.provider.supports_local_semantic_ranking()
273 }
274
275 pub fn provider_health_snapshot(&self) -> MemoryBackendHealthSnapshot {
276 self.stats.snapshot(self.primary_provider_name.clone())
277 }
278
279 pub fn should_pause_reflector_writes(&self) -> bool {
280 let snapshot = self.provider_health_snapshot();
281 snapshot.external_provider_enabled
282 && (!snapshot.startup_probe_ok.unwrap_or(true)
283 || snapshot.consecutive_primary_failures >= 3)
284 }
285
286 pub async fn run_startup_health_check(&self) -> Result<(), MicroClawError> {
287 let Some(primary) = &self.primary_provider else {
288 return Ok(());
289 };
290 match primary.get_all_memories_for_chat(None).await {
291 Ok(_) => {
292 self.stats.record_primary_success();
293 self.stats.startup_probe_ok.store(true, Ordering::SeqCst);
294 self.stats
295 .startup_probe_message
296 .store_string(Some("ok".to_string()));
297 Ok(())
298 }
299 Err(err) => {
300 self.stats.record_primary_failure();
301 self.stats.startup_probe_ok.store(false, Ordering::SeqCst);
302 self.stats
303 .startup_probe_message
304 .store_string(Some(err.to_string()));
305 Err(err)
306 }
307 }
308 }
309
310 pub async fn get_all_memories_for_chat(
311 &self,
312 chat_id: Option<i64>,
313 ) -> Result<Vec<Memory>, MicroClawError> {
314 self.provider.get_all_memories_for_chat(chat_id).await
315 }
316
317 pub async fn get_memories_for_context(
318 &self,
319 chat_id: i64,
320 limit: usize,
321 ) -> Result<Vec<Memory>, MicroClawError> {
322 self.provider.get_memories_for_context(chat_id, limit).await
323 }
324
325 pub async fn search_memories_with_options(
326 &self,
327 chat_id: i64,
328 query: &str,
329 limit: usize,
330 include_archived: bool,
331 broad_recall: bool,
332 ) -> Result<Vec<Memory>, MicroClawError> {
333 self.provider
334 .search_memories_with_options(chat_id, query, limit, include_archived, broad_recall)
335 .await
336 }
337
338 pub async fn get_memory_by_id(&self, id: i64) -> Result<Option<Memory>, MicroClawError> {
339 self.provider.get_memory_by_id(id).await
340 }
341
342 pub async fn insert_memory_with_metadata(
343 &self,
344 chat_id: Option<i64>,
345 content: &str,
346 category: &str,
347 source: &str,
348 confidence: f64,
349 ) -> Result<i64, MicroClawError> {
350 if !self.data_dir.is_empty() {
351 audit_memory_write(
352 &self.data_dir,
353 serde_json::json!({
354 "op": "insert",
355 "chat_id": chat_id,
356 "category": category,
357 "source": source,
358 "confidence": confidence,
359 "content_len": content.len(),
360 "content_preview": &content[..floor_char_boundary(content, 100)],
361 "ts": chrono::Utc::now().to_rfc3339(),
362 }),
363 );
364 }
365 self.provider
366 .insert_memory_with_metadata(chat_id, content, category, source, confidence)
367 .await
368 }
369
370 pub async fn update_memory_with_metadata(
371 &self,
372 id: i64,
373 content: &str,
374 category: &str,
375 confidence: f64,
376 source: &str,
377 ) -> Result<bool, MicroClawError> {
378 self.provider
379 .update_memory_with_metadata(id, content, category, confidence, source)
380 .await
381 }
382
383 pub async fn update_memory_content(
384 &self,
385 id: i64,
386 content: &str,
387 category: &str,
388 ) -> Result<bool, MicroClawError> {
389 self.update_memory_with_metadata(id, content, category, 0.8, "tool")
390 .await
391 }
392
393 pub async fn archive_memory(&self, id: i64) -> Result<bool, MicroClawError> {
394 self.provider.archive_memory(id).await
395 }
396
397 pub async fn supersede_memory(
398 &self,
399 from_memory_id: i64,
400 new_content: &str,
401 category: &str,
402 source: &str,
403 confidence: f64,
404 reason: Option<&str>,
405 ) -> Result<i64, MicroClawError> {
406 self.provider
407 .supersede_memory(
408 from_memory_id,
409 new_content,
410 category,
411 source,
412 confidence,
413 reason,
414 )
415 .await
416 }
417
418 pub async fn touch_memory_last_seen(
419 &self,
420 id: i64,
421 confidence_floor: Option<f64>,
422 ) -> Result<bool, MicroClawError> {
423 self.provider
424 .touch_memory_last_seen(id, confidence_floor)
425 .await
426 }
427}
428
429#[async_trait]
430pub trait MemoryProvider: Send + Sync {
431 fn supports_local_semantic_ranking(&self) -> bool {
432 true
433 }
434
435 async fn get_all_memories_for_chat(
436 &self,
437 chat_id: Option<i64>,
438 ) -> Result<Vec<Memory>, MicroClawError>;
439
440 async fn get_memories_for_context(
441 &self,
442 chat_id: i64,
443 limit: usize,
444 ) -> Result<Vec<Memory>, MicroClawError>;
445
446 async fn search_memories_with_options(
447 &self,
448 chat_id: i64,
449 query: &str,
450 limit: usize,
451 include_archived: bool,
452 broad_recall: bool,
453 ) -> Result<Vec<Memory>, MicroClawError>;
454
455 async fn get_memory_by_id(&self, id: i64) -> Result<Option<Memory>, MicroClawError>;
456
457 async fn insert_memory_with_metadata(
458 &self,
459 chat_id: Option<i64>,
460 content: &str,
461 category: &str,
462 source: &str,
463 confidence: f64,
464 ) -> Result<i64, MicroClawError>;
465
466 async fn update_memory_with_metadata(
467 &self,
468 id: i64,
469 content: &str,
470 category: &str,
471 confidence: f64,
472 source: &str,
473 ) -> Result<bool, MicroClawError>;
474
475 async fn archive_memory(&self, id: i64) -> Result<bool, MicroClawError>;
476
477 async fn supersede_memory(
478 &self,
479 from_memory_id: i64,
480 new_content: &str,
481 category: &str,
482 source: &str,
483 confidence: f64,
484 reason: Option<&str>,
485 ) -> Result<i64, MicroClawError>;
486
487 async fn touch_memory_last_seen(
488 &self,
489 id: i64,
490 confidence_floor: Option<f64>,
491 ) -> Result<bool, MicroClawError>;
492}
493
494#[derive(Debug, Clone)]
495pub struct MemoryBackendHealthSnapshot {
496 pub external_provider_enabled: bool,
497 pub primary_provider_name: Option<String>,
498 pub startup_probe_ok: Option<bool>,
499 pub startup_probe_message: Option<String>,
500 pub consecutive_primary_failures: u64,
501 pub total_fallbacks: u64,
502 pub last_primary_success_ts: Option<i64>,
503 pub last_primary_failure_ts: Option<i64>,
504 pub last_fallback_reason: Option<String>,
505}
506
507struct MemoryBackendStats {
508 startup_probe_ran: AtomicBool,
509 startup_probe_ok: AtomicBool,
510 consecutive_primary_failures: AtomicU64,
511 total_fallbacks: AtomicU64,
512 last_primary_success_ts: AtomicI64,
513 last_primary_failure_ts: AtomicI64,
514 startup_probe_message: AtomicStringCell,
515 last_fallback_reason: AtomicStringCell,
516}
517
518impl MemoryBackendStats {
519 fn new() -> Self {
520 Self {
521 startup_probe_ran: AtomicBool::new(false),
522 startup_probe_ok: AtomicBool::new(false),
523 consecutive_primary_failures: AtomicU64::new(0),
524 total_fallbacks: AtomicU64::new(0),
525 last_primary_success_ts: AtomicI64::new(0),
526 last_primary_failure_ts: AtomicI64::new(0),
527 startup_probe_message: AtomicStringCell::default(),
528 last_fallback_reason: AtomicStringCell::default(),
529 }
530 }
531
532 fn now_ts() -> i64 {
533 chrono::Utc::now().timestamp()
534 }
535
536 fn record_primary_success(&self) {
537 self.startup_probe_ran.store(true, Ordering::SeqCst);
538 self.consecutive_primary_failures.store(0, Ordering::SeqCst);
539 self.last_primary_success_ts
540 .store(Self::now_ts(), Ordering::SeqCst);
541 }
542
543 fn record_primary_failure(&self) {
544 self.startup_probe_ran.store(true, Ordering::SeqCst);
545 self.consecutive_primary_failures
546 .fetch_add(1, Ordering::SeqCst);
547 self.last_primary_failure_ts
548 .store(Self::now_ts(), Ordering::SeqCst);
549 }
550
551 fn record_fallback(&self, reason: String) {
552 self.total_fallbacks.fetch_add(1, Ordering::SeqCst);
553 self.last_fallback_reason.store_string(Some(reason));
554 }
555
556 fn snapshot(&self, primary_provider_name: Option<String>) -> MemoryBackendHealthSnapshot {
557 MemoryBackendHealthSnapshot {
558 external_provider_enabled: primary_provider_name.is_some(),
559 primary_provider_name,
560 startup_probe_ok: self
561 .startup_probe_ran
562 .load(Ordering::SeqCst)
563 .then(|| self.startup_probe_ok.load(Ordering::SeqCst)),
564 startup_probe_message: self.startup_probe_message.load_string(),
565 consecutive_primary_failures: self.consecutive_primary_failures.load(Ordering::SeqCst),
566 total_fallbacks: self.total_fallbacks.load(Ordering::SeqCst),
567 last_primary_success_ts: nonzero_i64(
568 self.last_primary_success_ts.load(Ordering::SeqCst),
569 ),
570 last_primary_failure_ts: nonzero_i64(
571 self.last_primary_failure_ts.load(Ordering::SeqCst),
572 ),
573 last_fallback_reason: self.last_fallback_reason.load_string(),
574 }
575 }
576}
577
578#[derive(Default)]
579struct AtomicStringCell(std::sync::Mutex<Option<String>>);
580
581impl AtomicStringCell {
582 fn store_string(&self, value: Option<String>) {
583 *self.0.lock().expect("atomic string cell poisoned") = value;
584 }
585
586 fn load_string(&self) -> Option<String> {
587 self.0.lock().expect("atomic string cell poisoned").clone()
588 }
589}
590
591fn nonzero_i64(value: i64) -> Option<i64> {
592 (value != 0).then_some(value)
593}
594
595struct SqliteMemoryProvider {
596 db: Arc<Database>,
597}
598
599impl SqliteMemoryProvider {
600 fn new(db: Arc<Database>) -> Self {
601 Self { db }
602 }
603}
604
605#[async_trait]
606impl MemoryProvider for SqliteMemoryProvider {
607 async fn get_all_memories_for_chat(
608 &self,
609 chat_id: Option<i64>,
610 ) -> Result<Vec<Memory>, MicroClawError> {
611 let chat = chat_id;
612 call_blocking(self.db.clone(), move |db| {
613 db.get_all_memories_for_chat(chat)
614 })
615 .await
616 }
617
618 async fn get_memories_for_context(
619 &self,
620 chat_id: i64,
621 limit: usize,
622 ) -> Result<Vec<Memory>, MicroClawError> {
623 call_blocking(self.db.clone(), move |db| {
624 db.get_memories_for_context(chat_id, limit)
625 })
626 .await
627 }
628
629 async fn search_memories_with_options(
630 &self,
631 chat_id: i64,
632 query: &str,
633 limit: usize,
634 include_archived: bool,
635 broad_recall: bool,
636 ) -> Result<Vec<Memory>, MicroClawError> {
637 let q = query.to_string();
638 call_blocking(self.db.clone(), move |db| {
639 db.search_memories_with_options(chat_id, &q, limit, include_archived, broad_recall)
640 })
641 .await
642 }
643
644 async fn get_memory_by_id(&self, id: i64) -> Result<Option<Memory>, MicroClawError> {
645 call_blocking(self.db.clone(), move |db| db.get_memory_by_id(id)).await
646 }
647
648 async fn insert_memory_with_metadata(
649 &self,
650 chat_id: Option<i64>,
651 content: &str,
652 category: &str,
653 source: &str,
654 confidence: f64,
655 ) -> Result<i64, MicroClawError> {
656 let text = content.to_string();
657 let cat = category.to_string();
658 let src = source.to_string();
659 call_blocking(self.db.clone(), move |db| {
660 db.insert_memory_with_metadata(chat_id, &text, &cat, &src, confidence)
661 })
662 .await
663 }
664
665 async fn update_memory_with_metadata(
666 &self,
667 id: i64,
668 content: &str,
669 category: &str,
670 confidence: f64,
671 source: &str,
672 ) -> Result<bool, MicroClawError> {
673 let text = content.to_string();
674 let cat = category.to_string();
675 let src = source.to_string();
676 call_blocking(self.db.clone(), move |db| {
677 db.update_memory_with_metadata(id, &text, &cat, confidence, &src)
678 })
679 .await
680 }
681
682 async fn archive_memory(&self, id: i64) -> Result<bool, MicroClawError> {
683 call_blocking(self.db.clone(), move |db| db.archive_memory(id)).await
684 }
685
686 async fn supersede_memory(
687 &self,
688 from_memory_id: i64,
689 new_content: &str,
690 category: &str,
691 source: &str,
692 confidence: f64,
693 reason: Option<&str>,
694 ) -> Result<i64, MicroClawError> {
695 let text = new_content.to_string();
696 let cat = category.to_string();
697 let src = source.to_string();
698 let why = reason.map(|value| value.to_string());
699 call_blocking(self.db.clone(), move |db| {
700 db.supersede_memory(
701 from_memory_id,
702 &text,
703 &cat,
704 &src,
705 confidence,
706 why.as_deref(),
707 )
708 })
709 .await
710 }
711
712 async fn touch_memory_last_seen(
713 &self,
714 id: i64,
715 confidence_floor: Option<f64>,
716 ) -> Result<bool, MicroClawError> {
717 call_blocking(self.db.clone(), move |db| {
718 db.touch_memory_last_seen(id, confidence_floor)
719 })
720 .await
721 }
722}
723
724struct McpMemoryProvider {
725 client: MemoryMcpClient,
726}
727
728impl McpMemoryProvider {
729 fn new(client: MemoryMcpClient) -> Self {
730 Self { client }
731 }
732}
733
734#[async_trait]
735impl MemoryProvider for McpMemoryProvider {
736 fn supports_local_semantic_ranking(&self) -> bool {
737 false
738 }
739
740 async fn get_all_memories_for_chat(
741 &self,
742 chat_id: Option<i64>,
743 ) -> Result<Vec<Memory>, MicroClawError> {
744 let op = "memory_query(list)";
745 let payload = serde_json::json!({
746 "op": "list",
747 "chat_id": chat_id,
748 });
749 let value = self
750 .client
751 .call_query(payload)
752 .await
753 .map_err(|err| classify_memory_error(op, err))?;
754 parse_memory_list_strict(&value).map_err(|err| invalid_memory_payload(op, err))
755 }
756
757 async fn get_memories_for_context(
758 &self,
759 chat_id: i64,
760 limit: usize,
761 ) -> Result<Vec<Memory>, MicroClawError> {
762 let op = "memory_query(context)";
763 let payload = serde_json::json!({
764 "op": "context",
765 "chat_id": chat_id,
766 "limit": limit,
767 });
768 let value = self
769 .client
770 .call_query(payload)
771 .await
772 .map_err(|err| classify_memory_error(op, err))?;
773 parse_memory_list_strict(&value).map_err(|err| invalid_memory_payload(op, err))
774 }
775
776 async fn search_memories_with_options(
777 &self,
778 chat_id: i64,
779 query: &str,
780 limit: usize,
781 include_archived: bool,
782 broad_recall: bool,
783 ) -> Result<Vec<Memory>, MicroClawError> {
784 let op = "memory_query(search)";
785 let payload = serde_json::json!({
786 "op": "search",
787 "chat_id": chat_id,
788 "query": query,
789 "limit": limit,
790 "include_archived": include_archived,
791 "broad_recall": broad_recall,
792 });
793 let value = self
794 .client
795 .call_query(payload)
796 .await
797 .map_err(|err| classify_memory_error(op, err))?;
798 parse_memory_list_strict(&value).map_err(|err| invalid_memory_payload(op, err))
799 }
800
801 async fn get_memory_by_id(&self, id: i64) -> Result<Option<Memory>, MicroClawError> {
802 let op = "memory_query(get)";
803 let payload = serde_json::json!({
804 "op": "get",
805 "id": id,
806 });
807 let value = self
808 .client
809 .call_query(payload)
810 .await
811 .map_err(|err| classify_memory_error(op, err))?;
812 if let Ok(memories) = parse_memory_list_strict(&value) {
813 return Ok(memories.into_iter().next());
814 }
815 if let Ok(memory) = parse_single_memory_strict(&value) {
816 return Ok(Some(memory));
817 }
818 Err(invalid_memory_payload(
819 op,
820 "expected single memory object or list response",
821 ))
822 }
823
824 async fn insert_memory_with_metadata(
825 &self,
826 chat_id: Option<i64>,
827 content: &str,
828 category: &str,
829 source: &str,
830 confidence: f64,
831 ) -> Result<i64, MicroClawError> {
832 let op = "memory_upsert(insert)";
833 let payload = serde_json::json!({
834 "op": "insert",
835 "chat_id": chat_id,
836 "content": content,
837 "category": category,
838 "source": source,
839 "confidence": confidence,
840 });
841 let value = self
842 .client
843 .call_upsert(payload)
844 .await
845 .map_err(|err| classify_memory_error(op, err))?;
846 extract_id(&value).ok_or_else(|| invalid_memory_payload(op, "expected `id`/`memory_id`"))
847 }
848
849 async fn update_memory_with_metadata(
850 &self,
851 id: i64,
852 content: &str,
853 category: &str,
854 confidence: f64,
855 source: &str,
856 ) -> Result<bool, MicroClawError> {
857 let op = "memory_upsert(update)";
858 let payload = serde_json::json!({
859 "op": "update",
860 "id": id,
861 "content": content,
862 "category": category,
863 "source": source,
864 "confidence": confidence,
865 });
866 let value = self
867 .client
868 .call_upsert(payload)
869 .await
870 .map_err(|err| classify_memory_error(op, err))?;
871 extract_bool_flag(&value)
872 .ok_or_else(|| invalid_memory_payload(op, "expected `updated`/`ok`/`success`"))
873 }
874
875 async fn archive_memory(&self, id: i64) -> Result<bool, MicroClawError> {
876 let op = "memory_upsert(archive)";
877 let payload = serde_json::json!({
878 "op": "archive",
879 "id": id,
880 });
881 let value = self
882 .client
883 .call_upsert(payload)
884 .await
885 .map_err(|err| classify_memory_error(op, err))?;
886 extract_bool_flag(&value)
887 .ok_or_else(|| invalid_memory_payload(op, "expected `updated`/`ok`/`success`"))
888 }
889
890 async fn supersede_memory(
891 &self,
892 from_memory_id: i64,
893 new_content: &str,
894 category: &str,
895 source: &str,
896 confidence: f64,
897 reason: Option<&str>,
898 ) -> Result<i64, MicroClawError> {
899 let op = "memory_upsert(supersede)";
900 let payload = serde_json::json!({
901 "op": "supersede",
902 "from_memory_id": from_memory_id,
903 "content": new_content,
904 "category": category,
905 "source": source,
906 "confidence": confidence,
907 "reason": reason,
908 });
909 let value = self
910 .client
911 .call_upsert(payload)
912 .await
913 .map_err(|err| classify_memory_error(op, err))?;
914 extract_id(&value).ok_or_else(|| invalid_memory_payload(op, "expected `id`/`memory_id`"))
915 }
916
917 async fn touch_memory_last_seen(
918 &self,
919 id: i64,
920 confidence_floor: Option<f64>,
921 ) -> Result<bool, MicroClawError> {
922 let op = "memory_upsert(touch)";
923 let payload = serde_json::json!({
924 "op": "touch",
925 "id": id,
926 "confidence_floor": confidence_floor,
927 });
928 let value = self
929 .client
930 .call_upsert(payload)
931 .await
932 .map_err(|err| classify_memory_error(op, err))?;
933 extract_bool_flag(&value)
934 .ok_or_else(|| invalid_memory_payload(op, "expected `updated`/`ok`/`success`"))
935 }
936}
937
938struct FallbackMemoryProvider {
939 primary: Arc<dyn MemoryProvider>,
940 fallback: Arc<dyn MemoryProvider>,
941 stats: Arc<MemoryBackendStats>,
942 primary_name: String,
943}
944
945impl FallbackMemoryProvider {
946 fn new(
947 primary: Arc<dyn MemoryProvider>,
948 fallback: Arc<dyn MemoryProvider>,
949 stats: Arc<MemoryBackendStats>,
950 primary_name: String,
951 ) -> Self {
952 Self {
953 primary,
954 fallback,
955 stats,
956 primary_name,
957 }
958 }
959
960 async fn fallback_on_err<T, FutPrimary, FutFallback>(
961 &self,
962 op_name: &str,
963 primary: FutPrimary,
964 fallback: FutFallback,
965 ) -> Result<T, MicroClawError>
966 where
967 FutPrimary: std::future::Future<Output = Result<T, MicroClawError>>,
968 FutFallback: std::future::Future<Output = Result<T, MicroClawError>>,
969 {
970 match primary.await {
971 Ok(value) => {
972 self.stats.record_primary_success();
973 Ok(value)
974 }
975 Err(err) => {
976 self.stats.record_primary_failure();
977 let reason = fallback_reason_for_error(op_name, &err);
978 self.stats.record_fallback(reason.clone());
979 warn!(
980 "{op_name} failed via primary memory provider '{}' ({reason}; {err}); falling back",
981 self.primary_name,
982 );
983 fallback.await
984 }
985 }
986 }
987}
988
989#[async_trait]
990impl MemoryProvider for FallbackMemoryProvider {
991 fn supports_local_semantic_ranking(&self) -> bool {
992 self.primary.supports_local_semantic_ranking()
993 }
994
995 async fn get_all_memories_for_chat(
996 &self,
997 chat_id: Option<i64>,
998 ) -> Result<Vec<Memory>, MicroClawError> {
999 self.fallback_on_err(
1000 "memory_query(list)",
1001 self.primary.get_all_memories_for_chat(chat_id),
1002 self.fallback.get_all_memories_for_chat(chat_id),
1003 )
1004 .await
1005 }
1006
1007 async fn get_memories_for_context(
1008 &self,
1009 chat_id: i64,
1010 limit: usize,
1011 ) -> Result<Vec<Memory>, MicroClawError> {
1012 self.fallback_on_err(
1013 "memory_query(context)",
1014 self.primary.get_memories_for_context(chat_id, limit),
1015 self.fallback.get_memories_for_context(chat_id, limit),
1016 )
1017 .await
1018 }
1019
1020 async fn search_memories_with_options(
1021 &self,
1022 chat_id: i64,
1023 query: &str,
1024 limit: usize,
1025 include_archived: bool,
1026 broad_recall: bool,
1027 ) -> Result<Vec<Memory>, MicroClawError> {
1028 self.fallback_on_err(
1029 "memory_query(search)",
1030 self.primary.search_memories_with_options(
1031 chat_id,
1032 query,
1033 limit,
1034 include_archived,
1035 broad_recall,
1036 ),
1037 self.fallback.search_memories_with_options(
1038 chat_id,
1039 query,
1040 limit,
1041 include_archived,
1042 broad_recall,
1043 ),
1044 )
1045 .await
1046 }
1047
1048 async fn get_memory_by_id(&self, id: i64) -> Result<Option<Memory>, MicroClawError> {
1049 self.fallback_on_err(
1050 "memory_query(get)",
1051 self.primary.get_memory_by_id(id),
1052 self.fallback.get_memory_by_id(id),
1053 )
1054 .await
1055 }
1056
1057 async fn insert_memory_with_metadata(
1058 &self,
1059 chat_id: Option<i64>,
1060 content: &str,
1061 category: &str,
1062 source: &str,
1063 confidence: f64,
1064 ) -> Result<i64, MicroClawError> {
1065 self.fallback_on_err(
1066 "memory_upsert(insert)",
1067 self.primary
1068 .insert_memory_with_metadata(chat_id, content, category, source, confidence),
1069 self.fallback
1070 .insert_memory_with_metadata(chat_id, content, category, source, confidence),
1071 )
1072 .await
1073 }
1074
1075 async fn update_memory_with_metadata(
1076 &self,
1077 id: i64,
1078 content: &str,
1079 category: &str,
1080 confidence: f64,
1081 source: &str,
1082 ) -> Result<bool, MicroClawError> {
1083 self.fallback_on_err(
1084 "memory_upsert(update)",
1085 self.primary
1086 .update_memory_with_metadata(id, content, category, confidence, source),
1087 self.fallback
1088 .update_memory_with_metadata(id, content, category, confidence, source),
1089 )
1090 .await
1091 }
1092
1093 async fn archive_memory(&self, id: i64) -> Result<bool, MicroClawError> {
1094 self.fallback_on_err(
1095 "memory_upsert(archive)",
1096 self.primary.archive_memory(id),
1097 self.fallback.archive_memory(id),
1098 )
1099 .await
1100 }
1101
1102 async fn supersede_memory(
1103 &self,
1104 from_memory_id: i64,
1105 new_content: &str,
1106 category: &str,
1107 source: &str,
1108 confidence: f64,
1109 reason: Option<&str>,
1110 ) -> Result<i64, MicroClawError> {
1111 self.fallback_on_err(
1112 "memory_upsert(supersede)",
1113 self.primary.supersede_memory(
1114 from_memory_id,
1115 new_content,
1116 category,
1117 source,
1118 confidence,
1119 reason,
1120 ),
1121 self.fallback.supersede_memory(
1122 from_memory_id,
1123 new_content,
1124 category,
1125 source,
1126 confidence,
1127 reason,
1128 ),
1129 )
1130 .await
1131 }
1132
1133 async fn touch_memory_last_seen(
1134 &self,
1135 id: i64,
1136 confidence_floor: Option<f64>,
1137 ) -> Result<bool, MicroClawError> {
1138 self.fallback_on_err(
1139 "memory_upsert(touch)",
1140 self.primary.touch_memory_last_seen(id, confidence_floor),
1141 self.fallback.touch_memory_last_seen(id, confidence_floor),
1142 )
1143 .await
1144 }
1145}
1146
1147fn parse_json_loose(text: &str) -> Result<serde_json::Value, String> {
1148 if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
1149 return Ok(v);
1150 }
1151 for (open, close) in [(b'[', b']'), (b'{', b'}')] {
1152 if let Some(start) = text.as_bytes().iter().position(|b| *b == open) {
1153 if let Some(end) = text.as_bytes().iter().rposition(|b| *b == close) {
1154 if start < end {
1155 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text[start..=end]) {
1156 return Ok(v);
1157 }
1158 }
1159 }
1160 }
1161 }
1162 Err("MCP memory response is not valid JSON".to_string())
1163}
1164
1165fn fallback_reason_for_error(op_name: &str, err: &MicroClawError) -> String {
1166 let structured = match err {
1167 MicroClawError::ToolExecution(message) => Some(message.as_str()),
1168 _ => None,
1169 };
1170 if let Some(message) = structured.filter(|message| message.starts_with(op_name)) {
1171 if let Some((_, rest)) = message.split_once('[') {
1172 if let Some((kind, _)) = rest.split_once(']') {
1173 let normalized = kind.trim().to_ascii_lowercase().replace(' ', "_");
1174 if !normalized.is_empty() {
1175 return format!("{op_name}:{normalized}");
1176 }
1177 }
1178 }
1179 }
1180 let err_text = err.to_string();
1181 MemoryProviderFailure::classify(op_name.to_string(), err_text).fallback_reason()
1182}
1183
1184fn parse_memory_list_strict(value: &serde_json::Value) -> Result<Vec<Memory>, String> {
1185 let arr = if let Some(arr) = value.as_array() {
1186 arr
1187 } else if let Some(obj) = value.as_object() {
1188 if let Some(arr) = obj.get("memories").and_then(|v| v.as_array()) {
1189 arr
1190 } else if let Some(arr) = obj.get("items").and_then(|v| v.as_array()) {
1191 arr
1192 } else {
1193 return Err(
1194 "memory payload must be an array or an object with `memories`/`items`".to_string(),
1195 );
1196 }
1197 } else {
1198 return Err("memory payload must be JSON array/object".to_string());
1199 };
1200
1201 let mut out = Vec::with_capacity(arr.len());
1202 for (idx, item) in arr.iter().enumerate() {
1203 out.push(
1204 parse_single_memory_strict(item)
1205 .map_err(|err| format!("invalid memory at index {idx}: {err}"))?,
1206 );
1207 }
1208 Ok(out)
1209}
1210
1211fn parse_single_memory_strict(value: &serde_json::Value) -> Result<Memory, String> {
1212 let obj = value
1213 .as_object()
1214 .ok_or_else(|| "memory item must be an object".to_string())?;
1215 let id = obj
1216 .get("id")
1217 .and_then(|v| v.as_i64())
1218 .ok_or_else(|| "missing numeric `id`".to_string())?;
1219 let content = obj
1220 .get("content")
1221 .and_then(|v| v.as_str())
1222 .map(str::trim)
1223 .filter(|v| !v.is_empty())
1224 .ok_or_else(|| "missing non-empty `content`".to_string())?
1225 .to_string();
1226 let category = obj
1227 .get("category")
1228 .and_then(|v| v.as_str())
1229 .unwrap_or("KNOWLEDGE")
1230 .to_string();
1231 let now = chrono::Utc::now().to_rfc3339();
1232 Ok(Memory {
1233 id,
1234 chat_id: obj.get("chat_id").and_then(|v| v.as_i64()),
1235 content,
1236 category,
1237 created_at: obj
1238 .get("created_at")
1239 .and_then(|v| v.as_str())
1240 .unwrap_or(&now)
1241 .to_string(),
1242 updated_at: obj
1243 .get("updated_at")
1244 .and_then(|v| v.as_str())
1245 .unwrap_or(&now)
1246 .to_string(),
1247 embedding_model: obj
1248 .get("embedding_model")
1249 .and_then(|v| v.as_str())
1250 .map(|v| v.to_string()),
1251 confidence: obj
1252 .get("confidence")
1253 .and_then(|v| v.as_f64())
1254 .unwrap_or(0.8),
1255 source: obj
1256 .get("source")
1257 .and_then(|v| v.as_str())
1258 .unwrap_or("mcp_memory")
1259 .to_string(),
1260 last_seen_at: obj
1261 .get("last_seen_at")
1262 .and_then(|v| v.as_str())
1263 .unwrap_or(&now)
1264 .to_string(),
1265 is_archived: obj
1266 .get("is_archived")
1267 .and_then(|v| v.as_bool())
1268 .unwrap_or(false),
1269 archived_at: obj
1270 .get("archived_at")
1271 .and_then(|v| v.as_str())
1272 .map(|v| v.to_string()),
1273 expires_at: obj
1274 .get("expires_at")
1275 .and_then(|v| v.as_str())
1276 .map(|v| v.to_string()),
1277 })
1278}
1279
1280fn extract_id(value: &serde_json::Value) -> Option<i64> {
1281 value
1282 .get("id")
1283 .and_then(|v| v.as_i64())
1284 .or_else(|| value.get("memory_id").and_then(|v| v.as_i64()))
1285 .or_else(|| {
1286 value
1287 .get("memory")
1288 .and_then(|v| v.get("id"))
1289 .and_then(|v| v.as_i64())
1290 })
1291}
1292
1293fn extract_bool_flag(value: &serde_json::Value) -> Option<bool> {
1294 value
1295 .get("updated")
1296 .and_then(|v| v.as_bool())
1297 .or_else(|| value.get("ok").and_then(|v| v.as_bool()))
1298 .or_else(|| value.get("success").and_then(|v| v.as_bool()))
1299}
1300
1301#[allow(dead_code)]
1302fn _extract_tool_info(tools: &[McpToolInfo]) -> Vec<String> {
1303 tools.iter().map(|t| t.name.clone()).collect()
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309 use std::sync::atomic::{AtomicUsize, Ordering};
1310
1311 fn sample_memory(id: i64, content: &str) -> Memory {
1312 Memory {
1313 id,
1314 chat_id: Some(42),
1315 content: content.to_string(),
1316 category: "KNOWLEDGE".to_string(),
1317 created_at: "2026-03-10T00:00:00Z".to_string(),
1318 updated_at: "2026-03-10T00:00:00Z".to_string(),
1319 embedding_model: None,
1320 confidence: 0.9,
1321 source: "test".to_string(),
1322 last_seen_at: "2026-03-10T00:00:00Z".to_string(),
1323 is_archived: false,
1324 archived_at: None,
1325 expires_at: None,
1326 }
1327 }
1328
1329 struct FakeProvider {
1330 supports_local_semantic_ranking: bool,
1331 get_context_memories: Vec<Memory>,
1332 get_context_error: Option<String>,
1333 get_context_calls: AtomicUsize,
1334 }
1335
1336 impl FakeProvider {
1337 fn success(memories: Vec<Memory>, supports_local_semantic_ranking: bool) -> Self {
1338 Self {
1339 supports_local_semantic_ranking,
1340 get_context_memories: memories,
1341 get_context_error: None,
1342 get_context_calls: AtomicUsize::new(0),
1343 }
1344 }
1345
1346 fn failure(message: &str, supports_local_semantic_ranking: bool) -> Self {
1347 Self {
1348 supports_local_semantic_ranking,
1349 get_context_memories: Vec::new(),
1350 get_context_error: Some(message.to_string()),
1351 get_context_calls: AtomicUsize::new(0),
1352 }
1353 }
1354 }
1355
1356 #[async_trait]
1357 impl MemoryProvider for FakeProvider {
1358 fn supports_local_semantic_ranking(&self) -> bool {
1359 self.supports_local_semantic_ranking
1360 }
1361
1362 async fn get_all_memories_for_chat(
1363 &self,
1364 _chat_id: Option<i64>,
1365 ) -> Result<Vec<Memory>, MicroClawError> {
1366 Ok(vec![sample_memory(1, "all")])
1367 }
1368
1369 async fn get_memories_for_context(
1370 &self,
1371 _chat_id: i64,
1372 _limit: usize,
1373 ) -> Result<Vec<Memory>, MicroClawError> {
1374 self.get_context_calls.fetch_add(1, Ordering::SeqCst);
1375 match &self.get_context_error {
1376 Some(message) => Err(MicroClawError::ToolExecution(message.clone())),
1377 None => Ok(self.get_context_memories.clone()),
1378 }
1379 }
1380
1381 async fn search_memories_with_options(
1382 &self,
1383 _chat_id: i64,
1384 _query: &str,
1385 _limit: usize,
1386 _include_archived: bool,
1387 _broad_recall: bool,
1388 ) -> Result<Vec<Memory>, MicroClawError> {
1389 Ok(vec![sample_memory(2, "search")])
1390 }
1391
1392 async fn get_memory_by_id(&self, id: i64) -> Result<Option<Memory>, MicroClawError> {
1393 Ok(Some(sample_memory(id, "by-id")))
1394 }
1395
1396 async fn insert_memory_with_metadata(
1397 &self,
1398 _chat_id: Option<i64>,
1399 _content: &str,
1400 _category: &str,
1401 _source: &str,
1402 _confidence: f64,
1403 ) -> Result<i64, MicroClawError> {
1404 Ok(10)
1405 }
1406
1407 async fn update_memory_with_metadata(
1408 &self,
1409 _id: i64,
1410 _content: &str,
1411 _category: &str,
1412 _confidence: f64,
1413 _source: &str,
1414 ) -> Result<bool, MicroClawError> {
1415 Ok(true)
1416 }
1417
1418 async fn archive_memory(&self, _id: i64) -> Result<bool, MicroClawError> {
1419 Ok(true)
1420 }
1421
1422 async fn supersede_memory(
1423 &self,
1424 _from_memory_id: i64,
1425 _new_content: &str,
1426 _category: &str,
1427 _source: &str,
1428 _confidence: f64,
1429 _reason: Option<&str>,
1430 ) -> Result<i64, MicroClawError> {
1431 Ok(11)
1432 }
1433
1434 async fn touch_memory_last_seen(
1435 &self,
1436 _id: i64,
1437 _confidence_floor: Option<f64>,
1438 ) -> Result<bool, MicroClawError> {
1439 Ok(true)
1440 }
1441 }
1442
1443 #[tokio::test]
1444 async fn test_backend_capability_reflects_provider() {
1445 let backend = MemoryBackend::from_provider(Arc::new(FakeProvider::success(
1446 vec![sample_memory(1, "x")],
1447 false,
1448 )));
1449 assert!(!backend.supports_local_semantic_ranking());
1450 }
1451
1452 #[tokio::test]
1453 async fn test_fallback_provider_uses_fallback_on_error() {
1454 let primary = Arc::new(FakeProvider::failure("boom", false));
1455 let fallback = Arc::new(FakeProvider::success(
1456 vec![sample_memory(7, "from fallback")],
1457 true,
1458 ));
1459 let stats = Arc::new(MemoryBackendStats::new());
1460 let backend = MemoryBackend::from_provider_with_stats(
1461 Arc::new(FallbackMemoryProvider::new(
1462 primary.clone(),
1463 fallback.clone(),
1464 stats.clone(),
1465 "fake-primary".to_string(),
1466 )),
1467 stats,
1468 Some("fake-primary".to_string()),
1469 );
1470
1471 let memories = backend.get_memories_for_context(42, 10).await.unwrap();
1472 let snapshot = backend.provider_health_snapshot();
1473 assert_eq!(memories.len(), 1);
1474 assert_eq!(memories[0].content, "from fallback");
1475 assert_eq!(primary.get_context_calls.load(Ordering::SeqCst), 1);
1476 assert_eq!(fallback.get_context_calls.load(Ordering::SeqCst), 1);
1477 assert_eq!(snapshot.total_fallbacks, 1);
1478 assert_eq!(snapshot.consecutive_primary_failures, 1);
1479 assert_eq!(
1480 snapshot.last_fallback_reason.as_deref(),
1481 Some("memory_query(context):transport")
1482 );
1483 assert!(!backend.supports_local_semantic_ranking());
1484 }
1485
1486 #[test]
1487 fn test_memory_provider_failure_classifies_timeout() {
1488 let failure = MemoryProviderFailure::classify(
1489 "memory_query(context)",
1490 "MCP tool call timed out for 'memory' after 60s",
1491 );
1492 assert_eq!(failure.kind, MemoryProviderErrorKind::Timeout);
1493 assert_eq!(failure.fallback_reason(), "memory_query(context):timeout");
1494 }
1495
1496 #[test]
1497 fn test_memory_provider_failure_classifies_unsupported_operation() {
1498 let failure = MemoryProviderFailure::classify(
1499 "memory_upsert(insert)",
1500 "tool not found: memory_upsert",
1501 );
1502 assert_eq!(failure.kind, MemoryProviderErrorKind::UnsupportedOperation);
1503 assert_eq!(
1504 failure.fallback_reason(),
1505 "memory_upsert(insert):unsupported_operation"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_invalid_memory_payload_uses_stable_category() {
1511 let err = invalid_memory_payload("memory_query(get)", "expected single memory object");
1512 let reason = fallback_reason_for_error("memory_query(get)", &err);
1513 assert_eq!(reason, "memory_query(get):invalid_payload");
1514 assert!(err
1515 .to_string()
1516 .contains("memory_query(get) [invalid_payload]"));
1517 }
1518
1519 #[test]
1520 fn test_parse_memory_list_strict_rejects_invalid_item() {
1521 let payload = serde_json::json!({
1522 "memories": [
1523 {"id": 1, "content": "ok"},
1524 {"id": 2}
1525 ]
1526 });
1527 let err = parse_memory_list_strict(&payload).unwrap_err();
1528 assert!(err.contains("invalid memory at index 1"));
1529 }
1530
1531 #[test]
1532 fn test_parse_memory_list_strict_accepts_empty_array() {
1533 let payload = serde_json::json!([]);
1534 let memories = parse_memory_list_strict(&payload).unwrap();
1535 assert!(memories.is_empty());
1536 }
1537
1538 #[test]
1539 fn test_parse_single_memory_strict_rejects_blank_content() {
1540 let payload = serde_json::json!({"id": 1, "content": " "});
1541 let err = parse_single_memory_strict(&payload).unwrap_err();
1542 assert!(err.contains("missing non-empty `content`"));
1543 }
1544}