Skip to main content

runifold_agent/conversation/
store.rs

1//! Conversation persistence domain and in-memory reference adapter.
2
3mod 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/// A boxed asynchronous conversation-store operation.
35#[cfg(not(target_arch = "wasm32"))]
36pub type ConversationStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
37
38/// A boxed conversation-store operation on single-threaded WASM.
39#[cfg(target_arch = "wasm32")]
40pub type ConversationStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
41
42/// Stable identity of one multi-turn conversation.
43#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
44#[serde(transparent)]
45pub struct ConversationId(CheckpointId);
46
47impl ConversationId {
48    /// Generates a time-ordered conversation identity.
49    pub fn new() -> Self {
50        Self(CheckpointId::new())
51    }
52
53    /// Reconstructs a conversation identity from durable storage.
54    pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
55        Self(id)
56    }
57
58    /// Returns the UUID-backed durable identity.
59    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/// Isolation namespace shared by related conversations and semantic memories.
71#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
72#[serde(transparent)]
73pub struct MemoryNamespace(String);
74
75impl MemoryNamespace {
76    /// Validates a portable memory namespace.
77    ///
78    /// # Errors
79    ///
80    /// Rejects blank, oversized, or non-portable values.
81    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    /// Returns the validated namespace.
97    pub fn as_str(&self) -> &str {
98        &self.0
99    }
100}
101
102/// Monotonic append version of one conversation transcript.
103#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
104#[serde(transparent)]
105pub struct ConversationVersion(u64);
106
107impl ConversationVersion {
108    /// Creates a version from durable storage.
109    pub const fn new(value: u64) -> Self {
110        Self(value)
111    }
112
113    /// Returns the numeric version.
114    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/// One-based stable position in an append-only transcript.
129#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
130#[serde(transparent)]
131pub struct ConversationSequence(NonZeroU64);
132
133impl ConversationSequence {
134    /// Creates a transcript sequence.
135    ///
136    /// # Errors
137    ///
138    /// Rejects zero.
139    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    /// Returns the one-based numeric position.
146    pub const fn get(self) -> u64 {
147        self.0.get()
148    }
149}
150
151/// Canonical model message stored in the append-only transcript.
152///
153/// This is conversation data, not an execution-journal event and not semantic
154/// memory. System instructions are deliberately rejected from this boundary.
155#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
156pub struct ConversationTranscriptEntry {
157    /// Stable position in the conversation.
158    pub sequence: ConversationSequence,
159    /// Original canonical model message.
160    pub message: Message,
161}
162
163/// Lossy compression of a prefix of the immutable transcript.
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165pub struct ConversationSummary {
166    /// Stable summary identity.
167    pub summary_id: CheckpointId,
168    /// Human- or model-produced summary text.
169    pub content: String,
170    /// Last transcript entry represented by this summary.
171    pub through_sequence: ConversationSequence,
172    /// Transcript version observed when the summary was committed.
173    pub transcript_version: ConversationVersion,
174    /// Store-authoritative creation time.
175    pub created_at_ms: u64,
176}
177
178/// Bounded context view derived without mutating the transcript.
179#[derive(Clone, Debug, PartialEq)]
180pub struct ConversationView {
181    /// Conversation identity.
182    pub conversation_id: ConversationId,
183    /// Namespace used for cross-conversation semantic memory.
184    pub namespace: MemoryNamespace,
185    /// Current transcript append version.
186    pub version: ConversationVersion,
187    /// Latest monotonic summary, when one exists.
188    pub summary: Option<ConversationSummary>,
189    /// Older unsummarized entries excluded from the live window.
190    pub summary_buffer: Vec<ConversationTranscriptEntry>,
191    /// Unsummarized entries still waiting behind the returned summary batch.
192    pub summary_backlog: u64,
193    /// Most recent model-visible transcript suffix.
194    pub window: Vec<ConversationTranscriptEntry>,
195}
196
197impl ConversationView {
198    /// Returns whether another summary must be committed before bounded Agent execution.
199    pub fn requires_summary(&self) -> bool {
200        !self.summary_buffer.is_empty()
201    }
202}
203
204/// Maximum number of recent transcript entries exposed as the live window.
205#[derive(Clone, Copy, Debug, Eq, PartialEq)]
206pub struct ConversationWindow(NonZeroU16);
207
208impl ConversationWindow {
209    /// Creates a bounded live-message window.
210    ///
211    /// # Errors
212    ///
213    /// Rejects zero and values above 4096.
214    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    /// Returns the validated entry limit.
224    pub const fn get(self) -> u16 {
225        self.0.get()
226    }
227}
228
229/// Maximum older transcript entries returned for one summary operation.
230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
231pub struct ConversationSummaryBatch(NonZeroU16);
232
233impl ConversationSummaryBatch {
234    /// Creates a bounded summary batch.
235    ///
236    /// # Errors
237    ///
238    /// Rejects zero and values above 4096.
239    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    /// Returns the validated entry limit.
251    pub const fn get(self) -> u16 {
252        self.0.get()
253    }
254}
255
256/// Bounded conversation and cross-session memory context policy.
257#[derive(Clone, Copy, Debug, Eq, PartialEq)]
258pub struct ConversationContextPolicy {
259    /// Recent transcript suffix.
260    pub window: ConversationWindow,
261    /// Maximum older entries loaded for one summary operation.
262    pub summary_batch: ConversationSummaryBatch,
263    /// Optional maximum semantic memories retrieved from the namespace.
264    pub semantic_memory_limit: Option<NonZeroU16>,
265}
266
267impl ConversationContextPolicy {
268    /// Creates a transcript-only context policy.
269    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    /// Replaces the maximum number of entries loaded for one summary pass.
278    #[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    /// Enables bounded cross-conversation semantic-memory lookup.
285    ///
286    /// # Errors
287    ///
288    /// Rejects zero and values above 256.
289    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/// Idempotent transcript append with optimistic concurrency control.
301#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
302pub struct ConversationAppend {
303    /// Target conversation.
304    pub conversation_id: ConversationId,
305    /// Version loaded before model execution.
306    pub expected_version: ConversationVersion,
307    /// Canonical user, assistant, and tool messages to append atomically.
308    pub messages: Vec<Message>,
309}
310
311/// Request to replace the current summary with a strictly newer prefix.
312#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
313pub struct ConversationSummaryCommit {
314    /// Target conversation.
315    pub conversation_id: ConversationId,
316    /// Transcript version used to produce the summary.
317    pub expected_version: ConversationVersion,
318    /// Last transcript entry represented by the summary.
319    pub through_sequence: ConversationSequence,
320    /// Replacement summary content.
321    pub content: String,
322}
323
324/// Stable identity of one explicitly curated semantic memory.
325#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
326#[serde(transparent)]
327pub struct SemanticMemoryId(CheckpointId);
328
329impl SemanticMemoryId {
330    /// Generates a time-ordered semantic-memory identity.
331    pub fn new() -> Self {
332        Self(CheckpointId::new())
333    }
334
335    /// Reconstructs a semantic-memory identity from durable storage.
336    pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
337        Self(id)
338    }
339
340    /// Returns the UUID-backed durable identity.
341    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/// Provenance link from semantic memory back to immutable conversation data.
353#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
354pub struct SemanticMemorySource {
355    /// Source conversation.
356    pub conversation_id: ConversationId,
357    /// First supporting transcript entry.
358    pub from_sequence: ConversationSequence,
359    /// Last supporting transcript entry.
360    pub through_sequence: ConversationSequence,
361}
362
363/// Cross-conversation semantic fact, preference, or durable user knowledge.
364///
365/// Semantic memory is never inferred merely by appending transcript messages.
366/// Applications must explicitly curate and upsert it with provenance.
367#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
368pub struct SemanticMemory {
369    /// Stable memory identity.
370    pub memory_id: SemanticMemoryId,
371    /// Isolation namespace.
372    pub namespace: MemoryNamespace,
373    /// Searchable semantic content.
374    pub content: String,
375    /// Immutable transcript provenance.
376    pub sources: Vec<SemanticMemorySource>,
377    /// Application-owned structured metadata.
378    pub metadata: BTreeMap<String, Value>,
379    /// Monotonic update revision.
380    pub revision: u64,
381    /// Store-authoritative creation time.
382    pub created_at_ms: u64,
383    /// Store-authoritative last-update time.
384    pub updated_at_ms: u64,
385}
386
387/// Create-or-CAS command for one semantic memory.
388#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
389pub struct SemanticMemoryUpsert {
390    /// Stable memory identity.
391    pub memory_id: SemanticMemoryId,
392    /// Isolation namespace.
393    pub namespace: MemoryNamespace,
394    /// Searchable semantic content.
395    pub content: String,
396    /// Immutable transcript provenance.
397    pub sources: Vec<SemanticMemorySource>,
398    /// Application metadata.
399    pub metadata: BTreeMap<String, Value>,
400    /// `None` creates; `Some` requires an exact current revision.
401    pub expected_revision: Option<u64>,
402}
403
404/// Semantic-memory write plus attributable embedding/storage usage.
405#[derive(Clone, Debug, PartialEq)]
406pub struct SemanticMemoryUpsertOutcome {
407    /// Persisted semantic memory.
408    pub memory: SemanticMemory,
409    /// Embedding and storage usage attributable to this operation.
410    pub usage: Usage,
411}
412
413/// Validated semantic-memory lookup.
414#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct SemanticMemoryQuery {
416    /// Isolation namespace.
417    pub namespace: MemoryNamespace,
418    /// Natural-language lookup text.
419    pub text: String,
420    /// Maximum returned memories.
421    pub limit: NonZeroU16,
422}
423
424/// Semantic-memory search results plus attributable embedding/storage usage.
425#[derive(Clone, Debug, Default, PartialEq)]
426pub struct SemanticMemorySearchOutcome {
427    /// Memories ordered by descending relevance.
428    pub memories: Vec<SemanticMemory>,
429    /// Query-embedding and storage usage attributable to this operation.
430    pub usage: Usage,
431}
432
433impl SemanticMemoryQuery {
434    /// Creates a bounded semantic-memory query.
435    ///
436    /// # Errors
437    ///
438    /// Rejects blank queries and limits outside 1..=256.
439    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/// Result of creating an idempotent conversation identity.
464#[derive(Clone, Copy, Debug, Eq, PartialEq)]
465#[non_exhaustive]
466pub enum ConversationCreateOutcome {
467    /// A new empty conversation was created.
468    Created,
469    /// The identity already exists in the same namespace.
470    Duplicate,
471}
472
473/// Successful Agent turn committed to one conversation.
474#[derive(Clone, Debug, PartialEq)]
475pub struct AgentConversationOutcome {
476    /// Canonical Agent execution outcome.
477    pub outcome: AgentOutcome,
478    /// Transcript version after the atomic append.
479    pub conversation_version: ConversationVersion,
480}
481
482/// Failure while loading, running, or committing one conversational Agent turn.
483#[derive(Debug, Error)]
484#[non_exhaustive]
485pub enum AgentConversationError {
486    /// Conversation or semantic-memory loading failed before execution.
487    #[error("conversation store failed: {0}")]
488    Store(#[from] ConversationStoreError),
489    /// Bounded execution requires the older unsummarized prefix to be summarized.
490    #[error(
491        "conversation `{conversation_id:?}` requires summarization of {buffered_entries} buffered entries"
492    )]
493    SummaryRequired {
494        /// Conversation requiring compaction.
495        conversation_id: ConversationId,
496        /// Unsummarized entries outside the configured live window.
497        buffered_entries: u64,
498    },
499    /// Automatic compaction reached its explicit pass limit with work remaining.
500    #[error(
501        "conversation `{conversation_id:?}` still has {remaining_entries} entries requiring summarization after the configured pass limit"
502    )]
503    SummaryPassLimitExceeded {
504        /// Conversation whose backlog remains.
505        conversation_id: ConversationId,
506        /// Older unsummarized entries still excluded from the live window.
507        remaining_entries: u64,
508    },
509    /// Automatic summary generation failed before any transcript mutation.
510    #[error("conversation summarization failed: {0}")]
511    Summarization(#[from] super::ConversationSummarizerError),
512    /// Canonical Agent execution failed; no transcript append was attempted.
513    #[error("conversational Agent execution failed: {0}")]
514    Run(#[source] AgentError),
515    /// Model execution succeeded but optimistic transcript commit conflicted.
516    #[error("Agent completed but conversation commit failed: {source}")]
517    Commit {
518        /// Store failure, normally a concurrent-version conflict.
519        #[source]
520        source: ConversationStoreError,
521        /// Preserved model outcome so successful work is never discarded.
522        outcome: Box<AgentOutcome>,
523    },
524}
525
526/// Stable conversation-store failure category.
527#[derive(Clone, Copy, Debug, Eq, PartialEq)]
528#[non_exhaustive]
529pub enum ConversationStoreErrorKind {
530    /// Input violated a domain invariant.
531    InvalidInput,
532    /// The requested resource does not exist.
533    NotFound,
534    /// A version or create-only precondition failed.
535    Conflict,
536    /// A resource belongs to another memory namespace.
537    NamespaceMismatch,
538    /// The backing store failed.
539    Storage,
540}
541
542/// Typed conversation and semantic-memory persistence failure.
543#[derive(Clone, Debug, Error, Eq, PartialEq)]
544#[error("{kind:?}: {message}")]
545pub struct ConversationStoreError {
546    /// Stable failure category.
547    pub kind: ConversationStoreErrorKind,
548    /// Safe application-facing explanation.
549    pub message: String,
550}
551
552impl ConversationStoreError {
553    /// Creates a normalized store error.
554    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
566/// Persistence boundary for conversation transcript, summaries, and semantic memory.
567///
568/// Execution-journal events deliberately do not appear in this trait. They
569/// remain owned by [`runifold_core::Journal`].
570pub trait ConversationStore: Send + Sync {
571    /// Idempotently creates one empty conversation.
572    fn create(
573        &self,
574        conversation_id: ConversationId,
575        namespace: MemoryNamespace,
576    ) -> ConversationStoreFuture<'_, Result<ConversationCreateOutcome, ConversationStoreError>>;
577
578    /// Loads a bounded view without deleting or rewriting transcript entries.
579    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    /// Lists immutable transcript entries strictly after an optional sequence.
588    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    /// Atomically appends canonical messages under the expected version.
597    fn append(
598        &self,
599        namespace: MemoryNamespace,
600        command: ConversationAppend,
601    ) -> ConversationStoreFuture<'_, Result<ConversationVersion, ConversationStoreError>>;
602
603    /// Monotonically replaces the lossy summary under the expected transcript version.
604    fn commit_summary(
605        &self,
606        namespace: MemoryNamespace,
607        command: ConversationSummaryCommit,
608    ) -> ConversationStoreFuture<'_, Result<ConversationSummary, ConversationStoreError>>;
609
610    /// Creates or compare-and-swaps one explicitly curated semantic memory.
611    fn upsert_memory(
612        &self,
613        command: SemanticMemoryUpsert,
614    ) -> ConversationStoreFuture<'_, Result<SemanticMemory, ConversationStoreError>>;
615
616    /// Writes memory under an explicit cancellation/deadline scope.
617    ///
618    /// Stores with embedding support should override this method and report
619    /// provider and storage usage. The default preserves lexical-store behavior.
620    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    /// Searches semantic memory without reading transcript or journal storage.
639    fn search_memory(
640        &self,
641        query: SemanticMemoryQuery,
642    ) -> ConversationStoreFuture<'_, Result<Vec<SemanticMemory>, ConversationStoreError>>;
643
644    /// Searches memory under an explicit cancellation/deadline scope.
645    ///
646    /// Stores with embedding support should override this method and report
647    /// query-embedding and storage usage.
648    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/// Deterministic in-memory reference store for tests and ephemeral applications.
668#[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    /// Creates an empty ephemeral store.
689    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}