Skip to main content

laser_sdk/
memory.rs

1use crate::agent::Laser;
2use crate::error::LaserError;
3use crate::provenance::{AgentTopic, Provenance};
4use crate::types::{AgentId, ConversationId, IdError};
5use iggy::prelude::*;
6use laser_wire::graph::SourceRef;
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::fmt::{self, Display, Formatter};
10use std::str::FromStr;
11use tokio::sync::Mutex;
12use ulid::Ulid;
13
14// Per-partition poll size when `LogMemory` catches its projection up to the tail.
15const READ_BATCH: u32 = 1000;
16
17/// How many items one consolidation pass scans (the recall window it operates
18/// over). Items older than this window are not recalled, so they are neither kept
19/// nor pruned by a pass, they simply age out of view.
20const CONSOLIDATION_WINDOW: usize = 10_000;
21
22/// The default message-expiry for a configured memory topic: how long the audit
23/// history of every remember, forget, and feedback lives on the stream. Bounds
24/// the topic without dropping too soon. The materialized read view keeps its own
25/// separate retention, so a value pruned there is still on the stream until this
26/// window passes. Thirty days. Override with
27/// [`MemoryTopicBuilder::ttl`]/[`no_expiry`](MemoryTopicBuilder::no_expiry).
28pub const DEFAULT_MEMORY_TOPIC_TTL: std::time::Duration =
29    std::time::Duration::from_secs(30 * 24 * 60 * 60);
30
31/// What a remembered item is. Agentic memory is an SDK layer over the streaming,
32/// query, and graph primitives, so this is an SDK type, not a wire op field.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum MemoryKind {
36    /// A standalone fact (the default). Semantic memory: what is known.
37    #[default]
38    Fact,
39    /// A conversation turn. Episodic memory: what happened.
40    Message,
41    /// A summary distilled from other items.
42    Summary,
43    /// An extracted entity, the body of a graph node.
44    Entity,
45    /// A feedback signal that reweights recall.
46    Feedback,
47    /// A reusable procedure: a skill or workflow recalled by task similarity and
48    /// improved by outcome feedback. Procedural memory: how things are done.
49    Procedure,
50}
51
52impl MemoryKind {
53    /// A stable one-byte discriminator, mixed into a content-addressed id so the
54    /// same body under different kinds gets distinct ids.
55    pub const fn code(self) -> u8 {
56        match self {
57            MemoryKind::Fact => 1,
58            MemoryKind::Message => 2,
59            MemoryKind::Summary => 3,
60            MemoryKind::Entity => 4,
61            MemoryKind::Feedback => 5,
62            MemoryKind::Procedure => 6,
63        }
64    }
65
66    /// The snake-case kind word, the form that rides the wire record and the
67    /// read view. `Fact` for an unknown word, so a newer kind read by an older
68    /// build degrades rather than fails.
69    pub fn from_word(word: &str) -> MemoryKind {
70        match word {
71            "message" => MemoryKind::Message,
72            "summary" => MemoryKind::Summary,
73            "entity" => MemoryKind::Entity,
74            "feedback" => MemoryKind::Feedback,
75            "procedure" => MemoryKind::Procedure,
76            _ => MemoryKind::Fact,
77        }
78    }
79
80    /// The memory class this kind belongs to, in the converging
81    /// episodic / semantic / procedural taxonomy. `Episodic` is what happened
82    /// (messages), `Procedural` is how things are done (procedures), and
83    /// everything else is `Semantic` (what is known).
84    pub const fn class(self) -> MemoryClass {
85        match self {
86            MemoryKind::Message => MemoryClass::Episodic,
87            MemoryKind::Procedure => MemoryClass::Procedural,
88            MemoryKind::Fact | MemoryKind::Summary | MemoryKind::Entity | MemoryKind::Feedback => {
89                MemoryClass::Semantic
90            }
91        }
92    }
93}
94
95/// The high-level class a [`MemoryKind`] falls into, the field's converging
96/// taxonomy: episodic (what happened), semantic (what is known), procedural (how
97/// things are done).
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum MemoryClass {
101    /// What happened: conversation turns and events.
102    Episodic,
103    /// What is known: facts, summaries, entities.
104    Semantic,
105    /// How things are done: procedures, skills, workflows.
106    Procedural,
107}
108
109/// How long a memory lives. `Session` is conversation-scoped and prunable, and
110/// `Durable` is shared across conversations and feeds the knowledge graph.
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum Lifetime {
114    /// Conversation-scoped, prunable. The default.
115    #[default]
116    Session,
117    /// Shared across conversations, graph-backed.
118    Durable,
119}
120
121/// How recall finds items. `Auto` lets the backend route from the available
122/// state (an embedder ranks semantically, a graph traverses, otherwise recency).
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum RecallStrategy {
126    /// Route to the best available strategy. The default.
127    #[default]
128    Auto,
129    /// Most recent items first.
130    Recent,
131    /// Rank by semantic similarity to the query (vector signal).
132    Semantic,
133    /// Rank by lexical overlap with the query (keyword signal), the complement to
134    /// `Semantic` for exact-term matches an embedding can blur.
135    Keyword,
136    /// Traverse the knowledge graph from the query seed.
137    Graph,
138    /// Items inside the time range, most recent first.
139    Temporal,
140    /// Fuse every available signal (semantic + keyword + feedback) into one score.
141    /// The multi-signal default for quality recall.
142    Hybrid,
143}
144
145/// A memory item's id (a ULID, so it sorts by creation time).
146#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
147pub struct MemoryId(Ulid);
148
149impl MemoryId {
150    /// A fresh, random memory id.
151    pub fn new() -> Self {
152        Self(Ulid::generate())
153    }
154
155    /// The raw 128-bit value.
156    pub fn as_u128(self) -> u128 {
157        self.0.0
158    }
159
160    /// Wrap a raw 128-bit value.
161    pub fn from_u128(value: u128) -> Self {
162        Self(Ulid(value))
163    }
164
165    /// A deterministic, content-addressed id: the same `owner`, `kind`, and
166    /// `body` always produce the same id, so a deduped remember stores one item.
167    /// The owner is the durable scope (stream, agent, user, and application,
168    /// never the conversation), so the same fact in two conversations is one
169    /// durable memory and two users never share an id. The hash is the wire
170    /// crate's one canonical [`content_id`](laser_wire::hashing::content_id),
171    /// so every SDK reproduces it from the same byte segments.
172    pub fn content(owner: &MemoryScope, kind: MemoryKind, body: &[u8]) -> Self {
173        let stream = owner.stream.as_deref().unwrap_or("").as_bytes();
174        let agent = owner.agent.as_ref().map(AgentId::as_str).unwrap_or("");
175        let user = owner.user.as_deref().unwrap_or("").as_bytes();
176        let application = owner.app.as_deref().unwrap_or("").as_bytes();
177        let kind = [kind.code()];
178        // Owner segments each end in a zero separator, then the kind byte and
179        // the body, the order every SDK hashes.
180        let segments: [&[u8]; 10] = [
181            stream,
182            &[0],
183            agent.as_bytes(),
184            &[0],
185            user,
186            &[0],
187            application,
188            &[0],
189            &kind,
190            body,
191        ];
192        Self::from_u128(laser_wire::hashing::content_id(&segments))
193    }
194}
195
196impl Serialize for MemoryId {
197    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
198        serializer.serialize_str(&self.to_string())
199    }
200}
201
202impl<'de> Deserialize<'de> for MemoryId {
203    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
204        let text = String::deserialize(deserializer)?;
205        text.parse().map_err(serde::de::Error::custom)
206    }
207}
208
209impl Default for MemoryId {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215impl Display for MemoryId {
216    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
217        Display::fmt(&self.0, f)
218    }
219}
220
221impl FromStr for MemoryId {
222    type Err = IdError;
223
224    fn from_str(s: &str) -> Result<Self, Self::Err> {
225        Ulid::from_string(s)
226            .map(Self)
227            .map_err(|_| IdError::InvalidUlid(s.to_owned()))
228    }
229}
230
231/// Scopes memory along the converging identity layers (user / agent / session /
232/// app) plus the physical stream, with a lifetime tier. Any field left unset
233/// widens recall to match across that dimension.
234///
235/// `conversation` is the session layer (a single run or thread). `user` and `app`
236/// are the broader identity layers: the end user the memory is about, and the
237/// application or org it belongs to within a deployment. They narrow recall on
238/// backends that store them (e.g. [`VectorMemory`]). The physical isolation
239/// boundary stays Iggy stream. (There is deliberately no account layer
240/// here: a deployment has one owner, and `app` is a scope within it.)
241#[derive(Debug, Clone, Default, bon::Builder)]
242pub struct MemoryScope {
243    /// Restrict to this stream (none = any). The physical isolation boundary.
244    pub stream: Option<String>,
245    /// Restrict to this end user (none = any). The identity the memory is about.
246    pub user: Option<String>,
247    /// Restrict to this agent (none = any).
248    pub agent: Option<AgentId>,
249    /// Restrict to this conversation, the session layer (none = any).
250    pub conversation: Option<ConversationId>,
251    /// Restrict to this application or org within the deployment (none = any).
252    pub app: Option<String>,
253    /// A write policy for custom backends. Built-in recall filters use the identity fields above. `Session` is the default. Log storage does not persist this field.
254    #[builder(default)]
255    pub lifetime: Lifetime,
256    /// The session record that motivated a remembered item. Written with the
257    /// item, never used to filter recall.
258    pub origin: Option<SourceRef>,
259    /// The component that produced a remembered item. Written with the item,
260    /// never used to filter recall.
261    pub producer: Option<laser_wire::graph::ProducerInfo>,
262}
263
264/// A remembered item: its id, payload, provenance, kind, and recall score.
265#[derive(Debug, Clone)]
266pub struct MemoryItem {
267    /// The item's id.
268    pub id: MemoryId,
269    /// The remembered body. Owned `Vec<u8>` so the public API never leaks the
270    /// `bytes` crate.
271    pub payload: Vec<u8>,
272    /// The scope it was stored under.
273    pub provenance: Provenance,
274    /// What the item is. `Fact` unless set on remember.
275    pub kind: MemoryKind,
276    /// The recall score, set by a ranking strategy (e.g. semantic similarity),
277    /// `None` for an unranked recall.
278    pub score: Option<f32>,
279    /// Which signals produced this candidate and their pre-fusion standing, so
280    /// a fused recall (`Hybrid`, a routed `Auto`) stays inspectable. Empty for
281    /// an unranked recall. Additive: nothing on the wire carries it.
282    pub signals: Vec<RecallSignal>,
283    /// The origin log record this item was folded from (stream, topic, partition,
284    /// offset), when the managed read view carried it, so a reader can navigate
285    /// back to the source message while it is still on the log. `None` for an
286    /// in-process recall or a read view without provenance.
287    pub source: Option<SourceRef>,
288    /// The session record that motivated the item, when it was remembered with one.
289    pub origin: Option<SourceRef>,
290    /// The component that produced the item, when it was remembered with one.
291    pub producer: Option<laser_wire::graph::ProducerInfo>,
292}
293
294impl MemoryItem {
295    /// The payload as UTF-8, lossy.
296    pub fn text(&self) -> String {
297        String::from_utf8_lossy(&self.payload).into_owned()
298    }
299
300    /// Decode the payload as JSON.
301    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, LaserError> {
302        serde_json::from_slice(&self.payload).map_err(|error| LaserError::Codec(error.to_string()))
303    }
304}
305
306#[cfg(test)]
307impl MemoryItem {
308    fn plain(id: MemoryId, payload: Vec<u8>, provenance: Provenance) -> Self {
309        Self {
310            id,
311            payload,
312            provenance,
313            kind: MemoryKind::Fact,
314            score: None,
315            signals: Vec::new(),
316            source: None,
317            origin: None,
318            producer: None,
319        }
320    }
321}
322
323/// One signal's contribution to a recalled item: the strategy that surfaced
324/// it, its rank within that signal (0 is best), and that signal's own score
325/// before fusion.
326#[derive(Debug, Clone, PartialEq)]
327pub struct RecallSignal {
328    /// The strategy that produced the candidate. For a routed `Auto` this is
329    /// the strategy `Auto` resolved to, which is how the routing is reported.
330    pub strategy: RecallStrategy,
331    /// The candidate's rank within this signal, 0 is best.
332    pub rank: usize,
333    /// The signal's own score before fusion (similarity, relevance, weight).
334    pub score: Option<f32>,
335}
336
337/// Render recalled `items` into one context block for a prompt, in rank order,
338/// filling up to `token_budget` (advisory tokens, estimated at ~4 bytes each).
339/// Each item's payload is read as UTF-8 (lossily). When the budget is reached the
340/// remaining items are dropped and a `[... N more recalled item(s) omitted ...]`
341/// marker is appended (summarize-on-overflow, the cheap form). `None` budget
342/// renders every item. The byte-based estimate avoids a tokenizer dependency. It can differ from the model's token count.
343pub fn to_context_block(items: &[MemoryItem], token_budget: Option<usize>) -> String {
344    let mut block = String::new();
345    let mut spent = 0usize;
346    let mut rendered = 0usize;
347    for item in items {
348        let text = String::from_utf8_lossy(&item.payload);
349        let cost = estimate_tokens(&text);
350        if let Some(budget) = token_budget
351            && rendered > 0
352            && spent + cost > budget
353        {
354            break;
355        }
356        if rendered > 0 {
357            block.push_str("\n\n");
358        }
359        block.push_str(&text);
360        spent += cost;
361        rendered += 1;
362    }
363    let omitted = items.len() - rendered;
364    if omitted > 0 {
365        if rendered > 0 {
366            block.push_str("\n\n");
367        }
368        block.push_str(&format!(
369            "[... {omitted} more recalled item(s) omitted ...]"
370        ));
371    }
372    block
373}
374
375/// A cheap token estimate: about four bytes per token, the common rough proxy
376/// when no tokenizer is available. Rounds up so an item never estimates as zero.
377fn estimate_tokens(text: &str) -> usize {
378    usize::try_from(laser_wire::agent::estimate_tokens(text.len())).unwrap_or(usize::MAX)
379}
380
381/// How to recall: a result limit, an optional agent filter, and an optional semantic query.
382#[derive(Debug, Clone, bon::Builder)]
383pub struct MemoryQuery {
384    /// Max items to return.
385    #[builder(default = 50)]
386    pub limit: usize,
387    /// An optional context-window budget in tokens, alongside the item-count
388    /// `limit`. The recall result is rendered to fit it by
389    /// [`to_context_block`], which fills in rank order until the budget is
390    /// reached. Advisory: tokens are estimated, not tokenized (no model
391    /// dependency in the SDK).
392    pub token_budget: Option<usize>,
393    pub agent: Option<AgentId>,
394    /// A semantic query. Backends that embed (e.g. `VectorMemory`) rank results
395    /// by similarity to this text. The log-backed default ignores it.
396    pub semantic: Option<String>,
397    /// How recall finds items. `Auto` (the default) lets the backend route from
398    /// the available state: a backend with an embedder and a `semantic` query
399    /// ranks by similarity, otherwise it returns the most recent.
400    #[builder(default)]
401    pub strategy: RecallStrategy,
402}
403
404/// A feedback signal on a recalled item: which item, and how to reweight it. A
405/// positive `weight` promotes the item in future recall, a negative one demotes
406/// it.
407#[derive(Debug, Clone)]
408pub struct Feedback {
409    /// The item the feedback is about.
410    pub target: MemoryId,
411    /// The reweighting, positive to promote, negative to demote.
412    pub weight: f32,
413    /// An optional human note.
414    pub note: Option<String>,
415}
416
417impl Feedback {
418    /// Feedback that promotes (`weight > 0`) or demotes (`weight < 0`) `target`.
419    pub fn new(target: MemoryId, weight: f32) -> Self {
420        Self {
421            target,
422            weight,
423            note: None,
424        }
425    }
426}
427
428/// Agent memory: `remember` / `recall` / `improve` / `forget`. (`Memory` is the
429/// `Send` variant.)
430#[trait_variant::make(Memory: Send)]
431pub trait LocalMemory {
432    /// Append `payload` to the memory under `scope`. The body is opaque bytes,
433    /// so encode it with whatever codec you like before calling.
434    async fn remember(&self, scope: &MemoryScope, payload: Vec<u8>)
435    -> Result<MemoryId, LaserError>;
436    /// Append a typed item. Custom backends can keep their remember fallback.
437    fn append(
438        &self,
439        scope: &MemoryScope,
440        id: MemoryId,
441        kind: MemoryKind,
442        payload: Vec<u8>,
443    ) -> impl std::future::Future<Output = Result<MemoryId, LaserError>>
444    where
445        Self: Sync,
446    {
447        async move {
448            let _ = (id, kind);
449            self.remember(scope, payload).await
450        }
451    }
452    async fn recall(
453        &self,
454        scope: &MemoryScope,
455        query: &MemoryQuery,
456    ) -> Result<Vec<MemoryItem>, LaserError>;
457    /// Record `feedback` on a recalled item, the log-native signal a ranking
458    /// backend folds into future recall. Returns the feedback record's id.
459    async fn improve(
460        &self,
461        scope: &MemoryScope,
462        feedback: Feedback,
463    ) -> Result<MemoryId, LaserError>;
464    async fn forget(&self, scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError>;
465}
466
467/// The dyn-safe form of [`Memory`], returning boxed futures so an external
468/// backend composes into the facade as a trait object. [`Memory`] is RPITIT (via
469/// `trait_variant`), so it is not object-safe. A blanket impl adapts any `Memory`,
470/// and a custom backend plugs in through
471/// [`Laser::memory_custom`](crate::laser::Laser::memory_custom). This mirrors the
472/// boxed-future seam pattern the reliable consumer uses for `AgentMiddleware`.
473pub trait DynMemory: Send + Sync {
474    /// Boxed [`Memory::remember`].
475    fn remember<'a>(
476        &'a self,
477        scope: &'a MemoryScope,
478        payload: Vec<u8>,
479    ) -> std::pin::Pin<
480        Box<dyn std::future::Future<Output = Result<MemoryId, LaserError>> + Send + 'a>,
481    >;
482    /// Boxed typed append, with the custom backend's remember fallback.
483    fn append<'a>(
484        &'a self,
485        scope: &'a MemoryScope,
486        id: MemoryId,
487        kind: MemoryKind,
488        payload: Vec<u8>,
489    ) -> std::pin::Pin<
490        Box<dyn std::future::Future<Output = Result<MemoryId, LaserError>> + Send + 'a>,
491    > {
492        let _ = (id, kind);
493        self.remember(scope, payload)
494    }
495    /// Boxed [`Memory::recall`].
496    fn recall<'a>(
497        &'a self,
498        scope: &'a MemoryScope,
499        query: &'a MemoryQuery,
500    ) -> std::pin::Pin<
501        Box<dyn std::future::Future<Output = Result<Vec<MemoryItem>, LaserError>> + Send + 'a>,
502    >;
503    /// Boxed [`Memory::improve`].
504    fn improve<'a>(
505        &'a self,
506        scope: &'a MemoryScope,
507        feedback: Feedback,
508    ) -> std::pin::Pin<
509        Box<dyn std::future::Future<Output = Result<MemoryId, LaserError>> + Send + 'a>,
510    >;
511    /// Boxed [`Memory::forget`].
512    fn forget<'a>(
513        &'a self,
514        scope: &'a MemoryScope,
515        id: MemoryId,
516    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), LaserError>> + Send + 'a>>;
517}
518
519impl<M: Memory + Send + Sync> DynMemory for M {
520    fn remember<'a>(
521        &'a self,
522        scope: &'a MemoryScope,
523        payload: Vec<u8>,
524    ) -> std::pin::Pin<
525        Box<dyn std::future::Future<Output = Result<MemoryId, LaserError>> + Send + 'a>,
526    > {
527        Box::pin(Memory::remember(self, scope, payload))
528    }
529
530    fn append<'a>(
531        &'a self,
532        scope: &'a MemoryScope,
533        id: MemoryId,
534        kind: MemoryKind,
535        payload: Vec<u8>,
536    ) -> std::pin::Pin<
537        Box<dyn std::future::Future<Output = Result<MemoryId, LaserError>> + Send + 'a>,
538    > {
539        Box::pin(Memory::append(self, scope, id, kind, payload))
540    }
541
542    fn recall<'a>(
543        &'a self,
544        scope: &'a MemoryScope,
545        query: &'a MemoryQuery,
546    ) -> std::pin::Pin<
547        Box<dyn std::future::Future<Output = Result<Vec<MemoryItem>, LaserError>> + Send + 'a>,
548    > {
549        Box::pin(Memory::recall(self, scope, query))
550    }
551
552    fn improve<'a>(
553        &'a self,
554        scope: &'a MemoryScope,
555        feedback: Feedback,
556    ) -> std::pin::Pin<
557        Box<dyn std::future::Future<Output = Result<MemoryId, LaserError>> + Send + 'a>,
558    > {
559        Box::pin(Memory::improve(self, scope, feedback))
560    }
561
562    fn forget<'a>(
563        &'a self,
564        scope: &'a MemoryScope,
565        id: MemoryId,
566    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), LaserError>> + Send + 'a>>
567    {
568        Box::pin(Memory::forget(self, scope, id))
569    }
570}
571
572/// One record on the log-backed memory topic. A typed, CBOR-encoded entry rather
573/// than a magic-prefixed payload: the fold matches on the variant, so the control
574/// records (forget, feedback) cannot collide with an item body.
575#[derive(Debug, Clone, Serialize, Deserialize)]
576enum MemoryLogEntry {
577    /// A remembered item: its id, kind, body, and lineage.
578    Item {
579        id: MemoryId,
580        kind: MemoryKind,
581        body: Vec<u8>,
582        #[serde(default, skip_serializing_if = "Option::is_none")]
583        origin: Option<SourceRef>,
584        #[serde(default, skip_serializing_if = "Option::is_none")]
585        producer: Option<laser_wire::graph::ProducerInfo>,
586    },
587    /// A tombstone removing an item from recall. With `conversation`, it only
588    /// removes an item remembered in that conversation.
589    Forget {
590        target: MemoryId,
591        #[serde(default, skip_serializing_if = "Option::is_none")]
592        conversation: Option<ConversationId>,
593    },
594    /// A feedback signal reweighting an item's recall rank. With
595    /// `conversation`, it only counts for an item remembered in that
596    /// conversation.
597    Feedback {
598        target: MemoryId,
599        weight: f32,
600        #[serde(default, skip_serializing_if = "Option::is_none")]
601        conversation: Option<ConversationId>,
602    },
603}
604
605impl MemoryLogEntry {
606    fn encode(&self) -> Result<Vec<u8>, LaserError> {
607        laser_wire::framing::encode_named(self)
608            .map_err(|error| LaserError::Codec(format!("encode memory entry: {error}")))
609    }
610
611    fn decode(payload: &[u8]) -> Result<Self, LaserError> {
612        laser_wire::framing::decode_named(payload)
613            .map_err(|error| LaserError::Codec(format!("decode memory entry: {error}")))
614    }
615}
616
617/// `Memory` over the append-only Iggy log (`AgentTopic::Memory`). `remember`
618/// appends, `forget` appends a tombstone, `recall` returns the folded items for a
619/// conversation (most recent first by `limit`), applying tombstones and the agent
620/// filter. The log is the source of truth.
621///
622/// Recall is **incremental**: `LogMemory` keeps a folded projection plus a
623/// per-partition cursor, and each `recall` drains only what was appended since the
624/// last one (the same partition-drain the [`Cursor`](crate::cursor::Cursor) is built
625/// on), with no rescan from offset 0. A fresh instance rebuilds the projection
626/// from the log on its first recall. There is no side checkpoint to keep
627/// because the log already holds the truth.
628pub struct LogMemory {
629    laser: Laser,
630    // The stream memory records ride. `None` uses the client's default stream.
631    stream: Option<String>,
632    // The topic memory records ride. Defaults to `AgentTopic::Memory`. A
633    // deployment can point it at its own topic (the configurable memory topic).
634    topic: Identifier,
635    // The namespace prefixing the named-item altitude's keys
636    // (set/fetch/update/remove), so two namespaces on one topic never collide.
637    // Defaults to the topic's name.
638    namespace: String,
639    projection: Mutex<Projection>,
640}
641
642// The folded view `recall` reads: every memory item seen so far (in arrival order),
643// the tombstoned ids, the accumulated feedback weight per item, and the
644// per-partition offset the fold has consumed up to.
645#[derive(Default)]
646struct Projection {
647    items: Vec<MemoryItem>,
648    scopes: std::collections::HashMap<MemoryId, StoredMemoryScope>,
649    // Tombstones and feedback keyed by target and the conversation they are
650    // limited to. `None` applies to the target in any conversation.
651    forgotten: HashSet<(MemoryId, Option<ConversationId>)>,
652    feedback: std::collections::HashMap<(MemoryId, Option<ConversationId>), f32>,
653    // The named-item altitude's point state, folded from the same topic: the
654    // latest body per prefixed key. A named record's id is not a ULID, so it
655    // decodes as the wire `MemoryRecord` (below), never as a recall item.
656    named: std::collections::HashMap<String, Vec<u8>>,
657    offsets: Vec<u64>,
658}
659
660#[derive(Default)]
661struct StoredMemoryScope {
662    user: Option<String>,
663    app: Option<String>,
664}
665
666impl Projection {
667    // Fold one audit message in by decoding its typed entry: a `Forget` records a
668    // tombstone, a `Feedback` accumulates a weight on its target, an `Item` becomes
669    // a recalled item. A record whose id is not a recall id (a named-item key)
670    // folds into the point-state map instead. A payload that is not a memory entry
671    // is ignored. Pure, so it is unit-tested.
672    #[cfg(test)]
673    fn absorb(&mut self, payload: &[u8], provenance: Provenance) {
674        self.absorb_scoped(payload, provenance, StoredMemoryScope::default(), None);
675    }
676
677    fn absorb_scoped(
678        &mut self,
679        payload: &[u8],
680        provenance: Provenance,
681        scope: StoredMemoryScope,
682        source: Option<SourceRef>,
683    ) {
684        // A named-item key is not a ULID, so `MemoryLogEntry` (whose ids are
685        // `MemoryId`) fails to decode it, so fall through to the string-id wire
686        // record, which the managed backend folds too.
687        let Ok(entry) = laser_wire::framing::decode_named::<MemoryLogEntry>(payload) else {
688            self.absorb_named(payload);
689            return;
690        };
691        match entry {
692            MemoryLogEntry::Forget {
693                target,
694                conversation,
695            } => {
696                // Drop the tombstoned item so its body is not pinned for the life of
697                // the projection. Keep the id so a tombstone that arrives before its
698                // item (a cross-partition reorder) still suppresses it on arrival.
699                let before = self.items.len();
700                self.items.retain(|item| {
701                    item.id != target
702                        || conversation
703                            .is_some_and(|scope| scope != item.provenance.conversation_id)
704                });
705                if self.items.len() != before {
706                    self.scopes.remove(&target);
707                }
708                self.forgotten.insert((target, conversation));
709            }
710            MemoryLogEntry::Feedback {
711                target,
712                weight,
713                conversation,
714            } => {
715                *self.feedback.entry((target, conversation)).or_insert(0.0) += weight;
716            }
717            MemoryLogEntry::Item {
718                id,
719                kind,
720                body,
721                origin,
722                producer,
723            } => {
724                if self.is_forgotten(id, provenance.conversation_id) {
725                    return;
726                }
727                // Dedup on fold: a content-addressed id appended twice (a deduped
728                // remember of the same body) is one item.
729                if self.items.iter().any(|item| item.id == id) {
730                    return;
731                }
732                self.scopes.insert(id, scope);
733                self.items.push(MemoryItem {
734                    id,
735                    payload: body,
736                    provenance,
737                    source,
738                    kind,
739                    score: None,
740                    signals: Vec::new(),
741                    origin,
742                    producer,
743                });
744            }
745        }
746    }
747
748    fn is_forgotten(&self, id: MemoryId, conversation: ConversationId) -> bool {
749        self.forgotten.contains(&(id, None)) || self.forgotten.contains(&(id, Some(conversation)))
750    }
751
752    fn feedback_for(&self, item: &MemoryItem) -> Option<f32> {
753        let any = self.feedback.get(&(item.id, None));
754        let scoped = self
755            .feedback
756            .get(&(item.id, Some(item.provenance.conversation_id)));
757        match (any, scoped) {
758            (None, None) => None,
759            (any, scoped) => Some(any.copied().unwrap_or(0.0) + scoped.copied().unwrap_or(0.0)),
760        }
761    }
762
763    // Fold a named-item record (a keyed set or forget). The record is the wire
764    // `MemoryRecord`, byte-identical to `MemoryLogEntry` but with a string id, so
765    // the named-item write path and the managed backend's fold agree on the bytes.
766    fn absorb_named(&mut self, payload: &[u8]) {
767        let Ok(record) =
768            laser_wire::framing::decode_named::<laser_wire::memory::MemoryRecord>(payload)
769        else {
770            return;
771        };
772        match record {
773            laser_wire::memory::MemoryRecord::Item { id, body, .. } => {
774                self.named.insert(id, body);
775            }
776            laser_wire::memory::MemoryRecord::Forget { target, .. } => {
777                self.named.remove(&target);
778            }
779            laser_wire::memory::MemoryRecord::Feedback { .. } => {}
780        }
781    }
782}
783
784fn folded_memory_items(
785    projection: &Projection,
786    scope: &MemoryScope,
787    query: &MemoryQuery,
788) -> Vec<MemoryItem> {
789    let agent_filter = query.agent.as_ref().or(scope.agent.as_ref());
790    let mut items: Vec<MemoryItem> = projection
791        .items
792        .iter()
793        .filter(|item| {
794            scope
795                .conversation
796                .is_none_or(|conversation| item.provenance.conversation_id == conversation)
797        })
798        .filter(|item| !projection.is_forgotten(item.id, item.provenance.conversation_id))
799        .filter(|item| {
800            let stored = projection.scopes.get(&item.id);
801            scope.user.as_ref().is_none_or(|expected| {
802                stored.and_then(|value| value.user.as_ref()) == Some(expected)
803            }) && scope.app.as_ref().is_none_or(|expected| {
804                stored.and_then(|value| value.app.as_ref()) == Some(expected)
805            })
806        })
807        .filter(|item| {
808            agent_filter.is_none_or(|agent| item.provenance.agent.as_ref() == Some(agent))
809        })
810        .filter(|item| memory_query_matches(query, &item.payload))
811        .cloned()
812        .collect();
813    items.reverse();
814    // Recent recall is recency alone, so feedback never reorders it.
815    if query.strategy != RecallStrategy::Recent && !projection.feedback.is_empty() {
816        for item in &mut items {
817            if let Some(weight) = projection.feedback_for(item) {
818                item.score = Some(weight);
819            }
820        }
821        items.sort_by(|left, right| {
822            right
823                .score
824                .unwrap_or(0.0)
825                .total_cmp(&left.score.unwrap_or(0.0))
826        });
827        for (rank, item) in items.iter_mut().enumerate() {
828            if let Some(score) = item.score {
829                // Feedback is not a strategy the caller picked, so its signal
830                // reads `Auto` in every backend.
831                item.signals.push(RecallSignal {
832                    strategy: RecallStrategy::Auto,
833                    rank,
834                    score: Some(score),
835                });
836            }
837        }
838    }
839    items.truncate(query.limit);
840    items
841}
842
843impl LogMemory {
844    fn stream_name(&self) -> Result<&str, LaserError> {
845        match self.stream.as_deref() {
846            Some(stream) => Ok(stream),
847            None => self.laser.stream_required(),
848        }
849    }
850
851    // The namespace the records' header and the key-value read view carry,
852    // scoped to the stream the records ride, so the fold writes where recall
853    // reads.
854    fn resource_namespace(&self) -> String {
855        self.laser
856            .resource_name_in(self.resource_stream(), &self.namespace)
857    }
858
859    fn resource_stream(&self) -> Option<&str> {
860        self.laser
861            .resource_scope(self.stream.as_deref().or(self.laser.default_stream()))
862    }
863
864    /// A log-backed memory over an owned `Laser` (cheap to clone, shares the one
865    /// connection). Hold a single instance to keep recall incremental: it folds
866    /// only what was appended since the last recall, never rescanning from offset
867    /// zero. A fresh instance rebuilds the projection from the log on its first
868    /// recall, so reuse one instance for an agent that recalls repeatedly.
869    pub fn new(laser: Laser) -> Self {
870        Self {
871            laser,
872            stream: None,
873            topic: AgentTopic::Memory.as_identifier(),
874            namespace: AgentTopic::Memory.name().unwrap_or("memory").to_owned(),
875            projection: Mutex::new(Projection::default()),
876        }
877    }
878
879    /// Like [`new`](Self::new) but carries a caller-named namespace: the prefix
880    /// the named-item altitude keys on. The records still ride the default
881    /// `AgentTopic::Memory`. This is what [`Laser::memory`] builds.
882    pub fn in_namespace(laser: Laser, namespace: impl Into<String>) -> Self {
883        Self {
884            laser,
885            stream: None,
886            topic: AgentTopic::Memory.as_identifier(),
887            namespace: namespace.into(),
888            projection: Mutex::new(Projection::default()),
889        }
890    }
891
892    /// Like [`new`](Self::new) but writes and folds a caller-named memory topic
893    /// instead of the default `AgentTopic::Memory`. The configurable memory
894    /// stream: one deployment can keep several isolated memory topics. The
895    /// namespace defaults to the topic's name.
896    pub fn on_topic(laser: Laser, topic: Identifier) -> Self {
897        Self::on_stream_topic(laser, None, topic)
898    }
899
900    /// Like [`on_topic`](Self::on_topic) but on a caller-named stream too, not
901    /// the client's default. `None` keeps the default stream.
902    pub fn on_stream_topic(laser: Laser, stream: Option<String>, topic: Identifier) -> Self {
903        let namespace = topic.get_string_value().unwrap_or_default();
904        Self {
905            laser,
906            stream,
907            topic,
908            namespace,
909            projection: Mutex::new(Projection::default()),
910        }
911    }
912
913    /// [`on_topic`](Self::on_topic) from a topic name, validating it.
914    pub fn on_topic_named(laser: Laser, topic: &str) -> Result<Self, LaserError> {
915        Ok(Self::on_topic(laser, Identifier::named(topic)?))
916    }
917
918    /// [`on_stream_topic`](Self::on_stream_topic) from a topic name, validating
919    /// it.
920    pub fn on_stream_topic_named(
921        laser: Laser,
922        stream: Option<String>,
923        topic: &str,
924    ) -> Result<Self, LaserError> {
925        Ok(Self::on_stream_topic(
926            laser,
927            stream,
928            Identifier::named(topic)?,
929        ))
930    }
931
932    async fn govern_payload(
933        &self,
934        provenance: &Provenance,
935        payload: Vec<u8>,
936    ) -> Result<Vec<u8>, LaserError> {
937        let topic = self.topic.to_string();
938        let action = crate::govern::GovernedAction {
939            kind: crate::govern::ActionKind::MemoryWrite,
940            stream: self.stream_name()?,
941            topic: &topic,
942            source: provenance.agent.as_ref().map(|agent| agent.as_str()),
943            target: None,
944            conversation: Some(provenance.conversation_id),
945            correlation: None,
946            operation: None,
947            tool: None,
948            on_behalf_of: None,
949            purpose: None,
950            data_classification: None,
951            payload: &payload,
952            signed: false,
953            counters: crate::govern::ActionCounters::default(),
954        };
955        Ok(self.laser.govern(action).await?.unwrap_or(payload))
956    }
957
958    // Publish an encoded entry to this memory's stream and topic, keyed by the
959    // conversation so a scope's records share a partition.
960    async fn publish_unchecked(
961        &self,
962        provenance: &Provenance,
963        scope: &MemoryScope,
964        payload: Vec<u8>,
965    ) -> Result<(), LaserError> {
966        let mut headers: std::collections::BTreeMap<HeaderKey, HeaderValue> =
967            provenance.try_into()?;
968        // The memory scope rides as headers so the read view is keyed on the
969        // scope, not the topic: one shared topic still resolves every context.
970        // The namespace always rides. User and app widen recall when unset.
971        put_header(
972            &mut headers,
973            laser_wire::headers::MEMORY_NAMESPACE,
974            &self.resource_namespace(),
975        )?;
976        if let Some(user) = &scope.user {
977            put_header(&mut headers, laser_wire::headers::MEMORY_USER, user)?;
978        }
979        if let Some(app) = &scope.app {
980            put_header(&mut headers, laser_wire::headers::MEMORY_APP, app)?;
981        }
982        let key = provenance.partition_key();
983        let topic = self.topic.to_string();
984        match &self.stream {
985            Some(stream) => self
986                .laser
987                .send_with_headers_on(stream, &topic, payload, headers, Some(&key))
988                .await
989                .map(|_| ()),
990            None => self
991                .laser
992                .send_with_headers(&topic, payload, headers, Some(&key))
993                .await
994                .map(|_| ()),
995        }
996    }
997
998    async fn publish(
999        &self,
1000        provenance: &Provenance,
1001        scope: &MemoryScope,
1002        payload: Vec<u8>,
1003    ) -> Result<(), LaserError> {
1004        let payload = self.govern_payload(provenance, payload).await?;
1005        self.publish_unchecked(provenance, scope, payload).await
1006    }
1007
1008    fn provenance(scope: &MemoryScope, idempotency_key: String) -> Provenance {
1009        let mut provenance = Provenance::builder()
1010            .conversation_id(scope.conversation.unwrap_or_default())
1011            .build();
1012        provenance.agent = scope.agent.clone();
1013        provenance.idempotency_key = Some(idempotency_key);
1014        provenance
1015    }
1016
1017    // Provenance for a named point-state write. Named items carry no conversation,
1018    // so the partition key is derived from the item's own id: every set, update,
1019    // and forget for one key then shares a partition and folds in offset order. A
1020    // default (random) conversation would scatter a key's writes across partitions,
1021    // and the fold applies partitions in index order, so an older write could win.
1022    fn named_provenance(id: String) -> Provenance {
1023        let mut provenance = Provenance::builder()
1024            .conversation_id(ConversationId::derive(&id))
1025            .build();
1026        provenance.idempotency_key = Some(id);
1027        provenance
1028    }
1029
1030    // Append an item under an explicit id and kind. The facade passes a
1031    // content-addressed id for a deduped remember (the fold drops a repeat of an
1032    // id it already holds) and a fresh id otherwise.
1033    pub(crate) async fn append(
1034        &self,
1035        scope: &MemoryScope,
1036        id: MemoryId,
1037        kind: MemoryKind,
1038        payload: Vec<u8>,
1039    ) -> Result<MemoryId, LaserError> {
1040        let provenance = Self::provenance(scope, id.to_string());
1041        let body = self.govern_payload(&provenance, payload).await?;
1042        let entry = MemoryLogEntry::Item {
1043            id,
1044            kind,
1045            body,
1046            origin: scope.origin.clone(),
1047            producer: scope.producer.clone(),
1048        };
1049        self.publish_unchecked(&provenance, scope, entry.encode()?)
1050            .await?;
1051        Ok(id)
1052    }
1053
1054    // The named-item altitude keys point state under `<namespace>/<key>`, so two
1055    // namespaces on one topic never collide.
1056    fn named_key(&self, key: &str) -> String {
1057        format!("{}/{}", self.namespace, key)
1058    }
1059
1060    /// Write named point state: publish a keyed item to the topic (a record, like
1061    /// every memory write) and reflect it in the projection for read-your-writes.
1062    pub async fn set_named(&self, key: &str, body: Vec<u8>) -> Result<(), LaserError> {
1063        let id = self.named_key(key);
1064        let record = laser_wire::memory::MemoryRecord::Item {
1065            origin: None,
1066            producer: None,
1067            id: id.clone(),
1068            // Named point state is a fact, and the wire record carries the kind word.
1069            kind: "fact".to_owned(),
1070            body: body.clone(),
1071        };
1072        let payload = laser_wire::framing::encode_named(&record)
1073            .map_err(|error| LaserError::Codec(format!("encode named memory item: {error}")))?;
1074        let scope = MemoryScope::default();
1075        let provenance = Self::named_provenance(id.clone());
1076        self.publish(&provenance, &scope, payload).await?;
1077        self.projection.lock().await.named.insert(id, body);
1078        Ok(())
1079    }
1080
1081    /// Merge-patch named point state (RFC 7386): read the current value, merge
1082    /// `patch`, and write the result back as one event.
1083    pub async fn update_named(&self, key: &str, patch: Vec<u8>) -> Result<(), LaserError> {
1084        // Read-merge-write needs read-your-writes, which the in-process fold gives
1085        // and the eventually-consistent read view does not, so the merge folds.
1086        let current = self.fetch_named_folded(key).await?;
1087        let merged = merge_named(current.as_deref(), &patch)?;
1088        self.set_named(key, merged).await
1089    }
1090
1091    /// Delete named point state: publish a keyed forget (a tombstone the managed backend
1092    /// applies as a delete) and drop it from the projection.
1093    pub async fn forget_named(&self, key: &str) -> Result<(), LaserError> {
1094        let id = self.named_key(key);
1095        let record = laser_wire::memory::MemoryRecord::Forget {
1096            target: id.clone(),
1097            conversation: None,
1098        };
1099        let payload = laser_wire::framing::encode_named(&record)
1100            .map_err(|error| LaserError::Codec(format!("encode named memory forget: {error}")))?;
1101        let scope = MemoryScope::default();
1102        let provenance = Self::named_provenance(id.clone());
1103        self.publish(&provenance, &scope, payload).await?;
1104        self.projection.lock().await.named.remove(&id);
1105        Ok(())
1106    }
1107
1108    /// Read named point state, folding the topic first so a fresh handle sees what
1109    /// was written before it.
1110    pub async fn fetch_named(&self, key: &str) -> Result<Option<Vec<u8>>, LaserError> {
1111        // Read the managed key-value read view, like recall. Folding the topic is
1112        // the opt-in `fetch_named_folded`, never the default.
1113        #[cfg(feature = "kv")]
1114        {
1115            self.laser
1116                .kv(self.resource_namespace())
1117                .get(self.named_key(key))
1118                .await
1119        }
1120        #[cfg(not(feature = "kv"))]
1121        {
1122            let _ = key;
1123            Err(LaserError::unsupported(
1124                "memory fetch",
1125                "named point-state read reads the managed key-value view. Build with the `kv` \
1126                 feature, or call `fetch_named_folded` to fold the topic in process",
1127            ))
1128        }
1129    }
1130
1131    /// Read named point state by folding the topic in process, the opt-in path
1132    /// for small serverless scenarios. Prefer [`fetch_named`](Self::fetch_named),
1133    /// which reads the materialized key-value view.
1134    pub async fn fetch_named_folded(&self, key: &str) -> Result<Option<Vec<u8>>, LaserError> {
1135        self.catch_up().await?;
1136        let id = self.named_key(key);
1137        Ok(self.projection.lock().await.named.get(&id).cloned())
1138    }
1139
1140    // Drain the audit topic from the projection's saved offsets to the current tail,
1141    // folding only the new messages. Reads incrementally, not from offset 0.
1142    //
1143    // The network polling runs WITHOUT the projection lock held, so a round-trip
1144    // never blocks a concurrent recall. The lock is taken only to snapshot the
1145    // frontier and, afterwards, to fold + advance (a CPU-bound critical section). A
1146    // partition's batch is applied only if no concurrent `catch_up` advanced that
1147    // partition past our snapshot in the meantime. Otherwise the other caller
1148    // already folded those messages and re-applying would duplicate items.
1149    async fn catch_up(&self) -> Result<(), LaserError> {
1150        let stream = Identifier::named(self.stream_name()?)?;
1151        let topic = self.topic.clone();
1152        let Some(details) = self.laser.client().get_topic(&stream, &topic).await? else {
1153            return Ok(());
1154        };
1155        let stream_id = self
1156            .laser
1157            .client()
1158            .get_stream(&stream)
1159            .await?
1160            .ok_or_else(|| LaserError::Invalid("memory stream is missing".to_owned()))?
1161            .id;
1162        let partitions = crate::poll::bounded_partitions(details.partitions_count);
1163        let consumer = Consumer::new(Identifier::named("laser-log-memory")?);
1164        let namespace = self.resource_namespace();
1165
1166        let from = {
1167            let mut projection = self.projection.lock().await;
1168            if projection.offsets.len() < partitions as usize {
1169                projection.offsets.resize(partitions as usize, 0);
1170            }
1171            projection.offsets.clone()
1172        };
1173
1174        let mut drained: Vec<(u32, u64, Vec<IggyMessage>)> = Vec::new();
1175        for partition in 0..partitions {
1176            let batch = crate::poll::drain_partition(
1177                &self.laser.client(),
1178                &stream,
1179                &topic,
1180                &consumer,
1181                crate::poll::DrainRange::open(partition, from[partition as usize]),
1182                READ_BATCH,
1183            )
1184            .await?;
1185            if !batch.messages.is_empty() {
1186                drained.push((partition, batch.next_offset, batch.messages));
1187            }
1188        }
1189        if drained.is_empty() {
1190            return Ok(());
1191        }
1192
1193        let mut projection = self.projection.lock().await;
1194        if projection.offsets.len() < partitions as usize {
1195            projection.offsets.resize(partitions as usize, 0);
1196        }
1197        for (partition, next_offset, messages) in drained {
1198            if projection.offsets[partition as usize] != from[partition as usize] {
1199                continue; // a concurrent catch_up already folded this partition
1200            }
1201            for message in messages {
1202                // One header decode serves provenance and the memory scope. A
1203                // record whose headers do not decode is not a memory record.
1204                let Ok(Some(headers)) = message.user_headers_map() else {
1205                    continue;
1206                };
1207                let Ok(provenance) = crate::provenance::provenance_from_headers(&headers) else {
1208                    continue;
1209                };
1210                let text = |name: &str| -> Option<String> {
1211                    let key = HeaderKey::from_str(name).ok()?;
1212                    headers.get(&key)?.as_str().ok().map(str::to_owned)
1213                };
1214                if text(laser_wire::headers::MEMORY_NAMESPACE).as_deref()
1215                    != Some(namespace.as_str())
1216                {
1217                    continue;
1218                }
1219                let source = SourceRef::Message {
1220                    generation: Some(details.created_at.as_micros()),
1221                    stream: stream_id,
1222                    topic: details.id,
1223                    partition,
1224                    offset: message.header.offset,
1225                    conversation: Some(provenance.conversation_id.to_string()),
1226                };
1227                projection.absorb_scoped(
1228                    &message.payload,
1229                    provenance,
1230                    StoredMemoryScope {
1231                        user: text(laser_wire::headers::MEMORY_USER),
1232                        app: text(laser_wire::headers::MEMORY_APP),
1233                    },
1234                    Some(source),
1235                );
1236            }
1237            projection.offsets[partition as usize] = next_offset;
1238        }
1239        Ok(())
1240    }
1241
1242    /// Recall by folding the topic in process: read every memory record for the
1243    /// scope's conversation off the log and return the most-recent items. This is
1244    /// the opt-in path for small, serverless scenarios with no managed read view.
1245    /// It does not scale: a large topic folds every record, so prefer the default
1246    /// [`recall`](Memory::recall), which reads the materialized key-value view.
1247    pub async fn recall_folded(
1248        &self,
1249        scope: &MemoryScope,
1250        query: &MemoryQuery,
1251    ) -> Result<Vec<MemoryItem>, LaserError> {
1252        if scope
1253            .stream
1254            .as_deref()
1255            .is_some_and(|stream| self.stream_name().ok() != Some(stream))
1256        {
1257            return Ok(Vec::new());
1258        }
1259        self.catch_up().await?;
1260        let projection = self.projection.lock().await;
1261        // `items` is in per-partition arrival order, not globally timestamp-sorted, so
1262        // `last limit` is the most-recent N only because all of one conversation's
1263        // messages land on one partition (the audit topic partitions by
1264        // `conversation_id`, see `Provenance::partition_key`) and are folded in offset
1265        // order. Narrowing to a single conversation first makes that tail correct.
1266        // An unset `scope.conversation` WIDENS recall across every conversation
1267        // (matching the `MemoryScope` widening rule) rather than returning nothing.
1268        // The tail is then only approximately most-recent, since items interleave
1269        // across partitions. The recall filter honors the scope it was stored under,
1270        // and an explicit `query.agent` narrows it further, overriding `scope.agent`.
1271        Ok(folded_memory_items(&projection, scope, query))
1272    }
1273
1274    /// Recall by reading the managed key-value read view. Scans the memory
1275    /// namespace narrowed to the scope's conversation (the lens the fold stamps),
1276    /// rebuilds each item from the stored scope, filters by agent/user/app, and
1277    /// returns the most recent by broker append time and source address. Backs the default [`recall`](Memory::recall).
1278    #[cfg(feature = "kv")]
1279    async fn recall_kv(
1280        &self,
1281        scope: &MemoryScope,
1282        query: &MemoryQuery,
1283    ) -> Result<Vec<MemoryItem>, LaserError> {
1284        if scope
1285            .stream
1286            .as_deref()
1287            .is_some_and(|stream| self.stream_name().ok() != Some(stream))
1288        {
1289            return Ok(Vec::new());
1290        }
1291        let kv = self.laser.kv(self.resource_namespace());
1292        let lens_stream = self.resource_stream().map(str::to_owned);
1293        recall_kv_pages(scope, query, |cursor| {
1294            let mut scan = kv.scan();
1295            if let Some(conversation) = scope.conversation {
1296                scan = scan
1297                    .conversation(conversation)
1298                    .lens_stream(lens_stream.clone());
1299            }
1300            if let Some(cursor) = cursor {
1301                scan = scan.cursor(cursor);
1302            }
1303            async move { scan.fetch().await }
1304        })
1305        .await
1306    }
1307}
1308
1309#[cfg(feature = "kv")]
1310async fn recall_kv_pages<F, Fut>(
1311    scope: &MemoryScope,
1312    query: &MemoryQuery,
1313    mut fetch: F,
1314) -> Result<Vec<MemoryItem>, LaserError>
1315where
1316    F: FnMut(Option<Vec<u8>>) -> Fut,
1317    Fut: std::future::Future<Output = Result<laser_wire::kv::KvPage, LaserError>>,
1318{
1319    if query.limit == 0 {
1320        return Ok(Vec::new());
1321    }
1322    let mut selected = std::collections::BTreeMap::new();
1323    let mut cursor = None;
1324    loop {
1325        let page = fetch(cursor.clone()).await?;
1326        for entry in page.entries {
1327            let arrival = arrival_key(&entry);
1328            if let Some(item) = memory_item_from_entry(scope, query.agent.as_ref(), entry)
1329                .filter(|item| memory_query_matches(query, &item.payload))
1330            {
1331                selected.insert((arrival, item.id), item);
1332                if selected.len() > query.limit {
1333                    selected.pop_first();
1334                }
1335            }
1336        }
1337        let Some(next) = page.cursor else { break };
1338        if cursor.as_ref() == Some(&next) {
1339            return Err(LaserError::Protocol(
1340                "memory scan cursor did not advance".to_owned(),
1341            ));
1342        }
1343        cursor = Some(next);
1344    }
1345    Ok(selected.into_values().rev().collect())
1346}
1347
1348// Arrival order of a memory view row across partitions: the broker append time,
1349// then the source address. Offsets from different topics or partitions are never
1350// compared alone. A row without a broker time sorts as the oldest.
1351#[cfg(feature = "kv")]
1352fn arrival_key(entry: &laser_wire::kv::KvEntry) -> (u64, u32, u32, u64) {
1353    let scope = entry.scope.as_deref();
1354    let timestamp = scope.and_then(|scope| scope.timestamp_micros).unwrap_or(0);
1355    match scope.and_then(|scope| scope.source.as_ref()) {
1356        Some(SourceRef::Message {
1357            topic,
1358            partition,
1359            offset,
1360            ..
1361        }) => (timestamp, *topic, *partition, *offset),
1362        _ => (timestamp, 0, 0, 0),
1363    }
1364}
1365
1366#[cfg(feature = "kv")]
1367fn memory_item_from_entry(
1368    scope: &MemoryScope,
1369    agent: Option<&AgentId>,
1370    entry: laser_wire::kv::KvEntry,
1371) -> Option<MemoryItem> {
1372    let id: MemoryId = entry.key_str()?.parse().ok()?;
1373    let stored = *entry.scope?;
1374    let conversation = match stored.conversation.as_deref() {
1375        Some(value) => value.parse().ok()?,
1376        None => scope.conversation?,
1377    };
1378    let agent = agent.or(scope.agent.as_ref());
1379    if scope
1380        .conversation
1381        .is_some_and(|expected| expected != conversation)
1382        || scope
1383            .user
1384            .as_ref()
1385            .is_some_and(|expected| stored.user.as_ref() != Some(expected))
1386        || scope
1387            .app
1388            .as_ref()
1389            .is_some_and(|expected| stored.app.as_ref() != Some(expected))
1390        || agent.is_some_and(|expected| stored.agent.as_deref() != Some(expected.as_str()))
1391    {
1392        return None;
1393    }
1394    let kind = stored
1395        .kind
1396        .as_deref()
1397        .map(MemoryKind::from_word)
1398        .unwrap_or_default();
1399    let mut provenance = Provenance::builder().conversation_id(conversation).build();
1400    provenance.agent = stored.agent.as_deref().and_then(|value| value.parse().ok());
1401    Some(MemoryItem {
1402        id,
1403        payload: entry.value,
1404        provenance,
1405        kind,
1406        source: stored.source,
1407        score: None,
1408        signals: Vec::new(),
1409        origin: None,
1410        producer: None,
1411    })
1412}
1413
1414impl Memory for LogMemory {
1415    async fn remember(
1416        &self,
1417        scope: &MemoryScope,
1418        payload: Vec<u8>,
1419    ) -> Result<MemoryId, LaserError> {
1420        self.append(scope, MemoryId::new(), MemoryKind::Fact, payload)
1421            .await
1422    }
1423
1424    async fn append(
1425        &self,
1426        scope: &MemoryScope,
1427        id: MemoryId,
1428        kind: MemoryKind,
1429        payload: Vec<u8>,
1430    ) -> Result<MemoryId, LaserError> {
1431        LogMemory::append(self, scope, id, kind, payload).await
1432    }
1433
1434    async fn recall(
1435        &self,
1436        scope: &MemoryScope,
1437        query: &MemoryQuery,
1438    ) -> Result<Vec<MemoryItem>, LaserError> {
1439        // Recall reads the managed key-value read view the deployment materializes
1440        // from the memory topic, so a huge topic never folds in process. Folding
1441        // is the opt-in [`recall_folded`](LogMemory::recall_folded), for small
1442        // serverless scenarios, never the default.
1443        #[cfg(feature = "kv")]
1444        {
1445            self.recall_kv(scope, query).await
1446        }
1447        #[cfg(not(feature = "kv"))]
1448        {
1449            let _ = (scope, query);
1450            Err(LaserError::unsupported(
1451                "memory recall",
1452                "durable recall reads the managed key-value view. Build with the `kv` feature, \
1453                 or call `recall_folded` to fold the topic in process",
1454            ))
1455        }
1456    }
1457
1458    async fn improve(
1459        &self,
1460        scope: &MemoryScope,
1461        feedback: Feedback,
1462    ) -> Result<MemoryId, LaserError> {
1463        // Append a feedback record. The fold (`Projection::absorb`) accumulates the
1464        // weight on its target and keeps it out of the recalled items, so recall
1465        // ranks by the accumulated feedback.
1466        let id = MemoryId::new();
1467        let entry = MemoryLogEntry::Feedback {
1468            target: feedback.target,
1469            weight: feedback.weight,
1470            conversation: scope.conversation,
1471        };
1472        let provenance = Self::provenance(scope, id.to_string());
1473        self.publish(&provenance, scope, entry.encode()?).await?;
1474        Ok(id)
1475    }
1476
1477    async fn forget(&self, scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
1478        let entry = MemoryLogEntry::Forget {
1479            target: id,
1480            conversation: scope.conversation,
1481        };
1482        let provenance = Self::provenance(scope, MemoryId::new().to_string());
1483        self.publish(&provenance, scope, entry.encode()?).await?;
1484        Ok(())
1485    }
1486}
1487
1488/// Turns text into an embedding vector. The SDK stays model-agnostic: the
1489/// implementation (an API call, a local model) lives in application code, the
1490/// same boundary as the `LlmClient` seam in the examples.
1491#[trait_variant::make(Embedder: Send)]
1492pub trait LocalEmbedder {
1493    async fn embed(&self, text: &str) -> Result<Vec<f32>, LaserError>;
1494}
1495
1496/// Re-scores recall candidates after the first-pass retrieval: the optional
1497/// second stage of a multi-signal recall pipeline (candidates -> fuse -> rerank
1498/// -> top_k). The SDK stays model-agnostic, so a cross-encoder, an LLM judge, or
1499/// a hosted rerank API lives in application code, the same seam as [`Embedder`].
1500/// `rerank` receives the candidate items already ordered by the first pass and
1501/// returns them re-ordered (and MAY drop or rescore them).
1502#[trait_variant::make(Reranker: Send)]
1503pub trait LocalReranker {
1504    async fn rerank(
1505        &self,
1506        query: &str,
1507        items: Vec<MemoryItem>,
1508    ) -> Result<Vec<MemoryItem>, LaserError>;
1509}
1510
1511/// Folds a batch of session bodies into one durable summary body. The seam the
1512/// consolidation summarize pass calls: a model client, a template, or a hosted
1513/// summarize API lives in application code, the same boundary as [`Embedder`]
1514/// and [`Reranker`]. The SDK ships no model client, ever.
1515#[trait_variant::make(Summarizer: Send)]
1516pub trait LocalSummarizer {
1517    async fn summarize(&self, bodies: Vec<Vec<u8>>) -> Result<Vec<u8>, LaserError>;
1518}
1519
1520/// What one consolidation pass changed. All counts are best-effort and advisory.
1521#[derive(Debug, Clone, Default, PartialEq, Eq)]
1522pub struct ConsolidationReport {
1523    /// Items folded into durable summaries.
1524    pub summarized: usize,
1525    /// Items whose recall weight was adjusted from feedback.
1526    pub reweighted: usize,
1527    /// Stale items pruned.
1528    pub pruned: usize,
1529    /// New facts or edges derived (e.g. graph extraction).
1530    pub derived: usize,
1531}
1532
1533/// Background memory evolution, the "memify" / sleep-time-compute pass: it runs off
1534/// the log to summarize sessions into durable items, fold feedback into recall
1535/// weights, prune stale items, and derive new facts and edges (the home for
1536/// projector-driven graph extraction managed-side). It is the asynchronous
1537/// counterpart to the synchronous [`Memory::improve`] verb. A seam like
1538/// [`Embedder`] and [`Reranker`]: the policy lives in application or managed code,
1539/// so the SDK ships the contract and a deployment fills it.
1540#[trait_variant::make(Consolidator: Send)]
1541pub trait LocalConsolidator {
1542    /// Run one consolidation pass over `scope`, returning what it changed.
1543    async fn consolidate(&self, scope: &MemoryScope) -> Result<ConsolidationReport, LaserError>;
1544}
1545
1546/// The mechanical consolidation pass: keep the most recent `max_items` per scope,
1547/// prune the rest (oldest first, by the ULID id's creation-time order). A real,
1548/// dependency-free bound on unbounded memory growth, the "prune" of the memify
1549/// pass. Reweighting from feedback already happens in the recall backends. The
1550/// summarize and fact-invalidation passes (closing a superseded graph edge's
1551/// `valid_to`) need a generation seam and the managed graph, and layer on top of
1552/// this rather than replacing it.
1553pub struct DefaultConsolidator<M, S = NoSummarizer> {
1554    memory: M,
1555    max_items: usize,
1556    summarizer: Option<S>,
1557    prune_summarized: bool,
1558}
1559
1560/// The placeholder for a consolidator built without a summarize pass. Never
1561/// called: `summarizer: None` skips the pass entirely.
1562pub struct NoSummarizer;
1563
1564impl Summarizer for NoSummarizer {
1565    async fn summarize(&self, _bodies: Vec<Vec<u8>>) -> Result<Vec<u8>, LaserError> {
1566        Err(LaserError::Config(
1567            "no summarizer was registered on this consolidator",
1568        ))
1569    }
1570}
1571
1572impl<M> DefaultConsolidator<M> {
1573    /// Keep at most `max_items` per scope on each pass (the prune half).
1574    pub fn new(memory: M, max_items: usize) -> Self {
1575        Self {
1576            memory,
1577            max_items,
1578            summarizer: None,
1579            prune_summarized: false,
1580        }
1581    }
1582}
1583
1584impl<M, S> DefaultConsolidator<M, S> {
1585    /// Add the summarize pass. `Message` items in the scope are folded through
1586    /// `summarizer` into one durable `Summary` per conversation, stored under
1587    /// the consolidation scope narrowed to that conversation. The SDK ships no
1588    /// model client, the seam is yours.
1589    pub fn with_summarizer<S2>(self, summarizer: S2) -> DefaultConsolidator<M, S2> {
1590        DefaultConsolidator {
1591            memory: self.memory,
1592            max_items: self.max_items,
1593            summarizer: Some(summarizer),
1594            prune_summarized: self.prune_summarized,
1595        }
1596    }
1597
1598    /// Forget the session items a summarize pass folded, so the durable
1599    /// summary replaces them instead of joining them.
1600    #[must_use]
1601    pub fn prune_summarized(mut self) -> Self {
1602        self.prune_summarized = true;
1603        self
1604    }
1605}
1606
1607impl<M: Memory + Sync, S: Summarizer + Sync> Consolidator for DefaultConsolidator<M, S> {
1608    async fn consolidate(&self, scope: &MemoryScope) -> Result<ConsolidationReport, LaserError> {
1609        let query = MemoryQuery::builder()
1610            .limit(CONSOLIDATION_WINDOW)
1611            .strategy(RecallStrategy::Recent)
1612            .build();
1613        let mut items = self.memory.recall(scope, &query).await?;
1614        let mut report = ConsolidationReport::default();
1615
1616        // Fold Message items into one durable Summary per conversation, so each
1617        // summary stays recallable in the conversation it distills. Custom
1618        // backends can keep their remember fallback.
1619        if let Some(summarizer) = &self.summarizer {
1620            let sessions: Vec<&MemoryItem> = items
1621                .iter()
1622                .filter(|item| item.kind == MemoryKind::Message)
1623                .collect();
1624            if !sessions.is_empty() {
1625                for (conversation, turns) in by_conversation(&sessions) {
1626                    let bodies = turns.iter().map(|item| item.payload.clone()).collect();
1627                    let summary = summarizer.summarize(bodies).await?;
1628                    let mut summary_scope = scope.clone();
1629                    summary_scope.conversation = Some(conversation);
1630                    summary_scope.lifetime = Lifetime::Durable;
1631                    self.memory
1632                        .append(
1633                            &summary_scope,
1634                            MemoryId::new(),
1635                            MemoryKind::Summary,
1636                            summary,
1637                        )
1638                        .await?;
1639                }
1640                report.summarized = sessions.len();
1641                if self.prune_summarized {
1642                    let ids: Vec<MemoryId> = sessions.iter().map(|item| item.id).collect();
1643                    for id in ids {
1644                        if self.memory.forget(scope, id).await.is_ok() {
1645                            report.pruned += 1;
1646                        }
1647                    }
1648                    items.retain(|item| item.kind != MemoryKind::Message);
1649                }
1650            }
1651        }
1652
1653        if items.len() <= self.max_items {
1654            return Ok(report);
1655        }
1656        // Recent recall is newest first, including for content-derived ids.
1657        let prune_count = items.len() - self.max_items;
1658        for item in items.into_iter().rev().take(prune_count) {
1659            if self.memory.forget(scope, item.id).await.is_ok() {
1660                report.pruned += 1;
1661            }
1662        }
1663        Ok(report)
1664    }
1665}
1666
1667// Group summarized turns by conversation, in the order each conversation
1668// first appears in the recall, keeping each group's recall order.
1669fn by_conversation<'a>(items: &[&'a MemoryItem]) -> Vec<(ConversationId, Vec<&'a MemoryItem>)> {
1670    let mut groups: Vec<(ConversationId, Vec<&'a MemoryItem>)> = Vec::new();
1671    for &item in items {
1672        let conversation = item.provenance.conversation_id;
1673        match groups.iter_mut().find(|(known, _)| *known == conversation) {
1674            Some((_, turns)) => turns.push(item),
1675            None => groups.push((conversation, vec![item])),
1676        }
1677    }
1678    groups
1679}
1680
1681/// Wraps any [`Memory`] with a [`Reranker`] second stage: `recall` runs the inner
1682/// backend's retrieval, then reorders the candidates through the reranker when the
1683/// query carries a `semantic` string (the rerank query). `remember` / `improve` /
1684/// `forget` delegate unchanged. Composition, not configuration: the rerank stage
1685/// is a wrapper so any backend gains it without a backend-specific flag.
1686pub struct RerankedMemory<M, R> {
1687    inner: M,
1688    reranker: R,
1689}
1690
1691impl<M, R> RerankedMemory<M, R> {
1692    /// Wrap `inner` so its recall is reranked by `reranker`.
1693    pub fn new(inner: M, reranker: R) -> Self {
1694        Self { inner, reranker }
1695    }
1696}
1697
1698impl<M: Memory + Sync, R: Reranker + Sync> Memory for RerankedMemory<M, R> {
1699    async fn remember(
1700        &self,
1701        scope: &MemoryScope,
1702        payload: Vec<u8>,
1703    ) -> Result<MemoryId, LaserError> {
1704        self.inner.remember(scope, payload).await
1705    }
1706
1707    async fn append(
1708        &self,
1709        scope: &MemoryScope,
1710        id: MemoryId,
1711        kind: MemoryKind,
1712        payload: Vec<u8>,
1713    ) -> Result<MemoryId, LaserError> {
1714        self.inner.append(scope, id, kind, payload).await
1715    }
1716
1717    async fn recall(
1718        &self,
1719        scope: &MemoryScope,
1720        query: &MemoryQuery,
1721    ) -> Result<Vec<MemoryItem>, LaserError> {
1722        let candidates = self.inner.recall(scope, query).await?;
1723        match &query.semantic {
1724            Some(text) => self.reranker.rerank(text, candidates).await,
1725            None => Ok(candidates),
1726        }
1727    }
1728
1729    async fn improve(
1730        &self,
1731        scope: &MemoryScope,
1732        feedback: Feedback,
1733    ) -> Result<MemoryId, LaserError> {
1734        self.inner.improve(scope, feedback).await
1735    }
1736
1737    async fn forget(&self, scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
1738        self.inner.forget(scope, id).await
1739    }
1740}
1741
1742/// A semantic `Memory` backend: embeds payloads on `remember` and ranks `recall`
1743/// by cosine similarity to `query.semantic`. The index is in-memory. A durable
1744/// vector store is a future drop-in behind the same trait.
1745pub struct VectorMemory<E> {
1746    embedder: E,
1747    items: Mutex<Vec<VectorEntry>>,
1748    governance: Option<VectorGovernance>,
1749}
1750
1751#[derive(Clone)]
1752struct VectorGovernance {
1753    laser: Laser,
1754}
1755
1756struct VectorEntry {
1757    id: MemoryId,
1758    scope: MemoryScope,
1759    embedding: Vec<f32>,
1760    item: MemoryItem,
1761    // Accumulated feedback weight, mutated by `improve` and read by `recall`,
1762    // both under the one `items` lock. No separate map or lock: `recall` already
1763    // scans the whole `Vec` under this lock, so the weight rides with its entry.
1764    feedback: f32,
1765}
1766
1767impl<E> VectorMemory<E> {
1768    /// An in-memory semantic memory using `embedder`.
1769    pub fn new(embedder: E) -> Self {
1770        Self::with_governance(embedder, None)
1771    }
1772
1773    /// An in-memory semantic memory whose writes run through the governor
1774    /// enrolled on `laser`. Directly constructed vector memories remain
1775    /// standalone. `Laser::memory_with(.., MemoryBackend::Vector)` uses this
1776    /// governed form.
1777    pub fn governed(laser: Laser, embedder: E) -> Self {
1778        Self::with_governance(embedder, Some(VectorGovernance { laser }))
1779    }
1780
1781    fn with_governance(embedder: E, governance: Option<VectorGovernance>) -> Self {
1782        Self {
1783            embedder,
1784            items: Mutex::new(Vec::new()),
1785            governance,
1786        }
1787    }
1788
1789    async fn govern_entry(
1790        &self,
1791        scope: &MemoryScope,
1792        entry: MemoryLogEntry,
1793    ) -> Result<MemoryLogEntry, LaserError> {
1794        let Some(governance) = &self.governance else {
1795            return Ok(entry);
1796        };
1797        let topic = AgentTopic::Memory.topic_string();
1798        let payload = match &entry {
1799            MemoryLogEntry::Item { body, .. } => body.clone(),
1800            MemoryLogEntry::Forget { .. } | MemoryLogEntry::Feedback { .. } => entry.encode()?,
1801        };
1802        let conversation = scope.conversation.unwrap_or_default();
1803        let action = crate::govern::GovernedAction {
1804            kind: crate::govern::ActionKind::MemoryWrite,
1805            stream: governance.laser.stream_required()?,
1806            topic: &topic,
1807            source: scope.agent.as_ref().map(|agent| agent.as_str()),
1808            target: None,
1809            conversation: Some(conversation),
1810            correlation: None,
1811            operation: None,
1812            tool: None,
1813            on_behalf_of: None,
1814            purpose: None,
1815            data_classification: None,
1816            payload: &payload,
1817            signed: false,
1818            counters: crate::govern::ActionCounters::default(),
1819        };
1820        match governance.laser.govern(action).await? {
1821            Some(modified) => match entry {
1822                MemoryLogEntry::Item {
1823                    id,
1824                    kind,
1825                    origin,
1826                    producer,
1827                    ..
1828                } => Ok(MemoryLogEntry::Item {
1829                    id,
1830                    kind,
1831                    body: modified,
1832                    origin,
1833                    producer,
1834                }),
1835                MemoryLogEntry::Forget { .. } | MemoryLogEntry::Feedback { .. } => {
1836                    MemoryLogEntry::decode(&modified)
1837                }
1838            },
1839            None => Ok(entry),
1840        }
1841    }
1842
1843    async fn append(
1844        &self,
1845        scope: &MemoryScope,
1846        id: MemoryId,
1847        kind: MemoryKind,
1848        payload: Vec<u8>,
1849    ) -> Result<MemoryId, LaserError>
1850    where
1851        E: Embedder + Sync,
1852    {
1853        let entry = self
1854            .govern_entry(
1855                scope,
1856                MemoryLogEntry::Item {
1857                    id,
1858                    kind,
1859                    body: payload,
1860                    origin: scope.origin.clone(),
1861                    producer: scope.producer.clone(),
1862                },
1863            )
1864            .await?;
1865        let MemoryLogEntry::Item {
1866            id,
1867            kind,
1868            body,
1869            origin,
1870            producer,
1871        } = entry
1872        else {
1873            return Err(LaserError::Invalid(
1874                "a memory-item governor modification must remain an item".to_owned(),
1875            ));
1876        };
1877        let embedding = self.embedder.embed(&String::from_utf8_lossy(&body)).await?;
1878        let mut provenance = Provenance::builder()
1879            .conversation_id(scope.conversation.unwrap_or_default())
1880            .build();
1881        provenance.agent = scope.agent.clone();
1882        provenance.idempotency_key = Some(id.to_string());
1883        let item = MemoryItem {
1884            id,
1885            payload: body,
1886            provenance,
1887            kind,
1888            score: None,
1889            signals: Vec::new(),
1890            source: None,
1891            origin,
1892            producer,
1893        };
1894        let mut items = self.items.lock().await;
1895        if items.iter().any(|entry| entry.id == id) {
1896            return Ok(id);
1897        }
1898        items.push(VectorEntry {
1899            id,
1900            scope: scope.clone(),
1901            embedding,
1902            item,
1903            feedback: 0.0,
1904        });
1905        Ok(id)
1906    }
1907}
1908
1909impl<E: Embedder + Sync> Memory for VectorMemory<E> {
1910    async fn remember(
1911        &self,
1912        scope: &MemoryScope,
1913        payload: Vec<u8>,
1914    ) -> Result<MemoryId, LaserError> {
1915        self.append(scope, MemoryId::new(), MemoryKind::Fact, payload)
1916            .await
1917    }
1918
1919    async fn append(
1920        &self,
1921        scope: &MemoryScope,
1922        id: MemoryId,
1923        kind: MemoryKind,
1924        payload: Vec<u8>,
1925    ) -> Result<MemoryId, LaserError> {
1926        VectorMemory::append(self, scope, id, kind, payload).await
1927    }
1928
1929    async fn recall(
1930        &self,
1931        scope: &MemoryScope,
1932        query: &MemoryQuery,
1933    ) -> Result<Vec<MemoryItem>, LaserError> {
1934        // Which signals this strategy fuses. `Auto`/`Semantic`/`Hybrid` use the
1935        // embedding. `Keyword`/`Hybrid` add lexical overlap. `Keyword` skips the
1936        // embed call entirely (no model round-trip when only lexical is wanted).
1937        let wants_semantic = !matches!(
1938            query.strategy,
1939            RecallStrategy::Keyword | RecallStrategy::Recent
1940        );
1941        let wants_keyword = matches!(
1942            query.strategy,
1943            RecallStrategy::Keyword | RecallStrategy::Hybrid
1944        );
1945        // Embed the query before locking so the network/model call never holds the lock.
1946        let query_embedding = match (&query.semantic, wants_semantic) {
1947            (Some(text), true) => Some(self.embedder.embed(text).await?),
1948            _ => None,
1949        };
1950        let query_tokens = match (&query.semantic, wants_keyword) {
1951            (Some(text), true) => Some(tokenize(text)),
1952            _ => None,
1953        };
1954        let agent_filter = query.agent.as_ref().or(scope.agent.as_ref());
1955        let items = self.items.lock().await;
1956        let matched: Vec<&VectorEntry> = items
1957            .iter()
1958            .filter(|entry| {
1959                scope
1960                    .conversation
1961                    .is_none_or(|c| entry.scope.conversation == Some(c))
1962                    && agent_filter.is_none_or(|a| entry.scope.agent.as_ref() == Some(a))
1963                    && scope
1964                        .user
1965                        .as_ref()
1966                        .is_none_or(|u| entry.scope.user.as_ref() == Some(u))
1967                    && scope
1968                        .app
1969                        .as_ref()
1970                        .is_none_or(|a| entry.scope.app.as_ref() == Some(a))
1971                    && scope
1972                        .stream
1973                        .as_ref()
1974                        .is_none_or(|stream| entry.scope.stream.as_ref() == Some(stream))
1975            })
1976            .collect();
1977
1978        // Recent recall is recency alone, so feedback never reorders it.
1979        let feedback_ranks = query.strategy != RecallStrategy::Recent;
1980        let any_feedback = feedback_ranks && matched.iter().any(|entry| entry.feedback != 0.0);
1981
1982        let mut scored: Vec<(f32, &VectorEntry, Vec<RecallSignal>)> = matched
1983            .iter()
1984            .map(|&entry| {
1985                let mut signals = Vec::new();
1986                if let Some(embedding) = &query_embedding {
1987                    signals.push(RecallSignal {
1988                        strategy: RecallStrategy::Semantic,
1989                        rank: 0,
1990                        score: Some(cosine(embedding, &entry.embedding)),
1991                    });
1992                }
1993                if let Some(tokens) = &query_tokens {
1994                    signals.push(RecallSignal {
1995                        strategy: RecallStrategy::Keyword,
1996                        rank: 0,
1997                        score: Some(keyword_score(tokens, &entry.item.payload)),
1998                    });
1999                }
2000                if feedback_ranks && entry.feedback != 0.0 {
2001                    signals.push(RecallSignal {
2002                        strategy: RecallStrategy::Auto,
2003                        rank: 0,
2004                        score: Some(entry.feedback),
2005                    });
2006                }
2007                let score = signals
2008                    .iter()
2009                    .map(|signal| signal.score.unwrap_or(0.0))
2010                    .sum();
2011                (score, entry, signals)
2012            })
2013            .collect();
2014        let any_query_signal = query_embedding.is_some() || query_tokens.is_some();
2015        if any_query_signal || any_feedback {
2016            for strategy in [
2017                RecallStrategy::Semantic,
2018                RecallStrategy::Keyword,
2019                RecallStrategy::Auto,
2020            ] {
2021                let mut ranks: Vec<(usize, f32)> = scored
2022                    .iter()
2023                    .enumerate()
2024                    .filter_map(|(index, (_, _, signals))| {
2025                        signals
2026                            .iter()
2027                            .find(|signal| signal.strategy == strategy)
2028                            .map(|signal| (index, signal.score.unwrap_or(0.0)))
2029                    })
2030                    .collect();
2031                ranks.sort_by(|left, right| right.1.total_cmp(&left.1));
2032                for (rank, (index, _)) in ranks.into_iter().enumerate() {
2033                    if let Some(signal) = scored[index]
2034                        .2
2035                        .iter_mut()
2036                        .find(|signal| signal.strategy == strategy)
2037                    {
2038                        signal.rank = rank;
2039                    }
2040                }
2041            }
2042            scored.sort_by(|left, right| right.0.total_cmp(&left.0));
2043            Ok(scored
2044                .into_iter()
2045                .take(query.limit)
2046                .map(|(score, entry, signals)| {
2047                    let mut item = entry.item.clone();
2048                    item.score = Some(score);
2049                    item.signals = signals;
2050                    item
2051                })
2052                .collect())
2053        } else {
2054            Ok(matched
2055                .into_iter()
2056                .rev()
2057                .take(query.limit)
2058                .map(|entry| entry.item.clone())
2059                .collect())
2060        }
2061    }
2062
2063    async fn improve(
2064        &self,
2065        scope: &MemoryScope,
2066        feedback: Feedback,
2067    ) -> Result<MemoryId, LaserError> {
2068        let entry = self
2069            .govern_entry(
2070                scope,
2071                MemoryLogEntry::Feedback {
2072                    target: feedback.target,
2073                    weight: feedback.weight,
2074                    conversation: scope.conversation,
2075                },
2076            )
2077            .await?;
2078        let MemoryLogEntry::Feedback {
2079            target,
2080            weight,
2081            conversation,
2082        } = entry
2083        else {
2084            return Err(LaserError::Invalid(
2085                "a memory-feedback governor modification must remain feedback".to_owned(),
2086            ));
2087        };
2088        // Accumulate the weight on its target under the one `items` lock, so the
2089        // next recall reranks. A signal for an unknown target is a no-op.
2090        let mut items = self.items.lock().await;
2091        if let Some(entry) = items
2092            .iter_mut()
2093            .find(|entry| entry.id == target && in_conversation(entry, conversation))
2094        {
2095            entry.feedback += weight;
2096        }
2097        Ok(MemoryId::new())
2098    }
2099
2100    async fn forget(&self, scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
2101        let entry = self
2102            .govern_entry(
2103                scope,
2104                MemoryLogEntry::Forget {
2105                    target: id,
2106                    conversation: scope.conversation,
2107                },
2108            )
2109            .await?;
2110        let MemoryLogEntry::Forget {
2111            target,
2112            conversation,
2113        } = entry
2114        else {
2115            return Err(LaserError::Invalid(
2116                "a memory-forget governor modification must remain a tombstone".to_owned(),
2117            ));
2118        };
2119        self.items
2120            .lock()
2121            .await
2122            .retain(|entry| entry.id != target || !in_conversation(entry, conversation));
2123        Ok(())
2124    }
2125}
2126
2127fn in_conversation(entry: &VectorEntry, conversation: Option<ConversationId>) -> bool {
2128    conversation.is_none_or(|scope| entry.item.provenance.conversation_id == scope)
2129}
2130
2131fn cosine(a: &[f32], b: &[f32]) -> f32 {
2132    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
2133    let norm_a = a.iter().map(|x| x * x).sum::<f32>().sqrt();
2134    let norm_b = b.iter().map(|x| x * x).sum::<f32>().sqrt();
2135    if norm_a == 0.0 || norm_b == 0.0 {
2136        0.0
2137    } else {
2138        dot / (norm_a * norm_b)
2139    }
2140}
2141
2142// Lowercase alphanumeric word tokens, the unit the in-process keyword signal
2143// scores on. A real backend uses BM25 over an inverted index. This is the
2144// dependency-free equivalent for `VectorMemory`.
2145fn tokenize(text: &str) -> HashSet<String> {
2146    text.split(|c: char| !c.is_alphanumeric())
2147        .filter(|token| !token.is_empty())
2148        .map(str::to_lowercase)
2149        .collect()
2150}
2151
2152// Insert one memory scope header, mapping an over-long key or value to a codec error.
2153fn put_header(
2154    headers: &mut std::collections::BTreeMap<HeaderKey, HeaderValue>,
2155    key: &str,
2156    value: &str,
2157) -> Result<(), LaserError> {
2158    let key = HeaderKey::from_str(key)
2159        .map_err(|error| LaserError::Codec(format!("memory header key {key}: {error}")))?;
2160    let value = HeaderValue::from_str(value)
2161        .map_err(|error| LaserError::Codec(format!("memory header value: {error}")))?;
2162    headers.insert(key, value);
2163    Ok(())
2164}
2165
2166/// Fuse several signals' ranked item lists into one by reciprocal rank: each
2167/// item scores the sum of `1 / (60 + rank)` over the signals that surfaced it
2168/// (60 is the standard RRF damping constant), so agreement between signals
2169/// outranks a single signal's top hit. Pure and portable across backends: the
2170/// per-signal attribution is preserved on each fused item's `signals`.
2171pub fn fuse_reciprocal_rank(signals: Vec<Vec<MemoryItem>>, limit: usize) -> Vec<MemoryItem> {
2172    const RRF_K: f32 = 60.0;
2173    let mut fused: Vec<MemoryItem> = Vec::new();
2174    for ranked in signals {
2175        for item in ranked {
2176            let contribution = item
2177                .signals
2178                .first()
2179                .map_or(0.0, |signal| 1.0 / (RRF_K + signal.rank as f32));
2180            match fused.iter_mut().find(|held| held.id == item.id) {
2181                Some(held) => {
2182                    held.score = Some(held.score.unwrap_or(0.0) + contribution);
2183                    held.signals.extend(item.signals);
2184                }
2185                None => {
2186                    let mut item = item;
2187                    item.score = Some(contribution);
2188                    fused.push(item);
2189                }
2190            }
2191        }
2192    }
2193    fused.sort_by(|a, b| b.score.unwrap_or(0.0).total_cmp(&a.score.unwrap_or(0.0)));
2194    fused.truncate(limit);
2195    fused
2196}
2197
2198// The lexical-overlap signal for the hybrid recall: the fraction of the query's
2199// tokens that appear in the item body, in [0, 1] so it composes on the same scale
2200// as cosine similarity. Returns 0 when the body is not UTF-8 or the query is empty.
2201fn keyword_score(query_tokens: &HashSet<String>, body: &[u8]) -> f32 {
2202    if query_tokens.is_empty() {
2203        return 0.0;
2204    }
2205    let Ok(text) = std::str::from_utf8(body) else {
2206        return 0.0;
2207    };
2208    let body_tokens = tokenize(text);
2209    let hits = query_tokens
2210        .iter()
2211        .filter(|token| body_tokens.contains(*token))
2212        .count();
2213    hits as f32 / query_tokens.len() as f32
2214}
2215
2216fn memory_query_matches(query: &MemoryQuery, body: &[u8]) -> bool {
2217    if query.strategy == RecallStrategy::Recent {
2218        return true;
2219    }
2220    let Some(text) = query.semantic.as_deref() else {
2221        return true;
2222    };
2223    let tokens = tokenize(text);
2224    tokens.is_empty() || keyword_score(&tokens, body) > 0.0
2225}
2226
2227/// The backend a memory handle runs on. [`Auto`](Self::Auto) is the default and
2228/// what [`Laser::memory`] uses. There is one durable model: memory over the
2229/// stream. [`Vector`](Self::Vector) is the in-process similarity index for tests
2230/// and offline recall.
2231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2232pub enum MemoryBackend {
2233    /// Agent memory over the stream: every write publishes to the memory topic
2234    /// (the durable, replayable audit) that a deployment materializes into the
2235    /// versioned key-value read view recall serves. The default.
2236    #[default]
2237    Auto,
2238    /// The same durable stream model as [`Auto`](Self::Auto), named explicitly.
2239    Log,
2240    /// An in-process vector index: embeds on remember, ranks recall by
2241    /// similarity, gone on drop. Needs an embedder registered with
2242    /// [`MemoryHandle::embedder`] before the first verb.
2243    Vector,
2244}
2245
2246/// Configures a memory topic, built with [`Laser::memory_topic`]. Tune the
2247/// stream, partition count, and message-expiry, then [`build`](Self::build)
2248/// ensures the topic and returns the handle.
2249pub struct MemoryTopicBuilder {
2250    laser: Laser,
2251    stream: Option<String>,
2252    topic: String,
2253    partitions: u32,
2254    ttl: Option<std::time::Duration>,
2255}
2256
2257impl MemoryTopicBuilder {
2258    /// The stream the memory topic lives on (default the client's default
2259    /// stream). Names an explicit stream so memory can be isolated from data.
2260    #[must_use]
2261    pub fn stream(mut self, stream: impl Into<String>) -> Self {
2262        self.stream = Some(stream.into());
2263        self
2264    }
2265
2266    /// The topic's partition count (default 1). Each scope's records share a
2267    /// partition key, so more partitions spread scopes, never split one scope.
2268    #[must_use]
2269    pub fn partitions(mut self, partitions: u32) -> Self {
2270        self.partitions = partitions.max(1);
2271        self
2272    }
2273
2274    /// Expire records on the stream after this long (default
2275    /// [`DEFAULT_MEMORY_TOPIC_TTL`], thirty days), bounding the audit history.
2276    /// The materialized read view keeps its own retention, so this only sets how
2277    /// long the raw history lives on the topic.
2278    #[must_use]
2279    pub fn ttl(mut self, ttl: std::time::Duration) -> Self {
2280        self.ttl = Some(ttl);
2281        self
2282    }
2283
2284    /// Keep records on the stream indefinitely (until topic retention rotates
2285    /// them out), opting out of the default message-expiry.
2286    #[must_use]
2287    pub fn no_expiry(mut self) -> Self {
2288        self.ttl = None;
2289        self
2290    }
2291
2292    /// Ensure the stream and topic (partitions and expiry) and return memory
2293    /// on it.
2294    pub async fn build(self) -> Result<MemoryHandle, LaserError> {
2295        let stream = match &self.stream {
2296            Some(stream) => stream.clone(),
2297            None => self.laser.stream_required()?.to_owned(),
2298        };
2299        let expiry = match self.ttl {
2300            Some(ttl) => IggyExpiry::ExpireDuration(IggyDuration::from(ttl)),
2301            None => IggyExpiry::NeverExpire,
2302        };
2303        self.laser
2304            .ensure_topic_on_with(&stream, &self.topic, self.partitions, expiry)
2305            .await?;
2306        let topic = Identifier::named(&self.topic)?;
2307        Ok(MemoryHandle::Log(LogMemory::on_stream_topic(
2308            self.laser,
2309            self.stream,
2310            topic,
2311        )))
2312    }
2313}
2314
2315/// The front door to agent memory: remember, recall, improve, forget. Built by
2316/// [`Laser::memory`], or [`Laser::memory_with`] for an explicit
2317/// [`MemoryBackend`]. Hold one instance per namespace and reuse it so recall
2318/// stays incremental across calls.
2319// The log variant carries its cursor inline (the durable path stays
2320// allocation-free), so it is larger than the pointer-sized vector variant. A
2321// handle is built once per `memory()` call and never stored in bulk, so the
2322// stack size of the largest variant is irrelevant here.
2323#[allow(clippy::large_enum_variant)]
2324pub enum MemoryHandle {
2325    /// The durable stream model: every write publishes to the memory topic, and
2326    /// a deployment materializes it into the versioned key-value read view.
2327    Log(LogMemory),
2328    /// The in-process vector backend, shared so the handle stays cheap to
2329    /// clone while the index lives across calls.
2330    Vector(std::sync::Arc<VectorMemory<SharedEmbedder>>),
2331    /// Any backend wrapped with a rerank second stage (see
2332    /// [`embedder`](Self::embedder) / [`reranker`](Self::reranker)).
2333    Reranked {
2334        inner: Box<MemoryHandle>,
2335        reranker: SharedReranker,
2336    },
2337    /// An external durable backend plugged in through
2338    /// [`Laser::memory_custom`](crate::laser::Laser::memory_custom).
2339    Custom(std::sync::Arc<dyn DynMemory>),
2340}
2341
2342/// A cloneable, type-erased [`Embedder`], how a handle owns the seam without a
2343/// generic parameter. Build one implicitly through
2344/// [`MemoryHandle::embedder`].
2345#[derive(Clone)]
2346pub struct SharedEmbedder(std::sync::Arc<dyn DynEmbedder>);
2347
2348impl Embedder for SharedEmbedder {
2349    async fn embed(&self, text: &str) -> Result<Vec<f32>, LaserError> {
2350        self.0.embed_dyn(text).await
2351    }
2352}
2353
2354trait DynEmbedder: Send + Sync {
2355    fn embed_dyn<'a>(
2356        &'a self,
2357        text: &'a str,
2358    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<f32>, LaserError>> + Send + 'a>>;
2359}
2360
2361impl<E: Embedder + Sync> DynEmbedder for E {
2362    fn embed_dyn<'a>(
2363        &'a self,
2364        text: &'a str,
2365    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<f32>, LaserError>> + Send + 'a>> {
2366        Box::pin(self.embed(text))
2367    }
2368}
2369
2370/// A cloneable, type-erased [`Reranker`], the rerank sibling of
2371/// [`SharedEmbedder`].
2372#[derive(Clone)]
2373pub struct SharedReranker(std::sync::Arc<dyn DynReranker>);
2374
2375impl Reranker for SharedReranker {
2376    async fn rerank(
2377        &self,
2378        query: &str,
2379        items: Vec<MemoryItem>,
2380    ) -> Result<Vec<MemoryItem>, LaserError> {
2381        self.0.rerank_dyn(query, items).await
2382    }
2383}
2384
2385trait DynReranker: Send + Sync {
2386    fn rerank_dyn<'a>(
2387        &'a self,
2388        query: &'a str,
2389        items: Vec<MemoryItem>,
2390    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, LaserError>> + Send + 'a>>;
2391}
2392
2393impl<R: Reranker + Sync> DynReranker for R {
2394    fn rerank_dyn<'a>(
2395        &'a self,
2396        query: &'a str,
2397        items: Vec<MemoryItem>,
2398    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, LaserError>> + Send + 'a>>
2399    {
2400        Box::pin(self.rerank(query, items))
2401    }
2402}
2403
2404impl MemoryHandle {
2405    /// Which backend this handle resolved to: `"log"` for the durable stream
2406    /// model (the default), `"vector"` for the in-process similarity index.
2407    #[must_use]
2408    pub fn backend(&self) -> &'static str {
2409        match self {
2410            Self::Log(_) => "log",
2411            Self::Vector(_) => "vector",
2412            Self::Reranked { inner, .. } => inner.backend(),
2413            Self::Custom(_) => "custom",
2414        }
2415    }
2416
2417    /// A standalone in-process vector handle over `embedder`, with no `Laser`
2418    /// and no governance. For a governed vector handle use
2419    /// [`Laser::memory_with`] with [`MemoryBackend::Vector`].
2420    pub fn vector(embedder: impl Embedder + Sync + 'static) -> Self {
2421        Self::Vector(std::sync::Arc::new(VectorMemory::new(SharedEmbedder(
2422            std::sync::Arc::new(embedder),
2423        ))))
2424    }
2425
2426    /// Register the embedding seam for similarity recall. It configures the
2427    /// in-process vector index. The durable log model recalls by recency, so an
2428    /// embedder on a log handle is unused (build with
2429    /// `memory_with(.., MemoryBackend::Vector)` for similarity recall).
2430    #[must_use]
2431    pub fn embedder(self, embedder: impl Embedder + Sync + 'static) -> Self {
2432        self.embedder_shared(SharedEmbedder(std::sync::Arc::new(embedder)))
2433    }
2434
2435    fn embedder_shared(self, shared: SharedEmbedder) -> Self {
2436        match self {
2437            Self::Vector(memory) => Self::Vector(std::sync::Arc::new(
2438                VectorMemory::with_governance(shared, memory.governance.clone()),
2439            )),
2440            Self::Reranked { inner, reranker } => Self::Reranked {
2441                inner: Box::new(inner.embedder_shared(shared)),
2442                reranker,
2443            },
2444            Self::Log(memory) => Self::Log(memory),
2445            // A custom backend owns its own retrieval. An embedder does not apply.
2446            Self::Custom(memory) => Self::Custom(memory),
2447        }
2448    }
2449
2450    /// Wrap recall with a rerank second stage, exactly as [`RerankedMemory`]
2451    /// does: the backend retrieves, the reranker reorders when the query
2452    /// carries its `semantic` text. Composition, not configuration.
2453    #[must_use]
2454    pub fn reranker(self, reranker: impl Reranker + Sync + 'static) -> Self {
2455        Self::Reranked {
2456            inner: Box::new(self),
2457            reranker: SharedReranker(std::sync::Arc::new(reranker)),
2458        }
2459    }
2460}
2461
2462impl Laser {
2463    /// Agent memory in `namespace`: remember facts, recall the relevant ones,
2464    /// improve their ranking with feedback, and forget what is stale. One model -
2465    /// every write publishes to the memory topic (the durable, replayable audit)
2466    /// that a deployment materializes into the versioned key-value read view.
2467    /// `namespace` prefixes the named-item altitude's keys
2468    /// ([`set`](MemoryHandle::set) / [`fetch`](MemoryHandle::fetch)), which ride
2469    /// the same topic as every other write. For an isolated, per-topic memory stream use
2470    /// [`memory_on_topic`](Self::memory_on_topic) or
2471    /// [`memory_topic`](Self::memory_topic). For similarity recall build
2472    /// [`memory_with`](Self::memory_with) with [`MemoryBackend::Vector`]. For the
2473    /// knowledge graph use [`graph`](Self::graph).
2474    pub fn memory(&self, namespace: impl Into<String>) -> MemoryHandle {
2475        self.memory_with(namespace, MemoryBackend::Auto)
2476    }
2477
2478    /// Agent memory in a caller-named topic, so a deployment can keep several
2479    /// isolated memory streams. The same verbs as [`memory`](Self::memory).
2480    /// `stream` names the stream the topic lives on, `None` keeps the client's
2481    /// default stream. Ensure the topic up front like any other, for example
2482    /// `laser.topic(name).ensure(partitions)`.
2483    pub fn memory_on_topic(
2484        &self,
2485        topic: impl AsRef<str>,
2486        stream: Option<&str>,
2487    ) -> Result<MemoryHandle, LaserError> {
2488        Ok(MemoryHandle::Log(LogMemory::on_stream_topic_named(
2489            self.clone(),
2490            stream.map(str::to_owned),
2491            topic.as_ref(),
2492        )?))
2493    }
2494
2495    /// Configure a memory topic before use: pick its name, partition count, and
2496    /// message-expiry, then `build` to ensure it and get the handle. Records for
2497    /// one scope always share a partition key (the conversation), so a
2498    /// multi-partition topic keeps each scope's history in order while spreading
2499    /// scopes across partitions.
2500    pub fn memory_topic(&self, topic: impl Into<String>) -> MemoryTopicBuilder {
2501        MemoryTopicBuilder {
2502            laser: self.clone(),
2503            stream: None,
2504            topic: topic.into(),
2505            partitions: 1,
2506            ttl: Some(DEFAULT_MEMORY_TOPIC_TTL),
2507        }
2508    }
2509
2510    /// Memory in `namespace` on an explicit [`MemoryBackend`]. `Auto` and `Log`
2511    /// are the durable stream model [`memory`](Self::memory) uses. `Vector` is
2512    /// the in-process similarity index (register an embedder with
2513    /// [`MemoryHandle::embedder`]) for tests and offline recall.
2514    pub fn memory_with(
2515        &self,
2516        namespace: impl Into<String>,
2517        backend: MemoryBackend,
2518    ) -> MemoryHandle {
2519        let namespace = namespace.into();
2520        match backend {
2521            MemoryBackend::Auto | MemoryBackend::Log => {
2522                MemoryHandle::Log(LogMemory::in_namespace(self.clone(), namespace))
2523            }
2524            MemoryBackend::Vector => {
2525                let _ = namespace;
2526                MemoryHandle::Vector(std::sync::Arc::new(VectorMemory::governed(
2527                    self.clone(),
2528                    SharedEmbedder(std::sync::Arc::new(MaybeEmbedder(None))),
2529                )))
2530            }
2531        }
2532    }
2533
2534    /// A memory handle over a custom [`DynMemory`]
2535    /// backend, so an external durable store plugs into the facade as a
2536    /// first-class backend beyond the `Auto`/`Log`/`Vector` built-ins. The backend
2537    /// owns its own scoping and id minting. The four verbs route straight to it.
2538    /// Any type implementing [`Memory`] qualifies via the
2539    /// blanket `DynMemory` impl.
2540    pub fn memory_custom(
2541        &self,
2542        backend: std::sync::Arc<dyn crate::memory::DynMemory>,
2543    ) -> MemoryHandle {
2544        MemoryHandle::Custom(backend)
2545    }
2546}
2547
2548impl MemoryHandle {
2549    // Append `payload` under `id` and `kind`, dispatching to the chosen backend.
2550    async fn append_id(
2551        &self,
2552        scope: &MemoryScope,
2553        id: MemoryId,
2554        kind: MemoryKind,
2555        payload: Vec<u8>,
2556    ) -> Result<MemoryId, LaserError> {
2557        match self {
2558            MemoryHandle::Log(memory) => memory.append(scope, id, kind, payload).await,
2559            MemoryHandle::Vector(memory) => memory.append(scope, id, kind, payload).await,
2560            MemoryHandle::Reranked { inner, .. } => {
2561                append_id_boxed(inner, scope, id, kind, payload).await
2562            }
2563            // `remember` special-cases Custom before reaching here, but the match
2564            // must be exhaustive: delegate, letting the backend mint its own id.
2565            MemoryHandle::Custom(memory) => memory.append(scope, id, kind, payload).await,
2566        }
2567    }
2568
2569    /// The simple altitude: write named point state under your own `key`.
2570    /// `remember`/`recall` address items by content and relevance.
2571    /// `set`/`fetch`/`update`/`remove` address them by name, the working-note
2572    /// shape ("the current plan", "the user's tier"). Every write is a durable
2573    /// event on the memory topic, like the rest of memory, so a deployment
2574    /// materializes it and other consumers see it. The in-process vector handle
2575    /// has no key space and refuses typed.
2576    pub async fn set(
2577        &self,
2578        key: impl AsRef<str>,
2579        payload: impl Into<Vec<u8>>,
2580    ) -> Result<(), LaserError> {
2581        self.named_memory("set")?
2582            .set_named(key.as_ref(), payload.into())
2583            .await
2584    }
2585
2586    /// Point-read the named item written by [`set`](Self::set), or `None`.
2587    pub async fn fetch(&self, key: impl AsRef<str>) -> Result<Option<Vec<u8>>, LaserError> {
2588        self.named_memory("fetch")?.fetch_named(key.as_ref()).await
2589    }
2590
2591    /// Point-read the named item by folding the topic in process, the opt-in path
2592    /// for a deployment with no managed read view. Prefer [`fetch`](Self::fetch),
2593    /// which reads the materialized key-value view.
2594    pub async fn fetch_folded(&self, key: impl AsRef<str>) -> Result<Option<Vec<u8>>, LaserError> {
2595        self.named_memory("fetch_folded")?
2596            .fetch_named_folded(key.as_ref())
2597            .await
2598    }
2599
2600    /// Merge-patch the named item (RFC 7386 over a JSON value): fields in
2601    /// `patch` overwrite, `null` removes. Reads the current value, merges, and
2602    /// writes the result back as one event. For a whole-value overwrite use
2603    /// [`set`](Self::set).
2604    pub async fn update(
2605        &self,
2606        key: impl AsRef<str>,
2607        patch: impl Into<Vec<u8>>,
2608    ) -> Result<(), LaserError> {
2609        self.named_memory("update")?
2610            .update_named(key.as_ref(), patch.into())
2611            .await
2612    }
2613
2614    /// Delete the named item. Idempotent: removing an absent key is fine.
2615    pub async fn remove(&self, key: impl AsRef<str>) -> Result<(), LaserError> {
2616        self.named_memory("remove")?
2617            .forget_named(key.as_ref())
2618            .await
2619    }
2620
2621    // The durable memory the named-item altitude rides. Only the log-backed
2622    // handle has a key space, and the vector handle refuses typed.
2623    fn named_memory(&self, verb: &str) -> Result<&LogMemory, LaserError> {
2624        match self {
2625            MemoryHandle::Log(memory) => Ok(memory),
2626            MemoryHandle::Reranked { inner, .. } => inner.named_memory(verb),
2627            other => Err(LaserError::unsupported(
2628                "memory",
2629                format!(
2630                    "{verb}(key) is the named-item altitude and needs the durable memory handle \
2631                     (this handle resolved to `{}`)",
2632                    other.backend()
2633                ),
2634            )),
2635        }
2636    }
2637
2638    /// Start a fluent remember. Chain `.scope`/`.agent`/`.durable`/`.dedup` and
2639    /// finish with `.send().await`.
2640    pub fn remember(&self, payload: impl Into<Vec<u8>>) -> RememberBuilder<'_> {
2641        RememberBuilder {
2642            handle: self,
2643            payload: payload.into(),
2644            scope: MemoryScope::default(),
2645            kind: MemoryKind::Fact,
2646            dedup: false,
2647        }
2648    }
2649
2650    /// Recall by folding the topic in process (the opt-in behind
2651    /// [`RecallBuilder::folded`]). The log backend folds, the vector backend is
2652    /// already in process, and a reranked backend folds its inner then reranks.
2653    pub async fn recall_folded(
2654        &self,
2655        scope: &MemoryScope,
2656        query: &MemoryQuery,
2657    ) -> Result<Vec<MemoryItem>, LaserError> {
2658        match self {
2659            MemoryHandle::Log(memory) => memory.recall_folded(scope, query).await,
2660            MemoryHandle::Vector(memory) => Memory::recall(memory.as_ref(), scope, query).await,
2661            MemoryHandle::Reranked { inner, reranker } => {
2662                let items = recall_folded_boxed(inner, scope, query).await?;
2663                match &query.semantic {
2664                    Some(text) => Reranker::rerank(reranker, text, items).await,
2665                    None => Ok(items),
2666                }
2667            }
2668            // A custom backend has no separate in-process fold: its own recall is
2669            // the closest, so folded recall delegates to it.
2670            MemoryHandle::Custom(memory) => memory.recall(scope, query).await,
2671        }
2672    }
2673
2674    /// Start a fluent recall in `conversation`, or across every conversation
2675    /// of the namespace with `None`. Chain `.semantic`/`.limit` and finish with
2676    /// `.fetch().await` (or await the builder itself).
2677    pub fn recall(&self, conversation: impl Into<Option<ConversationId>>) -> RecallBuilder<'_> {
2678        RecallBuilder {
2679            handle: self,
2680            scope: MemoryScope::builder()
2681                .maybe_conversation(conversation.into())
2682                .build(),
2683            limit: 50,
2684            semantic: None,
2685            strategy: RecallStrategy::Auto,
2686            folded: false,
2687        }
2688    }
2689
2690    /// The one-call context altitude: recall `conversation`'s most relevant
2691    /// items and render them as one prompt-ready block under an optional token
2692    /// budget (see [`to_context_block`]). The 90% case above the deep
2693    /// [`recall`](Self::recall) builder: assembled, budgeted, ready to prompt.
2694    pub async fn context(
2695        &self,
2696        conversation: ConversationId,
2697        token_budget: Option<usize>,
2698    ) -> Result<String, LaserError> {
2699        let items = self.recall(conversation).fetch().await?;
2700        Ok(to_context_block(&items, token_budget))
2701    }
2702
2703    /// One consolidation pass over `scope` with the default policy: keep the
2704    /// newest `max_items`, prune the rest. For the summarize and invalidation
2705    /// passes build a [`DefaultConsolidator`] with its seams and run it
2706    /// directly. This is the one-call altitude.
2707    pub async fn consolidate(
2708        &self,
2709        scope: &MemoryScope,
2710        max_items: usize,
2711    ) -> Result<ConsolidationReport, LaserError> {
2712        Consolidator::consolidate(&DefaultConsolidator::new(self, max_items), scope).await
2713    }
2714
2715    /// [`consolidate`](Self::consolidate) with the summarize pass: the
2716    /// `Message` items in `scope` are first folded through `summarizer` into one
2717    /// durable summary per conversation, and `prune_summarized` forgets the
2718    /// folded items so the summary replaces them.
2719    pub async fn consolidate_with(
2720        &self,
2721        scope: &MemoryScope,
2722        max_items: usize,
2723        summarizer: impl Summarizer + Sync,
2724        prune_summarized: bool,
2725    ) -> Result<ConsolidationReport, LaserError> {
2726        let consolidator = DefaultConsolidator::new(self, max_items).with_summarizer(summarizer);
2727        let consolidator = if prune_summarized {
2728            consolidator.prune_summarized()
2729        } else {
2730            consolidator
2731        };
2732        Consolidator::consolidate(&consolidator, scope).await
2733    }
2734
2735    /// Record `feedback` on a recalled item under `scope`.
2736    pub async fn improve(
2737        &self,
2738        scope: &MemoryScope,
2739        feedback: Feedback,
2740    ) -> Result<MemoryId, LaserError> {
2741        Memory::improve(self, scope, feedback).await
2742    }
2743
2744    /// Forget the item `id` under `scope`.
2745    pub async fn forget(&self, scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
2746        Memory::forget(self, scope, id).await
2747    }
2748}
2749
2750impl Memory for MemoryHandle {
2751    async fn remember(
2752        &self,
2753        scope: &MemoryScope,
2754        payload: Vec<u8>,
2755    ) -> Result<MemoryId, LaserError> {
2756        // A custom backend owns its own id and kind, so it does not route through
2757        // `append_id` (which mints a Fact for the built-in log/vector handles).
2758        if let MemoryHandle::Custom(memory) = self {
2759            return memory.remember(scope, payload).await;
2760        }
2761        self.append_id(scope, MemoryId::new(), MemoryKind::Fact, payload)
2762            .await
2763    }
2764
2765    async fn append(
2766        &self,
2767        scope: &MemoryScope,
2768        id: MemoryId,
2769        kind: MemoryKind,
2770        payload: Vec<u8>,
2771    ) -> Result<MemoryId, LaserError> {
2772        self.append_id(scope, id, kind, payload).await
2773    }
2774
2775    async fn recall(
2776        &self,
2777        scope: &MemoryScope,
2778        query: &MemoryQuery,
2779    ) -> Result<Vec<MemoryItem>, LaserError> {
2780        match self {
2781            MemoryHandle::Log(memory) => Memory::recall(memory, scope, query).await,
2782            MemoryHandle::Vector(memory) => Memory::recall(memory.as_ref(), scope, query).await,
2783            MemoryHandle::Reranked { inner, reranker } => {
2784                let items = recall_boxed(inner, scope, query).await?;
2785                match &query.semantic {
2786                    Some(text) => Reranker::rerank(reranker, text, items).await,
2787                    None => Ok(items),
2788                }
2789            }
2790            MemoryHandle::Custom(memory) => memory.recall(scope, query).await,
2791        }
2792    }
2793
2794    async fn improve(
2795        &self,
2796        scope: &MemoryScope,
2797        feedback: Feedback,
2798    ) -> Result<MemoryId, LaserError> {
2799        match self {
2800            MemoryHandle::Log(memory) => Memory::improve(memory, scope, feedback).await,
2801            MemoryHandle::Vector(memory) => Memory::improve(memory.as_ref(), scope, feedback).await,
2802            MemoryHandle::Reranked { inner, .. } => improve_boxed(inner, scope, feedback).await,
2803            MemoryHandle::Custom(memory) => memory.improve(scope, feedback).await,
2804        }
2805    }
2806
2807    async fn forget(&self, scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
2808        match self {
2809            MemoryHandle::Log(memory) => Memory::forget(memory, scope, id).await,
2810            MemoryHandle::Vector(memory) => Memory::forget(memory.as_ref(), scope, id).await,
2811            MemoryHandle::Reranked { inner, .. } => forget_boxed(inner, scope, id).await,
2812            MemoryHandle::Custom(memory) => memory.forget(scope, id).await,
2813        }
2814    }
2815}
2816
2817impl Memory for &MemoryHandle {
2818    async fn remember(
2819        &self,
2820        scope: &MemoryScope,
2821        payload: Vec<u8>,
2822    ) -> Result<MemoryId, LaserError> {
2823        Memory::remember(*self, scope, payload).await
2824    }
2825
2826    // An explicit future preserves the trait's Sync bound on a borrowed handle.
2827    #[allow(clippy::manual_async_fn)]
2828    fn append(
2829        &self,
2830        scope: &MemoryScope,
2831        id: MemoryId,
2832        kind: MemoryKind,
2833        payload: Vec<u8>,
2834    ) -> impl std::future::Future<Output = Result<MemoryId, LaserError>> + Send
2835    where
2836        Self: Sync,
2837    {
2838        async move { Memory::append(*self, scope, id, kind, payload).await }
2839    }
2840
2841    async fn recall(
2842        &self,
2843        scope: &MemoryScope,
2844        query: &MemoryQuery,
2845    ) -> Result<Vec<MemoryItem>, LaserError> {
2846        Memory::recall(*self, scope, query).await
2847    }
2848
2849    async fn improve(
2850        &self,
2851        scope: &MemoryScope,
2852        feedback: Feedback,
2853    ) -> Result<MemoryId, LaserError> {
2854        Memory::improve(*self, scope, feedback).await
2855    }
2856
2857    async fn forget(&self, scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
2858        Memory::forget(*self, scope, id).await
2859    }
2860}
2861
2862/// A cloneable, type-erased [`Consolidator`], how the agent builder's periodic
2863/// tick owns one without a generic parameter.
2864#[derive(Clone)]
2865pub struct SharedConsolidator(std::sync::Arc<dyn DynConsolidator>);
2866
2867impl SharedConsolidator {
2868    /// Erase `consolidator` for shared ownership.
2869    pub fn new(consolidator: impl Consolidator + Sync + 'static) -> Self {
2870        Self(std::sync::Arc::new(consolidator))
2871    }
2872}
2873
2874impl Consolidator for SharedConsolidator {
2875    async fn consolidate(&self, scope: &MemoryScope) -> Result<ConsolidationReport, LaserError> {
2876        self.0.consolidate_dyn(scope).await
2877    }
2878}
2879
2880trait DynConsolidator: Send + Sync {
2881    fn consolidate_dyn<'a>(
2882        &'a self,
2883        scope: &'a MemoryScope,
2884    ) -> std::pin::Pin<Box<dyn Future<Output = Result<ConsolidationReport, LaserError>> + Send + 'a>>;
2885}
2886
2887impl<C: Consolidator + Sync> DynConsolidator for C {
2888    fn consolidate_dyn<'a>(
2889        &'a self,
2890        scope: &'a MemoryScope,
2891    ) -> std::pin::Pin<Box<dyn Future<Output = Result<ConsolidationReport, LaserError>> + Send + 'a>>
2892    {
2893        Box::pin(Consolidator::consolidate(self, scope))
2894    }
2895}
2896
2897// Boxed recursion helpers: a `Reranked` handle wraps another handle, and the
2898// trait's anonymous futures cannot reference themselves, so the recursive hop
2899// goes through one boxed indirection per wrapper level.
2900fn append_id_boxed<'a>(
2901    inner: &'a MemoryHandle,
2902    scope: &'a MemoryScope,
2903    id: MemoryId,
2904    kind: MemoryKind,
2905    payload: Vec<u8>,
2906) -> std::pin::Pin<Box<dyn Future<Output = Result<MemoryId, LaserError>> + Send + 'a>> {
2907    Box::pin(inner.append_id(scope, id, kind, payload))
2908}
2909
2910fn recall_boxed<'a>(
2911    inner: &'a MemoryHandle,
2912    scope: &'a MemoryScope,
2913    query: &'a MemoryQuery,
2914) -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, LaserError>> + Send + 'a>> {
2915    Box::pin(Memory::recall(inner, scope, query))
2916}
2917
2918fn recall_folded_boxed<'a>(
2919    inner: &'a MemoryHandle,
2920    scope: &'a MemoryScope,
2921    query: &'a MemoryQuery,
2922) -> std::pin::Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, LaserError>> + Send + 'a>> {
2923    Box::pin(inner.recall_folded(scope, query))
2924}
2925
2926fn improve_boxed<'a>(
2927    inner: &'a MemoryHandle,
2928    scope: &'a MemoryScope,
2929    feedback: Feedback,
2930) -> std::pin::Pin<Box<dyn Future<Output = Result<MemoryId, LaserError>> + Send + 'a>> {
2931    Box::pin(Memory::improve(inner, scope, feedback))
2932}
2933
2934fn forget_boxed<'a>(
2935    inner: &'a MemoryHandle,
2936    scope: &'a MemoryScope,
2937    id: MemoryId,
2938) -> std::pin::Pin<Box<dyn Future<Output = Result<(), LaserError>> + Send + 'a>> {
2939    Box::pin(Memory::forget(inner, scope, id))
2940}
2941
2942// Merge `patch` into the named item's current value by RFC 7386 (JSON merge-patch)
2943// and return the bytes to write back. An absent current value merges onto null.
2944fn merge_named(current: Option<&[u8]>, patch: &[u8]) -> Result<Vec<u8>, LaserError> {
2945    let patch: serde_json::Value = serde_json::from_slice(patch)
2946        .map_err(|error| LaserError::Codec(format!("update patch is not JSON: {error}")))?;
2947    let mut base: serde_json::Value = match current {
2948        Some(payload) => serde_json::from_slice(payload)
2949            .map_err(|error| LaserError::Codec(format!("stored value is not JSON: {error}")))?,
2950        None => serde_json::Value::Null,
2951    };
2952    merge_patch(&mut base, &patch);
2953    serde_json::to_vec(&base)
2954        .map_err(|error| LaserError::Codec(format!("encode merged value: {error}")))
2955}
2956
2957// RFC 7386: an object patch overwrites matching fields (a null field removes),
2958// any non-object patch replaces the base wholesale. Recurses into nested objects.
2959fn merge_patch(base: &mut serde_json::Value, patch: &serde_json::Value) {
2960    let serde_json::Value::Object(fields) = patch else {
2961        *base = patch.clone();
2962        return;
2963    };
2964    if !base.is_object() {
2965        *base = serde_json::Value::Object(serde_json::Map::new());
2966    }
2967    let map = base.as_object_mut().expect("base was just made an object");
2968    for (key, value) in fields {
2969        if value.is_null() {
2970            map.remove(key);
2971        } else {
2972            merge_patch(
2973                map.entry(key.clone()).or_insert(serde_json::Value::Null),
2974                value,
2975            );
2976        }
2977    }
2978}
2979
2980/// A [`SharedEmbedder`] that may be absent: the keyword and recency strategies
2981/// never call it, the semantic ones surface the registration hint.
2982#[derive(Clone)]
2983pub struct MaybeEmbedder(Option<SharedEmbedder>);
2984
2985impl Embedder for MaybeEmbedder {
2986    async fn embed(&self, text: &str) -> Result<Vec<f32>, LaserError> {
2987        match &self.0 {
2988            Some(embedder) => Embedder::embed(embedder, text).await,
2989            None => Err(LaserError::Config(
2990                "this memory needs an embedder for semantic recall: register one with \
2991                 MemoryHandle::embedder(..)",
2992            )),
2993        }
2994    }
2995}
2996
2997/// A fluent remember, created by [`MemoryHandle::remember`].
2998pub struct RememberBuilder<'a> {
2999    handle: &'a MemoryHandle,
3000    payload: Vec<u8>,
3001    scope: MemoryScope,
3002    kind: MemoryKind,
3003    dedup: bool,
3004}
3005
3006impl RememberBuilder<'_> {
3007    /// Record the session record that motivated this item.
3008    #[must_use]
3009    pub fn origin(mut self, origin: SourceRef) -> Self {
3010        self.scope.origin = Some(origin);
3011        self
3012    }
3013
3014    /// Record the component that produced this item.
3015    #[must_use]
3016    pub fn producer(mut self, producer: laser_wire::graph::ProducerInfo) -> Self {
3017        self.scope.producer = Some(producer);
3018        self
3019    }
3020
3021    /// Scope to a conversation.
3022    #[must_use]
3023    pub fn scope(mut self, conversation: ConversationId) -> Self {
3024        self.scope.conversation = Some(conversation);
3025        self
3026    }
3027
3028    /// Attribute to an agent.
3029    #[must_use]
3030    pub fn agent(mut self, agent: AgentId) -> Self {
3031        self.scope.agent = Some(agent);
3032        self
3033    }
3034
3035    /// Scope to a stream (the widest tier).
3036    #[must_use]
3037    pub fn stream(mut self, stream: impl Into<String>) -> Self {
3038        self.scope.stream = Some(stream.into());
3039        self
3040    }
3041
3042    /// Attribute to an end user, the identity the memory is about.
3043    #[must_use]
3044    pub fn user(mut self, user: impl Into<String>) -> Self {
3045        self.scope.user = Some(user.into());
3046        self
3047    }
3048
3049    /// Attribute to an application or org within the deployment.
3050    #[must_use]
3051    pub fn application(mut self, application: impl Into<String>) -> Self {
3052        self.scope.app = Some(application.into());
3053        self
3054    }
3055
3056    /// Store as durable, shared across conversations (default is session-scoped).
3057    #[must_use]
3058    pub fn durable(mut self) -> Self {
3059        self.scope.lifetime = Lifetime::Durable;
3060        self
3061    }
3062
3063    /// Set the item kind.
3064    #[must_use]
3065    pub fn kind(mut self, kind: MemoryKind) -> Self {
3066        self.kind = kind;
3067        self
3068    }
3069
3070    /// Content-address the id, so storing the same body under the same durable
3071    /// owner is idempotent (remember twice, store once).
3072    #[must_use]
3073    pub fn dedup(mut self) -> Self {
3074        self.dedup = true;
3075        self
3076    }
3077
3078    /// Store the item, returning its id.
3079    pub async fn send(self) -> Result<MemoryId, LaserError> {
3080        let id = if self.dedup {
3081            MemoryId::content(&self.scope, self.kind, &self.payload)
3082        } else {
3083            MemoryId::new()
3084        };
3085        self.handle
3086            .append_id(&self.scope, id, self.kind, self.payload)
3087            .await
3088    }
3089}
3090
3091/// A fluent recall, created by [`MemoryHandle::recall`]. Awaiting the builder
3092/// runs [`fetch`](Self::fetch).
3093pub struct RecallBuilder<'a> {
3094    handle: &'a MemoryHandle,
3095    scope: MemoryScope,
3096    limit: usize,
3097    semantic: Option<String>,
3098    strategy: RecallStrategy,
3099    folded: bool,
3100}
3101
3102impl RecallBuilder<'_> {
3103    /// Rank by semantic similarity to `text` (sets the `Semantic` strategy).
3104    /// Honored by an embedding backend. The log backend returns the most recent.
3105    #[must_use]
3106    pub fn semantic(mut self, text: impl Into<String>) -> Self {
3107        self.semantic = Some(text.into());
3108        self.strategy = RecallStrategy::Semantic;
3109        self
3110    }
3111
3112    /// Return the most recent items (sets the `Recent` strategy).
3113    #[must_use]
3114    pub fn recent(mut self) -> Self {
3115        self.strategy = RecallStrategy::Recent;
3116        self
3117    }
3118
3119    /// Fold the topic in process instead of reading the managed key-value view,
3120    /// the opt-in path for small serverless scenarios with no deployment. The
3121    /// default reads the read view, which does not fold a large topic.
3122    #[must_use]
3123    pub fn folded(mut self) -> Self {
3124        self.folded = true;
3125        self
3126    }
3127
3128    /// Rank by lexical relevance to `text` (sets the `Keyword` strategy, the
3129    /// managed `Query.text` rider). The complement to `semantic` for the
3130    /// exact-term matches an embedding blurs.
3131    #[must_use]
3132    pub fn keyword(mut self, text: impl Into<String>) -> Self {
3133        self.semantic = Some(text.into());
3134        self.strategy = RecallStrategy::Keyword;
3135        self
3136    }
3137
3138    /// Combine semantic similarity, keyword overlap, and feedback by addition for `text`.
3139    /// Each item's `signals` keeps the individual scores and ranks.
3140    #[must_use]
3141    pub fn hybrid(mut self, text: impl Into<String>) -> Self {
3142        self.semantic = Some(text.into());
3143        self.strategy = RecallStrategy::Hybrid;
3144        self
3145    }
3146
3147    /// Set the recall strategy explicitly, the deep form behind the sugar
3148    /// above. Pair a text-driven strategy with the query text (`semantic`,
3149    /// `keyword`, or `hybrid` set it in one call).
3150    #[must_use]
3151    pub fn strategy(mut self, strategy: RecallStrategy) -> Self {
3152        self.strategy = strategy;
3153        self
3154    }
3155
3156    /// Narrow to an agent.
3157    #[must_use]
3158    pub fn agent(mut self, agent: AgentId) -> Self {
3159        self.scope.agent = Some(agent);
3160        self
3161    }
3162
3163    /// Narrow to an end user.
3164    #[must_use]
3165    pub fn user(mut self, user: impl Into<String>) -> Self {
3166        self.scope.user = Some(user.into());
3167        self
3168    }
3169
3170    /// Narrow to an application or org within the deployment.
3171    #[must_use]
3172    pub fn application(mut self, application: impl Into<String>) -> Self {
3173        self.scope.app = Some(application.into());
3174        self
3175    }
3176
3177    /// Narrow to items remembered on this stream.
3178    #[must_use]
3179    pub fn stream(mut self, stream: impl Into<String>) -> Self {
3180        self.scope.stream = Some(stream.into());
3181        self
3182    }
3183
3184    /// Cap the number of items returned.
3185    #[must_use]
3186    pub fn limit(mut self, limit: usize) -> Self {
3187        self.limit = limit;
3188        self
3189    }
3190
3191    /// Run the recall and render the items as one prompt-ready block under
3192    /// `token_budget`, see [`to_context_block`].
3193    pub async fn block(self, token_budget: Option<usize>) -> Result<String, LaserError> {
3194        Ok(to_context_block(&self.fetch().await?, token_budget))
3195    }
3196
3197    /// Run the recall.
3198    pub async fn fetch(self) -> Result<Vec<MemoryItem>, LaserError> {
3199        let query = MemoryQuery::builder()
3200            .limit(self.limit)
3201            .maybe_semantic(self.semantic)
3202            .strategy(self.strategy)
3203            .maybe_agent(self.scope.agent.clone())
3204            .build();
3205        if self.folded {
3206            self.handle.recall_folded(&self.scope, &query).await
3207        } else {
3208            Memory::recall(self.handle, &self.scope, &query).await
3209        }
3210    }
3211}
3212
3213impl<'a> std::future::IntoFuture for RecallBuilder<'a> {
3214    type Output = Result<Vec<MemoryItem>, LaserError>;
3215    type IntoFuture =
3216        std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send + 'a>>;
3217
3218    fn into_future(self) -> Self::IntoFuture {
3219        Box::pin(self.fetch())
3220    }
3221}
3222
3223#[cfg(test)]
3224mod tests {
3225    use super::*;
3226
3227    #[test]
3228    fn given_a_default_stream_when_naming_the_memory_namespace_then_should_scope_it_to_the_record_stream()
3229     {
3230        let laser = Laser::from_client(crate::iggy::prelude::IggyClient::default())
3231            .with_default_stream("acme");
3232        let memory = LogMemory::in_namespace(laser.clone(), "notes");
3233        assert_eq!(memory.resource_namespace(), "stream:acme/notes");
3234        assert_eq!(memory.named_key("k"), "notes/k");
3235        let elsewhere =
3236            LogMemory::on_stream_topic_named(laser.clone(), Some("fleet".to_owned()), "memory")
3237                .expect("a valid topic name");
3238        assert_eq!(elsewhere.resource_namespace(), "stream:fleet/memory");
3239        assert_eq!(elsewhere.resource_stream(), Some("fleet"));
3240        let bare = LogMemory::in_namespace(
3241            laser.with_resource_naming(crate::laser::ResourceNaming::Bare),
3242            "notes",
3243        );
3244        assert_eq!(bare.resource_namespace(), "notes");
3245        assert_eq!(bare.resource_stream(), None);
3246    }
3247
3248    #[tokio::test]
3249    async fn given_a_custom_memory_backend_when_used_through_the_handle_then_should_delegate() {
3250        // Any `Memory` impl plugs into the facade via `MemoryHandle::Custom` (the
3251        // blanket `DynMemory` bridge), so remember/recall route straight to it.
3252        struct FakeMemory(std::sync::Mutex<Vec<Vec<u8>>>);
3253        impl Memory for FakeMemory {
3254            async fn remember(
3255                &self,
3256                _scope: &MemoryScope,
3257                payload: Vec<u8>,
3258            ) -> Result<MemoryId, LaserError> {
3259                self.0.lock().expect("lock").push(payload);
3260                Ok(MemoryId::new())
3261            }
3262            async fn recall(
3263                &self,
3264                _scope: &MemoryScope,
3265                _query: &MemoryQuery,
3266            ) -> Result<Vec<MemoryItem>, LaserError> {
3267                let provenance = Provenance::builder()
3268                    .conversation_id(ConversationId::new())
3269                    .build();
3270                Ok(self
3271                    .0
3272                    .lock()
3273                    .expect("lock")
3274                    .iter()
3275                    .map(|payload| {
3276                        MemoryItem::plain(MemoryId::new(), payload.clone(), provenance.clone())
3277                    })
3278                    .collect())
3279            }
3280            async fn improve(
3281                &self,
3282                _scope: &MemoryScope,
3283                _feedback: Feedback,
3284            ) -> Result<MemoryId, LaserError> {
3285                Ok(MemoryId::new())
3286            }
3287            async fn forget(&self, _scope: &MemoryScope, _id: MemoryId) -> Result<(), LaserError> {
3288                Ok(())
3289            }
3290        }
3291
3292        let handle = MemoryHandle::Custom(std::sync::Arc::new(FakeMemory(std::sync::Mutex::new(
3293            Vec::new(),
3294        ))));
3295        let scope = MemoryScope::default();
3296        Memory::remember(&handle, &scope, b"hi".to_vec())
3297            .await
3298            .expect("custom remember");
3299        let items = Memory::recall(&handle, &scope, &MemoryQuery::builder().limit(10).build())
3300            .await
3301            .expect("custom recall");
3302        assert_eq!(items.len(), 1);
3303        assert_eq!(items[0].payload, b"hi");
3304    }
3305
3306    #[tokio::test]
3307    async fn given_user_and_application_when_building_then_should_reach_the_backend_scope() {
3308        type Seen = std::sync::Arc<std::sync::Mutex<Vec<MemoryScope>>>;
3309        struct ScopeProbe(Seen);
3310        impl Memory for ScopeProbe {
3311            async fn remember(
3312                &self,
3313                scope: &MemoryScope,
3314                _payload: Vec<u8>,
3315            ) -> Result<MemoryId, LaserError> {
3316                self.0.lock().expect("lock").push(scope.clone());
3317                Ok(MemoryId::new())
3318            }
3319            async fn recall(
3320                &self,
3321                scope: &MemoryScope,
3322                _query: &MemoryQuery,
3323            ) -> Result<Vec<MemoryItem>, LaserError> {
3324                self.0.lock().expect("lock").push(scope.clone());
3325                let provenance = Provenance::builder()
3326                    .conversation_id(ConversationId::new())
3327                    .build();
3328                Ok(vec![MemoryItem::plain(
3329                    MemoryId::new(),
3330                    b"node-7 rotates keys monthly".to_vec(),
3331                    provenance,
3332                )])
3333            }
3334            async fn improve(
3335                &self,
3336                _scope: &MemoryScope,
3337                _feedback: Feedback,
3338            ) -> Result<MemoryId, LaserError> {
3339                Ok(MemoryId::new())
3340            }
3341            async fn forget(&self, _scope: &MemoryScope, _id: MemoryId) -> Result<(), LaserError> {
3342                Ok(())
3343            }
3344        }
3345
3346        let seen = Seen::default();
3347        let handle = MemoryHandle::Custom(std::sync::Arc::new(ScopeProbe(seen.clone())));
3348        handle
3349            .remember(b"fact".to_vec())
3350            .user("operator-1")
3351            .application("fleet")
3352            .stream("fleet-memory")
3353            .send()
3354            .await
3355            .expect("the scoped remember should succeed");
3356        let block = handle
3357            .recall(ConversationId::new())
3358            .user("operator-1")
3359            .application("fleet")
3360            .stream("fleet-memory")
3361            .block(Some(100))
3362            .await
3363            .expect("the block should render");
3364        assert_eq!(block, "node-7 rotates keys monthly");
3365        let seen = seen.lock().expect("lock");
3366        assert_eq!(seen.len(), 2);
3367        for scope in seen.iter() {
3368            assert_eq!(scope.user.as_deref(), Some("operator-1"));
3369            assert_eq!(scope.app.as_deref(), Some("fleet"));
3370            assert_eq!(scope.stream.as_deref(), Some("fleet-memory"));
3371        }
3372    }
3373
3374    #[test]
3375    fn given_a_log_entry_when_encoded_then_the_wire_memory_record_decodes_it() {
3376        // The managed backend folds the memory topic through `laser_wire::memory::MemoryRecord`,
3377        // so the SDK's on-topic entry must decode into it byte-for-byte or the
3378        // materialization would miss records.
3379        let id = MemoryId::new();
3380        let entry = MemoryLogEntry::Item {
3381            id,
3382            kind: MemoryKind::Message,
3383            body: b"auth is slow".to_vec(),
3384            origin: None,
3385            producer: None,
3386        };
3387        let payload = entry.encode().expect("entry encodes");
3388        let record: laser_wire::memory::MemoryRecord =
3389            laser_wire::framing::decode_named(&payload).expect("wire record decodes the SDK entry");
3390        match record {
3391            laser_wire::memory::MemoryRecord::Item {
3392                origin: None,
3393                producer: None,
3394                id: rid,
3395                kind,
3396                body,
3397            } => {
3398                assert_eq!(rid, id.to_string());
3399                assert_eq!(kind, "message");
3400                assert_eq!(body, b"auth is slow");
3401            }
3402            other => panic!("expected an Item record, got {other:?}"),
3403        }
3404    }
3405
3406    #[test]
3407    fn given_a_memory_id_when_round_tripped_through_a_string_then_should_be_equal() {
3408        let id = MemoryId::new();
3409        let parsed = id
3410            .to_string()
3411            .parse::<MemoryId>()
3412            .expect("a formatted memory id should parse");
3413        assert_eq!(parsed, id);
3414    }
3415
3416    // An in-memory Memory for testing the consolidator: stores items in a Vec,
3417    // recall returns the newest items first, forget removes by id.
3418    struct MockMemory {
3419        items: std::sync::Mutex<Vec<MemoryItem>>,
3420    }
3421
3422    impl Memory for MockMemory {
3423        async fn remember(
3424            &self,
3425            _scope: &MemoryScope,
3426            payload: Vec<u8>,
3427        ) -> Result<MemoryId, LaserError> {
3428            let id = MemoryId::new();
3429            let provenance = Provenance::builder()
3430                .conversation_id(crate::types::ConversationId::new())
3431                .build();
3432            self.items
3433                .lock()
3434                .expect("lock")
3435                .push(MemoryItem::plain(id, payload, provenance));
3436            Ok(id)
3437        }
3438
3439        async fn recall(
3440            &self,
3441            _scope: &MemoryScope,
3442            query: &MemoryQuery,
3443        ) -> Result<Vec<MemoryItem>, LaserError> {
3444            Ok(self
3445                .items
3446                .lock()
3447                .expect("lock")
3448                .iter()
3449                .rev()
3450                .take(query.limit)
3451                .cloned()
3452                .collect())
3453        }
3454
3455        async fn improve(
3456            &self,
3457            _scope: &MemoryScope,
3458            _feedback: Feedback,
3459        ) -> Result<MemoryId, LaserError> {
3460            Ok(MemoryId::new())
3461        }
3462
3463        async fn forget(&self, _scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
3464            self.items
3465                .lock()
3466                .expect("lock")
3467                .retain(|item| item.id != id);
3468            Ok(())
3469        }
3470    }
3471
3472    #[test]
3473    fn given_more_than_max_items_when_consolidated_then_should_prune_down_to_the_cap() {
3474        let runtime = tokio::runtime::Builder::new_current_thread()
3475            .build()
3476            .expect("runtime builds");
3477        runtime.block_on(async {
3478            let mock = MockMemory {
3479                items: std::sync::Mutex::new(Vec::new()),
3480            };
3481            let scope = MemoryScope::builder().build();
3482            for i in 0..5 {
3483                Memory::remember(&mock, &scope, format!("item-{i}").into_bytes())
3484                    .await
3485                    .expect("remember");
3486            }
3487            let consolidator = DefaultConsolidator::new(mock, 2);
3488            let report = Consolidator::consolidate(&consolidator, &scope)
3489                .await
3490                .expect("consolidate");
3491            assert_eq!(
3492                report.pruned, 3,
3493                "five items down to a cap of two prunes three"
3494            );
3495
3496            let remaining = Memory::recall(
3497                &consolidator.memory,
3498                &scope,
3499                &MemoryQuery::builder().limit(100).build(),
3500            )
3501            .await
3502            .expect("recall");
3503            assert_eq!(remaining.len(), 2);
3504        });
3505    }
3506
3507    #[test]
3508    fn given_content_ids_when_consolidated_then_should_prune_in_arrival_order() {
3509        let runtime = tokio::runtime::Builder::new_current_thread()
3510            .build()
3511            .expect("runtime builds");
3512        runtime.block_on(async {
3513            let scope = MemoryScope::builder().build();
3514            let candidates: Vec<Vec<u8>> = (0..100)
3515                .map(|number| format!("item-{number}").into_bytes())
3516                .collect();
3517            let first = candidates
3518                .iter()
3519                .max_by_key(|body| MemoryId::content(&scope, MemoryKind::Fact, body))
3520                .expect("candidates")
3521                .clone();
3522            let first_id = MemoryId::content(&scope, MemoryKind::Fact, &first);
3523            let newest = candidates
3524                .iter()
3525                .min_by_key(|body| MemoryId::content(&scope, MemoryKind::Fact, body))
3526                .expect("candidates")
3527                .clone();
3528            let newest_id = MemoryId::content(&scope, MemoryKind::Fact, &newest);
3529            assert!(newest_id < first_id);
3530            let provenance = Provenance::builder()
3531                .conversation_id(crate::types::ConversationId::new())
3532                .build();
3533            let mock = MockMemory {
3534                items: std::sync::Mutex::new(vec![
3535                    MemoryItem::plain(first_id, first, provenance.clone()),
3536                    MemoryItem::plain(newest_id, newest.clone(), provenance),
3537                ]),
3538            };
3539            let consolidator = DefaultConsolidator::new(mock, 1);
3540            let report = Consolidator::consolidate(&consolidator, &scope)
3541                .await
3542                .expect("consolidate");
3543            assert_eq!(report.pruned, 1);
3544            let remaining = Memory::recall(
3545                &consolidator.memory,
3546                &scope,
3547                &MemoryQuery::builder().limit(10).build(),
3548            )
3549            .await
3550            .expect("recall");
3551            assert_eq!(remaining.len(), 1);
3552            assert_eq!(remaining[0].payload, newest);
3553        });
3554    }
3555
3556    struct StubSummarizer;
3557
3558    impl Summarizer for StubSummarizer {
3559        async fn summarize(&self, bodies: Vec<Vec<u8>>) -> Result<Vec<u8>, LaserError> {
3560            Ok(format!("summary of {}", bodies.len()).into_bytes())
3561        }
3562    }
3563
3564    #[tokio::test]
3565    async fn given_session_messages_when_consolidated_then_should_store_one_summary() {
3566        let memory = LogMemoryStub::default();
3567        let scope = MemoryScope::default();
3568        let conversation = ConversationId::new();
3569        for body in [b"turn one".to_vec(), b"turn two".to_vec()] {
3570            memory.push_in(MemoryKind::Message, body, conversation);
3571        }
3572        memory.push(MemoryKind::Fact, b"a durable fact".to_vec());
3573        let consolidator =
3574            DefaultConsolidator::new(memory.clone(), 100).with_summarizer(StubSummarizer);
3575        let report = Consolidator::consolidate(&consolidator, &scope)
3576            .await
3577            .expect("consolidates");
3578        assert_eq!(report.summarized, 2, "both session turns fold");
3579        assert_eq!(report.pruned, 0, "prune_summarized was not opted in");
3580        assert!(
3581            memory.bodies().iter().any(|body| body == b"summary of 2"),
3582            "the summary landed"
3583        );
3584    }
3585
3586    #[tokio::test]
3587    async fn given_turns_in_two_conversations_when_consolidated_then_should_store_one_summary_each()
3588    {
3589        let memory = LogMemoryStub::default();
3590        let first = ConversationId::new();
3591        let second = ConversationId::new();
3592        memory.push_in(MemoryKind::Message, b"first a".to_vec(), first);
3593        memory.push_in(MemoryKind::Message, b"second a".to_vec(), second);
3594        memory.push_in(MemoryKind::Message, b"first b".to_vec(), first);
3595        let scope = MemoryScope::builder()
3596            .agent("planner".parse().expect("valid agent"))
3597            .build();
3598        let consolidator =
3599            DefaultConsolidator::new(memory.clone(), 100).with_summarizer(StubSummarizer);
3600        let report = Consolidator::consolidate(&consolidator, &scope)
3601            .await
3602            .expect("consolidates");
3603        assert_eq!(report.summarized, 3);
3604        let summaries: Vec<_> = memory
3605            .appended()
3606            .into_iter()
3607            .map(|(scope, kind, body)| {
3608                (scope.conversation, scope.agent, scope.lifetime, kind, body)
3609            })
3610            .collect();
3611        let planner = Some("planner".parse().expect("valid agent"));
3612        assert_eq!(
3613            summaries,
3614            [
3615                (
3616                    Some(first),
3617                    planner.clone(),
3618                    Lifetime::Durable,
3619                    MemoryKind::Summary,
3620                    b"summary of 2".to_vec()
3621                ),
3622                (
3623                    Some(second),
3624                    planner,
3625                    Lifetime::Durable,
3626                    MemoryKind::Summary,
3627                    b"summary of 1".to_vec()
3628                ),
3629            ]
3630        );
3631        let stored = memory.items.lock().expect("stub lock").clone();
3632        let summary_conversations: Vec<_> = stored
3633            .iter()
3634            .filter(|item| item.kind == MemoryKind::Summary)
3635            .map(|item| item.provenance.conversation_id)
3636            .collect();
3637        assert_eq!(summary_conversations, [first, second]);
3638    }
3639
3640    #[tokio::test]
3641    async fn given_prune_summarized_when_consolidated_then_should_forget_the_turns() {
3642        let memory = LogMemoryStub::default();
3643        let scope = MemoryScope::default();
3644        memory.push(MemoryKind::Message, b"turn".to_vec());
3645        let consolidator = DefaultConsolidator::new(memory.clone(), 100)
3646            .with_summarizer(StubSummarizer)
3647            .prune_summarized();
3648        let report = Consolidator::consolidate(&consolidator, &scope)
3649            .await
3650            .expect("consolidates");
3651        assert_eq!(report.summarized, 1);
3652        assert_eq!(report.pruned, 1, "the folded turn is forgotten");
3653    }
3654
3655    // One typed append the stub saw: its scope, kind, and body.
3656    type Appended = (MemoryScope, MemoryKind, Vec<u8>);
3657
3658    /// A minimal in-memory `Memory` for consolidator tests: pushes are visible
3659    /// to recall, forget removes, nothing ranks. Clones share the store so a
3660    /// test can hand one clone to the consolidator and assert on the other.
3661    #[derive(Clone, Default)]
3662    struct LogMemoryStub {
3663        items: std::sync::Arc<std::sync::Mutex<Vec<MemoryItem>>>,
3664        appended: std::sync::Arc<std::sync::Mutex<Vec<Appended>>>,
3665    }
3666
3667    impl LogMemoryStub {
3668        fn push(&self, kind: MemoryKind, payload: Vec<u8>) {
3669            self.push_in(kind, payload, crate::types::ConversationId::new());
3670        }
3671
3672        fn push_in(&self, kind: MemoryKind, payload: Vec<u8>, conversation: ConversationId) {
3673            let provenance = Provenance::builder().conversation_id(conversation).build();
3674            let mut item = MemoryItem::plain(MemoryId::new(), payload, provenance);
3675            item.kind = kind;
3676            self.items.lock().expect("stub lock").push(item);
3677        }
3678
3679        fn appended(&self) -> Vec<Appended> {
3680            self.appended.lock().expect("stub lock").clone()
3681        }
3682
3683        fn bodies(&self) -> Vec<Vec<u8>> {
3684            self.items
3685                .lock()
3686                .expect("stub lock")
3687                .iter()
3688                .map(|item| item.payload.clone())
3689                .collect()
3690        }
3691    }
3692
3693    impl Memory for LogMemoryStub {
3694        async fn remember(
3695            &self,
3696            _scope: &MemoryScope,
3697            payload: Vec<u8>,
3698        ) -> Result<MemoryId, LaserError> {
3699            let id = MemoryId::new();
3700            let provenance = Provenance::builder()
3701                .conversation_id(crate::types::ConversationId::new())
3702                .build();
3703            self.items
3704                .lock()
3705                .expect("stub lock")
3706                .push(MemoryItem::plain(id, payload, provenance));
3707            Ok(id)
3708        }
3709
3710        async fn append(
3711            &self,
3712            scope: &MemoryScope,
3713            id: MemoryId,
3714            kind: MemoryKind,
3715            payload: Vec<u8>,
3716        ) -> Result<MemoryId, LaserError> {
3717            self.appended
3718                .lock()
3719                .expect("stub lock")
3720                .push((scope.clone(), kind, payload.clone()));
3721            self.push_in(kind, payload, scope.conversation.unwrap_or_default());
3722            Ok(id)
3723        }
3724
3725        async fn recall(
3726            &self,
3727            _scope: &MemoryScope,
3728            _query: &MemoryQuery,
3729        ) -> Result<Vec<MemoryItem>, LaserError> {
3730            Ok(self.items.lock().expect("stub lock").clone())
3731        }
3732
3733        async fn improve(
3734            &self,
3735            _scope: &MemoryScope,
3736            _feedback: Feedback,
3737        ) -> Result<MemoryId, LaserError> {
3738            Ok(MemoryId::new())
3739        }
3740
3741        async fn forget(&self, _scope: &MemoryScope, id: MemoryId) -> Result<(), LaserError> {
3742            self.items
3743                .lock()
3744                .expect("stub lock")
3745                .retain(|item| item.id != id);
3746            Ok(())
3747        }
3748    }
3749
3750    fn ranked_item(id: MemoryId, strategy: RecallStrategy, rank: usize) -> MemoryItem {
3751        let provenance = Provenance::builder()
3752            .conversation_id(crate::types::ConversationId::new())
3753            .build();
3754        let mut item = MemoryItem::plain(id, b"body".to_vec(), provenance);
3755        item.signals = vec![RecallSignal {
3756            strategy,
3757            rank,
3758            score: None,
3759        }];
3760        item
3761    }
3762
3763    #[test]
3764    fn given_two_signals_when_fused_then_should_rank_agreement_first() {
3765        let shared = MemoryId::new();
3766        let semantic_only = MemoryId::new();
3767        let keyword_only = MemoryId::new();
3768        let semantic = vec![
3769            ranked_item(semantic_only, RecallStrategy::Semantic, 0),
3770            ranked_item(shared, RecallStrategy::Semantic, 1),
3771        ];
3772        let keyword = vec![
3773            ranked_item(keyword_only, RecallStrategy::Keyword, 0),
3774            ranked_item(shared, RecallStrategy::Keyword, 1),
3775        ];
3776        let fused = fuse_reciprocal_rank(vec![semantic, keyword], 10);
3777        assert_eq!(fused[0].id, shared, "both signals agree on it");
3778        assert_eq!(fused[0].signals.len(), 2, "attribution keeps both signals");
3779        assert_eq!(fused.len(), 3);
3780    }
3781
3782    #[test]
3783    fn given_a_limit_when_fused_then_should_truncate_after_ranking() {
3784        let lists = vec![vec![
3785            ranked_item(MemoryId::new(), RecallStrategy::Semantic, 0),
3786            ranked_item(MemoryId::new(), RecallStrategy::Semantic, 1),
3787            ranked_item(MemoryId::new(), RecallStrategy::Semantic, 2),
3788        ]];
3789        assert_eq!(fuse_reciprocal_rank(lists, 2).len(), 2);
3790    }
3791
3792    fn context_item(body: &str) -> MemoryItem {
3793        let provenance = Provenance::builder()
3794            .conversation_id(crate::types::ConversationId::new())
3795            .build();
3796        MemoryItem::plain(MemoryId::new(), body.as_bytes().to_vec(), provenance)
3797    }
3798
3799    #[test]
3800    fn given_a_text_payload_when_read_then_should_return_the_text_and_json() {
3801        let item = context_item(r#"{"city":"Oslo"}"#);
3802        assert_eq!(item.text(), r#"{"city":"Oslo"}"#);
3803        let value: serde_json::Value = item.json().expect("json");
3804        assert_eq!(value["city"], "Oslo");
3805    }
3806
3807    #[test]
3808    fn given_a_non_json_payload_when_decoded_then_should_fail_with_a_codec_error() {
3809        let item = context_item("plain words");
3810        assert!(matches!(
3811            item.json::<serde_json::Value>(),
3812            Err(LaserError::Codec(_))
3813        ));
3814    }
3815
3816    #[test]
3817    fn given_no_budget_when_rendered_then_should_join_every_item() {
3818        let items = [context_item("alpha"), context_item("beta")];
3819        assert_eq!(to_context_block(&items, None), "alpha\n\nbeta");
3820    }
3821
3822    #[test]
3823    fn given_a_token_budget_when_rendered_then_should_fill_in_order_and_mark_the_overflow() {
3824        // ~4 bytes per token: a 6-token budget (24 bytes) fits the two 20-byte
3825        // items below only one at a time.
3826        let items = [
3827            context_item("first twenty bytes!!"),
3828            context_item("second twenty bytes!"),
3829        ];
3830        let block = to_context_block(&items, Some(6));
3831        assert_eq!(
3832            block,
3833            "first twenty bytes!!\n\n[... 1 more recalled item(s) omitted ...]"
3834        );
3835    }
3836
3837    #[test]
3838    fn given_a_budget_smaller_than_the_first_item_when_rendered_then_should_keep_at_least_one() {
3839        // The first item always renders, so a block is never empty when items exist.
3840        let items = [context_item("a much longer single memory item")];
3841        let block = to_context_block(&items, Some(1));
3842        assert_eq!(block, "a much longer single memory item");
3843    }
3844
3845    fn item_entry(id: MemoryId, body: &[u8]) -> Vec<u8> {
3846        MemoryLogEntry::Item {
3847            id,
3848            kind: MemoryKind::Fact,
3849            body: body.to_vec(),
3850            origin: None,
3851            producer: None,
3852        }
3853        .encode()
3854        .expect("an item entry encodes")
3855    }
3856
3857    #[test]
3858    fn given_messages_when_absorbed_then_should_fold_items_and_apply_tombstones() {
3859        let conversation = ConversationId::new();
3860        let prov = || Provenance::builder().conversation_id(conversation).build();
3861        let mut projection = Projection::default();
3862        let id_a = MemoryId::new();
3863        let id_b = MemoryId::new();
3864        projection.absorb(&item_entry(id_a, b"a"), prov());
3865        projection.absorb(&item_entry(id_b, b"b"), prov());
3866        assert_eq!(projection.items.len(), 2, "two items folded in");
3867
3868        // A tombstone for the first item records it as forgotten AND reclaims it
3869        // from `items` (no body pinned for the life of the projection).
3870        let tombstone = MemoryLogEntry::Forget {
3871            target: id_a,
3872            conversation: None,
3873        }
3874        .encode()
3875        .expect("a forget entry encodes");
3876        projection.absorb(&tombstone, prov());
3877        assert_eq!(projection.items.len(), 1, "tombstoned item reclaimed");
3878        assert_eq!(projection.items[0].id, id_b, "only the survivor remains");
3879        assert!(
3880            projection.is_forgotten(id_a, conversation),
3881            "first item forgotten"
3882        );
3883
3884        // An item whose id was already tombstoned (reorder) is not re-added.
3885        projection.absorb(&item_entry(id_a, b"a-again"), prov());
3886        assert_eq!(projection.items.len(), 1, "forgotten id not re-added");
3887
3888        // A payload that is not a memory entry is ignored.
3889        projection.absorb(b"not an entry", prov());
3890        assert_eq!(projection.items.len(), 1, "non-entry payload dropped");
3891    }
3892
3893    #[test]
3894    fn given_a_feedback_entry_when_absorbed_then_should_accumulate_the_weight() {
3895        let conversation = ConversationId::new();
3896        let prov = || Provenance::builder().conversation_id(conversation).build();
3897        let mut projection = Projection::default();
3898        let target = MemoryId::new();
3899        let feedback = MemoryLogEntry::Feedback {
3900            target,
3901            weight: 2.5,
3902            conversation: None,
3903        }
3904        .encode()
3905        .expect("a feedback entry encodes");
3906        projection.absorb(&feedback, prov());
3907        projection.absorb(&feedback, prov());
3908        assert_eq!(projection.feedback.get(&(target, None)), Some(&5.0));
3909        assert!(projection.items.is_empty(), "feedback is not an item");
3910    }
3911
3912    #[test]
3913    fn given_scoped_tombstone_and_feedback_when_absorbed_then_should_touch_only_their_conversation()
3914    {
3915        let owner = ConversationId::new();
3916        let other = ConversationId::new();
3917        let in_owner = || Provenance::builder().conversation_id(owner).build();
3918        let in_other = || Provenance::builder().conversation_id(other).build();
3919        let mut projection = Projection::default();
3920        let id = MemoryId::new();
3921        projection.absorb(&item_entry(id, b"kept"), in_owner());
3922        let scoped = |entry: MemoryLogEntry| entry.encode().expect("entry encodes");
3923        projection.absorb(
3924            &scoped(MemoryLogEntry::Feedback {
3925                target: id,
3926                weight: 4.0,
3927                conversation: Some(other),
3928            }),
3929            in_other(),
3930        );
3931        projection.absorb(
3932            &scoped(MemoryLogEntry::Forget {
3933                target: id,
3934                conversation: Some(other),
3935            }),
3936            in_other(),
3937        );
3938        assert_eq!(
3939            projection.items.len(),
3940            1,
3941            "another conversation cannot forget it"
3942        );
3943        assert_eq!(projection.feedback_for(&projection.items[0]), None);
3944        projection.absorb(
3945            &scoped(MemoryLogEntry::Feedback {
3946                target: id,
3947                weight: 2.0,
3948                conversation: Some(owner),
3949            }),
3950            in_owner(),
3951        );
3952        assert_eq!(projection.feedback_for(&projection.items[0]), Some(2.0));
3953        projection.absorb(
3954            &scoped(MemoryLogEntry::Forget {
3955                target: id,
3956                conversation: Some(owner),
3957            }),
3958            in_owner(),
3959        );
3960        assert!(
3961            projection.items.is_empty(),
3962            "the owning conversation forgets it"
3963        );
3964    }
3965
3966    // Deterministic stand-in for a real embedding model: a 3-dim count vector.
3967    struct WordEmbedder;
3968
3969    impl Embedder for WordEmbedder {
3970        async fn embed(&self, text: &str) -> Result<Vec<f32>, LaserError> {
3971            Ok(vec![
3972                text.matches("cat").count() as f32,
3973                text.matches("dog").count() as f32,
3974                text.matches("fish").count() as f32,
3975            ])
3976        }
3977    }
3978
3979    // `use super::*` brings both the base `LocalMemory` and the `Memory` Send
3980    // variant into scope, so the trait is named explicitly on each call.
3981    #[tokio::test]
3982    async fn given_a_semantic_query_when_recalling_then_should_rank_by_similarity() {
3983        let memory = VectorMemory::new(WordEmbedder);
3984        let scope = MemoryScope::builder()
3985            .conversation(ConversationId::new())
3986            .build();
3987        Memory::remember(&memory, &scope, b"the cat sat".to_vec())
3988            .await
3989            .expect("remembering the cat note should succeed");
3990        Memory::remember(&memory, &scope, b"the dog ran".to_vec())
3991            .await
3992            .expect("remembering the dog note should succeed");
3993
3994        let top = Memory::recall(
3995            &memory,
3996            &scope,
3997            &MemoryQuery::builder()
3998                .semantic("cat".to_owned())
3999                .limit(1)
4000                .build(),
4001        )
4002        .await
4003        .expect("recall should succeed");
4004        assert_eq!(top.len(), 1);
4005        assert_eq!(top[0].payload.as_slice(), b"the cat sat");
4006    }
4007
4008    #[tokio::test]
4009    async fn given_a_deduped_vector_remember_when_repeated_then_should_store_one_item() {
4010        let handle = MemoryHandle::Vector(std::sync::Arc::new(VectorMemory::new(SharedEmbedder(
4011            std::sync::Arc::new(WordEmbedder),
4012        ))));
4013        let conversation = ConversationId::new();
4014        let first = handle
4015            .remember(b"stable fact".to_vec())
4016            .scope(conversation)
4017            .dedup()
4018            .send()
4019            .await
4020            .expect("first remember succeeds");
4021        let second = handle
4022            .remember(b"stable fact".to_vec())
4023            .scope(conversation)
4024            .dedup()
4025            .send()
4026            .await
4027            .expect("second remember succeeds");
4028        let recalled = handle
4029            .recall(conversation)
4030            .fetch()
4031            .await
4032            .expect("vector recall succeeds");
4033        assert_eq!(first, second);
4034        assert_eq!(recalled.len(), 1);
4035    }
4036
4037    #[test]
4038    fn given_a_memory_backend_when_defaulted_then_should_be_auto() {
4039        assert_eq!(MemoryBackend::default(), MemoryBackend::Auto);
4040    }
4041
4042    #[test]
4043    fn given_memory_kinds_when_classified_then_should_map_to_the_taxonomy() {
4044        assert_eq!(MemoryKind::Message.class(), MemoryClass::Episodic);
4045        assert_eq!(MemoryKind::Procedure.class(), MemoryClass::Procedural);
4046        assert_eq!(MemoryKind::Fact.class(), MemoryClass::Semantic);
4047        assert_eq!(MemoryKind::Summary.class(), MemoryClass::Semantic);
4048    }
4049
4050    #[tokio::test]
4051    async fn given_a_keyword_strategy_when_recalling_then_should_match_exact_terms() {
4052        let memory = VectorMemory::new(WordEmbedder);
4053        let scope = MemoryScope::builder()
4054            .conversation(ConversationId::new())
4055            .build();
4056        Memory::remember(&memory, &scope, b"alpha beta".to_vec())
4057            .await
4058            .expect("remember alpha");
4059        Memory::remember(&memory, &scope, b"gamma delta".to_vec())
4060            .await
4061            .expect("remember gamma");
4062        let top = Memory::recall(
4063            &memory,
4064            &scope,
4065            &MemoryQuery::builder()
4066                .semantic("gamma".to_owned())
4067                .strategy(RecallStrategy::Keyword)
4068                .limit(1)
4069                .build(),
4070        )
4071        .await
4072        .expect("keyword recall");
4073        assert_eq!(top.len(), 1);
4074        assert_eq!(top[0].payload.as_slice(), b"gamma delta");
4075    }
4076
4077    #[tokio::test]
4078    async fn given_a_user_scope_when_recalling_then_should_narrow_to_that_user() {
4079        let memory = VectorMemory::new(WordEmbedder);
4080        let conversation = ConversationId::new();
4081        let alice = MemoryScope::builder()
4082            .conversation(conversation)
4083            .user("alice".to_owned())
4084            .build();
4085        let bob = MemoryScope::builder()
4086            .conversation(conversation)
4087            .user("bob".to_owned())
4088            .build();
4089        Memory::remember(&memory, &alice, b"alice secret".to_vec())
4090            .await
4091            .expect("remember alice");
4092        Memory::remember(&memory, &bob, b"bob secret".to_vec())
4093            .await
4094            .expect("remember bob");
4095        let hits = Memory::recall(&memory, &alice, &MemoryQuery::builder().build())
4096            .await
4097            .expect("recall alice");
4098        assert_eq!(hits.len(), 1, "only alice's item is in scope");
4099        assert_eq!(hits[0].payload.as_slice(), b"alice secret");
4100    }
4101
4102    struct ReverseReranker;
4103
4104    impl Reranker for ReverseReranker {
4105        async fn rerank(
4106            &self,
4107            _query: &str,
4108            mut items: Vec<MemoryItem>,
4109        ) -> Result<Vec<MemoryItem>, LaserError> {
4110            items.reverse();
4111            Ok(items)
4112        }
4113    }
4114
4115    #[tokio::test]
4116    async fn given_a_reranked_memory_when_recalling_then_should_apply_the_reranker() {
4117        let inner = VectorMemory::new(WordEmbedder);
4118        let scope = MemoryScope::builder()
4119            .conversation(ConversationId::new())
4120            .build();
4121        Memory::remember(&inner, &scope, b"the cat sat".to_vec())
4122            .await
4123            .expect("remember cat");
4124        Memory::remember(&inner, &scope, b"the dog ran".to_vec())
4125            .await
4126            .expect("remember dog");
4127        let reranked = RerankedMemory::new(inner, ReverseReranker);
4128        let hits = Memory::recall(
4129            &reranked,
4130            &scope,
4131            &MemoryQuery::builder().semantic("cat".to_owned()).build(),
4132        )
4133        .await
4134        .expect("reranked recall");
4135        assert_eq!(hits.len(), 2);
4136        assert_eq!(
4137            hits[0].payload.as_slice(),
4138            b"the dog ran",
4139            "the reranker moved the dog note to the front"
4140        );
4141    }
4142
4143    #[tokio::test]
4144    async fn given_a_reranked_handle_when_recalling_folded_then_should_apply_the_reranker() {
4145        let handle = MemoryHandle::vector(WordEmbedder).reranker(ReverseReranker);
4146        let conversation = ConversationId::new();
4147        for body in ["the cat sat", "the dog ran"] {
4148            handle
4149                .remember(body)
4150                .scope(conversation)
4151                .send()
4152                .await
4153                .expect("remember");
4154        }
4155        let scope = MemoryScope::builder().conversation(conversation).build();
4156        let query = MemoryQuery::builder().semantic("cat".to_owned()).build();
4157        let hits = handle
4158            .recall_folded(&scope, &query)
4159            .await
4160            .expect("folded recall");
4161        assert_eq!(
4162            hits.iter()
4163                .map(|item| item.payload.as_slice())
4164                .collect::<Vec<_>>(),
4165            [b"the dog ran".as_slice(), b"the cat sat"],
4166            "the reranker reversed the similarity order"
4167        );
4168    }
4169
4170    #[test]
4171    fn given_the_same_content_when_addressed_then_should_produce_the_same_id() {
4172        let owner = MemoryScope::builder()
4173            .agent("planner".parse().expect("valid agent"))
4174            .lifetime(Lifetime::Durable)
4175            .build();
4176        let a = MemoryId::content(&owner, MemoryKind::Fact, b"the budget is 5000");
4177        let b = MemoryId::content(&owner, MemoryKind::Fact, b"the budget is 5000");
4178        assert_eq!(a, b, "same owner, kind, and body must dedup to one id");
4179        let other = MemoryId::content(&owner, MemoryKind::Fact, b"the budget is 6000");
4180        assert_ne!(a, other, "a different body must produce a different id");
4181    }
4182
4183    #[test]
4184    fn given_a_known_content_when_addressed_then_should_match_the_cross_sdk_id() {
4185        // Pins the content-addressed id to golden values every SDK reproduces, so
4186        // a fact deduped in one SDK has the same id in the others.
4187        let agent_only = MemoryScope::builder()
4188            .agent("agent".parse().expect("valid agent"))
4189            .build();
4190        let full_owner = MemoryScope::builder()
4191            .stream("laser".to_owned())
4192            .agent("agent".parse().expect("valid agent"))
4193            .user("alice".to_owned())
4194            .app("console".to_owned())
4195            .build();
4196        let user_only = MemoryScope::builder().user("alice".to_owned()).build();
4197        let cases = [
4198            (
4199                &agent_only,
4200                MemoryKind::Fact,
4201                "63FZWE3WTSCVXAY845QK4TMVWM",
4202                0xc37f_f8e1_f359_66fa_af20_85bc_c9aa_6f94_u128,
4203            ),
4204            (
4205                &full_owner,
4206                MemoryKind::Fact,
4207                "28429K3B8MPSM8WHDQR474827P",
4208                0x4820_9331_ad14_b668_8e45_b7c1_0e44_08f6,
4209            ),
4210            (
4211                &user_only,
4212                MemoryKind::Procedure,
4213                "3E6HJGHKFDBMQ91TCJEHV28X40",
4214                0x6e34_6508_cded_5d2e_90e9_9274_7624_7480,
4215            ),
4216        ];
4217        for (owner, kind, rendered, value) in cases {
4218            let id = MemoryId::content(owner, kind, b"x");
4219            assert_eq!(id.as_u128(), value);
4220            assert_eq!(id.to_string(), rendered);
4221        }
4222    }
4223
4224    #[test]
4225    fn given_two_users_when_the_same_body_is_addressed_then_should_produce_distinct_ids() {
4226        let owner = |user: &str, application: &str| {
4227            MemoryScope::builder()
4228                .agent("agent".parse().expect("valid agent"))
4229                .user(user.to_owned())
4230                .app(application.to_owned())
4231                .build()
4232        };
4233        let alice = MemoryId::content(&owner("alice", "console"), MemoryKind::Fact, b"x");
4234        let bob = MemoryId::content(&owner("bob", "console"), MemoryKind::Fact, b"x");
4235        let support = MemoryId::content(&owner("alice", "support"), MemoryKind::Fact, b"x");
4236        assert_ne!(alice, bob, "the user is part of the owner");
4237        assert_ne!(alice, support, "the application is part of the owner");
4238        // The separators keep segment boundaries: moving bytes between the user
4239        // and the application must not collide.
4240        let shifted = MemoryId::content(&owner("alicecon", "sole"), MemoryKind::Fact, b"x");
4241        assert_ne!(
4242            owner("alicecon", "sole").user,
4243            owner("alice", "console").user
4244        );
4245        assert_ne!(alice, shifted);
4246    }
4247
4248    #[tokio::test]
4249    async fn given_positive_feedback_when_recalling_then_should_rank_the_target_first() {
4250        let memory = VectorMemory::new(WordEmbedder);
4251        let scope = MemoryScope::builder()
4252            .conversation(ConversationId::new())
4253            .build();
4254        let first = Memory::remember(&memory, &scope, b"the cat sat".to_vec())
4255            .await
4256            .expect("remember first");
4257        let second = Memory::remember(&memory, &scope, b"the dog ran".to_vec())
4258            .await
4259            .expect("remember second");
4260        // With no feedback, the newest item is first.
4261        let before = Memory::recall(&memory, &scope, &MemoryQuery::builder().build())
4262            .await
4263            .expect("recall before feedback");
4264        assert_eq!(before[0].id, second);
4265
4266        // Promote the older item, then it ranks ahead.
4267        Memory::improve(&memory, &scope, Feedback::new(first, 5.0))
4268            .await
4269            .expect("improve");
4270        let after = Memory::recall(&memory, &scope, &MemoryQuery::builder().build())
4271            .await
4272            .expect("recall after feedback");
4273        assert_eq!(after[0].id, first, "the promoted item ranks first");
4274        assert_eq!(after[0].score, Some(5.0));
4275    }
4276
4277    #[test]
4278    fn given_a_named_record_when_absorbed_then_should_fold_into_point_state() {
4279        let mut projection = Projection::default();
4280        let item = laser_wire::memory::MemoryRecord::Item {
4281            origin: None,
4282            producer: None,
4283            id: "notes/current-plan".to_owned(),
4284            kind: "fact".to_owned(),
4285            body: b"ship on friday".to_vec(),
4286        };
4287        projection.absorb(
4288            &laser_wire::framing::encode_named(&item).expect("encodes"),
4289            Provenance::builder()
4290                .conversation_id(ConversationId::new())
4291                .build(),
4292        );
4293        assert_eq!(
4294            projection
4295                .named
4296                .get("notes/current-plan")
4297                .map(Vec::as_slice),
4298            Some(b"ship on friday".as_slice()),
4299        );
4300        // The named record is not a recall item.
4301        assert!(projection.items.is_empty());
4302
4303        let forget = laser_wire::memory::MemoryRecord::Forget {
4304            target: "notes/current-plan".to_owned(),
4305            conversation: None,
4306        };
4307        projection.absorb(
4308            &laser_wire::framing::encode_named(&forget).expect("encodes"),
4309            Provenance::builder()
4310                .conversation_id(ConversationId::new())
4311                .build(),
4312        );
4313        assert!(projection.named.is_empty());
4314    }
4315
4316    #[test]
4317    fn given_a_merge_patch_when_applied_then_should_overwrite_add_and_remove_fields() {
4318        let current = br#"{"log_level":"info","replicas":3,"beta":true}"#;
4319        let patch = br#"{"log_level":"debug","region":"eu","beta":null}"#;
4320        let merged = merge_named(Some(current), patch).expect("merges");
4321        let value: serde_json::Value = serde_json::from_slice(&merged).expect("json");
4322        assert_eq!(value["log_level"], "debug"); // overwritten
4323        assert_eq!(value["replicas"], 3); // untouched
4324        assert_eq!(value["region"], "eu"); // added
4325        assert!(value.get("beta").is_none()); // null removes
4326    }
4327
4328    #[test]
4329    fn given_no_current_value_when_merge_patched_then_should_start_from_the_patch() {
4330        let merged = merge_named(None, br#"{"log_level":"debug"}"#).expect("merges");
4331        let value: serde_json::Value = serde_json::from_slice(&merged).expect("json");
4332        assert_eq!(value["log_level"], "debug");
4333    }
4334
4335    #[tokio::test]
4336    async fn given_a_forgotten_id_when_recalling_then_should_be_excluded() {
4337        let memory = VectorMemory::new(WordEmbedder);
4338        let scope = MemoryScope::builder()
4339            .conversation(ConversationId::new())
4340            .build();
4341        let id = Memory::remember(&memory, &scope, b"cat".to_vec())
4342            .await
4343            .expect("remember should succeed");
4344        Memory::forget(&memory, &scope, id)
4345            .await
4346            .expect("forget should succeed");
4347        let all = Memory::recall(&memory, &scope, &MemoryQuery::builder().build())
4348            .await
4349            .expect("recall should succeed");
4350        assert!(all.is_empty());
4351    }
4352    #[cfg(feature = "kv")]
4353    fn scoped_kv_entry(
4354        id: u128,
4355        conversation: ConversationId,
4356        user: &str,
4357        app: &str,
4358    ) -> laser_wire::kv::KvEntry {
4359        laser_wire::kv::KvEntry {
4360            key: Ulid::from(id).to_string().into_bytes(),
4361            value: format!("item-{id}").into_bytes(),
4362            expires_at_micros: None,
4363            version: 1,
4364            scope: Some(Box::new(laser_wire::kv::MemoryRowScope {
4365                kind: Some("fact".to_owned()),
4366                user: Some(user.to_owned()),
4367                app: Some(app.to_owned()),
4368                conversation: Some(conversation.to_string()),
4369                ..Default::default()
4370            })),
4371            source: None,
4372        }
4373    }
4374
4375    #[cfg(feature = "kv")]
4376    #[tokio::test]
4377    async fn given_paged_memory_scopes_when_recalled_without_a_conversation_then_should_keep_only_newest_matching_items()
4378     {
4379        let first = ConversationId::new();
4380        let second = ConversationId::new();
4381        let scope = MemoryScope::builder()
4382            .user("reader".to_owned())
4383            .app("diagnostics".to_owned())
4384            .build();
4385        let query = MemoryQuery::builder().limit(2).build();
4386        let mut pages = std::collections::VecDeque::from([
4387            laser_wire::kv::KvPage {
4388                entries: vec![
4389                    scoped_kv_entry(1, first, "reader", "diagnostics"),
4390                    scoped_kv_entry(2, first, "other", "diagnostics"),
4391                ],
4392                cursor: Some(vec![2]),
4393            },
4394            laser_wire::kv::KvPage {
4395                entries: vec![
4396                    scoped_kv_entry(3, second, "reader", "other"),
4397                    scoped_kv_entry(4, second, "reader", "diagnostics"),
4398                    scoped_kv_entry(5, first, "reader", "diagnostics"),
4399                ],
4400                cursor: None,
4401            },
4402        ]);
4403        let mut requested = Vec::new();
4404        let items = recall_kv_pages(&scope, &query, |cursor| {
4405            requested.push(cursor);
4406            std::future::ready(Ok(pages.pop_front().expect("next page")))
4407        })
4408        .await
4409        .expect("recall");
4410        assert_eq!(requested, [None, Some(vec![2])]);
4411        assert_eq!(
4412            items
4413                .iter()
4414                .map(|item| item.id.to_string())
4415                .collect::<Vec<_>>(),
4416            [Ulid::from(5u128).to_string(), Ulid::from(4u128).to_string()]
4417        );
4418        assert_eq!(items[0].provenance.conversation_id, first);
4419        assert_eq!(items[1].provenance.conversation_id, second);
4420    }
4421
4422    #[cfg(feature = "kv")]
4423    #[tokio::test]
4424    async fn given_content_ids_out_of_arrival_order_when_recalled_from_the_view_then_should_keep_the_latest_broker_time()
4425     {
4426        let conversation = ConversationId::new();
4427        let stamped = |id: u128, partition: u32, timestamp: u64| {
4428            let mut entry = scoped_kv_entry(id, conversation, "reader", "diagnostics");
4429            let scope = entry.scope.as_mut().expect("memory scope");
4430            scope.timestamp_micros = Some(timestamp);
4431            scope.source = Some(SourceRef::Message {
4432                stream: 1,
4433                topic: 2,
4434                partition,
4435                offset: 0,
4436                generation: None,
4437                conversation: None,
4438            });
4439            entry
4440        };
4441        let scope = MemoryScope::default();
4442        let query = MemoryQuery::builder().limit(2).build();
4443        let items = recall_kv_pages(&scope, &query, |_| {
4444            std::future::ready(Ok(laser_wire::kv::KvPage {
4445                entries: vec![stamped(9, 0, 10), stamped(1, 1, 30), stamped(5, 2, 20)],
4446                cursor: None,
4447            }))
4448        })
4449        .await
4450        .expect("recall");
4451        assert_eq!(
4452            items
4453                .iter()
4454                .map(|item| item.id.to_string())
4455                .collect::<Vec<_>>(),
4456            [Ulid::from(1u128).to_string(), Ulid::from(5u128).to_string()]
4457        );
4458    }
4459
4460    #[cfg(feature = "kv")]
4461    #[tokio::test]
4462    async fn given_a_repeating_memory_scan_cursor_when_recalled_then_should_refuse_to_loop() {
4463        let scope = MemoryScope::default();
4464        let query = MemoryQuery::builder().build();
4465        let result = recall_kv_pages(&scope, &query, |_| {
4466            std::future::ready(Ok(laser_wire::kv::KvPage {
4467                entries: Vec::new(),
4468                cursor: Some(vec![1]),
4469            }))
4470        })
4471        .await;
4472        assert!(matches!(result, Err(LaserError::Protocol(_))));
4473    }
4474
4475    #[test]
4476    fn given_folded_user_and_application_scopes_when_recalled_then_should_keep_the_newest_matching_records()
4477     {
4478        let conversation = ConversationId::new();
4479        let mut projection = Projection::default();
4480        for (id, user, app) in [
4481            (1u128, "reader", "diagnostics"),
4482            (2, "other", "diagnostics"),
4483            (3, "reader", "other"),
4484            (4, "reader", "diagnostics"),
4485        ] {
4486            let item = MemoryLogEntry::Item {
4487                id: MemoryId(Ulid::from(id)),
4488                kind: MemoryKind::Fact,
4489                body: format!("item-{id}").into_bytes(),
4490                origin: None,
4491                producer: None,
4492            };
4493            projection.absorb_scoped(
4494                &item.encode().expect("encode"),
4495                Provenance::builder().conversation_id(conversation).build(),
4496                StoredMemoryScope {
4497                    user: Some(user.to_owned()),
4498                    app: Some(app.to_owned()),
4499                },
4500                None,
4501            );
4502        }
4503        let scope = MemoryScope::builder()
4504            .conversation(conversation)
4505            .user("reader".to_owned())
4506            .app("diagnostics".to_owned())
4507            .build();
4508        let items = folded_memory_items(
4509            &projection,
4510            &scope,
4511            &MemoryQuery::builder().limit(1).build(),
4512        );
4513        assert_eq!(items.len(), 1);
4514        assert_eq!(items[0].payload, b"item-4");
4515    }
4516
4517    #[test]
4518    fn given_folded_log_memory_when_searched_then_should_exclude_unrelated_items_before_limiting() {
4519        let conversation = ConversationId::new();
4520        let mut projection = Projection::default();
4521        for (id, body) in [(1u128, b"apple".as_slice()), (2, b"orange"), (3, b"pear")] {
4522            projection.absorb(
4523                &item_entry(MemoryId(Ulid::from(id)), body),
4524                Provenance::builder().conversation_id(conversation).build(),
4525            );
4526        }
4527        for strategy in [
4528            RecallStrategy::Keyword,
4529            RecallStrategy::Semantic,
4530            RecallStrategy::Hybrid,
4531        ] {
4532            let query = MemoryQuery::builder()
4533                .semantic("apple".to_owned())
4534                .strategy(strategy)
4535                .limit(1)
4536                .build();
4537            let items = folded_memory_items(&projection, &MemoryScope::default(), &query);
4538            assert_eq!(items.len(), 1);
4539            assert_eq!(items[0].payload, b"apple");
4540        }
4541    }
4542
4543    #[cfg(feature = "kv")]
4544    #[tokio::test]
4545    async fn given_managed_log_memory_when_searched_then_should_scan_past_unrelated_newer_items() {
4546        let conversation = ConversationId::new();
4547        let mut matching = scoped_kv_entry(1, conversation, "reader", "diagnostics");
4548        matching.value = b"apple".to_vec();
4549        let mut unrelated = scoped_kv_entry(2, conversation, "reader", "diagnostics");
4550        unrelated.value = b"orange".to_vec();
4551        let scope = MemoryScope::builder().conversation(conversation).build();
4552        let query = MemoryQuery::builder()
4553            .semantic("apple".to_owned())
4554            .strategy(RecallStrategy::Keyword)
4555            .limit(1)
4556            .build();
4557        let mut page = Some(laser_wire::kv::KvPage {
4558            entries: vec![matching, unrelated],
4559            cursor: None,
4560        });
4561        let items = recall_kv_pages(&scope, &query, |_| {
4562            std::future::ready(Ok(page.take().expect("one page")))
4563        })
4564        .await
4565        .expect("recall");
4566        assert_eq!(items.len(), 1);
4567        assert_eq!(items[0].payload, b"apple");
4568    }
4569
4570    #[test]
4571    fn given_a_folded_memory_record_when_recalled_then_should_keep_its_source() {
4572        let conversation = ConversationId::new();
4573        let source = SourceRef::Message {
4574            generation: None,
4575            stream: 1,
4576            topic: 2,
4577            partition: 0,
4578            offset: 9,
4579            conversation: Some(conversation.to_string()),
4580        };
4581        let entry = MemoryLogEntry::Item {
4582            id: MemoryId(Ulid::from(7u128)),
4583            kind: MemoryKind::Fact,
4584            body: b"source fact".to_vec(),
4585            origin: None,
4586            producer: None,
4587        };
4588        let mut projection = Projection::default();
4589        projection.absorb_scoped(
4590            &entry.encode().expect("entry encodes"),
4591            Provenance::builder().conversation_id(conversation).build(),
4592            StoredMemoryScope::default(),
4593            Some(source.clone()),
4594        );
4595
4596        let items = folded_memory_items(
4597            &projection,
4598            &MemoryScope::builder().conversation(conversation).build(),
4599            &MemoryQuery::builder().build(),
4600        );
4601        assert_eq!(items[0].source, Some(source));
4602    }
4603
4604    fn projection_with_feedback_on_the_older_item() -> (Projection, MemoryId, MemoryId) {
4605        let conversation = ConversationId::new();
4606        let prov = || Provenance::builder().conversation_id(conversation).build();
4607        let mut projection = Projection::default();
4608        let older = MemoryId::new();
4609        let newer = MemoryId::new();
4610        projection.absorb(&item_entry(older, b"older"), prov());
4611        projection.absorb(&item_entry(newer, b"newer"), prov());
4612        let feedback = MemoryLogEntry::Feedback {
4613            target: older,
4614            weight: 3.0,
4615            conversation: None,
4616        }
4617        .encode()
4618        .expect("a feedback entry encodes");
4619        projection.absorb(&feedback, prov());
4620        (projection, older, newer)
4621    }
4622
4623    #[test]
4624    fn given_feedback_when_folded_recall_is_recent_then_should_keep_recency_order() {
4625        let (projection, older, newer) = projection_with_feedback_on_the_older_item();
4626        let items = folded_memory_items(
4627            &projection,
4628            &MemoryScope::default(),
4629            &MemoryQuery::builder()
4630                .strategy(RecallStrategy::Recent)
4631                .build(),
4632        );
4633        assert_eq!(
4634            items.iter().map(|item| item.id).collect::<Vec<_>>(),
4635            [newer, older]
4636        );
4637        assert!(items.iter().all(|item| item.score.is_none()));
4638        assert!(items.iter().all(|item| item.signals.is_empty()));
4639    }
4640
4641    #[test]
4642    fn given_feedback_when_folded_recall_ranks_then_should_label_the_signal_auto() {
4643        let (projection, older, _) = projection_with_feedback_on_the_older_item();
4644        let items = folded_memory_items(
4645            &projection,
4646            &MemoryScope::default(),
4647            &MemoryQuery::builder()
4648                .strategy(RecallStrategy::Semantic)
4649                .build(),
4650        );
4651        assert_eq!(items[0].id, older, "feedback promotes outside Recent");
4652        assert_eq!(
4653            items[0].signals,
4654            [RecallSignal {
4655                strategy: RecallStrategy::Auto,
4656                rank: 0,
4657                score: Some(3.0),
4658            }]
4659        );
4660    }
4661
4662    #[tokio::test]
4663    async fn given_feedback_when_vector_recall_is_recent_then_should_keep_recency_order() {
4664        let memory = VectorMemory::new(WordEmbedder);
4665        let scope = MemoryScope::default();
4666        let older = Memory::remember(&memory, &scope, b"cat".to_vec())
4667            .await
4668            .expect("remember older");
4669        let newer = Memory::remember(&memory, &scope, b"dog".to_vec())
4670            .await
4671            .expect("remember newer");
4672        Memory::improve(&memory, &scope, Feedback::new(older, 5.0))
4673            .await
4674            .expect("improve");
4675        let items = Memory::recall(
4676            &memory,
4677            &scope,
4678            &MemoryQuery::builder()
4679                .strategy(RecallStrategy::Recent)
4680                .build(),
4681        )
4682        .await
4683        .expect("recent recall");
4684        assert_eq!(
4685            items.iter().map(|item| item.id).collect::<Vec<_>>(),
4686            [newer, older]
4687        );
4688        assert!(items.iter().all(|item| item.signals.is_empty()));
4689    }
4690
4691    #[tokio::test]
4692    async fn given_a_builtin_memory_when_consolidated_then_should_append_a_durable_summary_kind() {
4693        let memory = MemoryHandle::vector(WordEmbedder);
4694        let conversation = ConversationId::new();
4695        memory
4696            .remember(b"cpu pressure".to_vec())
4697            .scope(conversation)
4698            .kind(MemoryKind::Message)
4699            .send()
4700            .await
4701            .expect("message");
4702        let scope = MemoryScope::builder().conversation(conversation).build();
4703        let consolidator = DefaultConsolidator::new(&memory, 100).with_summarizer(StubSummarizer);
4704        Consolidator::consolidate(&consolidator, &scope)
4705            .await
4706            .expect("consolidate");
4707        let MemoryHandle::Vector(store) = &memory else {
4708            panic!("vector")
4709        };
4710        let stored = store.items.lock().await;
4711        let summary = stored
4712            .iter()
4713            .find(|entry| entry.item.kind == MemoryKind::Summary)
4714            .expect("summary kind");
4715        assert_eq!(summary.scope.lifetime, Lifetime::Durable);
4716        assert_eq!(summary.item.payload, b"summary of 1");
4717    }
4718
4719    #[tokio::test]
4720    async fn given_recent_strategy_and_semantic_text_when_recalled_then_should_return_write_recency_without_embedding()
4721     {
4722        struct RecentEmbedder(std::sync::atomic::AtomicUsize);
4723        impl Embedder for RecentEmbedder {
4724            async fn embed(&self, _text: &str) -> Result<Vec<f32>, LaserError> {
4725                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4726                Ok(vec![1.0])
4727            }
4728        }
4729        let memory = VectorMemory::new(RecentEmbedder(std::sync::atomic::AtomicUsize::new(0)));
4730        let scope = MemoryScope::builder().stream("one".to_owned()).build();
4731        Memory::remember(&memory, &scope, b"old".to_vec())
4732            .await
4733            .expect("old");
4734        Memory::remember(&memory, &scope, b"new".to_vec())
4735            .await
4736            .expect("new");
4737        Memory::remember(
4738            &memory,
4739            &MemoryScope::builder().stream("two".to_owned()).build(),
4740            b"another stream".to_vec(),
4741        )
4742        .await
4743        .expect("other");
4744        let items = Memory::recall(
4745            &memory,
4746            &scope,
4747            &MemoryQuery::builder()
4748                .limit(1)
4749                .semantic("ignored".to_owned())
4750                .strategy(RecallStrategy::Recent)
4751                .build(),
4752        )
4753        .await
4754        .expect("recent");
4755        assert_eq!(items[0].payload, b"new");
4756        assert_eq!(
4757            memory.embedder.0.load(std::sync::atomic::Ordering::SeqCst),
4758            3
4759        );
4760    }
4761
4762    #[tokio::test]
4763    async fn given_hybrid_memory_signals_when_ranked_then_should_report_additive_scores_and_each_signal_rank()
4764     {
4765        let memory = VectorMemory::new(WordEmbedder);
4766        let scope = MemoryScope::default();
4767        let first = Memory::remember(&memory, &scope, b"auth".to_vec())
4768            .await
4769            .expect("first");
4770        let second = Memory::remember(&memory, &scope, b"auth latency".to_vec())
4771            .await
4772            .expect("second");
4773        Memory::improve(&memory, &scope, Feedback::new(second, 1.5))
4774            .await
4775            .expect("feedback");
4776        let items = Memory::recall(
4777            &memory,
4778            &scope,
4779            &MemoryQuery::builder()
4780                .semantic("auth".to_owned())
4781                .strategy(RecallStrategy::Hybrid)
4782                .build(),
4783        )
4784        .await
4785        .expect("hybrid");
4786        assert_eq!(items[0].id, second);
4787        assert_eq!(items[1].id, first);
4788        for item in &items {
4789            assert_eq!(
4790                item.score,
4791                Some(
4792                    item.signals
4793                        .iter()
4794                        .map(|signal| signal.score.unwrap_or(0.0))
4795                        .sum()
4796                )
4797            );
4798            assert!(
4799                item.signals
4800                    .iter()
4801                    .any(|signal| signal.strategy == RecallStrategy::Semantic)
4802            );
4803            assert!(
4804                item.signals
4805                    .iter()
4806                    .any(|signal| signal.strategy == RecallStrategy::Keyword)
4807            );
4808        }
4809        let semantic = items[1]
4810            .signals
4811            .iter()
4812            .find(|signal| signal.strategy == RecallStrategy::Semantic)
4813            .expect("semantic");
4814        assert_eq!(semantic.rank, 0);
4815        assert_eq!(
4816            items[0]
4817                .signals
4818                .iter()
4819                .find(|signal| signal.strategy == RecallStrategy::Auto)
4820                .expect("feedback")
4821                .rank,
4822            0
4823        );
4824    }
4825}