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
15pub struct SqliteShortTerm {
17 db: DbHandle,
18}
19
20impl SqliteShortTerm {
21 pub(crate) fn new(db: DbHandle) -> Self {
22 Self { db }
23 }
24}
25
26fn role_to_str(r: Role) -> &'static str {
27 match r {
28 Role::System => "system",
29 Role::User => "user",
30 Role::Assistant => "assistant",
31 Role::Tool => "tool",
32 _ => "user",
33 }
34}
35
36fn role_from_str(s: &str) -> Result<Role, MemoryError> {
37 match s {
38 "system" => Ok(Role::System),
39 "user" => Ok(Role::User),
40 "assistant" => Ok(Role::Assistant),
41 "tool" => Ok(Role::Tool),
42 other => Err(MemoryError::Serialization(format!("unknown role: {other}"))),
43 }
44}
45
46#[async_trait]
47impl ShortTermMemory for SqliteShortTerm {
48 async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError> {
49 let tool_calls_json = serde_json::to_string(&msg.tool_calls)
50 .map_err(|e| MemoryError::Serialization(e.to_string()))?;
51 let role = role_to_str(msg.role);
52 let now = Utc::now().to_rfc3339();
53 self.db
54 .execute(move |conn| {
55 let tx = conn.transaction()?;
56 let next_seq: i64 = tx
57 .query_row(
58 "SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
59 rusqlite::params![&thread.0],
60 |r| r.get(0),
61 )?;
62 tx.execute(
63 "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)",
64 rusqlite::params![
65 &thread.0,
66 next_seq,
67 role,
68 &msg.content,
69 &tool_calls_json,
70 &msg.tool_call_id,
71 &now,
72 ],
73 )?;
74 tx.commit()?;
75 Ok(())
76 })
77 .await
78 }
79
80 async fn append_batch(
81 &self,
82 thread: ThreadId,
83 messages: Vec<Message>,
84 ) -> Result<(), MemoryError> {
85 if messages.is_empty() {
86 return Ok(());
87 }
88 let mut rows: Vec<(&'static str, String, String, Option<String>)> =
92 Vec::with_capacity(messages.len());
93 for msg in &messages {
94 let tool_calls_json = serde_json::to_string(&msg.tool_calls)
95 .map_err(|e| MemoryError::Serialization(e.to_string()))?;
96 rows.push((
97 role_to_str(msg.role),
98 msg.content.clone(),
99 tool_calls_json,
100 msg.tool_call_id.clone(),
101 ));
102 }
103 let now = Utc::now().to_rfc3339();
104 self.db
105 .execute(move |conn| {
106 let tx = conn.transaction()?;
107 let mut next_seq: i64 = tx.query_row(
108 "SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
109 rusqlite::params![&thread.0],
110 |r| r.get(0),
111 )?;
112 for (role, content, tool_calls_json, tool_call_id) in &rows {
113 tx.execute(
114 "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)",
115 rusqlite::params![
116 &thread.0, next_seq, role, content, tool_calls_json, tool_call_id, &now,
117 ],
118 )?;
119 next_seq += 1;
120 }
121 tx.commit()?;
122 Ok(())
123 })
124 .await
125 }
126
127 async fn load(&self, thread: ThreadId, max_tokens: usize) -> Result<Vec<Message>, MemoryError> {
128 let row_cap =
133 i64::try_from(max_tokens.saturating_add(LOAD_ROW_CAP_BUFFER)).unwrap_or(i64::MAX);
134 let mut rows: Vec<(String, String, String, Option<String>)> = self
135 .db
136 .execute(move |conn| {
137 let mut stmt = conn.prepare(
138 "SELECT role, content, tool_calls, tool_call_id FROM short_term_messages WHERE thread_id = ?1 ORDER BY seq DESC LIMIT ?2",
139 )?;
140 let iter = stmt.query_map(rusqlite::params![&thread.0, row_cap], |row| {
141 Ok((
142 row.get::<_, String>(0)?,
143 row.get::<_, String>(1)?,
144 row.get::<_, String>(2)?,
145 row.get::<_, Option<String>>(3)?,
146 ))
147 })?;
148 iter.collect::<Result<Vec<_>, _>>()
149 })
150 .await?;
151 rows.reverse();
152
153 let mut messages: Vec<Message> = rows
154 .into_iter()
155 .map(|(role, content, tool_calls_json, tool_call_id)| {
156 let tool_calls: Vec<ToolCall> = serde_json::from_str(&tool_calls_json)
157 .map_err(|e| MemoryError::Serialization(e.to_string()))?;
158 Ok(Message {
159 role: role_from_str(&role)?,
160 content,
161 tool_calls,
162 tool_call_id,
163 })
164 })
165 .collect::<Result<Vec<_>, MemoryError>>()?;
166
167 let total: usize = messages.iter().map(|m| (m.content.len() / 4).max(1)).sum();
171 if total > max_tokens {
172 let mut kept = 0usize;
173 let mut keep_from = messages.len();
174 for (idx, msg) in messages.iter().enumerate().rev() {
175 let cost = (msg.content.len() / 4).max(1);
176 if kept + cost > max_tokens {
177 break;
178 }
179 kept += cost;
180 keep_from = idx;
181 }
182 messages = messages.split_off(keep_from);
183 }
184 Ok(messages)
185 }
186
187 async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError> {
188 self.db
189 .execute(move |conn| {
190 conn.execute(
191 "DELETE FROM short_term_messages WHERE thread_id = ?1",
192 rusqlite::params![&thread.0],
193 )?;
194 Ok(())
195 })
196 .await
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::connection::DbHandle;
204
205 fn user(text: &str) -> Message {
206 Message {
207 role: Role::User,
208 content: text.into(),
209 tool_calls: vec![],
210 tool_call_id: None,
211 }
212 }
213
214 async fn fresh() -> SqliteShortTerm {
215 let db = DbHandle::open(":memory:").await.unwrap();
216 SqliteShortTerm::new(db)
217 }
218
219 #[tokio::test]
220 async fn append_then_load_round_trips() {
221 let m = fresh().await;
222 let t = ThreadId::new("t1");
223 m.append(t.clone(), user("hello")).await.unwrap();
224 m.append(t.clone(), user("world")).await.unwrap();
225 let loaded = m.load(t, 10_000).await.unwrap();
226 assert_eq!(loaded.len(), 2);
227 assert_eq!(loaded[0].content, "hello");
228 assert_eq!(loaded[1].content, "world");
229 }
230
231 #[tokio::test]
232 async fn append_batch_preserves_order_and_continues_seq() {
233 let m = fresh().await;
234 let t = ThreadId::new("t1");
235 m.append(t.clone(), user("first")).await.unwrap();
238 m.append_batch(t.clone(), vec![user("second"), user("third")])
239 .await
240 .unwrap();
241 let loaded = m.load(t, 10_000).await.unwrap();
242 let contents: Vec<&str> = loaded.iter().map(|msg| msg.content.as_str()).collect();
243 assert_eq!(contents, vec!["first", "second", "third"]);
244 }
245
246 #[tokio::test]
247 async fn append_batch_with_empty_input_is_a_noop() {
248 let m = fresh().await;
249 let t = ThreadId::new("t1");
250 m.append_batch(t.clone(), vec![]).await.unwrap();
251 assert!(m.load(t, 10_000).await.unwrap().is_empty());
252 }
253
254 #[tokio::test]
255 async fn load_caps_rows_at_token_budget_plus_buffer() {
256 let m = fresh().await;
257 let t = ThreadId::new("t1");
258 let batch: Vec<Message> = (0..200).map(|i| user(&i.to_string())).collect();
261 m.append_batch(t.clone(), batch).await.unwrap();
262 let loaded = m.load(t, 5).await.unwrap();
263 assert!(
264 loaded.len() <= 5 + LOAD_ROW_CAP_BUFFER,
265 "load must bound the row scan; got {}",
266 loaded.len()
267 );
268 assert!(loaded.len() < 100, "cap must fire; got {}", loaded.len());
270 assert_eq!(loaded.last().unwrap().content, "199");
272 }
273
274 #[tokio::test]
275 async fn load_truncates_to_token_budget() {
276 let m = fresh().await;
277 let t = ThreadId::new("t1");
278 for i in 0..20 {
280 m.append(
281 t.clone(),
282 user(&format!("msg-{i:03}-padding-padding-padding")),
283 )
284 .await
285 .unwrap();
286 }
287 let loaded = m.load(t, 30).await.unwrap();
288 assert!(
290 loaded.len() <= 4 && !loaded.is_empty(),
291 "expected ~3 messages, got {}",
292 loaded.len()
293 );
294 assert!(
296 loaded.last().unwrap().content.contains("msg-019"),
297 "newest message must survive truncation"
298 );
299 }
300
301 #[tokio::test]
302 async fn clear_removes_thread() {
303 let m = fresh().await;
304 let t = ThreadId::new("t1");
305 m.append(t.clone(), user("hello")).await.unwrap();
306 m.clear(t.clone()).await.unwrap();
307 let loaded = m.load(t, 10_000).await.unwrap();
308 assert!(loaded.is_empty());
309 }
310
311 #[tokio::test]
312 async fn threads_are_isolated() {
313 let m = fresh().await;
314 m.append(ThreadId::new("a"), user("a-msg")).await.unwrap();
315 m.append(ThreadId::new("b"), user("b-msg")).await.unwrap();
316 let a = m.load(ThreadId::new("a"), 10_000).await.unwrap();
317 let b = m.load(ThreadId::new("b"), 10_000).await.unwrap();
318 assert_eq!(a.len(), 1);
319 assert_eq!(b.len(), 1);
320 assert_eq!(a[0].content, "a-msg");
321 assert_eq!(b[0].content, "b-msg");
322 }
323
324 #[tokio::test]
325 async fn role_round_trip_for_all_variants() {
326 let m = fresh().await;
327 let t = ThreadId::new("r");
328 for role in [Role::System, Role::User, Role::Assistant, Role::Tool] {
329 m.append(
330 t.clone(),
331 Message {
332 role,
333 content: "x".into(),
334 tool_calls: vec![],
335 tool_call_id: None,
336 },
337 )
338 .await
339 .unwrap();
340 }
341 let loaded = m.load(t, 10_000).await.unwrap();
342 assert_eq!(loaded.len(), 4);
343 assert_eq!(loaded[0].role, Role::System);
344 assert_eq!(loaded[3].role, Role::Tool);
345 }
346}