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
743pub async fn apply_reflector_extractions(
744    state: &Arc<AppState>,
745    chat_id: i64,
746    existing: &[Memory],
747    extracted: &[serde_json::Value],
748) -> ReflectorApplyOutcome {
749    let mut inserted = 0usize;
750    let mut updated = 0usize;
751    let mut skipped = 0usize;
752    #[cfg(feature = "sqlite-vec")]
753    let dedup_method = if state.embedding.is_some() {
754        "semantic"
755    } else {
756        "jaccard"
757    };
758    #[cfg(not(feature = "sqlite-vec"))]
759    let dedup_method = "jaccard";
760
761    let mut seen_contents: Vec<(i64, String)> =
762        existing.iter().map(|m| (m.id, m.content.clone())).collect();
763    let existing_by_id: std::collections::HashMap<i64, &Memory> =
764        existing.iter().map(|m| (m.id, m)).collect();
765    let mut topic_latest: std::collections::HashMap<String, i64> = existing
766        .iter()
767        .filter(|m| !m.is_archived)
768        .map(|m| (memory_quality::memory_topic_key(&m.content), m.id))
769        .collect();
770
771    for item in extracted {
772        let content = match item.get("content").and_then(|v| v.as_str()) {
773            Some(s) => s,
774            None => continue,
775        };
776        let category = item
777            .get("category")
778            .and_then(|v| v.as_str())
779            .unwrap_or("KNOWLEDGE")
780            .to_ascii_uppercase();
781        if !matches!(category.as_str(), "PROFILE" | "KNOWLEDGE" | "EVENT") {
782            continue;
783        }
784        let content = match memory_quality::normalize_memory_content(content, 180) {
785            Some(c) => c,
786            None => continue,
787        };
788        // Strip credentials / PII before the row is persisted, deduped by
789        // topic key, or shipped to the embedding model. Reflector-extracted
790        // memories quote conversation content verbatim; without this gate a
791        // user pasting an API key into chat would land that key in
792        // long-lived memory and any downstream embedding store.
793        let content = microclaw_core::redact::redact(&content);
794        if should_skip_memory_poisoning_risk(&content) {
795            skipped += 1;
796            continue;
797        }
798        if !memory_quality::memory_quality_ok(&content) {
799            continue;
800        }
801
802        let supersedes_id = item.get("supersedes_id").and_then(|v| v.as_i64());
803        if let Some(sid) = supersedes_id {
804            if existing.iter().any(|m| m.id == sid) {
805                let content = content.to_string();
806                let category = category.to_string();
807                let db_content = content.clone();
808                if state
809                    .memory_backend
810                    .update_memory_with_metadata(sid, &db_content, &category, 0.78, "reflector")
811                    .await
812                    .is_ok()
813                {
814                    updated += 1;
815                    #[cfg(feature = "sqlite-vec")]
816                    {
817                        let _ = upsert_memory_embedding(state, sid, &content).await;
818                    }
819                    seen_contents.push((sid, content));
820                }
821                continue;
822            }
823        }
824
825        let topic_key = memory_quality::memory_topic_key(&content);
826        if let Some(prev_id) = topic_latest.get(&topic_key).copied() {
827            if let Some(prev) = existing_by_id.get(&prev_id) {
828                if !prev.content.eq_ignore_ascii_case(&content)
829                    && !jaccard_similar(&prev.content, &content, 0.85)
830                {
831                    let new_content = content.to_string();
832                    let new_category = category.to_string();
833                    if let Ok(new_id) = state
834                        .memory_backend
835                        .supersede_memory(
836                            prev_id,
837                            &new_content,
838                            &new_category,
839                            "reflector_conflict",
840                            0.74,
841                            Some("topic_conflict"),
842                        )
843                        .await
844                    {
845                        updated += 1;
846                        #[cfg(feature = "sqlite-vec")]
847                        {
848                            let _ = upsert_memory_embedding(state, new_id, &content).await;
849                        }
850                        topic_latest.insert(topic_key, new_id);
851                        seen_contents.push((new_id, content));
852                        continue;
853                    }
854                }
855            }
856        }
857
858        let duplicate_id = {
859            #[cfg(feature = "sqlite-vec")]
860            {
861                if let Some(provider) = &state.embedding {
862                    if let Ok(query_vec) = provider.embed(&content).await {
863                        let nearest = call_blocking(state.db.clone(), move |db| {
864                            db.knn_memories(chat_id, &query_vec, 1)
865                        })
866                        .await
867                        .ok()
868                        .and_then(|rows| rows.first().copied());
869                        nearest.and_then(|(id, dist)| if dist < 0.15 { Some(id) } else { None })
870                    } else {
871                        seen_contents
872                            .iter()
873                            .find(|(_, existing)| jaccard_similar(existing, &content, 0.5))
874                            .map(|(id, _)| *id)
875                    }
876                } else {
877                    seen_contents
878                        .iter()
879                        .find(|(_, existing)| jaccard_similar(existing, &content, 0.5))
880                        .map(|(id, _)| *id)
881                }
882            }
883            #[cfg(not(feature = "sqlite-vec"))]
884            {
885                seen_contents
886                    .iter()
887                    .find(|(_, existing)| jaccard_similar(existing, &content, 0.5))
888                    .map(|(id, _)| *id)
889            }
890        };
891        if let Some(dup_id) = duplicate_id {
892            if let Some(existing_mem) = existing_by_id.get(&dup_id) {
893                if should_merge_duplicate(existing_mem, &content, &category) {
894                    let update_content = content.to_string();
895                    let update_category = category.to_string();
896                    if state
897                        .memory_backend
898                        .update_memory_with_metadata(
899                            dup_id,
900                            &update_content,
901                            &update_category,
902                            0.70,
903                            "reflector",
904                        )
905                        .await
906                        .is_ok()
907                    {
908                        updated += 1;
909                    } else {
910                        skipped += 1;
911                    }
912                } else {
913                    let _ = state
914                        .memory_backend
915                        .touch_memory_last_seen(dup_id, Some(0.55))
916                        .await;
917                    skipped += 1;
918                }
919            } else {
920                skipped += 1;
921            }
922            continue;
923        }
924
925        let content = content.to_string();
926        let db_content = content.clone();
927        let category = category.to_string();
928        let inserted_id = state
929            .memory_backend
930            .insert_memory_with_metadata(Some(chat_id), &db_content, &category, "reflector", 0.68)
931            .await
932            .ok();
933        if let Some(memory_id) = inserted_id {
934            inserted += 1;
935            #[cfg(feature = "sqlite-vec")]
936            {
937                let _ = upsert_memory_embedding(state, memory_id, &content).await;
938            }
939            #[cfg(not(feature = "sqlite-vec"))]
940            let _ = memory_id;
941            seen_contents.push((memory_id, content));
942            topic_latest.insert(topic_key, memory_id);
943        }
944    }
945
946    ReflectorApplyOutcome {
947        inserted,
948        updated,
949        skipped,
950        dedup_method,
951    }
952}
953
954#[cfg(feature = "sqlite-vec")]
955pub(crate) fn memory_supports_local_semantic_ranking(memory_backend: &MemoryBackend) -> bool {
956    memory_backend.supports_local_semantic_ranking()
957}
958
959#[cfg(test)]
960mod recency_tests {
961    use super::effective_memory_score;
962    use crate::internal::storage::db::Memory;
963
964    fn mk(category: &str, last_seen: &str, confidence: f64) -> Memory {
965        Memory {
966            id: 1,
967            chat_id: Some(1),
968            content: "x".into(),
969            category: category.into(),
970            created_at: last_seen.into(),
971            updated_at: last_seen.into(),
972            embedding_model: None,
973            confidence,
974            source: "test".into(),
975            last_seen_at: last_seen.into(),
976            is_archived: false,
977            archived_at: None,
978            expires_at: None,
979        }
980    }
981
982    #[test]
983    fn profile_memories_are_immune_to_decay() {
984        let now = chrono::Utc::now();
985        let stale = (now - chrono::Duration::days(365)).to_rfc3339();
986        let m = mk("PROFILE", &stale, 0.9);
987        assert!((effective_memory_score(&m, now, 30.0) - 0.9).abs() < 1e-9);
988    }
989
990    #[test]
991    fn knowledge_memories_decay_by_half_per_half_life() {
992        let now = chrono::Utc::now();
993        let half_life = 30.0;
994        let one_half = (now - chrono::Duration::days(30)).to_rfc3339();
995        let m = mk("KNOWLEDGE", &one_half, 1.0);
996        let s = effective_memory_score(&m, now, half_life);
997        assert!((s - 0.5).abs() < 0.01, "expected ~0.5, got {s}");
998    }
999
1000    #[test]
1001    fn zero_half_life_disables_decay() {
1002        let now = chrono::Utc::now();
1003        let stale = (now - chrono::Duration::days(365)).to_rfc3339();
1004        let m = mk("EVENT", &stale, 0.7);
1005        assert!((effective_memory_score(&m, now, 0.0) - 0.7).abs() < 1e-9);
1006    }
1007}
1008
1009#[cfg(test)]
1010mod consolidation_tests {
1011    use super::{select_duplicate_memories_to_archive, ConsolidationItem};
1012
1013    fn item(id: i64, content: &str, category: &str) -> ConsolidationItem {
1014        ConsolidationItem {
1015            id,
1016            content: content.into(),
1017            category: category.into(),
1018        }
1019    }
1020
1021    #[test]
1022    fn archives_near_duplicate_keeping_first() {
1023        let items = vec![
1024            item(
1025                1,
1026                "user prefers concise replies and bullet points",
1027                "KNOWLEDGE",
1028            ),
1029            item(
1030                2,
1031                "user prefers concise replies and bullet points please",
1032                "KNOWLEDGE",
1033            ),
1034            item(3, "user lives in Berlin", "KNOWLEDGE"),
1035        ];
1036        let archived = select_duplicate_memories_to_archive(&items, 0.7, 20);
1037        assert_eq!(
1038            archived,
1039            vec![2],
1040            "only the later near-duplicate is archived"
1041        );
1042    }
1043
1044    #[test]
1045    fn never_archives_profile() {
1046        let items = vec![
1047            item(1, "name is Alex", "PROFILE"),
1048            item(2, "name is Alex", "PROFILE"),
1049        ];
1050        let archived = select_duplicate_memories_to_archive(&items, 0.7, 20);
1051        assert!(archived.is_empty(), "PROFILE memories are always kept");
1052    }
1053
1054    #[test]
1055    fn different_categories_are_not_duplicates() {
1056        let items = vec![
1057            item(1, "shipping the release today", "EVENT"),
1058            item(2, "shipping the release today", "KNOWLEDGE"),
1059        ];
1060        let archived = select_duplicate_memories_to_archive(&items, 0.7, 20);
1061        assert!(archived.is_empty());
1062    }
1063
1064    #[test]
1065    fn respects_max_cap() {
1066        let items = vec![
1067            item(1, "alpha beta gamma delta", "KNOWLEDGE"),
1068            item(2, "alpha beta gamma delta", "KNOWLEDGE"),
1069            item(3, "alpha beta gamma delta", "KNOWLEDGE"),
1070        ];
1071        let archived = select_duplicate_memories_to_archive(&items, 0.7, 1);
1072        assert_eq!(archived.len(), 1, "cap limits archives per pass");
1073    }
1074
1075    #[test]
1076    fn distinct_memories_are_kept() {
1077        let items = vec![
1078            item(1, "user is a rust developer", "KNOWLEDGE"),
1079            item(2, "user enjoys hiking on weekends", "KNOWLEDGE"),
1080        ];
1081        let archived = select_duplicate_memories_to_archive(&items, 0.82, 20);
1082        assert!(archived.is_empty());
1083    }
1084}
1085
1086#[cfg(test)]
1087mod graph_recall_tests {
1088    use super::{extract_kg_seeds, render_graph_section};
1089    use crate::internal::storage::db::KgTriple;
1090
1091    fn triple(subject: &str, predicate: &str, object: &str) -> KgTriple {
1092        KgTriple {
1093            id: 1,
1094            subject: subject.into(),
1095            predicate: predicate.into(),
1096            object: object.into(),
1097            chat_id: Some(1),
1098            valid_from: "2026-01-01T00:00:00Z".into(),
1099            valid_to: None,
1100            confidence: 0.9,
1101            source: "test".into(),
1102            source_memory_id: None,
1103            created_at: "2026-01-01T00:00:00Z".into(),
1104        }
1105    }
1106
1107    #[test]
1108    fn seeds_match_multiword_entities_case_insensitively() {
1109        let entities = vec![
1110            "New York".to_string(),
1111            "Acme".to_string(),
1112            "Go".to_string(), // too short → ignored
1113        ];
1114        let seeds = extract_kg_seeds("Does acme still have an office in new york?", &entities, 8);
1115        assert!(seeds.contains(&"New York".to_string()));
1116        assert!(seeds.contains(&"Acme".to_string()));
1117        assert!(!seeds.iter().any(|s| s == "Go"));
1118    }
1119
1120    #[test]
1121    fn empty_query_yields_no_seeds() {
1122        let entities = vec!["Acme".to_string()];
1123        assert!(extract_kg_seeds("   ", &entities, 8).is_empty());
1124    }
1125
1126    #[test]
1127    fn graph_section_skips_already_stated_relationships() {
1128        let triples = vec![
1129            triple("Acme", "located_in", "Berlin"),
1130            triple("Bob", "manages", "Acme"),
1131        ];
1132        // The first relationship is already spelled out in an injected memory; the
1133        // second (Bob→Acme) is new and should survive.
1134        let injected = "[KNOWLEDGE] [chat] Acme is located_in Berlin\n";
1135        let section = render_graph_section(&triples, injected);
1136        assert!(!section.contains("Berlin"));
1137        assert!(section.contains("Bob"));
1138        assert!(section.contains("Acme"));
1139    }
1140}