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(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    /// Creates an empty ephemeral store.
698    pub fn new() -> Self {
699        Self::default()
700    }
701
702    /// Encodes the complete reference state for a durable adapter.
703    #[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    /// Restores the complete reference state for a durable adapter.
731    #[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}