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}