1use roboticus_core::config::MemoryConfig;
2use tracing::{debug, warn};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct MemoryBudgets {
6 pub working: usize,
7 pub episodic: usize,
8 pub semantic: usize,
9 pub procedural: usize,
10 pub relationship: usize,
11}
12
13pub struct MemoryBudgetManager {
14 config: MemoryConfig,
15}
16
17impl MemoryBudgetManager {
18 pub fn new(config: MemoryConfig) -> Self {
19 Self { config }
20 }
21
22 pub fn allocate_budgets(&self, total_tokens: usize) -> MemoryBudgets {
25 let working = pct(total_tokens, self.config.working_budget_pct);
26 let episodic = pct(total_tokens, self.config.episodic_budget_pct);
27 let semantic = pct(total_tokens, self.config.semantic_budget_pct);
28 let procedural = pct(total_tokens, self.config.procedural_budget_pct);
29 let relationship = pct(total_tokens, self.config.relationship_budget_pct);
30
31 let allocated = working + episodic + semantic + procedural + relationship;
32 let rollover = total_tokens.saturating_sub(allocated);
33
34 MemoryBudgets {
35 working: working + rollover,
36 episodic,
37 semantic,
38 procedural,
39 relationship,
40 }
41 }
42}
43
44fn pct(total: usize, percent: f64) -> usize {
45 ((total as f64) * percent / 100.0).floor() as usize
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum TurnType {
53 Reasoning,
54 ToolUse,
55 Creative,
56 Financial,
57 Social,
58}
59
60pub fn classify_turn(
62 user_msg: &str,
63 assistant_msg: &str,
64 tool_results: &[(String, String)],
65) -> TurnType {
66 if !tool_results.is_empty() {
67 return TurnType::ToolUse;
68 }
69 let user_lower = user_msg.to_lowercase();
72 let financial_keywords = [
73 "transfer",
74 "balance",
75 "wallet",
76 "payment",
77 "usdc",
78 "send funds",
79 ];
80 let financial_hits = financial_keywords
81 .iter()
82 .filter(|kw| user_lower.contains(*kw))
83 .count();
84 if financial_hits >= 2 {
85 return TurnType::Financial;
86 }
87 let combined = format!("{user_msg} {assistant_msg}").to_lowercase();
88 if combined.contains("hello")
89 || combined.contains("thanks")
90 || combined.contains("please")
91 || combined.contains("how are you")
92 {
93 return TurnType::Social;
94 }
95 if combined.contains("write a")
96 || combined.contains("create a")
97 || combined.contains("design a")
98 || combined.contains("compose a")
99 || combined.contains("draw")
100 || combined.contains("generate a")
101 {
102 return TurnType::Creative;
103 }
104 TurnType::Reasoning
105}
106
107fn is_derivable_tool(name: &str) -> bool {
118 matches!(
119 name,
120 "list_directory"
121 | "list-subagent-roster"
122 | "get_subagent_status"
123 | "get_runtime_context"
124 | "get_memory_stats"
125 | "get_channel_health"
126 | "list-open-tasks"
127 | "list-available-skills"
128 | "task-status"
129 | "get_wallet_balance"
130 | "read_file"
131 ) || name.starts_with("orchestrate-subagents")
132}
133
134pub fn ingest_turn(
139 db: &roboticus_db::Database,
140 session_id: &str,
141 user_msg: &str,
142 assistant_msg: &str,
143 tool_results: &[(String, String)],
144) {
145 let turn_type = classify_turn(user_msg, assistant_msg, tool_results);
146
147 let summary = if assistant_msg.len() > 200 {
149 &assistant_msg[..assistant_msg.floor_char_boundary(200)]
150 } else {
151 assistant_msg
152 };
153 if let Err(e) = roboticus_db::memory::store_working(db, session_id, "turn_summary", summary, 3)
154 {
155 warn!(error = %e, "failed to store working memory");
156 }
157
158 ingest_relationship_memory(db, session_id, user_msg, summary, turn_type);
159
160 match turn_type {
166 TurnType::ToolUse => {
167 for (tool_name, result) in tool_results {
168 if is_derivable_tool(tool_name) {
170 debug!(
171 tool = tool_name,
172 "skipping derivable tool output from memory"
173 );
174 continue;
175 }
176 let event = if result.len() > 200 {
178 format!(
179 "Executed '{tool_name}' (result: {}...)",
180 &result[..result.floor_char_boundary(150)]
181 )
182 } else {
183 format!("Executed '{tool_name}': {result}")
184 };
185 if roboticus_db::memory::episodic_content_exists(db, &event) {
187 debug!(tool = tool_name, "skipping duplicate episodic entry");
188 continue;
189 }
190 if let Err(e) = roboticus_db::memory::store_episodic(db, "tool_use", &event, 7) {
191 warn!(error = %e, "failed to store episodic tool_use memory");
192 }
193 }
194 }
195 TurnType::Financial => {
196 let event = format!("Financial interaction: {summary}");
197 if let Err(e) = roboticus_db::memory::store_episodic(db, "financial", &event, 8) {
198 warn!(error = %e, "failed to store episodic financial memory");
199 }
200 }
201 _ => {}
202 }
203
204 if assistant_msg.len() > 100
206 && (turn_type == TurnType::Reasoning || turn_type == TurnType::Creative)
207 {
208 let key_prefix = format!("session:{session_id}:");
209 let key = format!("{key_prefix}{}", uuid::Uuid::new_v4());
210 match roboticus_db::memory::store_semantic(db, "learned", &key, summary, 0.6) {
211 Ok(semantic_id) => {
212 if let Err(e) = roboticus_db::memory::mark_semantic_stale_by_category_and_key_prefix(
213 db,
214 "learned",
215 &key_prefix,
216 &semantic_id,
217 "superseded_by_newer_session_summary",
218 ) {
219 warn!(error = %e, session_id, "failed to mark older semantic memories stale");
220 }
221 }
222 Err(e) => warn!(error = %e, "failed to store semantic memory"),
223 }
224 }
225
226 if turn_type == TurnType::ToolUse {
228 for (tool_name, result) in tool_results {
229 if is_tool_failure(result) {
230 if let Err(e) = roboticus_db::memory::record_procedural_failure(db, tool_name) {
231 warn!(error = %e, tool = %tool_name, "failed to record procedural failure");
232 }
233 } else if let Err(e) = roboticus_db::memory::record_procedural_success(db, tool_name) {
234 warn!(error = %e, tool = %tool_name, "failed to record procedural success");
235 }
236 }
237 }
238}
239
240fn is_tool_failure(result: &str) -> bool {
246 let lower = result.to_lowercase();
247 let trimmed = lower.trim_start();
248
249 if trimmed.starts_with("error:")
251 || trimmed.starts_with("error -")
252 || trimmed.starts_with("failed:")
253 || trimmed.starts_with("failure:")
254 || trimmed.starts_with("fatal:")
255 || trimmed.starts_with("panic:")
256 {
257 return true;
258 }
259
260 if trimmed.starts_with("{\"error\"") || trimmed.starts_with("{\"err\"") {
262 return true;
263 }
264
265 if trimmed.contains("exit code") || trimmed.contains("exit status") {
269 let is_zero_exit = |s: &str, prefix: &str| -> bool {
272 if let Some(idx) = s.find(prefix) {
273 let after = &s[idx + prefix.len()..];
274 after.is_empty() || !after.starts_with(|c: char| c.is_ascii_digit())
276 } else {
277 false
278 }
279 };
280 if is_zero_exit(trimmed, "exit code 0") || is_zero_exit(trimmed, "exit status 0") {
281 return false;
282 }
283 return true;
284 }
285
286 false
287}
288
289fn ingest_relationship_memory(
290 db: &roboticus_db::Database,
291 session_id: &str,
292 user_msg: &str,
293 assistant_summary: &str,
294 turn_type: TurnType,
295) {
296 let Some(session) = roboticus_db::sessions::get_session(db, session_id)
297 .inspect_err(
298 |e| warn!(error = %e, session_id, "failed to load session for relationship ingest"),
299 )
300 .ok()
301 .flatten()
302 else {
303 return;
304 };
305
306 let Some((channel, peer_id)) = session.scope_key.as_deref().and_then(parse_peer_scope_key)
307 else {
308 return;
309 };
310
311 let entity_id = format!("peer:{channel}:{peer_id}");
312 let entity_name = peer_id;
313 let trust_score = match turn_type {
314 TurnType::Social => 0.8,
315 TurnType::Financial => 0.75,
316 TurnType::ToolUse | TurnType::Reasoning | TurnType::Creative => 0.65,
317 };
318 let interaction_summary = summarize_relationship_interaction(user_msg, assistant_summary);
319 if let Err(e) = roboticus_db::memory::store_relationship_interaction(
320 db,
321 &entity_id,
322 entity_name,
323 trust_score,
324 interaction_summary.as_deref(),
325 ) {
326 warn!(error = %e, entity_id, "failed to store relationship memory");
327 }
328}
329
330fn parse_peer_scope_key(scope_key: &str) -> Option<(&str, &str)> {
331 let rest = scope_key.strip_prefix("peer:")?;
332 let (channel, peer_id) = rest.split_once(':')?;
333 if channel.is_empty() || peer_id.is_empty() {
334 return None;
335 }
336 Some((channel, peer_id))
337}
338
339fn summarize_relationship_interaction(user_msg: &str, assistant_summary: &str) -> Option<String> {
340 let user_summary = user_msg.trim();
341 let assistant_summary = assistant_summary.trim();
342 if user_summary.is_empty() && assistant_summary.is_empty() {
343 return None;
344 }
345
346 let user_summary = if user_summary.len() > 120 {
347 &user_summary[..user_summary.floor_char_boundary(120)]
348 } else {
349 user_summary
350 };
351 let assistant_summary = if assistant_summary.len() > 120 {
352 &assistant_summary[..assistant_summary.floor_char_boundary(120)]
353 } else {
354 assistant_summary
355 };
356
357 Some(format!(
358 "User: {user_summary}; Assistant: {assistant_summary}"
359 ))
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn default_config() -> MemoryConfig {
367 MemoryConfig {
368 working_budget_pct: 30.0,
369 episodic_budget_pct: 25.0,
370 semantic_budget_pct: 20.0,
371 procedural_budget_pct: 15.0,
372 relationship_budget_pct: 10.0,
373 embedding_provider: None,
374 embedding_model: None,
375 hybrid_weight: 0.5,
376 ann_index: false,
377 similarity_threshold: 0.0,
378 decay_half_life_days: 7.0,
379 ann_activation_threshold: 1000,
380 }
381 }
382
383 #[test]
384 fn budget_allocation_matches_percentages() {
385 let mgr = MemoryBudgetManager::new(default_config());
386 let budgets = mgr.allocate_budgets(10_000);
387
388 assert_eq!(budgets.working, 3_000);
389 assert_eq!(budgets.episodic, 2_500);
390 assert_eq!(budgets.semantic, 2_000);
391 assert_eq!(budgets.procedural, 1_500);
392 assert_eq!(budgets.relationship, 1_000);
393
394 let sum = budgets.working
395 + budgets.episodic
396 + budgets.semantic
397 + budgets.procedural
398 + budgets.relationship;
399 assert_eq!(sum, 10_000);
400 }
401
402 #[test]
403 fn rollover_goes_to_working() {
404 let mgr = MemoryBudgetManager::new(default_config());
405 let budgets = mgr.allocate_budgets(99);
406
407 let sum = budgets.working
408 + budgets.episodic
409 + budgets.semantic
410 + budgets.procedural
411 + budgets.relationship;
412 assert_eq!(sum, 99, "all tokens must be distributed");
413 assert!(budgets.working >= pct(99, 30.0));
414 }
415
416 #[test]
417 fn zero_total_tokens() {
418 let mgr = MemoryBudgetManager::new(default_config());
419 let budgets = mgr.allocate_budgets(0);
420
421 assert_eq!(
422 budgets,
423 MemoryBudgets {
424 working: 0,
425 episodic: 0,
426 semantic: 0,
427 procedural: 0,
428 relationship: 0,
429 }
430 );
431 }
432
433 #[test]
434 fn classify_turn_tool_use() {
435 let results = vec![("echo".into(), "hello".into())];
436 assert_eq!(
437 classify_turn("test", "response", &results),
438 TurnType::ToolUse
439 );
440 }
441
442 #[test]
443 fn classify_turn_financial() {
444 assert_eq!(
445 classify_turn("check my wallet balance", "Your balance is 42 USDC", &[]),
446 TurnType::Financial
447 );
448 }
449
450 #[test]
451 fn classify_turn_social() {
452 assert_eq!(
453 classify_turn("hello how are you", "I'm great!", &[]),
454 TurnType::Social
455 );
456 }
457
458 #[test]
459 fn classify_turn_creative() {
460 assert_eq!(
461 classify_turn("write a poem about rust", "Here's a poem...", &[]),
462 TurnType::Creative
463 );
464 }
465
466 #[test]
467 fn classify_turn_reasoning() {
468 assert_eq!(
469 classify_turn("explain monads", "A monad is a design pattern...", &[]),
470 TurnType::Reasoning
471 );
472 }
473
474 #[test]
475 fn ingest_turn_stores_memories() {
476 let db = roboticus_db::Database::new(":memory:").unwrap();
477 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
478 ingest_turn(
479 &db,
480 &session_id,
481 "What is Rust?",
482 "Rust is a systems programming language focused on safety and performance.",
483 &[],
484 );
485 let working = roboticus_db::memory::retrieve_working(&db, &session_id).unwrap();
486 assert!(
487 !working.is_empty(),
488 "should store turn summary in working memory"
489 );
490 }
491
492 #[test]
493 fn ingest_turn_with_tools_stores_episodic() {
494 let db = roboticus_db::Database::new(":memory:").unwrap();
495 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
496 roboticus_db::memory::store_procedural(&db, "echo", "echo tool").ok();
497 ingest_turn(
498 &db,
499 &session_id,
500 "echo hello",
501 "Tool says: hello",
502 &[("echo".into(), "hello".into())],
503 );
504 let episodic = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
505 assert!(
506 !episodic.is_empty(),
507 "should store tool use in episodic memory"
508 );
509 }
510
511 #[test]
512 fn ingest_turn_financial_stores_episodic() {
513 let db = roboticus_db::Database::new(":memory:").unwrap();
514 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
515 ingest_turn(
516 &db,
517 &session_id,
518 "check my wallet balance",
519 "Your balance is 42 USDC",
520 &[],
521 );
522 let episodic = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
523 assert!(
524 !episodic.is_empty(),
525 "financial turn should store episodic memory"
526 );
527 assert!(
528 episodic
529 .iter()
530 .any(|e| e.content.contains("Financial interaction")),
531 "should prefix with 'Financial interaction'"
532 );
533 }
534
535 #[test]
536 fn ingest_turn_long_reasoning_stores_semantic() {
537 let db = roboticus_db::Database::new(":memory:").unwrap();
538 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
539 let long_response = "A ".repeat(60); ingest_turn(&db, &session_id, "explain monads", &long_response, &[]);
542 let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
543 assert!(
544 !semantic.is_empty(),
545 "long reasoning turn should store semantic memory"
546 );
547 assert!(
548 semantic[0]
549 .key
550 .starts_with(&format!("session:{session_id}:"))
551 );
552 assert_eq!(semantic[0].memory_state, "active");
553 }
554
555 #[test]
556 fn ingest_turn_long_creative_stores_semantic() {
557 let db = roboticus_db::Database::new(":memory:").unwrap();
558 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
559 let long_response = "B ".repeat(60); ingest_turn(
561 &db,
562 &session_id,
563 "write a poem about Rust",
564 &long_response,
565 &[],
566 );
567 let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
568 assert!(
569 !semantic.is_empty(),
570 "long creative turn should store semantic memory"
571 );
572 }
573
574 #[test]
575 fn ingest_turn_short_reasoning_skips_semantic() {
576 let db = roboticus_db::Database::new(":memory:").unwrap();
577 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
578 ingest_turn(&db, &session_id, "explain monads", "short answer", &[]);
580 let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
581 assert!(
582 semantic.is_empty(),
583 "short reasoning turn should not store semantic memory"
584 );
585 }
586
587 #[test]
588 fn ingest_turn_truncates_long_summary() {
589 let db = roboticus_db::Database::new(":memory:").unwrap();
590 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
591 let long_response = "X".repeat(300);
593 ingest_turn(&db, &session_id, "explain something", &long_response, &[]);
594 let working = roboticus_db::memory::retrieve_working(&db, &session_id).unwrap();
595 assert!(!working.is_empty());
596 for entry in &working {
598 assert!(
599 entry.content.len() <= 200,
600 "working memory summary should be truncated to 200 chars, got {}",
601 entry.content.len()
602 );
603 }
604 }
605
606 #[test]
607 fn ingest_turn_records_procedural_success() {
608 let db = roboticus_db::Database::new(":memory:").unwrap();
609 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
610 roboticus_db::memory::store_procedural(&db, "custom_tool", "a tool").ok();
611 ingest_turn(
612 &db,
613 &session_id,
614 "use custom_tool",
615 "done",
616 &[("custom_tool".into(), "success".into())],
617 );
618 }
621
622 #[test]
623 fn truncation_emoji_at_boundary() {
624 let msg = format!("{}{}", "A".repeat(198), "🦀");
626 assert!(msg.len() == 202);
627 let summary = if msg.len() > 200 {
628 &msg[..msg.floor_char_boundary(200)]
629 } else {
630 &msg
631 };
632 assert!(summary.len() <= 200);
633 assert!(summary.is_char_boundary(summary.len()));
634 }
635
636 #[test]
637 fn truncation_cjk_near_boundary() {
638 let msg = format!("{}{}", "B".repeat(199), "中");
640 assert!(msg.len() == 202);
641 let summary = if msg.len() > 200 {
642 &msg[..msg.floor_char_boundary(200)]
643 } else {
644 &msg
645 };
646 assert!(summary.len() <= 200);
647 assert!(summary.is_char_boundary(summary.len()));
648 }
649
650 #[test]
651 fn truncation_ascii_over_200() {
652 let msg = "C".repeat(300);
653 let summary = if msg.len() > 200 {
654 &msg[..msg.floor_char_boundary(200)]
655 } else {
656 &msg
657 };
658 assert_eq!(summary.len(), 200);
659 }
660
661 #[test]
662 fn classify_turn_financial_payment() {
663 assert_eq!(
665 classify_turn(
666 "make a payment of $50 from wallet",
667 "Processing payment",
668 &[]
669 ),
670 TurnType::Financial
671 );
672 }
673
674 #[test]
675 fn classify_turn_financial_transfer() {
676 assert_eq!(
677 classify_turn("transfer 10 USDC", "Transferring...", &[]),
678 TurnType::Financial
679 );
680 }
681
682 #[test]
683 fn classify_turn_creative_compose() {
684 assert_eq!(
685 classify_turn("compose a sonnet", "Here is your sonnet...", &[]),
686 TurnType::Creative
687 );
688 }
689
690 #[test]
691 fn classify_turn_creative_design() {
692 assert_eq!(
693 classify_turn("design a logo concept", "Here's the concept...", &[]),
694 TurnType::Creative
695 );
696 }
697
698 #[test]
699 fn classify_turn_creative_generate() {
700 assert_eq!(
701 classify_turn("generate a story", "Once upon a time...", &[]),
702 TurnType::Creative
703 );
704 }
705
706 #[test]
707 fn classify_turn_social_thanks() {
708 assert_eq!(
709 classify_turn("thanks for your help", "You're welcome!", &[]),
710 TurnType::Social
711 );
712 }
713
714 #[test]
715 fn classify_turn_tool_use_takes_precedence() {
716 assert_eq!(
718 classify_turn(
719 "check my wallet balance",
720 "Done",
721 &[("wallet".into(), "42".into())]
722 ),
723 TurnType::ToolUse
724 );
725 }
726
727 #[test]
730 fn tool_failure_error_prefix() {
731 assert!(is_tool_failure("Error: file not found"));
732 assert!(is_tool_failure("error: connection refused"));
733 assert!(is_tool_failure(" Error: indented"));
734 }
735
736 #[test]
737 fn tool_failure_failed_prefix() {
738 assert!(is_tool_failure("Failed: command returned non-zero"));
739 assert!(is_tool_failure("failure: assertion failed"));
740 assert!(is_tool_failure("fatal: not a git repository"));
741 assert!(is_tool_failure("panic: index out of bounds"));
742 }
743
744 #[test]
745 fn tool_failure_json_error() {
746 assert!(is_tool_failure(r#"{"error": "not found"}"#));
747 assert!(is_tool_failure(r#"{"err": "timeout"}"#));
748 }
749
750 #[test]
751 fn tool_failure_exit_code() {
752 assert!(is_tool_failure("process exited with exit code 1"));
753 assert!(is_tool_failure("exit status 127"));
754 assert!(!is_tool_failure("exit code 0 — success"));
755 assert!(!is_tool_failure("exit status 0"));
756 }
757
758 #[test]
759 fn tool_success_normal_output() {
760 assert!(!is_tool_failure("hello world"));
761 assert!(!is_tool_failure("42"));
762 assert!(!is_tool_failure("file created successfully"));
763 assert!(!is_tool_failure(""));
764 }
765
766 #[test]
767 fn ingest_turn_records_procedural_failure() {
768 let db = roboticus_db::Database::new(":memory:").unwrap();
769 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
770 roboticus_db::memory::store_procedural(&db, "bad_tool", "a tool").ok();
771 ingest_turn(
772 &db,
773 &session_id,
774 "use bad_tool",
775 "error occurred",
776 &[("bad_tool".into(), "Error: something broke".into())],
777 );
778 }
781
782 #[test]
783 fn ingest_turn_peer_scope_stores_relationship_memory() {
784 let db = roboticus_db::Database::new(":memory:").unwrap();
785 let scope = roboticus_db::sessions::SessionScope::Peer {
786 peer_id: "alice".into(),
787 channel: "telegram".into(),
788 };
789 let session_id =
790 roboticus_db::sessions::find_or_create(&db, "test-agent", Some(&scope)).unwrap();
791
792 ingest_turn(
793 &db,
794 &session_id,
795 "Can you remind me what we decided?",
796 "We agreed to prioritize the Telegram stability work first.",
797 &[],
798 );
799
800 let entry = roboticus_db::memory::retrieve_relationship(&db, "peer:telegram:alice")
801 .unwrap()
802 .expect("peer-scoped turns should create relationship memory");
803 assert_eq!(entry.entity_name.as_deref(), Some("alice"));
804 assert_eq!(entry.interaction_count, 1);
805 assert!(
806 entry
807 .interaction_summary
808 .as_deref()
809 .unwrap_or("")
810 .contains("prioritize the Telegram stability work"),
811 "relationship interaction summary should capture the turn context"
812 );
813 }
814
815 #[test]
816 fn parse_peer_scope_key_parses_identity() {
817 assert_eq!(
818 parse_peer_scope_key("peer:telegram:user-42"),
819 Some(("telegram", "user-42"))
820 );
821 assert_eq!(parse_peer_scope_key("agent"), None);
822 assert_eq!(parse_peer_scope_key("peer::user-42"), None);
823 }
824
825 #[test]
826 fn ingest_turn_marks_older_semantic_summaries_stale_per_session() {
827 let db = roboticus_db::Database::new(":memory:").unwrap();
828 let session_id = roboticus_db::sessions::find_or_create(&db, "test-agent", None).unwrap();
829 let first = "Alpha incident resolved after rollback with careful verification and communication to every stakeholder involved. ".repeat(2);
830 let second = "Beta migration is active with the new phased plan, improved monitoring, and rollback checkpoints in place. ".repeat(2);
831
832 ingest_turn(&db, &session_id, "summarize alpha", &first, &[]);
833 ingest_turn(&db, &session_id, "summarize beta", &second, &[]);
834
835 let semantic = roboticus_db::memory::retrieve_semantic(&db, "learned").unwrap();
836 assert_eq!(semantic.len(), 2);
837 let active = semantic
838 .iter()
839 .filter(|entry| entry.memory_state == "active")
840 .collect::<Vec<_>>();
841 let stale = semantic
842 .iter()
843 .filter(|entry| entry.memory_state == "stale")
844 .collect::<Vec<_>>();
845 assert_eq!(active.len(), 1);
846 assert_eq!(stale.len(), 1);
847 assert!(active[0].value.contains("Beta migration is active"));
848 assert!(stale[0].value.contains("Alpha incident resolved"));
849 assert_eq!(
850 stale[0].state_reason.as_deref(),
851 Some("superseded_by_newer_session_summary")
852 );
853 }
854}