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#[derive(Debug, Clone)]
104pub struct ConsolidationItem {
105 pub id: i64,
106 pub content: String,
107 pub category: String,
108}
109
110pub 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
337fn 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
364fn sanitize_memory_query(raw: &str) -> String {
368 let trimmed = raw.trim();
369 if trimmed.is_empty() || trimmed.len() < 200 {
370 return trimmed.to_string();
372 }
373
374 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 if trimmed.len() > 500 {
393 return trimmed.chars().take(500).collect();
394 }
395 return trimmed.to_string();
396 }
397
398 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 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
420fn 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
447fn 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 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#[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 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 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 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 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 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 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 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(§ion);
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 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(), ];
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 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}