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