Skip to main content

microclaw_engine/
memory_service.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use tracing::info;
5
6use crate::agent_engine::is_slash_command_text;
7use crate::embedding::EmbeddingProvider;
8use crate::internal::storage::db::{call_blocking, Database, KgTriple, Memory};
9use crate::internal::storage::memory_quality;
10use crate::memory_backend::MemoryBackend;
11use crate::runtime::AppState;
12
13pub struct ReflectorApplyOutcome {
14    pub inserted: usize,
15    pub updated: usize,
16    pub skipped: usize,
17    pub dedup_method: &'static str,
18}
19
20fn jaccard_similarity_ratio(a: &str, b: &str) -> f64 {
21    use std::collections::HashSet;
22    let a_words: HashSet<&str> = a.split_whitespace().collect();
23    let b_words: HashSet<&str> = b.split_whitespace().collect();
24    let intersection = a_words.intersection(&b_words).count();
25    let union = a_words.len() + b_words.len() - intersection;
26    if union == 0 {
27        1.0
28    } else {
29        intersection as f64 / union as f64
30    }
31}
32
33pub(crate) fn tokenize_for_relevance(text: &str) -> std::collections::HashSet<String> {
34    let mut out = std::collections::HashSet::new();
35
36    for token in text
37        .split_whitespace()
38        .map(|w| {
39            w.chars()
40                .filter(|c| c.is_alphanumeric())
41                .collect::<String>()
42                .to_lowercase()
43        })
44        .filter(|w| w.len() > 1)
45    {
46        out.insert(token);
47    }
48
49    let cjk_chars: Vec<char> = text.chars().filter(|c| is_cjk(*c)).collect();
50    if cjk_chars.len() >= 2 {
51        for pair in cjk_chars.windows(2) {
52            let gram: String = pair.iter().collect();
53            out.insert(gram);
54        }
55    } else if cjk_chars.len() == 1 {
56        out.insert(cjk_chars[0].to_string());
57    }
58
59    out
60}
61
62fn score_relevance_with_cache(
63    content: &str,
64    query_tokens: &std::collections::HashSet<String>,
65) -> usize {
66    if query_tokens.is_empty() {
67        return 0;
68    }
69    let content_tokens = tokenize_for_relevance(content);
70    content_tokens
71        .iter()
72        .filter(|t| query_tokens.contains(*t))
73        .count()
74}
75
76fn is_cjk(c: char) -> bool {
77    matches!(
78        c as u32,
79        0x4E00..=0x9FFF
80            | 0x3400..=0x4DBF
81            | 0x20000..=0x2A6DF
82            | 0x2A700..=0x2B73F
83            | 0x2B740..=0x2B81F
84            | 0x2B820..=0x2CEAF
85            | 0xF900..=0xFAFF
86            | 0x2F800..=0x2FA1F
87    )
88}
89
90pub fn jaccard_similar(a: &str, b: &str, threshold: f64) -> bool {
91    use std::collections::HashSet;
92    let a_words: HashSet<&str> = a.split_whitespace().collect();
93    let b_words: HashSet<&str> = b.split_whitespace().collect();
94    let intersection = a_words.intersection(&b_words).count();
95    let union = a_words.len() + b_words.len() - intersection;
96    if union == 0 {
97        return true;
98    }
99    intersection as f64 / union as f64 >= threshold
100}
101
102/// A minimal view of a memory for sleep-time consolidation.
103#[derive(Debug, Clone)]
104pub struct ConsolidationItem {
105    pub id: i64,
106    pub content: String,
107    pub category: String,
108}
109
110/// Select near-duplicate memories to archive during a sleep-time consolidation pass.
111///
112/// `items` must be pre-sorted **best-first** (e.g. highest confidence / most recent
113/// first): the first member of each duplicate group is kept and later same-category
114/// near-duplicates are archived. PROFILE memories are always kept (they describe the
115/// user's identity). Returns the ids to archive, capped at `max`.
116pub fn select_duplicate_memories_to_archive(
117    items: &[ConsolidationItem],
118    threshold: f64,
119    max: usize,
120) -> Vec<i64> {
121    let threshold = threshold.clamp(0.5, 1.0);
122    let mut kept: Vec<&ConsolidationItem> = Vec::new();
123    let mut archive: Vec<i64> = Vec::new();
124    for it in items {
125        if it.category == "PROFILE" {
126            kept.push(it);
127            continue;
128        }
129        let is_dup = kept.iter().any(|k| {
130            k.category == it.category && jaccard_similar(&k.content, &it.content, threshold)
131        });
132        if is_dup {
133            archive.push(it.id);
134            if archive.len() >= max {
135                break;
136            }
137        } else {
138            kept.push(it);
139        }
140    }
141    archive
142}
143
144fn should_merge_duplicate(
145    existing: &Memory,
146    incoming_content: &str,
147    incoming_category: &str,
148) -> bool {
149    if existing.is_archived {
150        return true;
151    }
152    if existing.content.eq_ignore_ascii_case(incoming_content) {
153        return false;
154    }
155    if incoming_category == "PROFILE" && existing.category != "PROFILE" {
156        return true;
157    }
158    incoming_content.len() > existing.content.len() + 8
159}
160
161fn is_corrective_action_item(content: &str) -> bool {
162    let lower = content.to_ascii_lowercase();
163    let trimmed = lower.trim();
164    trimmed.starts_with("todo:")
165        || trimmed.starts_with("todo ")
166        || trimmed.contains(" ensure ")
167        || trimmed.starts_with("ensure ")
168}
169
170fn looks_like_broken_behavior_fact(content: &str) -> bool {
171    let lower = content.to_ascii_lowercase();
172    let broken_cues = [
173        "tool calls were broken",
174        "typed tool calls as text",
175        "posted as text",
176        "authentication error",
177        "auth fails",
178        "not following instructions",
179        "isn't following instructions",
180        "failed",
181        "broke ",
182        "was broken",
183        "error on",
184    ];
185    broken_cues.iter().any(|cue| lower.contains(cue))
186}
187
188pub fn should_skip_memory_poisoning_risk(content: &str) -> bool {
189    looks_like_broken_behavior_fact(content) && !is_corrective_action_item(content)
190}
191
192#[cfg(feature = "sqlite-vec")]
193pub async fn upsert_memory_embedding(
194    state: &Arc<AppState>,
195    memory_id: i64,
196    content: &str,
197) -> Result<(), ()> {
198    let provider = match &state.embedding {
199        Some(p) => p,
200        None => return Ok(()),
201    };
202    upsert_memory_embedding_with_provider(state.db.clone(), provider, memory_id, content).await
203}
204
205#[cfg(feature = "sqlite-vec")]
206async fn upsert_memory_embedding_with_provider(
207    db: Arc<Database>,
208    provider: &Arc<dyn EmbeddingProvider>,
209    memory_id: i64,
210    content: &str,
211) -> Result<(), ()> {
212    let model_name = provider.model().to_string();
213    let embedding = provider.embed(content).await.map_err(|_| ())?;
214    call_blocking(db, move |db| {
215        db.upsert_memory_vec(memory_id, &embedding)?;
216        db.update_memory_embedding_model(memory_id, &model_name)?;
217        Ok(())
218    })
219    .await
220    .map_err(|_| ())
221}
222
223pub(crate) async fn maybe_handle_explicit_memory_command(
224    state: &AppState,
225    chat_id: i64,
226    override_prompt: Option<&str>,
227    image_data: Option<(String, String)>,
228) -> Result<Option<String>> {
229    if override_prompt.is_some() || image_data.is_some() {
230        return Ok(None);
231    }
232
233    let latest_user = call_blocking(state.db.clone(), move |db| {
234        db.get_recent_messages(chat_id, 10)
235    })
236    .await?;
237    let Some(last_user_text) = latest_user
238        .into_iter()
239        .rev()
240        .find(|m| !m.is_from_bot && !is_slash_command_text(&m.content))
241        .map(|m| m.content)
242    else {
243        return Ok(None);
244    };
245
246    let Some(explicit_content) = memory_quality::extract_explicit_memory_command(&last_user_text)
247    else {
248        return Ok(None);
249    };
250    if !memory_quality::memory_quality_ok(&explicit_content) {
251        return Ok(Some(
252            "I skipped saving that memory because it looked too vague. Please send a specific fact.".to_string(),
253        ));
254    }
255
256    let existing = state
257        .memory_backend
258        .get_all_memories_for_chat(Some(chat_id))
259        .await?;
260    let explicit_topic = memory_quality::memory_topic_key(&explicit_content);
261    if let Some(dup) = existing.iter().find(|m| {
262        !m.is_archived
263            && (m.content.eq_ignore_ascii_case(&explicit_content)
264                || jaccard_similarity_ratio(&m.content, &explicit_content) >= 0.55)
265    }) {
266        let memory_id = dup.id;
267        let content_for_update = explicit_content.clone();
268        let _ = state
269            .memory_backend
270            .update_memory_with_metadata(
271                memory_id,
272                &content_for_update,
273                "KNOWLEDGE",
274                0.95,
275                "explicit",
276            )
277            .await;
278        return Ok(Some(format!(
279            "Noted. Updated memory #{memory_id}: {explicit_content}"
280        )));
281    }
282
283    if let Some(conflict) = existing.iter().find(|m| {
284        !m.is_archived
285            && m.category == "KNOWLEDGE"
286            && memory_quality::memory_topic_key(&m.content) == explicit_topic
287            && !m.content.eq_ignore_ascii_case(&explicit_content)
288    }) {
289        let from_id = conflict.id;
290        let new_content = explicit_content.clone();
291        let superseded_id = state
292            .memory_backend
293            .supersede_memory(
294                from_id,
295                &new_content,
296                "KNOWLEDGE",
297                "explicit_conflict",
298                0.95,
299                Some("explicit_topic_conflict"),
300            )
301            .await?;
302        return Ok(Some(format!(
303            "Noted. Superseded memory #{from_id} with #{superseded_id}: {explicit_content}"
304        )));
305    }
306
307    let content_for_insert = explicit_content.clone();
308    let inserted_id = state
309        .memory_backend
310        .insert_memory_with_metadata(
311            Some(chat_id),
312            &content_for_insert,
313            "KNOWLEDGE",
314            "explicit",
315            0.95,
316        )
317        .await?;
318
319    #[cfg(feature = "sqlite-vec")]
320    {
321        if let Some(provider) = &state.embedding {
322            let _ = upsert_memory_embedding_with_provider(
323                state.db.clone(),
324                provider,
325                inserted_id,
326                &explicit_content,
327            )
328            .await;
329        }
330    }
331
332    Ok(Some(format!(
333        "Noted. Saved memory #{inserted_id}: {explicit_content}"
334    )))
335}
336
337/// Sanitize a query string for memory retrieval.
338///
339/// AI agents sometimes prepend system prompts or tool definitions to user messages,
340/// which destroys embedding quality for semantic search. This sanitizer extracts the
341/// likely user intent by:
342/// Effective ranking score for a memory: `confidence` × recency-decay.
343/// PROFILE memories are exempted (they describe the user, not transient state)
344/// and a non-positive `half_life_days` disables decay entirely.
345fn effective_memory_score(
346    m: &Memory,
347    now: chrono::DateTime<chrono::Utc>,
348    half_life_days: f64,
349) -> f64 {
350    if m.category == "PROFILE" || half_life_days <= 0.0 {
351        return m.confidence;
352    }
353    let parsed = chrono::DateTime::parse_from_rfc3339(&m.last_seen_at)
354        .or_else(|_| chrono::DateTime::parse_from_rfc3339(&m.updated_at));
355    let last_seen = match parsed {
356        Ok(t) => t.with_timezone(&chrono::Utc),
357        Err(_) => return m.confidence,
358    };
359    let age_days = (now - last_seen).num_seconds().max(0) as f64 / 86_400.0;
360    let decay = 0.5_f64.powf(age_days / half_life_days);
361    m.confidence * decay
362}
363
364/// 1. Detecting and stripping common system prompt patterns
365/// 2. Extracting the last meaningful sentence (most likely to be the actual query)
366/// 3. Truncating overly long queries that are likely contaminated
367fn sanitize_memory_query(raw: &str) -> String {
368    let trimmed = raw.trim();
369    if trimmed.is_empty() || trimmed.len() < 200 {
370        // Short queries are unlikely to contain system prompt contamination
371        return trimmed.to_string();
372    }
373
374    // Check for system prompt contamination markers
375    let lower = trimmed.to_ascii_lowercase();
376    let contamination_markers = [
377        "you are a",
378        "you are an",
379        "your role is",
380        "system prompt",
381        "instructions:",
382        "<system>",
383        "tool_use",
384        "tool_result",
385        "[scheduler]:",
386        "as an ai assistant",
387    ];
388    let is_contaminated = contamination_markers.iter().any(|m| lower.contains(m));
389
390    if !is_contaminated {
391        // Not contaminated, but still truncate if very long
392        if trimmed.len() > 500 {
393            return trimmed.chars().take(500).collect();
394        }
395        return trimmed.to_string();
396    }
397
398    // Strategy: extract the last meaningful sentence (tail extraction)
399    // Split on sentence boundaries and take the last non-trivial one
400    let sentences: Vec<&str> = trimmed
401        .split(['.', '?', '!', '\n'])
402        .map(|s| s.trim())
403        .filter(|s| s.len() > 10)
404        .collect();
405
406    if let Some(last) = sentences.last() {
407        return last.chars().take(300).collect();
408    }
409
410    // Fallback: take the last 200 chars
411    let start = trimmed
412        .char_indices()
413        .rev()
414        .nth(199)
415        .map(|(i, _)| i)
416        .unwrap_or(0);
417    trimmed[start..].to_string()
418}
419
420/// Find which knowledge-graph entities a query mentions, to seed graph-augmented
421/// retrieval. Uses case-insensitive substring matching (so multi-word entities
422/// like "New York" are caught), prefers longer entities first (`entities` is
423/// pre-sorted longest-first by the storage layer), and ignores 1-2 char entities
424/// to avoid noise. Bounded to `max_seeds`.
425fn extract_kg_seeds(query: &str, entities: &[String], max_seeds: usize) -> Vec<String> {
426    let q = query.to_lowercase();
427    if q.trim().is_empty() {
428        return Vec::new();
429    }
430    let mut seeds: Vec<String> = Vec::new();
431    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
432    for ent in entities {
433        if seeds.len() >= max_seeds {
434            break;
435        }
436        let lc = ent.to_lowercase();
437        if lc.chars().count() < 3 {
438            continue;
439        }
440        if q.contains(&lc) && seen.insert(lc) {
441            seeds.push(ent.clone());
442        }
443    }
444    seeds
445}
446
447/// Render a connected-facts block from graph-expanded triples, skipping triples
448/// whose meaning is already covered by an injected memory line so the block adds
449/// genuinely new, multi-hop context rather than repeating L0-L2.
450fn render_graph_section(triples: &[KgTriple], already_injected: &str) -> String {
451    let injected_lc = already_injected.to_lowercase();
452    let mut lines = String::new();
453    for t in triples {
454        // Cheap redundancy guard: if both endpoints already appear together in an
455        // injected memory line, the relationship is probably already stated.
456        let subj_lc = t.subject.to_lowercase();
457        let obj_lc = t.object.to_lowercase();
458        if injected_lc.contains(&subj_lc) && injected_lc.contains(&obj_lc) {
459            continue;
460        }
461        lines.push_str(&format!("{} —[{}]→ {}\n", t.subject, t.predicate, t.object));
462    }
463    lines
464}
465
466/// Build structured memory context using a 4-layer memory stack:
467///
468/// - **L0 (Identity)**: PROFILE memories — always loaded first. These define who the user is.
469///   Budget: up to 20% of total. Cost: ~100-200 tokens typically.
470/// - **L1 (Essential)**: Highest-confidence, most-recently-seen memories across all categories.
471///   These are the "essential story" — durable facts the agent should always know.
472///   Budget: up to 30% of total. Cost: ~300-500 tokens.
473/// - **L2 (Relevance)**: Query-relevant memories via semantic/keyword ranking.
474///   Loaded based on what the user is currently asking about.
475///   Budget: remaining tokens. Cost: variable.
476/// - **Connected**: Graph-augmented facts reached by expanding the temporal
477///   knowledge graph from entities the query mentions (1-2 hops). Surfaces
478///   multi-hop context the flat memory layers miss. Bounded and query-gated.
479/// - **L3 (Deep Search)**: Not injected here — available via `structured_memory_search` tool
480///   for on-demand deep retrieval when the agent needs more context.
481#[expect(clippy::too_many_arguments)]
482pub(crate) async fn build_db_memory_context(
483    memory_backend: &Arc<MemoryBackend>,
484    db: &Arc<Database>,
485    embedding: Option<&Arc<dyn EmbeddingProvider>>,
486    chat_id: i64,
487    query: &str,
488    token_budget: usize,
489    l0_identity_pct: usize,
490    l1_essential_pct: usize,
491    recency_half_life_days: f64,
492    graph_recall_enabled: bool,
493    graph_max_hops: usize,
494    graph_max_triples: usize,
495) -> String {
496    let query = &sanitize_memory_query(query);
497    let memories = match memory_backend.get_memories_for_context(chat_id, 100).await {
498        Ok(m) => m,
499        Err(_) => return String::new(),
500    };
501
502    if memories.is_empty() {
503        return String::new();
504    }
505
506    let budget = token_budget.max(1);
507    // Clamp percentages to sane range; remaining goes to L2 relevance
508    let l0_pct = l0_identity_pct.min(50);
509    let l1_pct = l1_essential_pct.min(50);
510    let mut used_tokens = 0usize;
511    let mut out = String::from("<structured_memories>\n");
512    let mut injected_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
513
514    // ── L0: Identity (PROFILE memories, up to l0_pct% of budget) ──
515    let l0_budget = budget * l0_pct / 100;
516    let mut profile_memories: Vec<&Memory> = memories
517        .iter()
518        .filter(|m| m.category == "PROFILE" && !m.is_archived)
519        .collect();
520    // Sort profiles by confidence desc, then recency
521    profile_memories.sort_by(|a, b| {
522        b.confidence
523            .partial_cmp(&a.confidence)
524            .unwrap_or(std::cmp::Ordering::Equal)
525    });
526
527    if !profile_memories.is_empty() {
528        out.push_str("# Identity\n");
529        for m in &profile_memories {
530            let est = (m.content.len() / 4) + 10;
531            if used_tokens + est > l0_budget && !injected_ids.is_empty() {
532                break;
533            }
534            used_tokens += est;
535            injected_ids.insert(m.id);
536            let scope = if m.chat_id.is_none() {
537                "global"
538            } else {
539                "chat"
540            };
541            out.push_str(&format!("[PROFILE] [{}] {}\n", scope, m.content));
542        }
543    }
544
545    // ── L1: Essential Story (highest-confidence memories, up to l1_pct% of budget) ──
546    let l1_budget = used_tokens + (budget * l1_pct / 100);
547    let now = chrono::Utc::now();
548    let mut essential: Vec<&Memory> = memories
549        .iter()
550        .filter(|m| !m.is_archived && !injected_ids.contains(&m.id))
551        .collect();
552    // Score by confidence × recency-decay (PROFILE memories don't decay; everyone
553    // else loses half their weight per `recency_half_life_days`). The decay
554    // pulls stale tactical facts down so durable knowledge floats.
555    essential.sort_by(|a, b| {
556        let sa = effective_memory_score(a, now, recency_half_life_days);
557        let sb = effective_memory_score(b, now, recency_half_life_days);
558        sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
559    });
560
561    let mut l1_count = 0usize;
562    if !essential.is_empty() {
563        out.push_str("# Essential\n");
564        for m in &essential {
565            let est = (m.content.len() / 4) + 10;
566            if used_tokens + est > l1_budget {
567                break;
568            }
569            used_tokens += est;
570            injected_ids.insert(m.id);
571            l1_count += 1;
572            let scope = if m.chat_id.is_none() {
573                "global"
574            } else {
575                "chat"
576            };
577            out.push_str(&format!("[{}] [{}] {}\n", m.category, scope, m.content));
578        }
579    }
580
581    // ── L2: Relevance-ranked (query-dependent, fills remaining budget) ──
582    // Build relevance-ordered list from memories not yet injected
583    let remaining: Vec<&Memory> = memories
584        .iter()
585        .filter(|m| !injected_ids.contains(&m.id) && !m.is_archived)
586        .collect();
587
588    if !remaining.is_empty() {
589        let mut relevance_ordered: Vec<&Memory> = Vec::new();
590
591        #[cfg(feature = "sqlite-vec")]
592        let mut retrieval_method = if memory_supports_local_semantic_ranking(memory_backend) {
593            "keyword"
594        } else {
595            "provider"
596        };
597        #[cfg(not(feature = "sqlite-vec"))]
598        let retrieval_method = "keyword";
599
600        #[cfg(feature = "sqlite-vec")]
601        {
602            if let Some(provider) = embedding {
603                if memory_supports_local_semantic_ranking(memory_backend)
604                    && !query.trim().is_empty()
605                {
606                    if let Ok(query_vec) = provider.embed(query).await {
607                        let knn_result = call_blocking(db.clone(), move |db| {
608                            db.knn_memories(chat_id, &query_vec, 20)
609                        })
610                        .await;
611                        if let Ok(knn_rows) = knn_result {
612                            let by_id: std::collections::HashMap<i64, &&Memory> =
613                                remaining.iter().map(|m| (m.id, m)).collect();
614                            for (id, _) in knn_rows {
615                                if let Some(mem) = by_id.get(&id) {
616                                    relevance_ordered.push(**mem);
617                                }
618                            }
619                            if !relevance_ordered.is_empty() {
620                                retrieval_method = "knn";
621                            }
622                        }
623                    }
624                }
625            }
626        }
627
628        #[cfg(not(feature = "sqlite-vec"))]
629        {
630            let _ = embedding;
631        }
632
633        if relevance_ordered.is_empty() {
634            let query_tokens = tokenize_for_relevance(query);
635            let mut scored: Vec<(usize, usize, &&Memory)> = remaining
636                .iter()
637                .enumerate()
638                .map(|(idx, m)| {
639                    (
640                        score_relevance_with_cache(&m.content, &query_tokens),
641                        idx,
642                        m,
643                    )
644                })
645                .collect();
646            if !query.is_empty() {
647                scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
648            }
649            relevance_ordered = scored.into_iter().map(|(_, _, m)| *m).collect();
650        }
651
652        let mut l2_count = 0usize;
653        let mut l2_omitted = 0usize;
654        if !relevance_ordered.is_empty() {
655            out.push_str("# Relevant\n");
656        }
657        for (idx, m) in relevance_ordered.iter().enumerate() {
658            let est = (m.content.len() / 4) + 10;
659            if used_tokens + est > budget {
660                l2_omitted = relevance_ordered.len().saturating_sub(idx);
661                break;
662            }
663            used_tokens += est;
664            injected_ids.insert(m.id);
665            l2_count += 1;
666            let scope = if m.chat_id.is_none() {
667                "global"
668            } else {
669                "chat"
670            };
671            out.push_str(&format!("[{}] [{}] {}\n", m.category, scope, m.content));
672        }
673
674        if l2_omitted > 0 {
675            out.push_str(&format!(
676                "(+{l2_omitted} memories available via structured_memory_search tool)\n"
677            ));
678        }
679
680        let _ = retrieval_method;
681        let _ = l1_count;
682        let _ = l2_count;
683    }
684
685    // ── Connected: graph-augmented retrieval over the temporal knowledge graph ──
686    // Seed from entities the query mentions, expand a couple of hops over the KG,
687    // and surface connected facts the flat L0-L2 layers miss (multi-hop context).
688    // Local-only: no embeddings, no LLM, bounded by triple count and token budget.
689    if graph_recall_enabled && graph_max_triples > 0 && !query.trim().is_empty() {
690        let q = query.to_string();
691        let entities = call_blocking(db.clone(), move |d| {
692            d.kg_distinct_entities(Some(chat_id), 500)
693        })
694        .await
695        .unwrap_or_default();
696        let seeds = extract_kg_seeds(&q, &entities, 8);
697        if !seeds.is_empty() {
698            let hops = graph_max_hops.clamp(1, 3);
699            let max_triples = graph_max_triples;
700            let triples = call_blocking(db.clone(), move |d| {
701                d.kg_neighborhood(Some(chat_id), &seeds, hops, max_triples)
702            })
703            .await
704            .unwrap_or_default();
705            if !triples.is_empty() {
706                let section = render_graph_section(&triples, &out);
707                let est = (section.len() / 4) + 12;
708                if !section.trim().is_empty() && used_tokens + est <= budget {
709                    out.push_str("# Connected\n");
710                    out.push_str(&section);
711                    used_tokens += est;
712                }
713            }
714        }
715    }
716
717    out.push_str("</structured_memories>\n");
718
719    let candidate_count = memories.len();
720    let selected_count = injected_ids.len();
721    let omitted = candidate_count.saturating_sub(selected_count);
722    let retrieval_method = "layered";
723    let retrieval_method_owned = retrieval_method.to_string();
724    let _ = call_blocking(db.clone(), move |d| {
725        d.log_memory_injection(
726            chat_id,
727            &retrieval_method_owned,
728            candidate_count,
729            selected_count,
730            omitted,
731            used_tokens,
732        )
733        .map(|_| ())
734    })
735    .await;
736    info!(
737        "Memory injection (4-layer): chat {} -> {}/{} memories (L0:identity + L1:essential + L2:relevant), tokens_est={}, omitted={}",
738        chat_id, selected_count, candidate_count, used_tokens, omitted
739    );
740    out
741}
742
743/// Split a rendered `<structured_memories>` block into the part that is stable
744/// for a chat and the part that is ranked against the current user query.
745///
746/// `Identity` and `Essential` are the same from turn to turn, so they can live
747/// in the cached system-prompt prefix. `Relevant` and `Connected` are selected
748/// by scoring the current query, so they change on every question and must go
749/// into the per-turn context block instead.
750///
751/// Returns `(stable, query_scoped)`; either side may be empty.
752pub(crate) fn split_db_memory_context(rendered: &str) -> (String, String) {
753    const OPEN_TAG: &str = "<structured_memories>\n";
754    const CLOSE_TAG: &str = "</structured_memories>\n";
755    // First query-scoped section marker wins; either can appear without the other.
756    const MARKERS: [&str; 2] = ["# Relevant\n", "# Connected\n"];
757
758    if !rendered.starts_with(OPEN_TAG) {
759        // Unrecognized shape: keep it all on the stable side rather than
760        // dropping context the model was relying on.
761        return (rendered.to_string(), String::new());
762    }
763
764    let body_start = OPEN_TAG.len();
765    let body_end = rendered
766        .strip_suffix(CLOSE_TAG)
767        .map(|body| body.len())
768        .unwrap_or(rendered.len());
769    if body_start >= body_end {
770        return (rendered.to_string(), String::new());
771    }
772    let body = &rendered[body_start..body_end];
773
774    let split_at = MARKERS
775        .iter()
776        .filter_map(|marker| body.find(marker).map(|idx| (idx, marker.len())))
777        .min_by_key(|(idx, _)| *idx);
778
779    match split_at {
780        Some((idx, _)) => (
781            format!("{OPEN_TAG}{}{CLOSE_TAG}", &body[..idx]),
782            body[idx..].to_string(),
783        ),
784        None => (rendered.to_string(), String::new()),
785    }
786}
787
788pub async fn apply_reflector_extractions(
789    state: &Arc<AppState>,
790    chat_id: i64,
791    existing: &[Memory],
792    extracted: &[serde_json::Value],
793) -> ReflectorApplyOutcome {
794    let mut inserted = 0usize;
795    let mut updated = 0usize;
796    let mut skipped = 0usize;
797    #[cfg(feature = "sqlite-vec")]
798    let dedup_method = if state.embedding.is_some() {
799        "semantic"
800    } else {
801        "jaccard"
802    };
803    #[cfg(not(feature = "sqlite-vec"))]
804    let dedup_method = "jaccard";
805
806    let mut seen_contents: Vec<(i64, String)> =
807        existing.iter().map(|m| (m.id, m.content.clone())).collect();
808    let existing_by_id: std::collections::HashMap<i64, &Memory> =
809        existing.iter().map(|m| (m.id, m)).collect();
810    let mut topic_latest: std::collections::HashMap<String, i64> = existing
811        .iter()
812        .filter(|m| !m.is_archived)
813        .map(|m| (memory_quality::memory_topic_key(&m.content), m.id))
814        .collect();
815
816    for item in extracted {
817        let content = match item.get("content").and_then(|v| v.as_str()) {
818            Some(s) => s,
819            None => continue,
820        };
821        let category = item
822            .get("category")
823            .and_then(|v| v.as_str())
824            .unwrap_or("KNOWLEDGE")
825            .to_ascii_uppercase();
826        if !matches!(category.as_str(), "PROFILE" | "KNOWLEDGE" | "EVENT") {
827            continue;
828        }
829        let content = match memory_quality::normalize_memory_content(content, 180) {
830            Some(c) => c,
831            None => continue,
832        };
833        // Strip credentials / PII before the row is persisted, deduped by
834        // topic key, or shipped to the embedding model. Reflector-extracted
835        // memories quote conversation content verbatim; without this gate a
836        // user pasting an API key into chat would land that key in
837        // long-lived memory and any downstream embedding store.
838        let content = microclaw_core::redact::redact(&content);
839        if should_skip_memory_poisoning_risk(&content) {
840            skipped += 1;
841            continue;
842        }
843        if !memory_quality::memory_quality_ok(&content) {
844            continue;
845        }
846
847        let supersedes_id = item.get("supersedes_id").and_then(|v| v.as_i64());
848        if let Some(sid) = supersedes_id {
849            if existing.iter().any(|m| m.id == sid) {
850                let content = content.to_string();
851                let category = category.to_string();
852                let db_content = content.clone();
853                if state
854                    .memory_backend
855                    .update_memory_with_metadata(sid, &db_content, &category, 0.78, "reflector")
856                    .await
857                    .is_ok()
858                {
859                    updated += 1;
860                    #[cfg(feature = "sqlite-vec")]
861                    {
862                        let _ = upsert_memory_embedding(state, sid, &content).await;
863                    }
864                    seen_contents.push((sid, content));
865                }
866                continue;
867            }
868        }
869
870        let topic_key = memory_quality::memory_topic_key(&content);
871        if let Some(prev_id) = topic_latest.get(&topic_key).copied() {
872            if let Some(prev) = existing_by_id.get(&prev_id) {
873                if !prev.content.eq_ignore_ascii_case(&content)
874                    && !jaccard_similar(&prev.content, &content, 0.85)
875                {
876                    let new_content = content.to_string();
877                    let new_category = category.to_string();
878                    if let Ok(new_id) = state
879                        .memory_backend
880                        .supersede_memory(
881                            prev_id,
882                            &new_content,
883                            &new_category,
884                            "reflector_conflict",
885                            0.74,
886                            Some("topic_conflict"),
887                        )
888                        .await
889                    {
890                        updated += 1;
891                        #[cfg(feature = "sqlite-vec")]
892                        {
893                            let _ = upsert_memory_embedding(state, new_id, &content).await;
894                        }
895                        topic_latest.insert(topic_key, new_id);
896                        seen_contents.push((new_id, content));
897                        continue;
898                    }
899                }
900            }
901        }
902
903        let duplicate_id = {
904            #[cfg(feature = "sqlite-vec")]
905            {
906                if let Some(provider) = &state.embedding {
907                    if let Ok(query_vec) = provider.embed(&content).await {
908                        let nearest = call_blocking(state.db.clone(), move |db| {
909                            db.knn_memories(chat_id, &query_vec, 1)
910                        })
911                        .await
912                        .ok()
913                        .and_then(|rows| rows.first().copied());
914                        nearest.and_then(|(id, dist)| if dist < 0.15 { Some(id) } else { None })
915                    } else {
916                        seen_contents
917                            .iter()
918                            .find(|(_, existing)| jaccard_similar(existing, &content, 0.5))
919                            .map(|(id, _)| *id)
920                    }
921                } else {
922                    seen_contents
923                        .iter()
924                        .find(|(_, existing)| jaccard_similar(existing, &content, 0.5))
925                        .map(|(id, _)| *id)
926                }
927            }
928            #[cfg(not(feature = "sqlite-vec"))]
929            {
930                seen_contents
931                    .iter()
932                    .find(|(_, existing)| jaccard_similar(existing, &content, 0.5))
933                    .map(|(id, _)| *id)
934            }
935        };
936        if let Some(dup_id) = duplicate_id {
937            if let Some(existing_mem) = existing_by_id.get(&dup_id) {
938                if should_merge_duplicate(existing_mem, &content, &category) {
939                    let update_content = content.to_string();
940                    let update_category = category.to_string();
941                    if state
942                        .memory_backend
943                        .update_memory_with_metadata(
944                            dup_id,
945                            &update_content,
946                            &update_category,
947                            0.70,
948                            "reflector",
949                        )
950                        .await
951                        .is_ok()
952                    {
953                        updated += 1;
954                    } else {
955                        skipped += 1;
956                    }
957                } else {
958                    let _ = state
959                        .memory_backend
960                        .touch_memory_last_seen(dup_id, Some(0.55))
961                        .await;
962                    skipped += 1;
963                }
964            } else {
965                skipped += 1;
966            }
967            continue;
968        }
969
970        let content = content.to_string();
971        let db_content = content.clone();
972        let category = category.to_string();
973        let inserted_id = state
974            .memory_backend
975            .insert_memory_with_metadata(Some(chat_id), &db_content, &category, "reflector", 0.68)
976            .await
977            .ok();
978        if let Some(memory_id) = inserted_id {
979            inserted += 1;
980            #[cfg(feature = "sqlite-vec")]
981            {
982                let _ = upsert_memory_embedding(state, memory_id, &content).await;
983            }
984            #[cfg(not(feature = "sqlite-vec"))]
985            let _ = memory_id;
986            seen_contents.push((memory_id, content));
987            topic_latest.insert(topic_key, memory_id);
988        }
989    }
990
991    ReflectorApplyOutcome {
992        inserted,
993        updated,
994        skipped,
995        dedup_method,
996    }
997}
998
999#[cfg(feature = "sqlite-vec")]
1000pub(crate) fn memory_supports_local_semantic_ranking(memory_backend: &MemoryBackend) -> bool {
1001    memory_backend.supports_local_semantic_ranking()
1002}
1003
1004#[cfg(test)]
1005mod split_context_tests {
1006    use super::split_db_memory_context;
1007
1008    #[test]
1009    fn splits_relevant_layer_out_of_the_stable_prefix() {
1010        let rendered = "<structured_memories>\n# Identity\n[PROFILE] [global] Ada\n# Essential\n[FACT] [chat] tea\n# Relevant\n[PREFERENCE] [chat] likes earl grey\n</structured_memories>\n";
1011        let (stable, volatile) = split_db_memory_context(rendered);
1012        assert!(stable.contains("# Identity"));
1013        assert!(stable.contains("# Essential"));
1014        assert!(!stable.contains("# Relevant"));
1015        assert!(!stable.contains("earl grey"));
1016        assert!(volatile.contains("# Relevant"));
1017        assert!(volatile.contains("earl grey"));
1018        // Both halves stay well-formed for the model.
1019        assert!(stable.starts_with("<structured_memories>"));
1020        assert!(stable.trim_end().ends_with("</structured_memories>"));
1021    }
1022
1023    #[test]
1024    fn splits_graph_section_when_no_relevant_layer() {
1025        let rendered =
1026            "<structured_memories>\n# Essential\n[FACT] [chat] tea\n# Connected\n- a -> b\n</structured_memories>\n";
1027        let (stable, volatile) = split_db_memory_context(rendered);
1028        assert!(!stable.contains("# Connected"));
1029        assert!(!stable.contains("a -> b"));
1030        assert!(volatile.contains("# Connected"));
1031    }
1032
1033    #[test]
1034    fn keeps_everything_stable_when_no_query_layers_present() {
1035        let rendered =
1036            "<structured_memories>\n# Identity\n[PROFILE] [global] Ada\n</structured_memories>\n";
1037        let (stable, volatile) = split_db_memory_context(rendered);
1038        assert_eq!(stable, rendered);
1039        assert!(volatile.is_empty());
1040    }
1041
1042    #[test]
1043    fn unrecognized_shape_is_preserved_whole() {
1044        let rendered = "not a structured block at all";
1045        let (stable, volatile) = split_db_memory_context(rendered);
1046        assert_eq!(stable, rendered);
1047        assert!(volatile.is_empty());
1048    }
1049}
1050
1051#[cfg(test)]
1052mod recency_tests {
1053    use super::effective_memory_score;
1054    use crate::internal::storage::db::Memory;
1055
1056    fn mk(category: &str, last_seen: &str, confidence: f64) -> Memory {
1057        Memory {
1058            id: 1,
1059            chat_id: Some(1),
1060            content: "x".into(),
1061            category: category.into(),
1062            created_at: last_seen.into(),
1063            updated_at: last_seen.into(),
1064            embedding_model: None,
1065            confidence,
1066            source: "test".into(),
1067            last_seen_at: last_seen.into(),
1068            is_archived: false,
1069            archived_at: None,
1070            expires_at: None,
1071        }
1072    }
1073
1074    #[test]
1075    fn profile_memories_are_immune_to_decay() {
1076        let now = chrono::Utc::now();
1077        let stale = (now - chrono::Duration::days(365)).to_rfc3339();
1078        let m = mk("PROFILE", &stale, 0.9);
1079        assert!((effective_memory_score(&m, now, 30.0) - 0.9).abs() < 1e-9);
1080    }
1081
1082    #[test]
1083    fn knowledge_memories_decay_by_half_per_half_life() {
1084        let now = chrono::Utc::now();
1085        let half_life = 30.0;
1086        let one_half = (now - chrono::Duration::days(30)).to_rfc3339();
1087        let m = mk("KNOWLEDGE", &one_half, 1.0);
1088        let s = effective_memory_score(&m, now, half_life);
1089        assert!((s - 0.5).abs() < 0.01, "expected ~0.5, got {s}");
1090    }
1091
1092    #[test]
1093    fn zero_half_life_disables_decay() {
1094        let now = chrono::Utc::now();
1095        let stale = (now - chrono::Duration::days(365)).to_rfc3339();
1096        let m = mk("EVENT", &stale, 0.7);
1097        assert!((effective_memory_score(&m, now, 0.0) - 0.7).abs() < 1e-9);
1098    }
1099}
1100
1101#[cfg(test)]
1102mod consolidation_tests {
1103    use super::{select_duplicate_memories_to_archive, ConsolidationItem};
1104
1105    fn item(id: i64, content: &str, category: &str) -> ConsolidationItem {
1106        ConsolidationItem {
1107            id,
1108            content: content.into(),
1109            category: category.into(),
1110        }
1111    }
1112
1113    #[test]
1114    fn archives_near_duplicate_keeping_first() {
1115        let items = vec![
1116            item(
1117                1,
1118                "user prefers concise replies and bullet points",
1119                "KNOWLEDGE",
1120            ),
1121            item(
1122                2,
1123                "user prefers concise replies and bullet points please",
1124                "KNOWLEDGE",
1125            ),
1126            item(3, "user lives in Berlin", "KNOWLEDGE"),
1127        ];
1128        let archived = select_duplicate_memories_to_archive(&items, 0.7, 20);
1129        assert_eq!(
1130            archived,
1131            vec![2],
1132            "only the later near-duplicate is archived"
1133        );
1134    }
1135
1136    #[test]
1137    fn never_archives_profile() {
1138        let items = vec![
1139            item(1, "name is Alex", "PROFILE"),
1140            item(2, "name is Alex", "PROFILE"),
1141        ];
1142        let archived = select_duplicate_memories_to_archive(&items, 0.7, 20);
1143        assert!(archived.is_empty(), "PROFILE memories are always kept");
1144    }
1145
1146    #[test]
1147    fn different_categories_are_not_duplicates() {
1148        let items = vec![
1149            item(1, "shipping the release today", "EVENT"),
1150            item(2, "shipping the release today", "KNOWLEDGE"),
1151        ];
1152        let archived = select_duplicate_memories_to_archive(&items, 0.7, 20);
1153        assert!(archived.is_empty());
1154    }
1155
1156    #[test]
1157    fn respects_max_cap() {
1158        let items = vec![
1159            item(1, "alpha beta gamma delta", "KNOWLEDGE"),
1160            item(2, "alpha beta gamma delta", "KNOWLEDGE"),
1161            item(3, "alpha beta gamma delta", "KNOWLEDGE"),
1162        ];
1163        let archived = select_duplicate_memories_to_archive(&items, 0.7, 1);
1164        assert_eq!(archived.len(), 1, "cap limits archives per pass");
1165    }
1166
1167    #[test]
1168    fn distinct_memories_are_kept() {
1169        let items = vec![
1170            item(1, "user is a rust developer", "KNOWLEDGE"),
1171            item(2, "user enjoys hiking on weekends", "KNOWLEDGE"),
1172        ];
1173        let archived = select_duplicate_memories_to_archive(&items, 0.82, 20);
1174        assert!(archived.is_empty());
1175    }
1176}
1177
1178#[cfg(test)]
1179mod graph_recall_tests {
1180    use super::{extract_kg_seeds, render_graph_section};
1181    use crate::internal::storage::db::KgTriple;
1182
1183    fn triple(subject: &str, predicate: &str, object: &str) -> KgTriple {
1184        KgTriple {
1185            id: 1,
1186            subject: subject.into(),
1187            predicate: predicate.into(),
1188            object: object.into(),
1189            chat_id: Some(1),
1190            valid_from: "2026-01-01T00:00:00Z".into(),
1191            valid_to: None,
1192            confidence: 0.9,
1193            source: "test".into(),
1194            source_memory_id: None,
1195            created_at: "2026-01-01T00:00:00Z".into(),
1196        }
1197    }
1198
1199    #[test]
1200    fn seeds_match_multiword_entities_case_insensitively() {
1201        let entities = vec![
1202            "New York".to_string(),
1203            "Acme".to_string(),
1204            "Go".to_string(), // too short → ignored
1205        ];
1206        let seeds = extract_kg_seeds("Does acme still have an office in new york?", &entities, 8);
1207        assert!(seeds.contains(&"New York".to_string()));
1208        assert!(seeds.contains(&"Acme".to_string()));
1209        assert!(!seeds.iter().any(|s| s == "Go"));
1210    }
1211
1212    #[test]
1213    fn empty_query_yields_no_seeds() {
1214        let entities = vec!["Acme".to_string()];
1215        assert!(extract_kg_seeds("   ", &entities, 8).is_empty());
1216    }
1217
1218    #[test]
1219    fn graph_section_skips_already_stated_relationships() {
1220        let triples = vec![
1221            triple("Acme", "located_in", "Berlin"),
1222            triple("Bob", "manages", "Acme"),
1223        ];
1224        // The first relationship is already spelled out in an injected memory; the
1225        // second (Bob→Acme) is new and should survive.
1226        let injected = "[KNOWLEDGE] [chat] Acme is located_in Berlin\n";
1227        let section = render_graph_section(&triples, injected);
1228        assert!(!section.contains("Berlin"));
1229        assert!(section.contains("Bob"));
1230        assert!(section.contains("Acme"));
1231    }
1232}