Skip to main content

mnemo/
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::{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 { role, content: content.into(), vector }
77    }
78    /// Shorthand for a [`Role::User`] turn.
79    pub fn user(content: impl Into<String>, vector: Vec<f32>) -> Self {
80        Self::new(Role::User, content, vector)
81    }
82    /// Shorthand for a [`Role::Assistant`] turn.
83    pub fn assistant(content: impl Into<String>, vector: Vec<f32>) -> Self {
84        Self::new(Role::Assistant, content, vector)
85    }
86    /// Shorthand for a [`Role::System`] turn.
87    pub fn system(content: impl Into<String>, vector: Vec<f32>) -> Self {
88        Self::new(Role::System, content, vector)
89    }
90}
91
92/// A scoped conversation session over a [`Mnemo`] database.
93///
94/// Created by [`Mnemo::session`]. Holds the database mutably until it is
95/// closed (or dropped), so all writes for the conversation flow through it.
96pub struct Session<'db> {
97    db: &'db mut Mnemo,
98    agent_id: String,
99    session_id: String,
100    turns: Vec<Ulid>,
101}
102
103impl<'db> Session<'db> {
104    /// Begin a session for `agent_id` with a fresh, time-sortable session id.
105    pub(crate) fn new(db: &'db mut Mnemo, agent_id: String) -> Self {
106        Self {
107            db,
108            agent_id,
109            session_id: Ulid::new().to_string(),
110            turns: Vec::new(),
111        }
112    }
113
114    /// This session's unique id (a ULID string), stamped on every turn.
115    pub fn id(&self) -> &str {
116        &self.session_id
117    }
118
119    /// The agent this session belongs to.
120    pub fn agent(&self) -> &str {
121        &self.agent_id
122    }
123
124    /// Ids of the turns recorded so far, in order.
125    pub fn turn_ids(&self) -> &[Ulid] {
126        &self.turns
127    }
128
129    /// How many turns have been recorded.
130    pub fn turn_count(&self) -> usize {
131        self.turns.len()
132    }
133
134    /// Record a conversation turn as a `Working` memory tagged with this
135    /// session and agent. The turn's role is kept in the memory's metadata.
136    /// Writes are staged; they reach disk on [`Session::close`] (or any later
137    /// flush of the database).
138    pub fn add_turn(&mut self, turn: Turn) -> Result<Ulid> {
139        let memory = Memory::new(turn.content, MemoryType::Working, turn.vector)
140            .with_agent(&self.agent_id)
141            .with_session(&self.session_id)
142            .with_meta("role", Value::String(turn.role.as_str().to_string()));
143        let id = self.db.remember(memory)?;
144        self.turns.push(id);
145        Ok(id)
146    }
147
148    /// Retrieve memories for context injection, scoped to this session's
149    /// agent. The agent filter on `request` is overridden — a session always
150    /// recalls within its own agent's view (its private memories plus shared
151    /// ones). All other request fields (type filter, weights, top-k) apply.
152    pub fn recall(&mut self, mut request: RecallRequest) -> Result<Vec<RecallResult>> {
153        request.agent_id = Some(self.agent_id.clone());
154        self.db.recall(&request)
155    }
156
157    /// End the session, **consolidating** its working turns into episodic
158    /// memory: each turn still typed `Working` is promoted to `Episodic`, the
159    /// store of "what happened". Returns the number of turns promoted; the
160    /// database is flushed before returning.
161    pub fn close(self) -> Result<usize> {
162        let Session { db, turns, .. } = self;
163        let mut promoted = 0;
164        for id in &turns {
165            // A turn could have been deleted elsewhere — skip if it is gone.
166            let mut memory = match db.get(id) {
167                Ok(m) => m,
168                Err(_) => continue,
169            };
170            if memory.memory_type == MemoryType::Working {
171                memory.memory_type = MemoryType::Episodic;
172                db.remember(memory)?;
173                promoted += 1;
174            }
175        }
176        db.flush()?;
177        Ok(promoted)
178    }
179
180    /// End the session, **discarding** its working turns instead of
181    /// consolidating them — every turn this session recorded is deleted.
182    /// Returns the number removed; the database is flushed before returning.
183    pub fn discard(self) -> Result<usize> {
184        let Session { db, turns, .. } = self;
185        let mut removed = 0;
186        for id in &turns {
187            if db.delete(id).is_ok() {
188                removed += 1;
189            }
190        }
191        db.flush()?;
192        Ok(removed)
193    }
194}