Skip to main content

mnemo_engine/
session.rs

1//! Session lifecycle wrapper (Phase 5.5 of the build plan).
2//!
3//! A [`Session`] is a thin, scoped handle over a [`Mnemo`] database for the
4//! duration of one conversation. It bundles an agent id with a freshly
5//! generated session id, records conversation turns as `Working` memories
6//! tagged with that session, and — on [`Session::close`] — **consolidates**
7//! those turns into durable `Episodic` memory, exactly the working-memory →
8//! episodic promotion the build plan calls for.
9//!
10//! The session borrows the database mutably for its lifetime, so the
11//! single-writer discipline is enforced by the compiler: while a session is
12//! open, the database is reached only through it.
13//!
14//! ```no_run
15//! # use mnemo_engine::{Mnemo, MnemoConfig, Role, Turn, Result};
16//! # fn main() -> Result<()> {
17//! let mut db = Mnemo::open("agent.mnemo", "passphrase")?;
18//! let mut session = db.session("assistant-1");
19//!
20//! session.add_turn(Turn::user("what's the weather?", vec![0.1, 0.2, 0.3]))?;
21//! session.add_turn(Turn::assistant("clear and mild", vec![0.2, 0.1, 0.4]))?;
22//!
23//! // End the session — its turns are promoted to episodic memory.
24//! let promoted = session.close()?;
25//! assert_eq!(promoted, 2);
26//! # Ok(())
27//! # }
28//! ```
29
30use serde_json::Value;
31use ulid::Ulid;
32
33use crate::error::Result;
34use crate::memory::{Memory, MemoryType};
35use crate::store::{Mnemo, RecallRequest, RecallResult};
36
37/// Who produced a conversation turn.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum Role {
40    /// The end user.
41    User,
42    /// The agent / assistant.
43    Assistant,
44    /// A system or tool message.
45    System,
46}
47
48impl Role {
49    /// Lowercase string form — how the role is stored in turn metadata.
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            Role::User => "user",
53            Role::Assistant => "assistant",
54            Role::System => "system",
55        }
56    }
57}
58
59/// One conversation turn, written to working memory by [`Session::add_turn`].
60///
61/// The `vector` is the caller-supplied embedding of `content`; Mnemo does not
62/// embed text itself.
63#[derive(Clone, Debug)]
64pub struct Turn {
65    /// Who produced the turn.
66    pub role: Role,
67    /// The turn's text.
68    pub content: String,
69    /// Embedding of `content`.
70    pub vector: Vec<f32>,
71}
72
73impl Turn {
74    /// A turn with an explicit role.
75    pub fn new(role: Role, content: impl Into<String>, vector: Vec<f32>) -> Self {
76        Self {
77            role,
78            content: content.into(),
79            vector,
80        }
81    }
82    /// Shorthand for a [`Role::User`] turn.
83    pub fn user(content: impl Into<String>, vector: Vec<f32>) -> Self {
84        Self::new(Role::User, content, vector)
85    }
86    /// Shorthand for a [`Role::Assistant`] turn.
87    pub fn assistant(content: impl Into<String>, vector: Vec<f32>) -> Self {
88        Self::new(Role::Assistant, content, vector)
89    }
90    /// Shorthand for a [`Role::System`] turn.
91    pub fn system(content: impl Into<String>, vector: Vec<f32>) -> Self {
92        Self::new(Role::System, content, vector)
93    }
94}
95
96/// A scoped conversation session over a [`Mnemo`] database.
97///
98/// Created by [`Mnemo::session`]. Holds the database mutably until it is
99/// closed (or dropped), so all writes for the conversation flow through it.
100pub struct Session<'db> {
101    db: &'db mut Mnemo,
102    agent_id: String,
103    session_id: String,
104    turns: Vec<Ulid>,
105}
106
107impl<'db> Session<'db> {
108    /// Begin a session for `agent_id` with a fresh, time-sortable session id.
109    pub(crate) fn new(db: &'db mut Mnemo, agent_id: String) -> Self {
110        Self {
111            db,
112            agent_id,
113            session_id: Ulid::new().to_string(),
114            turns: Vec::new(),
115        }
116    }
117
118    /// This session's unique id (a ULID string), stamped on every turn.
119    pub fn id(&self) -> &str {
120        &self.session_id
121    }
122
123    /// The agent this session belongs to.
124    pub fn agent(&self) -> &str {
125        &self.agent_id
126    }
127
128    /// Ids of the turns recorded so far, in order.
129    pub fn turn_ids(&self) -> &[Ulid] {
130        &self.turns
131    }
132
133    /// How many turns have been recorded.
134    pub fn turn_count(&self) -> usize {
135        self.turns.len()
136    }
137
138    /// Record a conversation turn as a `Working` memory tagged with this
139    /// session and agent. The turn's role is kept in the memory's metadata.
140    /// Writes are staged; they reach disk on [`Session::close`] (or any later
141    /// flush of the database).
142    pub fn add_turn(&mut self, turn: Turn) -> Result<Ulid> {
143        let memory = Memory::new(turn.content, MemoryType::Working, turn.vector)
144            .with_agent(&self.agent_id)
145            .with_session(&self.session_id)
146            .with_meta("role", Value::String(turn.role.as_str().to_string()));
147        let id = self.db.remember(memory)?;
148        self.turns.push(id);
149        Ok(id)
150    }
151
152    /// Retrieve memories for context injection, scoped to this session's
153    /// agent. The agent filter on `request` is overridden — a session always
154    /// recalls within its own agent's view (its private memories plus shared
155    /// ones). All other request fields (type filter, weights, top-k) apply.
156    pub fn recall(&mut self, mut request: RecallRequest) -> Result<Vec<RecallResult>> {
157        request.agent_id = Some(self.agent_id.clone());
158        self.db.recall(&request)
159    }
160
161    /// End the session, **consolidating** its working turns into episodic
162    /// memory: each turn still typed `Working` is promoted to `Episodic`, the
163    /// store of "what happened". Returns the number of turns promoted; the
164    /// database is flushed before returning.
165    pub fn close(self) -> Result<usize> {
166        let Session { db, turns, .. } = self;
167        let mut promoted = 0;
168        for id in &turns {
169            // A turn could have been deleted elsewhere — skip if it is gone.
170            let mut memory = match db.get(id) {
171                Ok(m) => m,
172                Err(_) => continue,
173            };
174            if memory.memory_type == MemoryType::Working {
175                memory.memory_type = MemoryType::Episodic;
176                db.remember(memory)?;
177                promoted += 1;
178            }
179        }
180        db.flush()?;
181        Ok(promoted)
182    }
183
184    /// End the session, **discarding** its working turns instead of
185    /// consolidating them — every turn this session recorded is deleted.
186    /// Returns the number removed; the database is flushed before returning.
187    pub fn discard(self) -> Result<usize> {
188        let Session { db, turns, .. } = self;
189        let mut removed = 0;
190        for id in &turns {
191            if db.delete(id).is_ok() {
192                removed += 1;
193            }
194        }
195        db.flush()?;
196        Ok(removed)
197    }
198}