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(Clone, Debug, Deserialize, Serialize)]
680struct StoredConversation {
681 namespace: MemoryNamespace,
682 version: ConversationVersion,
683 transcript: Vec<ConversationTranscriptEntry>,
684 summary: Option<ConversationSummary>,
685}
686
687const PERSISTENT_SNAPSHOT_VERSION: u32 = 1;
688
689#[derive(Deserialize, Serialize)]
690struct PersistentConversationSnapshot {
691 version: u32,
692 conversations: Vec<(ConversationId, StoredConversation)>,
693 memories: Vec<(SemanticMemoryId, SemanticMemory)>,
694}
695
696impl InMemoryConversationStore {
697 pub fn new() -> Self {
699 Self::default()
700 }
701
702 #[doc(hidden)]
704 pub fn export_persistent_snapshot(&self) -> Result<Vec<u8>, ConversationStoreError> {
705 let state = self
706 .state
707 .lock()
708 .unwrap_or_else(std::sync::PoisonError::into_inner);
709 let snapshot = PersistentConversationSnapshot {
710 version: PERSISTENT_SNAPSHOT_VERSION,
711 conversations: state
712 .conversations
713 .iter()
714 .map(|(id, conversation)| (*id, conversation.clone()))
715 .collect(),
716 memories: state
717 .memories
718 .iter()
719 .map(|(id, memory)| (*id, memory.clone()))
720 .collect(),
721 };
722 serde_json::to_vec(&snapshot).map_err(|error| {
723 ConversationStoreError::new(
724 ConversationStoreErrorKind::Storage,
725 format!("conversation snapshot encoding failed: {error}"),
726 )
727 })
728 }
729
730 #[doc(hidden)]
732 pub fn from_persistent_snapshot(encoded: &[u8]) -> Result<Self, ConversationStoreError> {
733 let snapshot: PersistentConversationSnapshot =
734 serde_json::from_slice(encoded).map_err(|error| {
735 ConversationStoreError::new(
736 ConversationStoreErrorKind::Storage,
737 format!("conversation snapshot decoding failed: {error}"),
738 )
739 })?;
740 if snapshot.version != PERSISTENT_SNAPSHOT_VERSION {
741 return Err(ConversationStoreError::new(
742 ConversationStoreErrorKind::Storage,
743 format!(
744 "unsupported conversation snapshot version {}",
745 snapshot.version
746 ),
747 ));
748 }
749 Ok(Self {
750 state: Arc::new(Mutex::new(ConversationState {
751 conversations: snapshot.conversations.into_iter().collect(),
752 memories: snapshot.memories.into_iter().collect(),
753 })),
754 })
755 }
756}
757
758impl ConversationStore for InMemoryConversationStore {
759 fn create(
760 &self,
761 conversation_id: ConversationId,
762 namespace: MemoryNamespace,
763 ) -> ConversationStoreFuture<'_, Result<ConversationCreateOutcome, ConversationStoreError>>
764 {
765 Box::pin(async move {
766 let mut state = self
767 .state
768 .lock()
769 .unwrap_or_else(std::sync::PoisonError::into_inner);
770 if let Some(existing) = state.conversations.get(&conversation_id) {
771 return if existing.namespace == namespace {
772 Ok(ConversationCreateOutcome::Duplicate)
773 } else {
774 Err(namespace_mismatch())
775 };
776 }
777 state.conversations.insert(
778 conversation_id,
779 StoredConversation {
780 namespace,
781 version: ConversationVersion::default(),
782 transcript: Vec::new(),
783 summary: None,
784 },
785 );
786 Ok(ConversationCreateOutcome::Created)
787 })
788 }
789
790 fn load_view(
791 &self,
792 conversation_id: ConversationId,
793 namespace: MemoryNamespace,
794 window: ConversationWindow,
795 summary_batch: ConversationSummaryBatch,
796 ) -> ConversationStoreFuture<'_, Result<ConversationView, ConversationStoreError>> {
797 Box::pin(async move {
798 let state = self
799 .state
800 .lock()
801 .unwrap_or_else(std::sync::PoisonError::into_inner);
802 let stored = state
803 .conversations
804 .get(&conversation_id)
805 .ok_or_else(conversation_not_found)?;
806 require_namespace(&stored.namespace, &namespace)?;
807 let summarized_through = stored
808 .summary
809 .as_ref()
810 .map_or(0, |summary| summary.through_sequence.get());
811 let unsummarized = stored
812 .transcript
813 .iter()
814 .filter(|entry| entry.sequence.get() > summarized_through)
815 .cloned()
816 .collect::<Vec<_>>();
817 let window_start = unsummarized.len().saturating_sub(usize::from(window.get()));
818 let summary_end = window_start.min(usize::from(summary_batch.get()));
819 Ok(ConversationView {
820 conversation_id,
821 namespace,
822 version: stored.version,
823 summary: stored.summary.clone(),
824 summary_buffer: unsummarized[..summary_end].to_vec(),
825 summary_backlog: u64::try_from(window_start.saturating_sub(summary_end))
826 .unwrap_or(u64::MAX),
827 window: unsummarized[window_start..].to_vec(),
828 })
829 })
830 }
831
832 fn append(
833 &self,
834 namespace: MemoryNamespace,
835 command: ConversationAppend,
836 ) -> ConversationStoreFuture<'_, Result<ConversationVersion, ConversationStoreError>> {
837 Box::pin(async move {
838 validate_transcript_messages(&command.messages)?;
839 let mut state = self
840 .state
841 .lock()
842 .unwrap_or_else(std::sync::PoisonError::into_inner);
843 let stored = state
844 .conversations
845 .get_mut(&command.conversation_id)
846 .ok_or_else(conversation_not_found)?;
847 require_namespace(&stored.namespace, &namespace)?;
848 if stored.version != command.expected_version {
849 return Err(ConversationStoreError::new(
850 ConversationStoreErrorKind::Conflict,
851 "conversation transcript version precondition failed",
852 ));
853 }
854 let next_version = stored.version.next()?;
855 for message in command.messages {
856 let sequence = u64::try_from(stored.transcript.len())
857 .ok()
858 .and_then(|value| value.checked_add(1))
859 .and_then(NonZeroU64::new)
860 .map(ConversationSequence)
861 .ok_or_else(|| {
862 ConversationStoreError::new(
863 ConversationStoreErrorKind::Conflict,
864 "conversation transcript sequence overflow",
865 )
866 })?;
867 stored
868 .transcript
869 .push(ConversationTranscriptEntry { sequence, message });
870 }
871 stored.version = next_version;
872 Ok(next_version)
873 })
874 }
875
876 fn list_transcript(
877 &self,
878 conversation_id: ConversationId,
879 namespace: MemoryNamespace,
880 after: Option<ConversationSequence>,
881 limit: ConversationWindow,
882 ) -> ConversationStoreFuture<'_, Result<Vec<ConversationTranscriptEntry>, ConversationStoreError>>
883 {
884 Box::pin(async move {
885 let state = self
886 .state
887 .lock()
888 .unwrap_or_else(std::sync::PoisonError::into_inner);
889 let stored = state
890 .conversations
891 .get(&conversation_id)
892 .ok_or_else(conversation_not_found)?;
893 require_namespace(&stored.namespace, &namespace)?;
894 let after = after.map_or(0, ConversationSequence::get);
895 Ok(stored
896 .transcript
897 .iter()
898 .filter(|entry| entry.sequence.get() > after)
899 .take(usize::from(limit.get()))
900 .cloned()
901 .collect())
902 })
903 }
904
905 fn commit_summary(
906 &self,
907 namespace: MemoryNamespace,
908 command: ConversationSummaryCommit,
909 ) -> ConversationStoreFuture<'_, Result<ConversationSummary, ConversationStoreError>> {
910 Box::pin(async move {
911 validate_summary(&command.content)?;
912 let mut state = self
913 .state
914 .lock()
915 .unwrap_or_else(std::sync::PoisonError::into_inner);
916 let stored = state
917 .conversations
918 .get_mut(&command.conversation_id)
919 .ok_or_else(conversation_not_found)?;
920 require_namespace(&stored.namespace, &namespace)?;
921 if stored.version != command.expected_version {
922 return Err(ConversationStoreError::new(
923 ConversationStoreErrorKind::Conflict,
924 "conversation summary version precondition failed",
925 ));
926 }
927 let last_sequence = u64::try_from(stored.transcript.len()).unwrap_or(u64::MAX);
928 let previous = stored
929 .summary
930 .as_ref()
931 .map_or(0, |summary| summary.through_sequence.get());
932 if command.through_sequence.get() <= previous
933 || command.through_sequence.get() > last_sequence
934 {
935 return Err(ConversationStoreError::invalid_input(
936 "conversation summary must cover a newer existing transcript prefix",
937 ));
938 }
939 let summary = ConversationSummary {
940 summary_id: CheckpointId::new(),
941 content: command.content,
942 through_sequence: command.through_sequence,
943 transcript_version: stored.version,
944 created_at_ms: now_ms(),
945 };
946 stored.summary = Some(summary.clone());
947 Ok(summary)
948 })
949 }
950
951 fn upsert_memory(
952 &self,
953 command: SemanticMemoryUpsert,
954 ) -> ConversationStoreFuture<'_, Result<SemanticMemory, ConversationStoreError>> {
955 Box::pin(async move {
956 validate_memory(&command)?;
957 let mut state = self
958 .state
959 .lock()
960 .unwrap_or_else(std::sync::PoisonError::into_inner);
961 let current = state.memories.get(&command.memory_id);
962 let revision = match (current, command.expected_revision) {
963 (None, None) => 0,
964 (Some(current), Some(expected))
965 if current.revision == expected && current.namespace == command.namespace =>
966 {
967 expected.checked_add(1).ok_or_else(|| {
968 ConversationStoreError::new(
969 ConversationStoreErrorKind::Conflict,
970 "semantic memory revision overflow",
971 )
972 })?
973 }
974 (Some(current), _) if current.namespace != command.namespace => {
975 return Err(namespace_mismatch());
976 }
977 _ => {
978 return Err(ConversationStoreError::new(
979 ConversationStoreErrorKind::Conflict,
980 "semantic memory revision precondition failed",
981 ));
982 }
983 };
984 validate_sources(&state.conversations, &command)?;
985 let now = now_ms();
986 let created_at_ms = current.map_or(now, |memory| memory.created_at_ms);
987 let memory = SemanticMemory {
988 memory_id: command.memory_id,
989 namespace: command.namespace,
990 content: command.content,
991 sources: command.sources,
992 metadata: command.metadata,
993 revision,
994 created_at_ms,
995 updated_at_ms: now,
996 };
997 state.memories.insert(memory.memory_id, memory.clone());
998 Ok(memory)
999 })
1000 }
1001
1002 fn search_memory(
1003 &self,
1004 query: SemanticMemoryQuery,
1005 ) -> ConversationStoreFuture<'_, Result<Vec<SemanticMemory>, ConversationStoreError>> {
1006 Box::pin(async move {
1007 let query_terms = normalized_terms(&query.text);
1008 let state = self
1009 .state
1010 .lock()
1011 .unwrap_or_else(std::sync::PoisonError::into_inner);
1012 let mut ranked = state
1013 .memories
1014 .values()
1015 .filter(|memory| memory.namespace == query.namespace)
1016 .filter_map(|memory| {
1017 let terms = normalized_terms(&memory.content);
1018 let score = query_terms.intersection(&terms).count();
1019 (score > 0).then_some((score, memory))
1020 })
1021 .collect::<Vec<_>>();
1022 ranked.sort_by(|(left_score, left), (right_score, right)| {
1023 right_score
1024 .cmp(left_score)
1025 .then_with(|| right.updated_at_ms.cmp(&left.updated_at_ms))
1026 .then_with(|| left.memory_id.cmp(&right.memory_id))
1027 });
1028 Ok(ranked
1029 .into_iter()
1030 .take(usize::from(query.limit.get()))
1031 .map(|(_, memory)| memory.clone())
1032 .collect())
1033 })
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use std::collections::BTreeMap;
1040
1041 use futures_executor::block_on;
1042 use runifold_model::{ContentPart, Message, Role};
1043
1044 use super::*;
1045
1046 fn namespace(value: &str) -> MemoryNamespace {
1047 MemoryNamespace::parse(value).unwrap()
1048 }
1049
1050 fn assistant(text: &str) -> Message {
1051 Message::new(Role::Assistant, vec![ContentPart::text(text)]).unwrap()
1052 }
1053
1054 fn transcript() -> Vec<Message> {
1055 vec![
1056 Message::user("u1"),
1057 assistant("a1"),
1058 Message::user("u2"),
1059 assistant("a2"),
1060 Message::user("u3"),
1061 assistant("a3"),
1062 ]
1063 }
1064
1065 #[test]
1066 fn transcript_summary_buffer_and_window_remain_distinct() {
1067 let store = InMemoryConversationStore::new();
1068 let conversation_id = ConversationId::new();
1069 let namespace = namespace("tenant.user");
1070 block_on(store.create(conversation_id, namespace.clone())).unwrap();
1071 let version = block_on(store.append(
1072 namespace.clone(),
1073 ConversationAppend {
1074 conversation_id,
1075 expected_version: ConversationVersion::default(),
1076 messages: transcript(),
1077 },
1078 ))
1079 .unwrap();
1080 let view = block_on(store.load_view(
1081 conversation_id,
1082 namespace.clone(),
1083 ConversationWindow::new(2).unwrap(),
1084 ConversationSummaryBatch::new(4).unwrap(),
1085 ))
1086 .unwrap();
1087 assert_eq!(view.summary_buffer.len(), 4);
1088 assert_eq!(view.summary_backlog, 0);
1089 assert_eq!(view.window.len(), 2);
1090 assert!(view.requires_summary());
1091
1092 let summary = block_on(store.commit_summary(
1093 namespace.clone(),
1094 ConversationSummaryCommit {
1095 conversation_id,
1096 expected_version: version,
1097 through_sequence: ConversationSequence::new(4).unwrap(),
1098 content: "The first two exchanges".into(),
1099 },
1100 ))
1101 .unwrap();
1102 let compacted = block_on(store.load_view(
1103 conversation_id,
1104 namespace.clone(),
1105 ConversationWindow::new(2).unwrap(),
1106 ConversationSummaryBatch::new(4).unwrap(),
1107 ))
1108 .unwrap();
1109 assert_eq!(compacted.summary, Some(summary));
1110 assert!(compacted.summary_buffer.is_empty());
1111 assert_eq!(compacted.summary_backlog, 0);
1112 assert_eq!(compacted.window.len(), 2);
1113
1114 let immutable = block_on(store.list_transcript(
1115 conversation_id,
1116 namespace,
1117 None,
1118 ConversationWindow::new(16).unwrap(),
1119 ))
1120 .unwrap();
1121 assert_eq!(immutable.len(), 6);
1122 assert_eq!(immutable[0].message, Message::user("u1"));
1123 }
1124
1125 #[test]
1126 fn conversation_view_bounds_summary_batch_and_reports_remaining_backlog() {
1127 let store = InMemoryConversationStore::new();
1128 let conversation_id = ConversationId::new();
1129 let namespace = MemoryNamespace::parse("tenant.bounded").unwrap();
1130 block_on(store.create(conversation_id, namespace.clone())).unwrap();
1131 let messages = (1..=10)
1132 .map(|sequence| Message::user(format!("message-{sequence}")))
1133 .collect();
1134 block_on(store.append(
1135 namespace.clone(),
1136 ConversationAppend {
1137 conversation_id,
1138 expected_version: ConversationVersion::default(),
1139 messages,
1140 },
1141 ))
1142 .unwrap();
1143
1144 let view = block_on(store.load_view(
1145 conversation_id,
1146 namespace,
1147 ConversationWindow::new(2).unwrap(),
1148 ConversationSummaryBatch::new(3).unwrap(),
1149 ))
1150 .unwrap();
1151
1152 assert_eq!(
1153 view.summary_buffer
1154 .iter()
1155 .map(|entry| entry.sequence.get())
1156 .collect::<Vec<_>>(),
1157 vec![1, 2, 3]
1158 );
1159 assert_eq!(view.summary_backlog, 5);
1160 assert_eq!(
1161 view.window
1162 .iter()
1163 .map(|entry| entry.sequence.get())
1164 .collect::<Vec<_>>(),
1165 vec![9, 10]
1166 );
1167 }
1168
1169 #[test]
1170 fn transcript_append_is_versioned_and_rejects_system_messages() {
1171 let store = InMemoryConversationStore::new();
1172 let conversation_id = ConversationId::new();
1173 let namespace = namespace("tenant.user");
1174 block_on(store.create(conversation_id, namespace.clone())).unwrap();
1175 let version = block_on(store.append(
1176 namespace.clone(),
1177 ConversationAppend {
1178 conversation_id,
1179 expected_version: ConversationVersion::default(),
1180 messages: vec![Message::user("hello")],
1181 },
1182 ))
1183 .unwrap();
1184 assert_eq!(version, ConversationVersion::new(1));
1185
1186 let conflict = block_on(store.append(
1187 namespace.clone(),
1188 ConversationAppend {
1189 conversation_id,
1190 expected_version: ConversationVersion::default(),
1191 messages: vec![Message::user("stale")],
1192 },
1193 ))
1194 .unwrap_err();
1195 assert_eq!(conflict.kind, ConversationStoreErrorKind::Conflict);
1196
1197 let invalid = block_on(store.append(
1198 namespace,
1199 ConversationAppend {
1200 conversation_id,
1201 expected_version: version,
1202 messages: vec![Message::system("do not persist policy")],
1203 },
1204 ))
1205 .unwrap_err();
1206 assert_eq!(invalid.kind, ConversationStoreErrorKind::InvalidInput);
1207 }
1208
1209 #[test]
1210 fn semantic_memory_is_explicit_cross_conversation_and_provenanced() {
1211 let store = InMemoryConversationStore::new();
1212 let namespace = namespace("tenant.user");
1213 let source_id = ConversationId::new();
1214 let other_id = ConversationId::new();
1215 block_on(store.create(source_id, namespace.clone())).unwrap();
1216 block_on(store.create(other_id, namespace.clone())).unwrap();
1217 block_on(store.append(
1218 namespace.clone(),
1219 ConversationAppend {
1220 conversation_id: source_id,
1221 expected_version: ConversationVersion::default(),
1222 messages: vec![
1223 Message::user("I prefer Rust"),
1224 assistant("Preference recorded"),
1225 ],
1226 },
1227 ))
1228 .unwrap();
1229 let memory_id = SemanticMemoryId::new();
1230 let memory = block_on(store.upsert_memory(SemanticMemoryUpsert {
1231 memory_id,
1232 namespace: namespace.clone(),
1233 content: "The user prefers Rust for systems programming".into(),
1234 sources: vec![SemanticMemorySource {
1235 conversation_id: source_id,
1236 from_sequence: ConversationSequence::new(1).unwrap(),
1237 through_sequence: ConversationSequence::new(2).unwrap(),
1238 }],
1239 metadata: BTreeMap::new(),
1240 expected_revision: None,
1241 }))
1242 .unwrap();
1243 assert_eq!(memory.revision, 0);
1244
1245 let found = block_on(store.search_memory(
1246 SemanticMemoryQuery::new(namespace.clone(), "Rust preference", 4).unwrap(),
1247 ))
1248 .unwrap();
1249 assert_eq!(found, vec![memory]);
1250 assert!(
1251 block_on(store.list_transcript(
1252 other_id,
1253 namespace,
1254 None,
1255 ConversationWindow::new(4).unwrap(),
1256 ))
1257 .unwrap()
1258 .is_empty()
1259 );
1260 }
1261}