Skip to main content

microclaw_engine/
memory_backend.rs

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
167/// Append a write-ahead log entry for memory operations (for auditing and poisoning detection).
168/// Spawns a background task — never blocks the write path.
169fn 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}