|
| 1 | +//! The episodic family: the turn-by-turn record of conversations. |
| 2 | +//! |
| 3 | +//! A driver advertising [`Capability::Episodic`](crate::capabilities::Capability::Episodic) |
| 4 | +//! stores every chat turn in a full-text index and groups consecutive turns |
| 5 | +//! into *conversation segments* — a segment being a stretch of turns about one |
| 6 | +//! thing, closed when the subject changes and then summarised and embedded. |
| 7 | +//! |
| 8 | +//! # Why this is a family rather than a raw connection |
| 9 | +//! |
| 10 | +//! It is the last thing in the host that held a live `rusqlite::Connection`. |
| 11 | +//! The archivist hook was handed one straight out of the session factory and |
| 12 | +//! called free functions on it, which worked only because the engine was |
| 13 | +//! compiled into this process. A connection cannot cross a bus, so either the |
| 14 | +//! archivist's operations become a contract family or episodic capture stays |
| 15 | +//! behind and the engine can never leave. |
| 16 | +//! |
| 17 | +//! What crosses is small and already typed: insert a turn, read a session's |
| 18 | +//! turns back, and six segment-lifecycle operations. That was the whole surface |
| 19 | +//! the raw connection was used for — no ad-hoc SQL, no schema knowledge. |
| 20 | +//! |
| 21 | +//! # The host keeps the policy, and it is not a small share |
| 22 | +//! |
| 23 | +//! Two of the archivist's eight engine calls took no connection at all — |
| 24 | +//! deciding *whether* a new turn starts a new segment, and composing a summary |
| 25 | +//! when no model is available. Neither touches storage, so both stay host-side |
| 26 | +//! in `agent::harness::archivist`, next to the recap logic and the boundary |
| 27 | +//! thresholds they read. This family persists what the host decided; it does |
| 28 | +//! not decide. |
| 29 | +//! |
| 30 | +//! # `insert_turn` returns the id, and that is load-bearing |
| 31 | +//! |
| 32 | +//! The old code inserted a row and then issued `SELECT last_insert_rowid()` on |
| 33 | +//! the same connection to learn its id. That is two operations relying on a |
| 34 | +//! *connection-local* side effect, and it is wrong the moment anything else |
| 35 | +//! shares the connection or the two hops cross a bus — `last_insert_rowid` is |
| 36 | +//! per-connection state, so an interleaved insert from another task yields the |
| 37 | +//! wrong id and the turn is filed under the wrong segment. |
| 38 | +//! |
| 39 | +//! Returning the id from the insert removes both problems at once: one round |
| 40 | +//! trip instead of two, and no reliance on connection-local state. The engine |
| 41 | +//! knows the id it just wrote; nothing else has to guess. |
| 42 | +
|
| 43 | +use async_trait::async_trait; |
| 44 | +use serde::{Deserialize, Serialize}; |
| 45 | + |
| 46 | +use crate::error::MemoryError; |
| 47 | + |
| 48 | +/// One recorded turn. |
| 49 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 50 | +pub struct EpisodicTurn { |
| 51 | + /// Row id, assigned by the driver on insert. |
| 52 | + /// |
| 53 | + /// `None` when the host is describing a turn to be written; always `Some` |
| 54 | + /// on a turn read back. |
| 55 | + #[serde(default)] |
| 56 | + pub id: Option<i64>, |
| 57 | + /// Session this turn belongs to. |
| 58 | + pub session_id: String, |
| 59 | + /// When it happened, epoch seconds with sub-second resolution. |
| 60 | + /// |
| 61 | + /// The archivist offsets an assistant turn by 1 ms from the user turn it |
| 62 | + /// answers so the pair sorts in order within one exchange; that convention |
| 63 | + /// is the host's and the driver must preserve the value it is given rather |
| 64 | + /// than re-stamping it. |
| 65 | + pub timestamp: f64, |
| 66 | + /// `"user"` or `"assistant"`. Open vocabulary — a driver must not reject an |
| 67 | + /// unfamiliar role. |
| 68 | + pub role: String, |
| 69 | + /// The turn's text. |
| 70 | + pub content: String, |
| 71 | + /// A short lesson extracted from tool failures, when there was one. |
| 72 | + #[serde(default)] |
| 73 | + pub lesson: Option<String>, |
| 74 | + /// Serialized tool-call summary, when the turn made any. |
| 75 | + #[serde(default)] |
| 76 | + pub tool_calls_json: Option<String>, |
| 77 | + /// Cost attributed to this turn, in microdollars. |
| 78 | + #[serde(default)] |
| 79 | + pub cost_microdollars: i64, |
| 80 | +} |
| 81 | + |
| 82 | +/// A stretch of consecutive turns about one subject. |
| 83 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 84 | +pub struct ConversationSegment { |
| 85 | + /// Stable id, chosen by the host. |
| 86 | + pub segment_id: String, |
| 87 | + /// Session the segment belongs to. |
| 88 | + pub session_id: String, |
| 89 | + /// Owning namespace. |
| 90 | + pub namespace: String, |
| 91 | + /// Row id of the first turn in the segment. |
| 92 | + pub start_episodic_id: i64, |
| 93 | + /// Row id of the last turn, once one has been appended. |
| 94 | + #[serde(default)] |
| 95 | + pub end_episodic_id: Option<i64>, |
| 96 | + /// Timestamp of the first turn. |
| 97 | + pub start_timestamp: f64, |
| 98 | + /// Timestamp of the last turn, once one has been appended. |
| 99 | + #[serde(default)] |
| 100 | + pub end_timestamp: Option<f64>, |
| 101 | + /// How many turns the segment holds. |
| 102 | + pub turn_count: i32, |
| 103 | + /// Summary, once the segment has been closed and summarised. |
| 104 | + #[serde(default)] |
| 105 | + pub summary: Option<String>, |
| 106 | + /// The segment's running embedding centroid, when it has one. |
| 107 | + /// |
| 108 | + /// Carried on the read so the host can run boundary detection against it |
| 109 | + /// without a second call: deciding whether the next turn still belongs to |
| 110 | + /// this segment is host policy, but it needs the centroid the driver |
| 111 | + /// holds. |
| 112 | + #[serde(default)] |
| 113 | + pub embedding: Option<Vec<f32>>, |
| 114 | + /// Whether the segment is still open. |
| 115 | + pub open: bool, |
| 116 | +} |
| 117 | + |
| 118 | +/// The turn-by-turn conversation record. |
| 119 | +/// |
| 120 | +/// Reached through [`MemoryProvider::as_episodic`](super::MemoryProvider::as_episodic). |
| 121 | +#[async_trait] |
| 122 | +pub trait MemoryEpisodic: Send + Sync { |
| 123 | + /// Record one turn, returning the id the driver assigned it. |
| 124 | + /// |
| 125 | + /// See the module docs for why the id comes back from the insert rather |
| 126 | + /// than from a follow-up `last_insert_rowid` call. |
| 127 | + /// |
| 128 | + /// # Errors |
| 129 | + /// |
| 130 | + /// Backend failures. A driver that refuses a turn on safety grounds (a |
| 131 | + /// secret-shaped session id, say) reports [`MemoryError::Invalid`] rather |
| 132 | + /// than silently dropping it — the host cannot notice a missing turn. |
| 133 | + async fn insert_turn(&self, turn: &EpisodicTurn) -> Result<i64, MemoryError>; |
| 134 | + |
| 135 | + /// Every recorded turn for one session, oldest first. |
| 136 | + /// |
| 137 | + /// # Errors |
| 138 | + /// |
| 139 | + /// Backend failures; an unknown session yields an empty vector. |
| 140 | + async fn session_turns(&self, session_id: &str) -> Result<Vec<EpisodicTurn>, MemoryError>; |
| 141 | + |
| 142 | + /// The open segment for a session, when there is one. |
| 143 | + /// |
| 144 | + /// # Errors |
| 145 | + /// |
| 146 | + /// Backend failures only; no open segment yields `Ok(None)`. |
| 147 | + async fn open_segment( |
| 148 | + &self, |
| 149 | + session_id: &str, |
| 150 | + ) -> Result<Option<ConversationSegment>, MemoryError>; |
| 151 | + |
| 152 | + /// Start a new segment at `start_episodic_id`. |
| 153 | + /// |
| 154 | + /// # Errors |
| 155 | + /// |
| 156 | + /// Backend failures only. |
| 157 | + async fn create_segment( |
| 158 | + &self, |
| 159 | + segment_id: &str, |
| 160 | + session_id: &str, |
| 161 | + namespace: &str, |
| 162 | + start_episodic_id: i64, |
| 163 | + start_timestamp: f64, |
| 164 | + now: f64, |
| 165 | + ) -> Result<(), MemoryError>; |
| 166 | + |
| 167 | + /// Extend a segment to include one more turn. |
| 168 | + /// |
| 169 | + /// # Errors |
| 170 | + /// |
| 171 | + /// Backend failures only. |
| 172 | + async fn append_turn( |
| 173 | + &self, |
| 174 | + segment_id: &str, |
| 175 | + episodic_id: i64, |
| 176 | + timestamp: f64, |
| 177 | + now: f64, |
| 178 | + ) -> Result<(), MemoryError>; |
| 179 | + |
| 180 | + /// Mark a segment closed. Idempotent. |
| 181 | + /// |
| 182 | + /// # Errors |
| 183 | + /// |
| 184 | + /// Backend failures only. |
| 185 | + async fn close_segment(&self, segment_id: &str, now: f64) -> Result<(), MemoryError>; |
| 186 | + |
| 187 | + /// Attach a summary to a segment. |
| 188 | + /// |
| 189 | + /// Separate from [`Self::close_segment`] because the two happen at |
| 190 | + /// different times: a segment closes the moment the subject changes, and is |
| 191 | + /// summarised afterwards by a model call that may be slow, may fail, or may |
| 192 | + /// fall back to a composed summary. Folding them together would mean either |
| 193 | + /// holding the segment open across an inference call or losing the summary |
| 194 | + /// when one fails. |
| 195 | + /// |
| 196 | + /// # Errors |
| 197 | + /// |
| 198 | + /// Backend failures only. |
| 199 | + async fn set_segment_summary( |
| 200 | + &self, |
| 201 | + segment_id: &str, |
| 202 | + summary: &str, |
| 203 | + now: f64, |
| 204 | + ) -> Result<(), MemoryError>; |
| 205 | + |
| 206 | + /// Store a segment's embedding under `model_signature`, replacing any |
| 207 | + /// vector already held for that signature. |
| 208 | + /// |
| 209 | + /// The signature must be produced the same way the rest of the store |
| 210 | + /// produces it — see `docs/specs/2026-08-13-memory-module-port.md` §3 for |
| 211 | + /// why a mismatch here is silent. |
| 212 | + /// |
| 213 | + /// # Errors |
| 214 | + /// |
| 215 | + /// Backend failures only. |
| 216 | + async fn upsert_segment_embedding( |
| 217 | + &self, |
| 218 | + segment_id: &str, |
| 219 | + model_signature: &str, |
| 220 | + embedding: &[f32], |
| 221 | + created_at: f64, |
| 222 | + ) -> Result<(), MemoryError>; |
| 223 | +} |
0 commit comments