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(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 const MARKERS: [&str; 2] = ["# Relevant\n", "# Connected\n"];
757
758 if !rendered.starts_with(OPEN_TAG) {
759 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 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 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(), ];
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 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}