1mod support;
4
5use support::{
6 conversation_not_found, namespace_mismatch, normalized_terms, now_ms, require_namespace,
7 retrieval_store_error, validate_memory, validate_sources, validate_summary,
8 validate_transcript_messages,
9};
10pub(crate) use support::{is_transient_context, semantic_memory_message, summary_message};
11
12use std::{
13 collections::BTreeMap,
14 future::Future,
15 num::{NonZeroU16, NonZeroU64},
16 pin::Pin,
17 sync::{Arc, Mutex},
18};
19
20use runifold_core::{CheckpointId, Usage};
21use runifold_model::Message;
22use runifold_retrieval::RetrievalContext;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use thiserror::Error;
26
27use crate::{AgentError, AgentOutcome};
28
29const MAX_NAMESPACE_BYTES: usize = 128;
30const MAX_SUMMARY_BYTES: usize = 262_144;
31const MAX_MEMORY_BYTES: usize = 262_144;
32pub(crate) const TRANSIENT_CONTEXT_METADATA: &str = "runifold.context.transient";
33
34#[cfg(not(target_arch = "wasm32"))]
36pub type ConversationStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
37
38#[cfg(target_arch = "wasm32")]
40pub type ConversationStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
41
42#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
44#[serde(transparent)]
45pub struct ConversationId(CheckpointId);
46
47impl ConversationId {
48 pub fn new() -> Self {
50 Self(CheckpointId::new())
51 }
52
53 pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
55 Self(id)
56 }
57
58 pub const fn as_checkpoint_id(self) -> CheckpointId {
60 self.0
61 }
62}
63
64impl Default for ConversationId {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
72#[serde(transparent)]
73pub struct MemoryNamespace(String);
74
75impl MemoryNamespace {
76 pub fn parse(value: impl Into<String>) -> Result<Self, ConversationStoreError> {
82 let value = value.into();
83 if value.is_empty()
84 || value.len() > MAX_NAMESPACE_BYTES
85 || !value
86 .bytes()
87 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
88 {
89 return Err(ConversationStoreError::invalid_input(
90 "memory namespace must contain 1..=128 portable ASCII characters",
91 ));
92 }
93 Ok(Self(value))
94 }
95
96 pub fn as_str(&self) -> &str {
98 &self.0
99 }
100}
101
102#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
104#[serde(transparent)]
105pub struct ConversationVersion(u64);
106
107impl ConversationVersion {
108 pub const fn new(value: u64) -> Self {
110 Self(value)
111 }
112
113 pub const fn get(self) -> u64 {
115 self.0
116 }
117
118 fn next(self) -> Result<Self, ConversationStoreError> {
119 self.0.checked_add(1).map(Self).ok_or_else(|| {
120 ConversationStoreError::new(
121 ConversationStoreErrorKind::Conflict,
122 "conversation version overflow",
123 )
124 })
125 }
126}
127
128#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
130#[serde(transparent)]
131pub struct ConversationSequence(NonZeroU64);
132
133impl ConversationSequence {
134 pub fn new(value: u64) -> Result<Self, ConversationStoreError> {
140 NonZeroU64::new(value).map(Self).ok_or_else(|| {
141 ConversationStoreError::invalid_input("conversation sequence must be positive")
142 })
143 }
144
145 pub const fn get(self) -> u64 {
147 self.0.get()
148 }
149}
150
151#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
156pub struct ConversationTranscriptEntry {
157 pub sequence: ConversationSequence,
159 pub message: Message,
161}
162
163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165pub struct ConversationSummary {
166 pub summary_id: CheckpointId,
168 pub content: String,
170 pub through_sequence: ConversationSequence,
172 pub transcript_version: ConversationVersion,
174 pub created_at_ms: u64,
176}
177
178#[derive(Clone, Debug, PartialEq)]
180pub struct ConversationView {
181 pub conversation_id: ConversationId,
183 pub namespace: MemoryNamespace,
185 pub version: ConversationVersion,
187 pub summary: Option<ConversationSummary>,
189 pub summary_buffer: Vec<ConversationTranscriptEntry>,
191 pub summary_backlog: u64,
193 pub window: Vec<ConversationTranscriptEntry>,
195}
196
197impl ConversationView {
198 pub fn requires_summary(&self) -> bool {
200 !self.summary_buffer.is_empty()
201 }
202}
203
204#[derive(Clone, Copy, Debug, Eq, PartialEq)]
206pub struct ConversationWindow(NonZeroU16);
207
208impl ConversationWindow {
209 pub fn new(value: u16) -> Result<Self, ConversationStoreError> {
215 NonZeroU16::new(value)
216 .filter(|value| value.get() <= 4_096)
217 .map(Self)
218 .ok_or_else(|| {
219 ConversationStoreError::invalid_input("conversation window must be in 1..=4096")
220 })
221 }
222
223 pub const fn get(self) -> u16 {
225 self.0.get()
226 }
227}
228
229#[derive(Clone, Copy, Debug, Eq, PartialEq)]
231pub struct ConversationSummaryBatch(NonZeroU16);
232
233impl ConversationSummaryBatch {
234 pub fn new(value: u16) -> Result<Self, ConversationStoreError> {
240 NonZeroU16::new(value)
241 .filter(|value| value.get() <= 4_096)
242 .map(Self)
243 .ok_or_else(|| {
244 ConversationStoreError::invalid_input(
245 "conversation summary batch must be in 1..=4096",
246 )
247 })
248 }
249
250 pub const fn get(self) -> u16 {
252 self.0.get()
253 }
254}
255
256#[derive(Clone, Copy, Debug, Eq, PartialEq)]
258pub struct ConversationContextPolicy {
259 pub window: ConversationWindow,
261 pub summary_batch: ConversationSummaryBatch,
263 pub semantic_memory_limit: Option<NonZeroU16>,
265}
266
267impl ConversationContextPolicy {
268 pub const fn new(window: ConversationWindow) -> Self {
270 Self {
271 window,
272 summary_batch: ConversationSummaryBatch(window.0),
273 semantic_memory_limit: None,
274 }
275 }
276
277 #[must_use]
279 pub const fn with_summary_batch(mut self, summary_batch: ConversationSummaryBatch) -> Self {
280 self.summary_batch = summary_batch;
281 self
282 }
283
284 pub fn with_semantic_memory(mut self, limit: u16) -> Result<Self, ConversationStoreError> {
290 self.semantic_memory_limit = NonZeroU16::new(limit).filter(|value| value.get() <= 256);
291 if self.semantic_memory_limit.is_none() {
292 return Err(ConversationStoreError::invalid_input(
293 "semantic memory context limit must be in 1..=256",
294 ));
295 }
296 Ok(self)
297 }
298}
299
300#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
302pub struct ConversationAppend {
303 pub conversation_id: ConversationId,
305 pub expected_version: ConversationVersion,
307 pub messages: Vec<Message>,
309}
310
311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
313pub struct ConversationSummaryCommit {
314 pub conversation_id: ConversationId,
316 pub expected_version: ConversationVersion,
318 pub through_sequence: ConversationSequence,
320 pub content: String,
322}
323
324#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
326#[serde(transparent)]
327pub struct SemanticMemoryId(CheckpointId);
328
329impl SemanticMemoryId {
330 pub fn new() -> Self {
332 Self(CheckpointId::new())
333 }
334
335 pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
337 Self(id)
338 }
339
340 pub const fn as_checkpoint_id(self) -> CheckpointId {
342 self.0
343 }
344}
345
346impl Default for SemanticMemoryId {
347 fn default() -> Self {
348 Self::new()
349 }
350}
351
352#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
354pub struct SemanticMemorySource {
355 pub conversation_id: ConversationId,
357 pub from_sequence: ConversationSequence,
359 pub through_sequence: ConversationSequence,
361}
362
363#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
368pub struct SemanticMemory {
369 pub memory_id: SemanticMemoryId,
371 pub namespace: MemoryNamespace,
373 pub content: String,
375 pub sources: Vec<SemanticMemorySource>,
377 pub metadata: BTreeMap<String, Value>,
379 pub revision: u64,
381 pub created_at_ms: u64,
383 pub updated_at_ms: u64,
385}
386
387#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
389pub struct SemanticMemoryUpsert {
390 pub memory_id: SemanticMemoryId,
392 pub namespace: MemoryNamespace,
394 pub content: String,
396 pub sources: Vec<SemanticMemorySource>,
398 pub metadata: BTreeMap<String, Value>,
400 pub expected_revision: Option<u64>,
402}
403
404#[derive(Clone, Debug, PartialEq)]
406pub struct SemanticMemoryUpsertOutcome {
407 pub memory: SemanticMemory,
409 pub usage: Usage,
411}
412
413#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct SemanticMemoryQuery {
416 pub namespace: MemoryNamespace,
418 pub text: String,
420 pub limit: NonZeroU16,
422}
423
424#[derive(Clone, Debug, Default, PartialEq)]
426pub struct SemanticMemorySearchOutcome {
427 pub memories: Vec<SemanticMemory>,
429 pub usage: Usage,
431}
432
433impl SemanticMemoryQuery {
434 pub fn new(
440 namespace: MemoryNamespace,
441 text: impl Into<String>,
442 limit: u16,
443 ) -> Result<Self, ConversationStoreError> {
444 let text = text.into();
445 let Some(limit) = NonZeroU16::new(limit).filter(|value| value.get() <= 256) else {
446 return Err(ConversationStoreError::invalid_input(
447 "semantic memory query requires text and a limit in 1..=256",
448 ));
449 };
450 if text.trim().is_empty() {
451 return Err(ConversationStoreError::invalid_input(
452 "semantic memory query requires text and a limit in 1..=256",
453 ));
454 }
455 Ok(Self {
456 namespace,
457 text,
458 limit,
459 })
460 }
461}
462
463#[derive(Clone, Copy, Debug, Eq, PartialEq)]
465#[non_exhaustive]
466pub enum ConversationCreateOutcome {
467 Created,
469 Duplicate,
471}
472
473#[derive(Clone, Debug, PartialEq)]
475pub struct AgentConversationOutcome {
476 pub outcome: AgentOutcome,
478 pub conversation_version: ConversationVersion,
480}
481
482#[derive(Debug, Error)]
484#[non_exhaustive]
485pub enum AgentConversationError {
486 #[error("conversation store failed: {0}")]
488 Store(#[from] ConversationStoreError),
489 #[error(
491 "conversation `{conversation_id:?}` requires summarization of {buffered_entries} buffered entries"
492 )]
493 SummaryRequired {
494 conversation_id: ConversationId,
496 buffered_entries: u64,
498 },
499 #[error(
501 "conversation `{conversation_id:?}` still has {remaining_entries} entries requiring summarization after the configured pass limit"
502 )]
503 SummaryPassLimitExceeded {
504 conversation_id: ConversationId,
506 remaining_entries: u64,
508 },
509 #[error("conversation summarization failed: {0}")]
511 Summarization(#[from] super::ConversationSummarizerError),
512 #[error("conversational Agent execution failed: {0}")]
514 Run(#[source] AgentError),
515 #[error("Agent completed but conversation commit failed: {source}")]
517 Commit {
518 #[source]
520 source: ConversationStoreError,
521 outcome: Box<AgentOutcome>,
523 },
524}
525
526#[derive(Clone, Copy, Debug, Eq, PartialEq)]
528#[non_exhaustive]
529pub enum ConversationStoreErrorKind {
530 InvalidInput,
532 NotFound,
534 Conflict,
536 NamespaceMismatch,
538 Storage,
540}
541
542#[derive(Clone, Debug, Error, Eq, PartialEq)]
544#[error("{kind:?}: {message}")]
545pub struct ConversationStoreError {
546 pub kind: ConversationStoreErrorKind,
548 pub message: String,
550}
551
552impl ConversationStoreError {
553 pub fn new(kind: ConversationStoreErrorKind, message: impl Into<String>) -> Self {
555 Self {
556 kind,
557 message: message.into(),
558 }
559 }
560
561 fn invalid_input(message: impl Into<String>) -> Self {
562 Self::new(ConversationStoreErrorKind::InvalidInput, message)
563 }
564}
565
566pub trait ConversationStore: Send + Sync {
571 fn create(
573 &self,
574 conversation_id: ConversationId,
575 namespace: MemoryNamespace,
576 ) -> ConversationStoreFuture<'_, Result<ConversationCreateOutcome, ConversationStoreError>>;
577
578 fn load_view(
580 &self,
581 conversation_id: ConversationId,
582 namespace: MemoryNamespace,
583 window: ConversationWindow,
584 summary_batch: ConversationSummaryBatch,
585 ) -> ConversationStoreFuture<'_, Result<ConversationView, ConversationStoreError>>;
586
587 fn list_transcript(
589 &self,
590 conversation_id: ConversationId,
591 namespace: MemoryNamespace,
592 after: Option<ConversationSequence>,
593 limit: ConversationWindow,
594 ) -> ConversationStoreFuture<'_, Result<Vec<ConversationTranscriptEntry>, ConversationStoreError>>;
595
596 fn append(
598 &self,
599 namespace: MemoryNamespace,
600 command: ConversationAppend,
601 ) -> ConversationStoreFuture<'_, Result<ConversationVersion, ConversationStoreError>>;
602
603 fn commit_summary(
605 &self,
606 namespace: MemoryNamespace,
607 command: ConversationSummaryCommit,
608 ) -> ConversationStoreFuture<'_, Result<ConversationSummary, ConversationStoreError>>;
609
610 fn upsert_memory(
612 &self,
613 command: SemanticMemoryUpsert,
614 ) -> ConversationStoreFuture<'_, Result<SemanticMemory, ConversationStoreError>>;
615
616 fn upsert_memory_scoped(
621 &self,
622 command: SemanticMemoryUpsert,
623 context: RetrievalContext,
624 ) -> ConversationStoreFuture<'_, Result<SemanticMemoryUpsertOutcome, ConversationStoreError>>
625 {
626 Box::pin(async move {
627 context
628 .check_live()
629 .map_err(|error| retrieval_store_error(&error))?;
630 let memory = self.upsert_memory(command).await?;
631 Ok(SemanticMemoryUpsertOutcome {
632 memory,
633 usage: Usage::default(),
634 })
635 })
636 }
637
638 fn search_memory(
640 &self,
641 query: SemanticMemoryQuery,
642 ) -> ConversationStoreFuture<'_, Result<Vec<SemanticMemory>, ConversationStoreError>>;
643
644 fn search_memory_scoped(
649 &self,
650 query: SemanticMemoryQuery,
651 context: RetrievalContext,
652 ) -> ConversationStoreFuture<'_, Result<SemanticMemorySearchOutcome, ConversationStoreError>>
653 {
654 Box::pin(async move {
655 context
656 .check_live()
657 .map_err(|error| retrieval_store_error(&error))?;
658 let memories = self.search_memory(query).await?;
659 Ok(SemanticMemorySearchOutcome {
660 memories,
661 usage: Usage::default(),
662 })
663 })
664 }
665}
666
667#[derive(Clone, Debug, Default)]
669pub struct InMemoryConversationStore {
670 state: Arc<Mutex<ConversationState>>,
671}
672
673#[derive(Debug, Default)]
674struct ConversationState {
675 conversations: BTreeMap<ConversationId, StoredConversation>,
676 memories: BTreeMap<SemanticMemoryId, SemanticMemory>,
677}
678
679#[derive(Debug)]
680struct StoredConversation {
681 namespace: MemoryNamespace,
682 version: ConversationVersion,
683 transcript: Vec<ConversationTranscriptEntry>,
684 summary: Option<ConversationSummary>,
685}
686
687impl InMemoryConversationStore {
688 pub fn new() -> Self {
690 Self::default()
691 }
692}
693
694impl ConversationStore for InMemoryConversationStore {
695 fn create(
696 &self,
697 conversation_id: ConversationId,
698 namespace: MemoryNamespace,
699 ) -> ConversationStoreFuture<'_, Result<ConversationCreateOutcome, ConversationStoreError>>
700 {
701 Box::pin(async move {
702 let mut state = self
703 .state
704 .lock()
705 .unwrap_or_else(std::sync::PoisonError::into_inner);
706 if let Some(existing) = state.conversations.get(&conversation_id) {
707 return if existing.namespace == namespace {
708 Ok(ConversationCreateOutcome::Duplicate)
709 } else {
710 Err(namespace_mismatch())
711 };
712 }
713 state.conversations.insert(
714 conversation_id,
715 StoredConversation {
716 namespace,
717 version: ConversationVersion::default(),
718 transcript: Vec::new(),
719 summary: None,
720 },
721 );
722 Ok(ConversationCreateOutcome::Created)
723 })
724 }
725
726 fn load_view(
727 &self,
728 conversation_id: ConversationId,
729 namespace: MemoryNamespace,
730 window: ConversationWindow,
731 summary_batch: ConversationSummaryBatch,
732 ) -> ConversationStoreFuture<'_, Result<ConversationView, ConversationStoreError>> {
733 Box::pin(async move {
734 let state = self
735 .state
736 .lock()
737 .unwrap_or_else(std::sync::PoisonError::into_inner);
738 let stored = state
739 .conversations
740 .get(&conversation_id)
741 .ok_or_else(conversation_not_found)?;
742 require_namespace(&stored.namespace, &namespace)?;
743 let summarized_through = stored
744 .summary
745 .as_ref()
746 .map_or(0, |summary| summary.through_sequence.get());
747 let unsummarized = stored
748 .transcript
749 .iter()
750 .filter(|entry| entry.sequence.get() > summarized_through)
751 .cloned()
752 .collect::<Vec<_>>();
753 let window_start = unsummarized.len().saturating_sub(usize::from(window.get()));
754 let summary_end = window_start.min(usize::from(summary_batch.get()));
755 Ok(ConversationView {
756 conversation_id,
757 namespace,
758 version: stored.version,
759 summary: stored.summary.clone(),
760 summary_buffer: unsummarized[..summary_end].to_vec(),
761 summary_backlog: u64::try_from(window_start.saturating_sub(summary_end))
762 .unwrap_or(u64::MAX),
763 window: unsummarized[window_start..].to_vec(),
764 })
765 })
766 }
767
768 fn append(
769 &self,
770 namespace: MemoryNamespace,
771 command: ConversationAppend,
772 ) -> ConversationStoreFuture<'_, Result<ConversationVersion, ConversationStoreError>> {
773 Box::pin(async move {
774 validate_transcript_messages(&command.messages)?;
775 let mut state = self
776 .state
777 .lock()
778 .unwrap_or_else(std::sync::PoisonError::into_inner);
779 let stored = state
780 .conversations
781 .get_mut(&command.conversation_id)
782 .ok_or_else(conversation_not_found)?;
783 require_namespace(&stored.namespace, &namespace)?;
784 if stored.version != command.expected_version {
785 return Err(ConversationStoreError::new(
786 ConversationStoreErrorKind::Conflict,
787 "conversation transcript version precondition failed",
788 ));
789 }
790 let next_version = stored.version.next()?;
791 for message in command.messages {
792 let sequence = u64::try_from(stored.transcript.len())
793 .ok()
794 .and_then(|value| value.checked_add(1))
795 .and_then(NonZeroU64::new)
796 .map(ConversationSequence)
797 .ok_or_else(|| {
798 ConversationStoreError::new(
799 ConversationStoreErrorKind::Conflict,
800 "conversation transcript sequence overflow",
801 )
802 })?;
803 stored
804 .transcript
805 .push(ConversationTranscriptEntry { sequence, message });
806 }
807 stored.version = next_version;
808 Ok(next_version)
809 })
810 }
811
812 fn list_transcript(
813 &self,
814 conversation_id: ConversationId,
815 namespace: MemoryNamespace,
816 after: Option<ConversationSequence>,
817 limit: ConversationWindow,
818 ) -> ConversationStoreFuture<'_, Result<Vec<ConversationTranscriptEntry>, ConversationStoreError>>
819 {
820 Box::pin(async move {
821 let state = self
822 .state
823 .lock()
824 .unwrap_or_else(std::sync::PoisonError::into_inner);
825 let stored = state
826 .conversations
827 .get(&conversation_id)
828 .ok_or_else(conversation_not_found)?;
829 require_namespace(&stored.namespace, &namespace)?;
830 let after = after.map_or(0, ConversationSequence::get);
831 Ok(stored
832 .transcript
833 .iter()
834 .filter(|entry| entry.sequence.get() > after)
835 .take(usize::from(limit.get()))
836 .cloned()
837 .collect())
838 })
839 }
840
841 fn commit_summary(
842 &self,
843 namespace: MemoryNamespace,
844 command: ConversationSummaryCommit,
845 ) -> ConversationStoreFuture<'_, Result<ConversationSummary, ConversationStoreError>> {
846 Box::pin(async move {
847 validate_summary(&command.content)?;
848 let mut state = self
849 .state
850 .lock()
851 .unwrap_or_else(std::sync::PoisonError::into_inner);
852 let stored = state
853 .conversations
854 .get_mut(&command.conversation_id)
855 .ok_or_else(conversation_not_found)?;
856 require_namespace(&stored.namespace, &namespace)?;
857 if stored.version != command.expected_version {
858 return Err(ConversationStoreError::new(
859 ConversationStoreErrorKind::Conflict,
860 "conversation summary version precondition failed",
861 ));
862 }
863 let last_sequence = u64::try_from(stored.transcript.len()).unwrap_or(u64::MAX);
864 let previous = stored
865 .summary
866 .as_ref()
867 .map_or(0, |summary| summary.through_sequence.get());
868 if command.through_sequence.get() <= previous
869 || command.through_sequence.get() > last_sequence
870 {
871 return Err(ConversationStoreError::invalid_input(
872 "conversation summary must cover a newer existing transcript prefix",
873 ));
874 }
875 let summary = ConversationSummary {
876 summary_id: CheckpointId::new(),
877 content: command.content,
878 through_sequence: command.through_sequence,
879 transcript_version: stored.version,
880 created_at_ms: now_ms(),
881 };
882 stored.summary = Some(summary.clone());
883 Ok(summary)
884 })
885 }
886
887 fn upsert_memory(
888 &self,
889 command: SemanticMemoryUpsert,
890 ) -> ConversationStoreFuture<'_, Result<SemanticMemory, ConversationStoreError>> {
891 Box::pin(async move {
892 validate_memory(&command)?;
893 let mut state = self
894 .state
895 .lock()
896 .unwrap_or_else(std::sync::PoisonError::into_inner);
897 let current = state.memories.get(&command.memory_id);
898 let revision = match (current, command.expected_revision) {
899 (None, None) => 0,
900 (Some(current), Some(expected))
901 if current.revision == expected && current.namespace == command.namespace =>
902 {
903 expected.checked_add(1).ok_or_else(|| {
904 ConversationStoreError::new(
905 ConversationStoreErrorKind::Conflict,
906 "semantic memory revision overflow",
907 )
908 })?
909 }
910 (Some(current), _) if current.namespace != command.namespace => {
911 return Err(namespace_mismatch());
912 }
913 _ => {
914 return Err(ConversationStoreError::new(
915 ConversationStoreErrorKind::Conflict,
916 "semantic memory revision precondition failed",
917 ));
918 }
919 };
920 validate_sources(&state.conversations, &command)?;
921 let now = now_ms();
922 let created_at_ms = current.map_or(now, |memory| memory.created_at_ms);
923 let memory = SemanticMemory {
924 memory_id: command.memory_id,
925 namespace: command.namespace,
926 content: command.content,
927 sources: command.sources,
928 metadata: command.metadata,
929 revision,
930 created_at_ms,
931 updated_at_ms: now,
932 };
933 state.memories.insert(memory.memory_id, memory.clone());
934 Ok(memory)
935 })
936 }
937
938 fn search_memory(
939 &self,
940 query: SemanticMemoryQuery,
941 ) -> ConversationStoreFuture<'_, Result<Vec<SemanticMemory>, ConversationStoreError>> {
942 Box::pin(async move {
943 let query_terms = normalized_terms(&query.text);
944 let state = self
945 .state
946 .lock()
947 .unwrap_or_else(std::sync::PoisonError::into_inner);
948 let mut ranked = state
949 .memories
950 .values()
951 .filter(|memory| memory.namespace == query.namespace)
952 .filter_map(|memory| {
953 let terms = normalized_terms(&memory.content);
954 let score = query_terms.intersection(&terms).count();
955 (score > 0).then_some((score, memory))
956 })
957 .collect::<Vec<_>>();
958 ranked.sort_by(|(left_score, left), (right_score, right)| {
959 right_score
960 .cmp(left_score)
961 .then_with(|| right.updated_at_ms.cmp(&left.updated_at_ms))
962 .then_with(|| left.memory_id.cmp(&right.memory_id))
963 });
964 Ok(ranked
965 .into_iter()
966 .take(usize::from(query.limit.get()))
967 .map(|(_, memory)| memory.clone())
968 .collect())
969 })
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use std::collections::BTreeMap;
976
977 use futures_executor::block_on;
978 use runifold_model::{ContentPart, Message, Role};
979
980 use super::*;
981
982 fn namespace(value: &str) -> MemoryNamespace {
983 MemoryNamespace::parse(value).unwrap()
984 }
985
986 fn assistant(text: &str) -> Message {
987 Message::new(Role::Assistant, vec![ContentPart::text(text)]).unwrap()
988 }
989
990 fn transcript() -> Vec<Message> {
991 vec![
992 Message::user("u1"),
993 assistant("a1"),
994 Message::user("u2"),
995 assistant("a2"),
996 Message::user("u3"),
997 assistant("a3"),
998 ]
999 }
1000
1001 #[test]
1002 fn transcript_summary_buffer_and_window_remain_distinct() {
1003 let store = InMemoryConversationStore::new();
1004 let conversation_id = ConversationId::new();
1005 let namespace = namespace("tenant.user");
1006 block_on(store.create(conversation_id, namespace.clone())).unwrap();
1007 let version = block_on(store.append(
1008 namespace.clone(),
1009 ConversationAppend {
1010 conversation_id,
1011 expected_version: ConversationVersion::default(),
1012 messages: transcript(),
1013 },
1014 ))
1015 .unwrap();
1016 let view = block_on(store.load_view(
1017 conversation_id,
1018 namespace.clone(),
1019 ConversationWindow::new(2).unwrap(),
1020 ConversationSummaryBatch::new(4).unwrap(),
1021 ))
1022 .unwrap();
1023 assert_eq!(view.summary_buffer.len(), 4);
1024 assert_eq!(view.summary_backlog, 0);
1025 assert_eq!(view.window.len(), 2);
1026 assert!(view.requires_summary());
1027
1028 let summary = block_on(store.commit_summary(
1029 namespace.clone(),
1030 ConversationSummaryCommit {
1031 conversation_id,
1032 expected_version: version,
1033 through_sequence: ConversationSequence::new(4).unwrap(),
1034 content: "The first two exchanges".into(),
1035 },
1036 ))
1037 .unwrap();
1038 let compacted = block_on(store.load_view(
1039 conversation_id,
1040 namespace.clone(),
1041 ConversationWindow::new(2).unwrap(),
1042 ConversationSummaryBatch::new(4).unwrap(),
1043 ))
1044 .unwrap();
1045 assert_eq!(compacted.summary, Some(summary));
1046 assert!(compacted.summary_buffer.is_empty());
1047 assert_eq!(compacted.summary_backlog, 0);
1048 assert_eq!(compacted.window.len(), 2);
1049
1050 let immutable = block_on(store.list_transcript(
1051 conversation_id,
1052 namespace,
1053 None,
1054 ConversationWindow::new(16).unwrap(),
1055 ))
1056 .unwrap();
1057 assert_eq!(immutable.len(), 6);
1058 assert_eq!(immutable[0].message, Message::user("u1"));
1059 }
1060
1061 #[test]
1062 fn conversation_view_bounds_summary_batch_and_reports_remaining_backlog() {
1063 let store = InMemoryConversationStore::new();
1064 let conversation_id = ConversationId::new();
1065 let namespace = MemoryNamespace::parse("tenant.bounded").unwrap();
1066 block_on(store.create(conversation_id, namespace.clone())).unwrap();
1067 let messages = (1..=10)
1068 .map(|sequence| Message::user(format!("message-{sequence}")))
1069 .collect();
1070 block_on(store.append(
1071 namespace.clone(),
1072 ConversationAppend {
1073 conversation_id,
1074 expected_version: ConversationVersion::default(),
1075 messages,
1076 },
1077 ))
1078 .unwrap();
1079
1080 let view = block_on(store.load_view(
1081 conversation_id,
1082 namespace,
1083 ConversationWindow::new(2).unwrap(),
1084 ConversationSummaryBatch::new(3).unwrap(),
1085 ))
1086 .unwrap();
1087
1088 assert_eq!(
1089 view.summary_buffer
1090 .iter()
1091 .map(|entry| entry.sequence.get())
1092 .collect::<Vec<_>>(),
1093 vec![1, 2, 3]
1094 );
1095 assert_eq!(view.summary_backlog, 5);
1096 assert_eq!(
1097 view.window
1098 .iter()
1099 .map(|entry| entry.sequence.get())
1100 .collect::<Vec<_>>(),
1101 vec![9, 10]
1102 );
1103 }
1104
1105 #[test]
1106 fn transcript_append_is_versioned_and_rejects_system_messages() {
1107 let store = InMemoryConversationStore::new();
1108 let conversation_id = ConversationId::new();
1109 let namespace = namespace("tenant.user");
1110 block_on(store.create(conversation_id, namespace.clone())).unwrap();
1111 let version = block_on(store.append(
1112 namespace.clone(),
1113 ConversationAppend {
1114 conversation_id,
1115 expected_version: ConversationVersion::default(),
1116 messages: vec![Message::user("hello")],
1117 },
1118 ))
1119 .unwrap();
1120 assert_eq!(version, ConversationVersion::new(1));
1121
1122 let conflict = block_on(store.append(
1123 namespace.clone(),
1124 ConversationAppend {
1125 conversation_id,
1126 expected_version: ConversationVersion::default(),
1127 messages: vec![Message::user("stale")],
1128 },
1129 ))
1130 .unwrap_err();
1131 assert_eq!(conflict.kind, ConversationStoreErrorKind::Conflict);
1132
1133 let invalid = block_on(store.append(
1134 namespace,
1135 ConversationAppend {
1136 conversation_id,
1137 expected_version: version,
1138 messages: vec![Message::system("do not persist policy")],
1139 },
1140 ))
1141 .unwrap_err();
1142 assert_eq!(invalid.kind, ConversationStoreErrorKind::InvalidInput);
1143 }
1144
1145 #[test]
1146 fn semantic_memory_is_explicit_cross_conversation_and_provenanced() {
1147 let store = InMemoryConversationStore::new();
1148 let namespace = namespace("tenant.user");
1149 let source_id = ConversationId::new();
1150 let other_id = ConversationId::new();
1151 block_on(store.create(source_id, namespace.clone())).unwrap();
1152 block_on(store.create(other_id, namespace.clone())).unwrap();
1153 block_on(store.append(
1154 namespace.clone(),
1155 ConversationAppend {
1156 conversation_id: source_id,
1157 expected_version: ConversationVersion::default(),
1158 messages: vec![
1159 Message::user("I prefer Rust"),
1160 assistant("Preference recorded"),
1161 ],
1162 },
1163 ))
1164 .unwrap();
1165 let memory_id = SemanticMemoryId::new();
1166 let memory = block_on(store.upsert_memory(SemanticMemoryUpsert {
1167 memory_id,
1168 namespace: namespace.clone(),
1169 content: "The user prefers Rust for systems programming".into(),
1170 sources: vec![SemanticMemorySource {
1171 conversation_id: source_id,
1172 from_sequence: ConversationSequence::new(1).unwrap(),
1173 through_sequence: ConversationSequence::new(2).unwrap(),
1174 }],
1175 metadata: BTreeMap::new(),
1176 expected_revision: None,
1177 }))
1178 .unwrap();
1179 assert_eq!(memory.revision, 0);
1180
1181 let found = block_on(store.search_memory(
1182 SemanticMemoryQuery::new(namespace.clone(), "Rust preference", 4).unwrap(),
1183 ))
1184 .unwrap();
1185 assert_eq!(found, vec![memory]);
1186 assert!(
1187 block_on(store.list_transcript(
1188 other_id,
1189 namespace,
1190 None,
1191 ConversationWindow::new(4).unwrap(),
1192 ))
1193 .unwrap()
1194 .is_empty()
1195 );
1196 }
1197}