Skip to main content

meerkat_core/
memory.rs

1//! MemoryStore trait — semantic memory indexing for discarded conversation history.
2//!
3//! Implementations live in `meerkat-memory` crate.
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9/// Typed session-snapshot outbox carrier for compaction projection intent.
10pub const SESSION_COMPACTION_PROJECTION_INTENTS_KEY: &str = "session_compaction_projection_intents";
11
12/// Durable identity of the semantic-memory projection paired with one
13/// authoritative compaction transcript rewrite.
14///
15/// The identity is derived from the exact [`crate::TranscriptRewriteCommit`]
16/// rather than a turn counter, wall clock, or locally minted batch id. That
17/// makes stage/finalize/recovery idempotent across cancellation and process
18/// loss while keeping the transcript rewrite as the semantic owner.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
20pub struct CompactionProjectionId {
21    session_id: crate::types::SessionId,
22    parent_revision: String,
23    revision: String,
24    /// Canonical identity of the exact semantic rewrite commit. Wall-clock
25    /// `committed_at` is deliberately excluded so a cancelled retry of the
26    /// same rewrite remains idempotent.
27    commit_fingerprint: String,
28}
29
30#[derive(Serialize)]
31struct CompactionCommitFingerprint<'a> {
32    selection: &'a crate::TranscriptRewriteSelection,
33    original_span_digest: &'a str,
34    replacement_digest: &'a str,
35    messages_before: usize,
36    messages_after: usize,
37    actor: &'a Option<String>,
38}
39
40/// Pre-typed-selection fingerprint retained strictly as a durable decoder for
41/// compaction intents written before the semantic authority field existed.
42#[derive(Serialize)]
43struct LegacyCompactionCommitFingerprint<'a> {
44    selection: &'a crate::TranscriptRewriteSelection,
45    original_span_digest: &'a str,
46    replacement_digest: &'a str,
47    messages_before: usize,
48    messages_after: usize,
49    reason: &'a crate::TranscriptRewriteReason,
50    actor: &'a Option<String>,
51}
52
53/// Exact post-commit projection work carried by the session snapshot into the
54/// runtime's atomic-apply outbox.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct CompactionProjectionIntent {
57    pub projection: CompactionProjectionId,
58    pub summary_tokens: u64,
59    pub messages_before: usize,
60    pub messages_after: usize,
61}
62
63impl CompactionProjectionId {
64    /// Mint the projection identity for the exact validated compaction rewrite.
65    ///
66    /// This is crate-private: a durable semantic tag is sufficient to validate
67    /// an already-carried ID during recovery, but never to mint a new ID. Only
68    /// the core compaction owner can supply the opaque witness, which is
69    /// rechecked against the commit's exact pre/post transcript digests.
70    pub(crate) fn from_validated_transcript_rewrite(
71        session_id: crate::types::SessionId,
72        commit: &crate::TranscriptRewriteCommit,
73        authority: &crate::agent::compact::ValidatedCompactionRewrite,
74    ) -> Option<Self> {
75        if !authority.authorizes_commit(commit) {
76            return None;
77        }
78        Self::derive_from_typed_transcript_rewrite(session_id, commit)
79    }
80
81    fn derive_from_typed_transcript_rewrite(
82        session_id: crate::types::SessionId,
83        commit: &crate::TranscriptRewriteCommit,
84    ) -> Option<Self> {
85        if commit.selection.semantic() != crate::TranscriptRewriteSemantic::Compaction {
86            return None;
87        }
88        let canonical = serde_json::to_vec(&CompactionCommitFingerprint {
89            selection: &commit.selection,
90            original_span_digest: &commit.original_span_digest,
91            replacement_digest: &commit.replacement_digest,
92            messages_before: commit.messages_before,
93            messages_after: commit.messages_after,
94            actor: &commit.actor,
95        })
96        .ok()?;
97        let digest = Sha256::digest(canonical);
98        let mut commit_fingerprint = String::with_capacity("sha256:".len() + digest.len() * 2);
99        commit_fingerprint.push_str("sha256:");
100        const HEX: &[u8; 16] = b"0123456789abcdef";
101        for byte in digest {
102            commit_fingerprint.push(HEX[(byte >> 4) as usize] as char);
103            commit_fingerprint.push(HEX[(byte & 0x0f) as usize] as char);
104        }
105        Some(Self {
106            session_id,
107            parent_revision: commit.parent_revision.clone(),
108            revision: commit.revision.clone(),
109            commit_fingerprint,
110        })
111    }
112
113    /// Validate this identity against a typed compaction commit, admitting the
114    /// exact legacy fingerprint only for backward-compatible persisted data.
115    pub(crate) fn matches_transcript_rewrite(
116        &self,
117        session_id: &crate::types::SessionId,
118        commit: &crate::TranscriptRewriteCommit,
119    ) -> bool {
120        if Self::derive_from_typed_transcript_rewrite(session_id.clone(), commit).as_ref()
121            == Some(self)
122        {
123            return true;
124        }
125        if commit.selection.semantic() != crate::TranscriptRewriteSemantic::Compaction {
126            return false;
127        }
128        Self::legacy_from_typed_compaction(session_id.clone(), commit).as_ref() == Some(self)
129    }
130
131    fn legacy_from_typed_compaction(
132        session_id: crate::types::SessionId,
133        commit: &crate::TranscriptRewriteCommit,
134    ) -> Option<Self> {
135        if commit.selection.semantic() != crate::TranscriptRewriteSemantic::Compaction {
136            return None;
137        }
138        let (start, end) = commit.selection.bounds();
139        let legacy_selection = crate::TranscriptRewriteSelection::MessageRange { start, end };
140        let canonical = serde_json::to_vec(&LegacyCompactionCommitFingerprint {
141            selection: &legacy_selection,
142            original_span_digest: &commit.original_span_digest,
143            replacement_digest: &commit.replacement_digest,
144            messages_before: commit.messages_before,
145            messages_after: commit.messages_after,
146            reason: &commit.reason,
147            actor: &commit.actor,
148        })
149        .ok()?;
150        let digest = Sha256::digest(canonical);
151        let mut fingerprint = String::with_capacity("sha256:".len() + digest.len() * 2);
152        fingerprint.push_str("sha256:");
153        const HEX: &[u8; 16] = b"0123456789abcdef";
154        for byte in digest {
155            fingerprint.push(HEX[(byte >> 4) as usize] as char);
156            fingerprint.push(HEX[(byte & 0x0f) as usize] as char);
157        }
158        Some(Self {
159            session_id,
160            parent_revision: commit.parent_revision.clone(),
161            revision: commit.revision.clone(),
162            commit_fingerprint: fingerprint,
163        })
164    }
165
166    pub fn session_id(&self) -> &crate::types::SessionId {
167        &self.session_id
168    }
169
170    pub fn parent_revision(&self) -> &str {
171        &self.parent_revision
172    }
173
174    pub fn revision(&self) -> &str {
175        &self.revision
176    }
177
178    pub fn commit_fingerprint(&self) -> &str {
179        &self.commit_fingerprint
180    }
181}
182
183/// Store behavior for compaction-derived semantic-memory projection.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum CompactionProjectionPersistence {
186    /// Store does not implement the compaction projection lifecycle. Recall
187    /// and explicit indexing may still work, but compaction must preserve the
188    /// transcript rather than publishing an unpaired memory projection.
189    Unsupported,
190    /// Process-local store with no durable crash window. The agent may publish
191    /// the batch immediately after its in-memory transcript rewrite succeeds.
192    EphemeralImmediate,
193    /// Durable store. Batches must first be persisted invisibly, then finalized
194    /// only after the runtime atomically commits the paired transcript rewrite.
195    DurableStaged,
196}
197
198/// Resultful runtime handoff that authorizes a durable transcript+memory pair.
199///
200/// Runtime-backed construction injects this handle from the runtime epoch.
201/// Standalone construction has no coordinator and therefore fails closed for
202/// [`CompactionProjectionPersistence::DurableStaged`] stores.
203pub trait CompactionCommitCoordinator: Send + Sync {
204    fn authorize_projection(
205        &self,
206        projection: &CompactionProjectionId,
207    ) -> Result<(), CompactionCommitCoordinationError>;
208}
209
210#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
211pub enum CompactionCommitCoordinationError {
212    #[error(
213        "compaction projection session mismatch: coordinator owns {expected}, projection owns {actual}"
214    )]
215    SessionMismatch {
216        expected: crate::types::SessionId,
217        actual: crate::types::SessionId,
218    },
219    #[error("compaction projection coordinator rejected the handoff: {0}")]
220    Rejected(String),
221}
222
223/// Receipt for an invisible durable stage.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct CompactionStageReceipt {
226    pub projection: CompactionProjectionId,
227    pub staged_entries: usize,
228}
229
230/// Receipt for idempotent stage reconciliation at agent-build ingress.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
232pub struct CompactionStageReconcileReceipt {
233    pub retained_committed: usize,
234    pub aborted_orphans: usize,
235}
236
237/// Canonical semantic-memory owner.
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239pub struct MemoryOwner {
240    /// Session that owns the indexed memory shard.
241    session_id: crate::types::SessionId,
242}
243
244impl MemoryOwner {
245    pub fn canonical_session(session_id: crate::types::SessionId) -> Self {
246        Self { session_id }
247    }
248
249    pub fn session_id(&self) -> &crate::types::SessionId {
250        &self.session_id
251    }
252
253    fn includes(&self, metadata: &MemoryMetadata) -> bool {
254        metadata.session_id == self.session_id
255    }
256}
257
258/// Half-open range `[start, end)` of message offsets within a session's
259/// history that a memory entry was derived from.
260///
261/// This is the typed source-provenance handle: it records the *origin* of the
262/// indexed content (which messages it came from), independent of when the
263/// entry happened to be indexed.
264#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
265pub struct MessageRange {
266    start: u64,
267    end: u64,
268}
269
270impl MessageRange {
271    /// Construct a half-open range `[start, end)`.
272    ///
273    /// Fails closed when `start > end` rather than silently normalizing —
274    /// an inverted range is a provenance bug at the call site.
275    pub fn new(start: u64, end: u64) -> Result<Self, MemoryStoreError> {
276        if start > end {
277            return Err(MemoryStoreError::SourceRange { start, end });
278        }
279        Ok(Self { start, end })
280    }
281
282    /// A range covering a single message at `offset`.
283    pub fn single(offset: u64) -> Self {
284        Self {
285            start: offset,
286            end: offset.saturating_add(1),
287        }
288    }
289
290    pub fn start(&self) -> u64 {
291        self.start
292    }
293
294    pub fn end(&self) -> u64 {
295        self.end
296    }
297
298    /// Number of source messages covered by this range.
299    pub fn len(&self) -> u64 {
300        self.end - self.start
301    }
302
303    pub fn is_empty(&self) -> bool {
304        self.start == self.end
305    }
306
307    /// Whether this half-open range overlaps `other`.
308    ///
309    /// Half-open semantics: ranges that merely touch (`self.end ==
310    /// other.start`) do not overlap, and an empty range overlaps nothing.
311    pub fn overlaps(&self, other: &MessageRange) -> bool {
312        !self.is_empty() && !other.is_empty() && self.start < other.end && other.start < self.end
313    }
314}
315
316/// Typed origin of an indexed memory entry.
317///
318/// Memory provenance is a typed owner, not an absent/stringly fact: every
319/// entry records *where* its content came from so retrieval can expose the
320/// real source rather than a proxy (e.g. the turn at which compaction ran).
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322#[serde(tag = "kind", rename_all = "snake_case")]
323pub enum MemorySource {
324    /// Content discarded during compaction, identified by the range of source
325    /// session-history message offsets it was derived from.
326    Compaction {
327        /// Range of source message offsets this entry was derived from.
328        source_range: MessageRange,
329    },
330}
331
332impl MemorySource {
333    /// The source message range, if this origin carries one.
334    pub fn source_range(&self) -> Option<MessageRange> {
335        match self {
336            MemorySource::Compaction { source_range } => Some(*source_range),
337        }
338    }
339}
340
341/// Metadata associated with an indexed memory entry.
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct MemoryMetadata {
344    /// The session ID this memory originated from.
345    pub session_id: crate::types::SessionId,
346    /// Typed origin of the indexed content (the canonical source handle).
347    pub source: MemorySource,
348    /// When the memory was indexed.
349    pub indexed_at: crate::time_compat::SystemTime,
350}
351
352/// A memory search result.
353#[derive(Debug, Clone)]
354pub struct MemoryResult {
355    /// The text content of the memory.
356    pub content: String,
357    /// Metadata about the source.
358    pub metadata: MemoryMetadata,
359    /// Relevance score (0.0 = no match, 1.0 = perfect match).
360    pub score: f32,
361}
362
363/// Typed owner/scope for semantic memory retrieval.
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
365pub struct MemorySearchScope {
366    /// Canonical owner whose indexed memory is visible to this search.
367    pub owner: MemoryOwner,
368}
369
370impl MemorySearchScope {
371    pub fn for_session(session_id: crate::types::SessionId) -> Self {
372        Self {
373            owner: MemoryOwner::canonical_session(session_id),
374        }
375    }
376
377    pub fn for_owner(owner: MemoryOwner) -> Self {
378        Self { owner }
379    }
380
381    pub fn session_id(&self) -> &crate::types::SessionId {
382        self.owner.session_id()
383    }
384
385    pub fn includes(&self, metadata: &MemoryMetadata) -> bool {
386        self.owner.includes(metadata)
387    }
388}
389
390/// Typed owner/scope for semantic memory indexing.
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392pub struct MemoryIndexScope {
393    /// Canonical owner receiving the indexed memory projection.
394    pub owner: MemoryOwner,
395}
396
397impl MemoryIndexScope {
398    pub fn for_session(session_id: crate::types::SessionId) -> Self {
399        Self {
400            owner: MemoryOwner::canonical_session(session_id),
401        }
402    }
403
404    pub fn for_owner(owner: MemoryOwner) -> Self {
405        Self { owner }
406    }
407
408    pub fn session_id(&self) -> &crate::types::SessionId {
409        self.owner.session_id()
410    }
411
412    pub fn includes(&self, metadata: &MemoryMetadata) -> bool {
413        self.owner.includes(metadata)
414    }
415}
416
417/// One scoped semantic-memory indexing request.
418///
419/// The request carries the typed [`MemoryIndexableContent`] decision rather
420/// than a flattened `String`, so the store — not the producer — owns the
421/// include/exclude policy. An `Excluded(_)` request reaches the store with its
422/// typed exclusion reason intact; the store decides what (if anything) to
423/// index. This removes the empty-string "not indexable" convention from the
424/// producer/store seam.
425#[derive(Debug, Clone)]
426pub struct MemoryIndexRequest {
427    scope: MemoryIndexScope,
428    content: crate::types::MemoryIndexableContent,
429    metadata: MemoryMetadata,
430}
431
432impl MemoryIndexRequest {
433    pub fn new(
434        scope: MemoryIndexScope,
435        content: crate::types::MemoryIndexableContent,
436        metadata: MemoryMetadata,
437    ) -> Result<Self, MemoryStoreError> {
438        if !scope.includes(&metadata) {
439            return Err(MemoryStoreError::Scope(format!(
440                "memory metadata session {} is outside indexing scope {}",
441                metadata.session_id,
442                scope.session_id()
443            )));
444        }
445        Ok(Self {
446            scope,
447            content,
448            metadata,
449        })
450    }
451
452    pub fn scope(&self) -> &MemoryIndexScope {
453        &self.scope
454    }
455
456    /// The typed indexability decision the store owns.
457    pub fn content(&self) -> &crate::types::MemoryIndexableContent {
458        &self.content
459    }
460
461    /// Borrow the indexable text, or `None` when the message is excluded.
462    pub fn indexable_text(&self) -> Option<&str> {
463        self.content.indexable_text()
464    }
465
466    pub fn metadata(&self) -> &MemoryMetadata {
467        &self.metadata
468    }
469
470    pub fn into_parts(
471        self,
472    ) -> (
473        MemoryIndexScope,
474        crate::types::MemoryIndexableContent,
475        MemoryMetadata,
476    ) {
477        (self.scope, self.content, self.metadata)
478    }
479}
480
481/// Atomic scoped semantic-memory indexing batch.
482#[derive(Debug, Clone)]
483pub struct MemoryIndexBatch {
484    scope: MemoryIndexScope,
485    requests: Vec<MemoryIndexRequest>,
486}
487
488impl MemoryIndexBatch {
489    pub fn new(
490        scope: MemoryIndexScope,
491        requests: Vec<MemoryIndexRequest>,
492    ) -> Result<Self, MemoryStoreError> {
493        for request in &requests {
494            if request.scope() != &scope {
495                return Err(MemoryStoreError::Scope(format!(
496                    "memory index request scope {} is outside batch scope {}",
497                    request.scope().session_id(),
498                    scope.session_id()
499                )));
500            }
501        }
502        Ok(Self { scope, requests })
503    }
504
505    pub fn single(request: MemoryIndexRequest) -> Self {
506        Self {
507            scope: request.scope.clone(),
508            requests: vec![request],
509        }
510    }
511
512    pub fn scope(&self) -> &MemoryIndexScope {
513        &self.scope
514    }
515
516    pub fn len(&self) -> usize {
517        self.requests.len()
518    }
519
520    pub fn is_empty(&self) -> bool {
521        self.requests.is_empty()
522    }
523
524    pub fn into_parts(self) -> (MemoryIndexScope, Vec<MemoryIndexRequest>) {
525        (self.scope, self.requests)
526    }
527}
528
529/// Successful delivery receipt for a scoped memory index request.
530#[derive(Debug, Clone)]
531pub struct MemoryIndexReceipt {
532    pub scope: MemoryIndexScope,
533    pub indexed_entries: usize,
534}
535
536/// Typed compaction-to-memory delivery outcome.
537#[derive(Debug)]
538pub enum MemoryIndexDelivery {
539    NoStore {
540        scope: MemoryIndexScope,
541    },
542    Delivered(MemoryIndexReceipt),
543    Rejected {
544        scope: MemoryIndexScope,
545        attempted_entries: usize,
546        error: MemoryStoreError,
547    },
548}
549
550/// Successful receipt for an all-or-nothing scope drop.
551#[derive(Debug, Clone)]
552pub struct MemoryScopeDropReceipt {
553    /// Owner whose indexed entries were dropped.
554    pub owner: MemoryOwner,
555    /// Number of durable entries removed by the drop.
556    pub dropped_entries: usize,
557}
558
559/// Typed request for one page of scoped memory enumeration.
560///
561/// `offset` (and the resulting page's `next_offset`) count RAW scope rows in
562/// durable-id order, not post-filter records: paging stays deterministic and
563/// iteration stays complete even when `source_overlap` / `indexed_after`
564/// filter rows out of a page, so a page may carry fewer than `limit` records.
565#[derive(Debug, Clone, Copy)]
566pub struct MemoryEnumerationRequest {
567    /// Maximum number of raw scope rows scanned for this page.
568    pub limit: usize,
569    /// Raw scope-row offset (durable-id order) to start scanning from.
570    pub offset: usize,
571    /// Admit only records whose typed source message range overlaps this
572    /// half-open range.
573    pub source_overlap: Option<MessageRange>,
574    /// Admit only records indexed strictly after this instant. Source-range
575    /// offsets restart per compaction generation; scopes are append-only, so
576    /// the previous generation's `indexed_at` high-water disambiguates them.
577    pub indexed_after: Option<crate::time_compat::SystemTime>,
578}
579
580impl MemoryEnumerationRequest {
581    /// Whether `metadata` survives this request's post-deserialize filters.
582    ///
583    /// The filter semantics have exactly one owner (this method) so every
584    /// store applies them identically: `source_overlap` admits records whose
585    /// typed source range overlaps the requested half-open range (a source
586    /// without a range never overlaps); `indexed_after` admits records
587    /// indexed strictly after the given instant.
588    pub fn admits(&self, metadata: &MemoryMetadata) -> bool {
589        if let Some(range) = self.source_overlap {
590            match metadata.source.source_range() {
591                Some(source_range) if source_range.overlaps(&range) => {}
592                _ => return false,
593            }
594        }
595        if let Some(after) = self.indexed_after
596            && metadata.indexed_at <= after
597        {
598            return false;
599        }
600        true
601    }
602}
603
604/// One page of scoped memory enumeration.
605#[derive(Debug, Clone)]
606pub struct MemoryEnumerationPage {
607    /// Records surviving the request's post-filters, in durable-id order.
608    pub records: Vec<MemoryRecord>,
609    /// Raw scope-row offset of the next page, or `None` when no raw scope
610    /// rows remain past this page.
611    pub next_offset: Option<usize>,
612}
613
614/// One enumerated memory record: content plus typed metadata, no ranking
615/// score (enumeration is provenance-ordered, not relevance-ranked).
616#[derive(Debug, Clone)]
617pub struct MemoryRecord {
618    /// The text content of the memory.
619    pub content: String,
620    /// Metadata about the source.
621    pub metadata: MemoryMetadata,
622}
623
624/// Typed embedding model contract owning vector generation.
625///
626/// The model is the authority for how text becomes a ranking vector; stores
627/// consume an injected model rather than hard-coding an embedding scheme.
628pub trait EmbeddingModel: Send + Sync {
629    /// Dimensionality of vectors produced by [`EmbeddingModel::embed`].
630    ///
631    /// Must be stable for the lifetime of the model and match the length of
632    /// every returned vector.
633    fn dimension(&self) -> usize;
634
635    /// Embed `text` into a ranking vector of length [`EmbeddingModel::dimension`].
636    fn embed(&self, text: &str) -> Vec<f32>;
637}
638
639/// Typed HNSW index parameters.
640///
641/// These were previously store-local magic constants; they are now an
642/// injected, typed part of the ranking policy.
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub struct HnswParams {
645    /// Maximum neighbors per layer.
646    pub max_nb_connection: usize,
647    /// Maximum number of layers.
648    pub max_layer: usize,
649    /// Construction-time exploration factor.
650    pub ef_construction: usize,
651    /// Query-time exploration factor floor.
652    pub ef_search: usize,
653}
654
655impl Default for HnswParams {
656    fn default() -> Self {
657        Self {
658            max_nb_connection: 16,
659            max_layer: 16,
660            ef_construction: 200,
661            ef_search: 200,
662        }
663    }
664}
665
666/// Typed ranking policy: the authority for embedding generation and index
667/// parameters used by a semantic memory store.
668#[derive(Clone)]
669pub struct MemoryRankingPolicy {
670    embedding_model: std::sync::Arc<dyn EmbeddingModel>,
671    hnsw_params: HnswParams,
672}
673
674impl MemoryRankingPolicy {
675    /// Build a ranking policy from an embedding model and index parameters.
676    pub fn new(
677        embedding_model: std::sync::Arc<dyn EmbeddingModel>,
678        hnsw_params: HnswParams,
679    ) -> Self {
680        Self {
681            embedding_model,
682            hnsw_params,
683        }
684    }
685
686    /// The embedding model that owns vector generation.
687    pub fn embedding_model(&self) -> &std::sync::Arc<dyn EmbeddingModel> {
688        &self.embedding_model
689    }
690
691    /// The typed HNSW index parameters.
692    pub fn hnsw_params(&self) -> HnswParams {
693        self.hnsw_params
694    }
695
696    /// Embedding dimension (delegates to the model).
697    pub fn dimension(&self) -> usize {
698        self.embedding_model.dimension()
699    }
700
701    /// Embed `text` via the policy's model.
702    pub fn embed(&self, text: &str) -> Vec<f32> {
703        self.embedding_model.embed(text)
704    }
705}
706
707impl std::fmt::Debug for MemoryRankingPolicy {
708    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709        f.debug_struct("MemoryRankingPolicy")
710            .field("dimension", &self.embedding_model.dimension())
711            .field("hnsw_params", &self.hnsw_params)
712            .finish()
713    }
714}
715
716/// Semantic memory store for indexing and searching conversation history.
717#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
718#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
719pub trait MemoryStore: Send + Sync {
720    /// Compaction projection durability contract for this store.
721    fn compaction_projection_persistence(&self) -> CompactionProjectionPersistence {
722        // Unknown/custom stores fail closed without making legacy recall-only
723        // implementations pretend to support durable staging/reconciliation.
724        CompactionProjectionPersistence::Unsupported
725    }
726
727    /// Index a typed, owner-scoped memory request.
728    async fn index_scoped(
729        &self,
730        request: MemoryIndexRequest,
731    ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
732        self.index_scoped_batch(MemoryIndexBatch::single(request))
733            .await
734    }
735
736    /// Atomically index a typed, owner-scoped memory batch.
737    ///
738    /// Implementations must either make every request in the batch visible or
739    /// make none of them visible.
740    async fn index_scoped_batch(
741        &self,
742        batch: MemoryIndexBatch,
743    ) -> Result<MemoryIndexReceipt, MemoryStoreError>;
744
745    /// Persist a compaction batch durably but invisibly.
746    ///
747    /// Durable stores override this. Search/enumeration must not observe any
748    /// staged row until [`MemoryStore::finalize_compaction_batch`] succeeds.
749    async fn stage_compaction_batch(
750        &self,
751        projection: CompactionProjectionId,
752        batch: MemoryIndexBatch,
753    ) -> Result<CompactionStageReceipt, MemoryStoreError> {
754        let _ = (projection, batch);
755        Err(MemoryStoreError::Unsupported {
756            operation: "stage_compaction_batch",
757        })
758    }
759
760    /// Idempotently publish one previously staged durable batch.
761    async fn finalize_compaction_batch(
762        &self,
763        projection: &CompactionProjectionId,
764    ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
765        let _ = projection;
766        Err(MemoryStoreError::Unsupported {
767            operation: "finalize_compaction_batch",
768        })
769    }
770
771    /// Idempotently discard one uncommitted invisible stage.
772    async fn abort_compaction_batch(
773        &self,
774        projection: &CompactionProjectionId,
775    ) -> Result<(), MemoryStoreError> {
776        let _ = projection;
777        Err(MemoryStoreError::Unsupported {
778            operation: "abort_compaction_batch",
779        })
780    }
781
782    /// Reconcile durable invisible stages against authoritative transcript
783    /// rewrite identities at agent-build ingress.
784    ///
785    /// Stages absent from `committed` are crash/cancellation orphans and are
786    /// aborted. Matching stages remain invisible for the runtime outbox owner
787    /// to finalize.
788    async fn reconcile_compaction_stages(
789        &self,
790        owner: &MemoryOwner,
791        committed: &[CompactionProjectionId],
792    ) -> Result<CompactionStageReconcileReceipt, MemoryStoreError> {
793        let _ = (owner, committed);
794        Err(MemoryStoreError::Unsupported {
795            operation: "reconcile_compaction_stages",
796        })
797    }
798
799    /// Semantic search: return up to `limit` results ordered by relevance.
800    async fn search(
801        &self,
802        scope: &MemorySearchScope,
803        query: &str,
804        limit: usize,
805    ) -> Result<Vec<MemoryResult>, MemoryStoreError>;
806
807    /// Atomically and permanently drop every indexed entry owned by `owner`.
808    ///
809    /// All-or-nothing per scope: either every durable entry for the owner is
810    /// removed or none are. Durable staging implementations must retain a
811    /// deletion-wins tombstone so stale stage/finalize/reconcile/index work
812    /// cannot republish this identity after concurrency or restart; a pending
813    /// finalize may acknowledge the deletion with a zero-entry success. Other
814    /// owners' entries are untouched. Unsupported by default for stores
815    /// without a deletion capability.
816    async fn drop_scope(
817        &self,
818        owner: &MemoryOwner,
819    ) -> Result<MemoryScopeDropReceipt, MemoryStoreError> {
820        let _ = owner;
821        Err(MemoryStoreError::Unsupported {
822            operation: "drop_scope",
823        })
824    }
825
826    /// Enumerate one page of a scope's records in durable-id order.
827    ///
828    /// Paging counts raw scope rows (see [`MemoryEnumerationRequest`]); the
829    /// `source_overlap` / `indexed_after` filters run on typed metadata after
830    /// deserialization, so a page may return fewer than `limit` records.
831    /// Corrupt durable rows propagate as typed faults, never silently
832    /// skipped. Unsupported by default.
833    async fn enumerate_scoped(
834        &self,
835        scope: &MemorySearchScope,
836        request: MemoryEnumerationRequest,
837    ) -> Result<MemoryEnumerationPage, MemoryStoreError> {
838        let _ = (scope, request);
839        Err(MemoryStoreError::Unsupported {
840            operation: "enumerate_scoped",
841        })
842    }
843}
844
845/// Errors from memory store operations.
846///
847/// Each underlying failure is a distinct typed variant so callers can
848/// distinguish (e.g.) a poisoned index lock from a storage fault from an
849/// embedding serialization error without parsing message substrings.
850#[derive(Debug, thiserror::Error)]
851pub enum MemoryStoreError {
852    /// An indexing/search scope did not contain the supplied metadata.
853    #[error("Scope error: {0}")]
854    Scope(String),
855
856    /// An inverted message source range (`start > end`).
857    #[error("invalid memory source range: start {start} > end {end}")]
858    SourceRange { start: u64, end: u64 },
859
860    /// Embedding/metadata serialization or deserialization failed.
861    #[error("Embedding error: {0}")]
862    Embedding(String),
863
864    /// The backing index/metadata store reported a failure.
865    #[error("Storage error: {0}")]
866    Storage(String),
867
868    /// The in-memory index lock was poisoned by a panicking holder.
869    #[error("memory index lock poisoned")]
870    LockPoisoned,
871
872    /// A point ID could not be represented in the target integer width.
873    #[error("memory point ID out of range")]
874    PointIdOutOfRange,
875
876    /// Allocating the next point ID would overflow the ID space.
877    #[error("memory point ID overflow")]
878    PointIdOverflow,
879
880    /// A background store task failed to join.
881    #[error("memory store task join failed: {0}")]
882    TaskJoin(String),
883
884    /// Durable memory text bytes are not valid UTF-8. Corrupt durable bytes
885    /// are a typed store-corruption fault, never lossy-decoded into
886    /// searchable/returned memory content.
887    #[error("memory text corruption at point {point_id}: stored bytes are not valid UTF-8")]
888    TextCorruption { point_id: i64 },
889
890    /// The live nearest-neighbor index referenced a point that has no durable
891    /// row. The index and the durable store have diverged; results derived
892    /// from the divergent candidate set must not be silently filtered.
893    #[error(
894        "memory index/store divergence at point {point_id}: live index references a missing durable row"
895    )]
896    IndexDivergence { point_id: i64 },
897
898    /// The scoped live index is poisoned: a failed batch could not be
899    /// repaired from durable state, so reads fail closed until the scope is
900    /// rebuilt (next successful index attempt or store reopen).
901    #[error("memory scope index is poisoned pending rebuild from durable state")]
902    ScopePoisoned,
903
904    /// A failed batch could not be rolled back/repaired from durable state.
905    /// The scoped live index is poisoned (fails closed) until rebuilt.
906    #[error(
907        "memory scope repair failed after partial index failure: {repair} (original failure: {original})"
908    )]
909    ScopeRepairFailed {
910        original: Box<MemoryStoreError>,
911        repair: Box<MemoryStoreError>,
912    },
913
914    /// The store does not implement the requested optional operation.
915    #[error("memory store operation '{operation}' is unsupported by this store")]
916    Unsupported { operation: &'static str },
917
918    /// An enumeration request carried `limit == 0`, which cannot advance the
919    /// raw-row cursor: `next_offset` would equal the request offset and a
920    /// standard follow-`next_offset` pagination loop would never terminate.
921    #[error("memory enumeration limit must be non-zero")]
922    EnumerationLimitZero,
923
924    /// An underlying filesystem operation failed.
925    #[error("IO error: {0}")]
926    Io(#[from] std::io::Error),
927}
928
929impl MemoryStoreError {
930    /// Stable discriminant for the failure class.
931    ///
932    /// Callers (e.g. tool surfaces) use this to preserve the typed distinction
933    /// downstream without parsing message substrings.
934    pub fn error_code(&self) -> &'static str {
935        match self {
936            Self::Scope(_) => "memory_scope",
937            Self::SourceRange { .. } => "memory_source_range",
938            Self::Embedding(_) => "memory_embedding",
939            Self::Storage(_) => "memory_storage",
940            Self::LockPoisoned => "memory_lock_poisoned",
941            Self::PointIdOutOfRange => "memory_point_id_out_of_range",
942            Self::PointIdOverflow => "memory_point_id_overflow",
943            Self::TaskJoin(_) => "memory_task_join",
944            Self::TextCorruption { .. } => "memory_text_corruption",
945            Self::IndexDivergence { .. } => "memory_index_divergence",
946            Self::ScopePoisoned => "memory_scope_poisoned",
947            Self::ScopeRepairFailed { .. } => "memory_scope_repair_failed",
948            Self::Unsupported { .. } => "memory_unsupported",
949            Self::EnumerationLimitZero => "memory_enumeration_limit_zero",
950            Self::Io(_) => "memory_io",
951        }
952    }
953}
954
955#[cfg(test)]
956#[allow(clippy::unwrap_used, clippy::expect_used)]
957mod tests {
958    use super::*;
959    use crate::time_compat::{Duration, UNIX_EPOCH};
960
961    fn range(start: u64, end: u64) -> MessageRange {
962        MessageRange::new(start, end).unwrap()
963    }
964
965    fn compaction_commit(
966        committed_at: crate::time_compat::SystemTime,
967    ) -> (
968        crate::TranscriptRewriteCommit,
969        crate::agent::compact::ValidatedCompactionRewrite,
970    ) {
971        let mut session = crate::Session::new();
972        session.push(crate::types::Message::User(
973            crate::types::UserMessage::text("verbose context one"),
974        ));
975        session.push(crate::types::Message::User(
976            crate::types::UserMessage::text("verbose context two"),
977        ));
978        let replacement = vec![crate::types::Message::User(
979            crate::types::UserMessage::compaction_summary("compacted context"),
980        )];
981        let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
982            session.messages(),
983            &replacement,
984        )
985        .unwrap();
986        let mut commit = session
987            .replace_messages_for_compaction_internal(replacement, &authority)
988            .unwrap()
989            .unwrap();
990        commit.committed_at = committed_at;
991        (commit, authority)
992    }
993
994    #[test]
995    fn projection_identity_fingerprints_semantic_commit_but_excludes_wall_time() {
996        let session_id = crate::types::SessionId::new();
997        let (first_commit, first_authority) =
998            compaction_commit(UNIX_EPOCH + Duration::from_secs(1));
999        let first = CompactionProjectionId::from_validated_transcript_rewrite(
1000            session_id.clone(),
1001            &first_commit,
1002            &first_authority,
1003        )
1004        .unwrap();
1005        let (retry_commit, retry_authority) =
1006            compaction_commit(UNIX_EPOCH + Duration::from_secs(2));
1007        let retry = CompactionProjectionId::from_validated_transcript_rewrite(
1008            session_id.clone(),
1009            &retry_commit,
1010            &retry_authority,
1011        )
1012        .unwrap();
1013        assert_eq!(first, retry, "wall time must not break cancellation retry");
1014
1015        let (mut distinct_commit, distinct_authority) =
1016            compaction_commit(UNIX_EPOCH + Duration::from_secs(1));
1017        distinct_commit.actor = Some("agent-b".to_string());
1018        let distinct = CompactionProjectionId::from_validated_transcript_rewrite(
1019            session_id,
1020            &distinct_commit,
1021            &distinct_authority,
1022        )
1023        .unwrap();
1024        assert_ne!(first, distinct, "semantic commit fields must fence aliases");
1025        assert_ne!(first.commit_fingerprint(), distinct.commit_fingerprint());
1026
1027        let (mut presentation_only, presentation_authority) =
1028            compaction_commit(UNIX_EPOCH + Duration::from_secs(3));
1029        presentation_only.reason = crate::TranscriptRewriteReason {
1030            kind: "context_reduction".to_string(),
1031            note: Some("free-form audit wording changed".to_string()),
1032        };
1033        let presentation_only = CompactionProjectionId::from_validated_transcript_rewrite(
1034            first.session_id().clone(),
1035            &presentation_only,
1036            &presentation_authority,
1037        )
1038        .unwrap();
1039        assert_eq!(
1040            first, presentation_only,
1041            "free-form audit reason must not participate in projection authority or identity"
1042        );
1043    }
1044
1045    #[test]
1046    fn free_form_compaction_reason_cannot_mint_projection_authority() {
1047        let (mut generic, authority) = compaction_commit(UNIX_EPOCH);
1048        generic.selection = crate::TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
1049        generic.reason = crate::TranscriptRewriteReason::new("compaction");
1050        assert!(
1051            CompactionProjectionId::from_validated_transcript_rewrite(
1052                crate::types::SessionId::new(),
1053                &generic,
1054                &authority,
1055            )
1056            .is_none(),
1057            "display reason text is never a compaction witness"
1058        );
1059    }
1060
1061    #[test]
1062    fn typed_compaction_commit_accepts_exact_pre_semantic_fingerprint_for_prior_data() {
1063        let session_id = crate::types::SessionId::new();
1064        let (commit, authority) = compaction_commit(UNIX_EPOCH);
1065        let current = CompactionProjectionId::from_validated_transcript_rewrite(
1066            session_id.clone(),
1067            &commit,
1068            &authority,
1069        )
1070        .unwrap();
1071        let legacy =
1072            CompactionProjectionId::legacy_from_typed_compaction(session_id.clone(), &commit)
1073                .unwrap();
1074        assert_ne!(
1075            legacy, current,
1076            "typed semantic changes the canonical fingerprint"
1077        );
1078        assert!(legacy.matches_transcript_rewrite(&session_id, &commit));
1079    }
1080
1081    #[test]
1082    fn overlaps_is_half_open() {
1083        // Proper overlap.
1084        assert!(range(0, 5).overlaps(&range(4, 6)));
1085        assert!(range(4, 6).overlaps(&range(0, 5)));
1086        // Containment overlaps.
1087        assert!(range(0, 10).overlaps(&range(3, 4)));
1088        assert!(range(3, 4).overlaps(&range(0, 10)));
1089        // Touching ranges do not overlap (half-open).
1090        assert!(!range(0, 5).overlaps(&range(5, 10)));
1091        assert!(!range(5, 10).overlaps(&range(0, 5)));
1092        // Disjoint ranges do not overlap.
1093        assert!(!range(0, 2).overlaps(&range(7, 9)));
1094    }
1095
1096    #[test]
1097    fn empty_range_never_overlaps() {
1098        assert!(!range(3, 3).overlaps(&range(0, 10)));
1099        assert!(!range(0, 10).overlaps(&range(3, 3)));
1100        assert!(!range(3, 3).overlaps(&range(3, 3)));
1101    }
1102
1103    #[test]
1104    fn unsupported_error_code_is_stable() {
1105        assert_eq!(
1106            MemoryStoreError::Unsupported {
1107                operation: "drop_scope",
1108            }
1109            .error_code(),
1110            "memory_unsupported"
1111        );
1112    }
1113
1114    fn metadata_at(
1115        indexed_at: crate::time_compat::SystemTime,
1116        source: MemorySource,
1117    ) -> MemoryMetadata {
1118        MemoryMetadata {
1119            session_id: crate::types::SessionId::new(),
1120            source,
1121            indexed_at,
1122        }
1123    }
1124
1125    #[test]
1126    fn enumeration_request_admits_on_source_overlap() {
1127        let request = MemoryEnumerationRequest {
1128            limit: 10,
1129            offset: 0,
1130            source_overlap: Some(range(4, 6)),
1131            indexed_after: None,
1132        };
1133        let overlapping = metadata_at(
1134            UNIX_EPOCH,
1135            MemorySource::Compaction {
1136                source_range: range(0, 5),
1137            },
1138        );
1139        let disjoint = metadata_at(
1140            UNIX_EPOCH,
1141            MemorySource::Compaction {
1142                source_range: range(6, 9),
1143            },
1144        );
1145        assert!(request.admits(&overlapping));
1146        assert!(!request.admits(&disjoint));
1147    }
1148
1149    #[test]
1150    fn enumeration_request_indexed_after_is_strict() {
1151        let boundary = UNIX_EPOCH + Duration::from_secs(100);
1152        let request = MemoryEnumerationRequest {
1153            limit: 10,
1154            offset: 0,
1155            source_overlap: None,
1156            indexed_after: Some(boundary),
1157        };
1158        let at_boundary = metadata_at(
1159            boundary,
1160            MemorySource::Compaction {
1161                source_range: range(0, 1),
1162            },
1163        );
1164        let after_boundary = metadata_at(
1165            boundary + Duration::from_secs(1),
1166            MemorySource::Compaction {
1167                source_range: range(0, 1),
1168            },
1169        );
1170        let before_boundary = metadata_at(
1171            UNIX_EPOCH,
1172            MemorySource::Compaction {
1173                source_range: range(0, 1),
1174            },
1175        );
1176        assert!(!request.admits(&at_boundary));
1177        assert!(request.admits(&after_boundary));
1178        assert!(!request.admits(&before_boundary));
1179    }
1180
1181    #[test]
1182    fn enumeration_request_filters_compose() {
1183        let request = MemoryEnumerationRequest {
1184            limit: 10,
1185            offset: 0,
1186            source_overlap: Some(range(0, 5)),
1187            indexed_after: Some(UNIX_EPOCH + Duration::from_secs(100)),
1188        };
1189        let both = metadata_at(
1190            UNIX_EPOCH + Duration::from_secs(200),
1191            MemorySource::Compaction {
1192                source_range: range(2, 3),
1193            },
1194        );
1195        let wrong_range = metadata_at(
1196            UNIX_EPOCH + Duration::from_secs(200),
1197            MemorySource::Compaction {
1198                source_range: range(5, 9),
1199            },
1200        );
1201        let too_early = metadata_at(
1202            UNIX_EPOCH,
1203            MemorySource::Compaction {
1204                source_range: range(2, 3),
1205            },
1206        );
1207        assert!(request.admits(&both));
1208        assert!(!request.admits(&wrong_range));
1209        assert!(!request.admits(&too_early));
1210    }
1211
1212    /// Minimal store implementing only the required trait surface, pinning
1213    /// the fail-closed `Unsupported` defaults for the optional lifecycle
1214    /// methods.
1215    struct MinimalStore;
1216
1217    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1218    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1219    impl MemoryStore for MinimalStore {
1220        async fn index_scoped_batch(
1221            &self,
1222            batch: MemoryIndexBatch,
1223        ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
1224            let (scope, requests) = batch.into_parts();
1225            Ok(MemoryIndexReceipt {
1226                scope,
1227                indexed_entries: requests.len(),
1228            })
1229        }
1230
1231        async fn search(
1232            &self,
1233            _scope: &MemorySearchScope,
1234            _query: &str,
1235            _limit: usize,
1236        ) -> Result<Vec<MemoryResult>, MemoryStoreError> {
1237            Ok(Vec::new())
1238        }
1239    }
1240
1241    #[tokio::test]
1242    async fn drop_scope_default_is_typed_unsupported() {
1243        let store = MinimalStore;
1244        assert_eq!(
1245            store.compaction_projection_persistence(),
1246            CompactionProjectionPersistence::Unsupported
1247        );
1248        let owner = MemoryOwner::canonical_session(crate::types::SessionId::new());
1249        let error = store.drop_scope(&owner).await.unwrap_err();
1250        assert!(matches!(
1251            error,
1252            MemoryStoreError::Unsupported {
1253                operation: "drop_scope",
1254            }
1255        ));
1256        assert_eq!(error.error_code(), "memory_unsupported");
1257    }
1258
1259    #[tokio::test]
1260    async fn reconcile_compaction_stages_default_is_typed_unsupported() {
1261        let store = MinimalStore;
1262        let owner = MemoryOwner::canonical_session(crate::types::SessionId::new());
1263        let error = store
1264            .reconcile_compaction_stages(&owner, &[])
1265            .await
1266            .unwrap_err();
1267        assert!(matches!(
1268            error,
1269            MemoryStoreError::Unsupported {
1270                operation: "reconcile_compaction_stages",
1271            }
1272        ));
1273    }
1274
1275    #[tokio::test]
1276    async fn enumerate_scoped_default_is_typed_unsupported() {
1277        let store = MinimalStore;
1278        let scope = MemorySearchScope::for_session(crate::types::SessionId::new());
1279        let error = store
1280            .enumerate_scoped(
1281                &scope,
1282                MemoryEnumerationRequest {
1283                    limit: 10,
1284                    offset: 0,
1285                    source_overlap: None,
1286                    indexed_after: None,
1287                },
1288            )
1289            .await
1290            .unwrap_err();
1291        assert!(matches!(
1292            error,
1293            MemoryStoreError::Unsupported {
1294                operation: "enumerate_scoped",
1295            }
1296        ));
1297        assert_eq!(error.error_code(), "memory_unsupported");
1298    }
1299}