1use 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
11const LOAD_ROW_CAP_BUFFER: usize = 64;
14
15const CHARS_PER_TOKEN: usize = 4;
18
19fn approximate_token_cost(msg: &Message) -> usize {
22 (msg.content.chars().count() / CHARS_PER_TOKEN).max(1)
23}
24
25pub 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 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 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 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 #[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 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 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 assert!(loaded.len() < 100, "cap must fire; got {}", loaded.len());
296 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 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 assert!(
316 loaded.len() <= 4 && !loaded.is_empty(),
317 "expected ~3 messages, got {}",
318 loaded.len()
319 );
320 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}