Skip to main content

klieo_memory_sqlite/
short_term.rs

1//! `SqliteShortTerm` — `ShortTermMemory` over a SQLite table.
2
3use crate::connection::DbHandle;
4use async_trait::async_trait;
5use chrono::Utc;
6use klieo_core::error::MemoryError;
7use klieo_core::ids::ThreadId;
8use klieo_core::llm::{Message, Role, ToolCall};
9use klieo_core::memory::ShortTermMemory;
10
11/// Extra rows fetched beyond the token budget so the in-Rust truncation always
12/// has the full kept-suffix available even when messages are tiny (~1 token).
13const LOAD_ROW_CAP_BUFFER: usize = 64;
14
15/// Characters per token, matching `klieo_core::memory::CHARS_PER_TOKEN` and
16/// `summarize::approximate_tokens`.
17const CHARS_PER_TOKEN: usize = 4;
18
19/// A message's approximate token cost, floored at 1 so the row cap in `load`
20/// stays a valid bound (every message must cost at least one token).
21fn approximate_token_cost(msg: &Message) -> usize {
22    (msg.content.chars().count() / CHARS_PER_TOKEN).max(1)
23}
24
25/// SQLite-backed short-term conversation memory.
26pub struct SqliteShortTerm {
27    db: DbHandle,
28}
29
30impl SqliteShortTerm {
31    pub(crate) fn new(db: DbHandle) -> Self {
32        Self { db }
33    }
34}
35
36fn role_to_str(r: Role) -> &'static str {
37    match r {
38        Role::System => "system",
39        Role::User => "user",
40        Role::Assistant => "assistant",
41        Role::Tool => "tool",
42        _ => "user",
43    }
44}
45
46fn role_from_str(s: &str) -> Result<Role, MemoryError> {
47    match s {
48        "system" => Ok(Role::System),
49        "user" => Ok(Role::User),
50        "assistant" => Ok(Role::Assistant),
51        "tool" => Ok(Role::Tool),
52        other => Err(MemoryError::Serialization(format!("unknown role: {other}"))),
53    }
54}
55
56#[async_trait]
57impl ShortTermMemory for SqliteShortTerm {
58    async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError> {
59        let tool_calls_json = serde_json::to_string(&msg.tool_calls)
60            .map_err(|e| MemoryError::Serialization(e.to_string()))?;
61        let role = role_to_str(msg.role);
62        let now = Utc::now().to_rfc3339();
63        self.db
64            .execute(move |conn| {
65                let tx = conn.transaction()?;
66                let next_seq: i64 = tx
67                    .query_row(
68                        "SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
69                        rusqlite::params![&thread.0],
70                        |r| r.get(0),
71                    )?;
72                tx.execute(
73                    "INSERT INTO short_term_messages (thread_id, seq, role, content, tool_calls, tool_call_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
74                    rusqlite::params![
75                        &thread.0,
76                        next_seq,
77                        role,
78                        &msg.content,
79                        &tool_calls_json,
80                        &msg.tool_call_id,
81                        &now,
82                    ],
83                )?;
84                tx.commit()?;
85                Ok(())
86            })
87            .await
88    }
89
90    async fn append_batch(
91        &self,
92        thread: ThreadId,
93        messages: Vec<Message>,
94    ) -> Result<(), MemoryError> {
95        if messages.is_empty() {
96            return Ok(());
97        }
98        // Serialize before opening the transaction so a bad message can't abort
99        // a half-written batch. The whole batch then lands in one transaction
100        // with a single MAX(seq) probe.
101        let mut rows: Vec<(&'static str, String, String, Option<String>)> =
102            Vec::with_capacity(messages.len());
103        for msg in &messages {
104            let tool_calls_json = serde_json::to_string(&msg.tool_calls)
105                .map_err(|e| MemoryError::Serialization(e.to_string()))?;
106            rows.push((
107                role_to_str(msg.role),
108                msg.content.clone(),
109                tool_calls_json,
110                msg.tool_call_id.clone(),
111            ));
112        }
113        let now = Utc::now().to_rfc3339();
114        self.db
115            .execute(move |conn| {
116                let tx = conn.transaction()?;
117                let start_seq: i64 = tx.query_row(
118                    "SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
119                    rusqlite::params![&thread.0],
120                    |r| r.get(0),
121                )?;
122                for (seq, (role, content, tool_calls_json, tool_call_id)) in (start_seq..).zip(&rows) {
123                    tx.execute(
124                        "INSERT INTO short_term_messages (thread_id, seq, role, content, tool_calls, tool_call_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
125                        rusqlite::params![
126                            &thread.0, seq, role, content, tool_calls_json, tool_call_id, &now,
127                        ],
128                    )?;
129                }
130                tx.commit()?;
131                Ok(())
132            })
133            .await
134    }
135
136    async fn load(&self, thread: ThreadId, max_tokens: usize) -> Result<Vec<Message>, MemoryError> {
137        // Cap the row scan so a long-lived thread doesn't read every message
138        // into memory before the token-budget truncation runs. Each message
139        // costs >= 1 token, so at most `max_tokens` rows can survive the budget;
140        // a small buffer keeps the cap safe.
141        let row_cap =
142            i64::try_from(max_tokens.saturating_add(LOAD_ROW_CAP_BUFFER)).unwrap_or(i64::MAX);
143        let mut rows: Vec<(String, String, String, Option<String>)> = self
144            .db
145            .execute(move |conn| {
146                let mut stmt = conn.prepare(
147                    "SELECT role, content, tool_calls, tool_call_id FROM short_term_messages WHERE thread_id = ?1 ORDER BY seq DESC LIMIT ?2",
148                )?;
149                let iter = stmt.query_map(rusqlite::params![&thread.0, row_cap], |row| {
150                    Ok((
151                        row.get::<_, String>(0)?,
152                        row.get::<_, String>(1)?,
153                        row.get::<_, String>(2)?,
154                        row.get::<_, Option<String>>(3)?,
155                    ))
156                })?;
157                iter.collect::<Result<Vec<_>, _>>()
158            })
159            .await?;
160        rows.reverse();
161
162        let mut messages: Vec<Message> = rows
163            .into_iter()
164            .map(|(role, content, tool_calls_json, tool_call_id)| {
165                let tool_calls: Vec<ToolCall> = serde_json::from_str(&tool_calls_json)
166                    .map_err(|e| MemoryError::Serialization(e.to_string()))?;
167                Ok(Message {
168                    role: role_from_str(&role)?,
169                    content,
170                    tool_calls,
171                    tool_call_id,
172                })
173            })
174            .collect::<Result<Vec<_>, MemoryError>>()?;
175
176        // Approximate token-budget truncation: ~4 characters per token. Walk
177        // from newest to oldest accumulating cost; split off everything
178        // older than the kept-suffix boundary in O(n).
179        //
180        // Counts Unicode scalar values, not UTF-8 bytes. `content.len()` here
181        // charged CJK, emoji and accented text roughly three times its real
182        // token cost, so this store kept a third as much multibyte history as
183        // `InMemoryShortTerm` at the same budget (measured 1 vs 3 and 2 vs 6
184        // messages) while both passed conformance. It also disagreed with
185        // klieo's own `summarize::approximate_tokens`, which has always
186        // counted characters — so compaction and this loader priced the same
187        // thread differently.
188        let total: usize = messages.iter().map(approximate_token_cost).sum();
189        if total > max_tokens {
190            let mut kept = 0usize;
191            let mut keep_from = messages.len();
192            for (idx, msg) in messages.iter().enumerate().rev() {
193                let cost = approximate_token_cost(msg);
194                if kept + cost > max_tokens {
195                    break;
196                }
197                kept += cost;
198                keep_from = idx;
199            }
200            messages = messages.split_off(keep_from);
201        }
202        Ok(messages)
203    }
204
205    async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError> {
206        self.db
207            .execute(move |conn| {
208                conn.execute(
209                    "DELETE FROM short_term_messages WHERE thread_id = ?1",
210                    rusqlite::params![&thread.0],
211                )?;
212                Ok(())
213            })
214            .await
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::connection::DbHandle;
222
223    fn user(text: &str) -> Message {
224        Message {
225            role: Role::User,
226            content: text.into(),
227            tool_calls: vec![],
228            tool_call_id: None,
229        }
230    }
231
232    async fn fresh() -> SqliteShortTerm {
233        let db = DbHandle::open(":memory:").await.unwrap();
234        SqliteShortTerm::new(db)
235    }
236
237    /// Holds this backend to the same contract as every other
238    /// `ShortTermMemory`, rather than only to the behaviours its own tests
239    /// happened to assert.
240    #[tokio::test]
241    async fn satisfies_short_term_conformance() {
242        klieo_core::conformance::short_term_memory(&fresh().await).await;
243    }
244
245    #[tokio::test]
246    async fn append_then_load_round_trips() {
247        let m = fresh().await;
248        let t = ThreadId::new("t1");
249        m.append(t.clone(), user("hello")).await.unwrap();
250        m.append(t.clone(), user("world")).await.unwrap();
251        let loaded = m.load(t, 10_000).await.unwrap();
252        assert_eq!(loaded.len(), 2);
253        assert_eq!(loaded[0].content, "hello");
254        assert_eq!(loaded[1].content, "world");
255    }
256
257    #[tokio::test]
258    async fn append_batch_preserves_order_and_continues_seq() {
259        let m = fresh().await;
260        let t = ThreadId::new("t1");
261        // A prior single append, then a batch — the batch must continue the
262        // sequence after it, in order.
263        m.append(t.clone(), user("first")).await.unwrap();
264        m.append_batch(t.clone(), vec![user("second"), user("third")])
265            .await
266            .unwrap();
267        let loaded = m.load(t, 10_000).await.unwrap();
268        let contents: Vec<&str> = loaded.iter().map(|msg| msg.content.as_str()).collect();
269        assert_eq!(contents, vec!["first", "second", "third"]);
270    }
271
272    #[tokio::test]
273    async fn append_batch_with_empty_input_is_a_noop() {
274        let m = fresh().await;
275        let t = ThreadId::new("t1");
276        m.append_batch(t.clone(), vec![]).await.unwrap();
277        assert!(m.load(t, 10_000).await.unwrap().is_empty());
278    }
279
280    #[tokio::test]
281    async fn load_caps_rows_at_token_budget_plus_buffer() {
282        let m = fresh().await;
283        let t = ThreadId::new("t1");
284        // 200 single-char messages (~1 token each); a budget of 5 must keep
285        // only the newest few, never load all 200.
286        let batch: Vec<Message> = (0..200).map(|i| user(&i.to_string())).collect();
287        m.append_batch(t.clone(), batch).await.unwrap();
288        let loaded = m.load(t, 5).await.unwrap();
289        assert!(
290            loaded.len() <= 5 + LOAD_ROW_CAP_BUFFER,
291            "load must bound the row scan; got {}",
292            loaded.len()
293        );
294        // Far below the 200 written — proves the LIMIT actually fired.
295        assert!(loaded.len() < 100, "cap must fire; got {}", loaded.len());
296        // The kept suffix is the newest messages, in order.
297        assert_eq!(loaded.last().unwrap().content, "199");
298    }
299
300    #[tokio::test]
301    async fn load_truncates_to_token_budget() {
302        let m = fresh().await;
303        let t = ThreadId::new("t1");
304        // Each ~40-char message ~= 10 tokens.
305        for i in 0..20 {
306            m.append(
307                t.clone(),
308                user(&format!("msg-{i:03}-padding-padding-padding")),
309            )
310            .await
311            .unwrap();
312        }
313        let loaded = m.load(t, 30).await.unwrap();
314        // Should keep ~3 messages (30 tokens / 10 each), oldest dropped.
315        assert!(
316            loaded.len() <= 4 && !loaded.is_empty(),
317            "expected ~3 messages, got {}",
318            loaded.len()
319        );
320        // Newest still present.
321        assert!(
322            loaded.last().unwrap().content.contains("msg-019"),
323            "newest message must survive truncation"
324        );
325    }
326
327    #[tokio::test]
328    async fn clear_removes_thread() {
329        let m = fresh().await;
330        let t = ThreadId::new("t1");
331        m.append(t.clone(), user("hello")).await.unwrap();
332        m.clear(t.clone()).await.unwrap();
333        let loaded = m.load(t, 10_000).await.unwrap();
334        assert!(loaded.is_empty());
335    }
336
337    #[tokio::test]
338    async fn threads_are_isolated() {
339        let m = fresh().await;
340        m.append(ThreadId::new("a"), user("a-msg")).await.unwrap();
341        m.append(ThreadId::new("b"), user("b-msg")).await.unwrap();
342        let a = m.load(ThreadId::new("a"), 10_000).await.unwrap();
343        let b = m.load(ThreadId::new("b"), 10_000).await.unwrap();
344        assert_eq!(a.len(), 1);
345        assert_eq!(b.len(), 1);
346        assert_eq!(a[0].content, "a-msg");
347        assert_eq!(b[0].content, "b-msg");
348    }
349
350    #[tokio::test]
351    async fn role_round_trip_for_all_variants() {
352        let m = fresh().await;
353        let t = ThreadId::new("r");
354        for role in [Role::System, Role::User, Role::Assistant, Role::Tool] {
355            m.append(
356                t.clone(),
357                Message {
358                    role,
359                    content: "x".into(),
360                    tool_calls: vec![],
361                    tool_call_id: None,
362                },
363            )
364            .await
365            .unwrap();
366        }
367        let loaded = m.load(t, 10_000).await.unwrap();
368        assert_eq!(loaded.len(), 4);
369        assert_eq!(loaded[0].role, Role::System);
370        assert_eq!(loaded[3].role, Role::Tool);
371    }
372}