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
15type StoredRow = (i64, String, String, String, Option<String>);
17
18pub struct SqliteShortTerm {
20 db: DbHandle,
21}
22
23impl SqliteShortTerm {
24 pub(crate) fn new(db: DbHandle) -> Self {
25 Self { db }
26 }
27
28 async fn newest_window_with_head(
38 &self,
39 thread: &ThreadId,
40 max_tokens: usize,
41 ) -> Result<Vec<StoredRow>, MemoryError> {
42 let row_cap =
47 i64::try_from(max_tokens.saturating_add(LOAD_ROW_CAP_BUFFER)).unwrap_or(i64::MAX);
48 let thread_id = thread.0.clone();
49 let mut rows: Vec<StoredRow> = self
50 .db
51 .execute(move |conn| {
52 let mut stmt = conn.prepare(
53 "SELECT * FROM (\
54 SELECT seq, role, content, tool_calls, tool_call_id \
55 FROM short_term_messages WHERE thread_id = ?1 ORDER BY seq DESC LIMIT ?2\
56 ) \
57 UNION \
58 SELECT * FROM (\
59 SELECT seq, role, content, tool_calls, tool_call_id \
60 FROM short_term_messages WHERE thread_id = ?1 ORDER BY seq ASC LIMIT 1\
61 )",
62 )?;
63 let iter = stmt.query_map(rusqlite::params![&thread_id, row_cap], |row| {
64 Ok((
65 row.get::<_, i64>(0)?,
66 row.get::<_, String>(1)?,
67 row.get::<_, String>(2)?,
68 row.get::<_, String>(3)?,
69 row.get::<_, Option<String>>(4)?,
70 ))
71 })?;
72 iter.collect::<Result<Vec<_>, _>>()
73 })
74 .await?;
75 rows.sort_by_key(|(seq, ..)| *seq);
77 Ok(rows)
78 }
79}
80
81fn elided_between_rows(rows: &[StoredRow]) -> usize {
88 rows.windows(2)
89 .map(|pair| {
90 let (previous, next) = (pair[0].0, pair[1].0);
91 usize::try_from((next - previous - 1).max(0)).unwrap_or(0)
92 })
93 .sum()
94}
95
96fn to_messages(rows: Vec<StoredRow>) -> Result<Vec<Message>, MemoryError> {
97 rows.into_iter()
98 .map(|(_, role, content, tool_calls_json, tool_call_id)| {
99 let tool_calls: Vec<ToolCall> = serde_json::from_str(&tool_calls_json)
100 .map_err(|e| MemoryError::Serialization(e.to_string()))?;
101 Ok(Message {
102 role: role_from_str(&role)?,
103 content,
104 tool_calls,
105 tool_call_id,
106 })
107 })
108 .collect()
109}
110
111fn mark_row_cap_gap(
114 budgeted: klieo_core::memory::BudgetedHistory,
115 elided_by_row_cap: usize,
116) -> Vec<Message> {
117 let mut kept = budgeted.kept;
118 if elided_by_row_cap == 0 || budgeted.dropped > 0 || kept.len() < 2 {
119 return kept;
120 }
121 kept.insert(
122 1,
123 Message::system(format!(
124 "{}{elided_by_row_cap} earlier message(s) omitted to fit max_history_tokens; the \
125 first message and the most recent turns are intact]",
126 klieo_core::memory::ELIDED_MESSAGES_MARKER_PREFIX
127 )),
128 );
129 kept
130}
131
132fn role_to_str(r: Role) -> &'static str {
133 match r {
134 Role::System => "system",
135 Role::User => "user",
136 Role::Assistant => "assistant",
137 Role::Tool => "tool",
138 _ => "user",
139 }
140}
141
142fn role_from_str(s: &str) -> Result<Role, MemoryError> {
143 match s {
144 "system" => Ok(Role::System),
145 "user" => Ok(Role::User),
146 "assistant" => Ok(Role::Assistant),
147 "tool" => Ok(Role::Tool),
148 other => Err(MemoryError::Serialization(format!("unknown role: {other}"))),
149 }
150}
151
152#[async_trait]
153impl ShortTermMemory for SqliteShortTerm {
154 async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError> {
155 let tool_calls_json = serde_json::to_string(&msg.tool_calls)
156 .map_err(|e| MemoryError::Serialization(e.to_string()))?;
157 let role = role_to_str(msg.role);
158 let now = Utc::now().to_rfc3339();
159 self.db
160 .execute(move |conn| {
161 let tx = conn.transaction()?;
162 let next_seq: i64 = tx
163 .query_row(
164 "SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
165 rusqlite::params![&thread.0],
166 |r| r.get(0),
167 )?;
168 tx.execute(
169 "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)",
170 rusqlite::params![
171 &thread.0,
172 next_seq,
173 role,
174 &msg.content,
175 &tool_calls_json,
176 &msg.tool_call_id,
177 &now,
178 ],
179 )?;
180 tx.commit()?;
181 Ok(())
182 })
183 .await
184 }
185
186 async fn append_batch(
187 &self,
188 thread: ThreadId,
189 messages: Vec<Message>,
190 ) -> Result<(), MemoryError> {
191 if messages.is_empty() {
192 return Ok(());
193 }
194 let mut rows: Vec<(&'static str, String, String, Option<String>)> =
198 Vec::with_capacity(messages.len());
199 for msg in &messages {
200 let tool_calls_json = serde_json::to_string(&msg.tool_calls)
201 .map_err(|e| MemoryError::Serialization(e.to_string()))?;
202 rows.push((
203 role_to_str(msg.role),
204 msg.content.clone(),
205 tool_calls_json,
206 msg.tool_call_id.clone(),
207 ));
208 }
209 let now = Utc::now().to_rfc3339();
210 self.db
211 .execute(move |conn| {
212 let tx = conn.transaction()?;
213 let start_seq: i64 = tx.query_row(
214 "SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
215 rusqlite::params![&thread.0],
216 |r| r.get(0),
217 )?;
218 for (seq, (role, content, tool_calls_json, tool_call_id)) in (start_seq..).zip(&rows) {
219 tx.execute(
220 "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)",
221 rusqlite::params![
222 &thread.0, seq, role, content, tool_calls_json, tool_call_id, &now,
223 ],
224 )?;
225 }
226 tx.commit()?;
227 Ok(())
228 })
229 .await
230 }
231
232 async fn load(&self, thread: ThreadId, max_tokens: usize) -> Result<Vec<Message>, MemoryError> {
233 let rows = self.newest_window_with_head(&thread, max_tokens).await?;
234 let elided_by_row_cap = elided_between_rows(&rows);
235 let messages = to_messages(rows)?;
236
237 let budgeted =
245 klieo_core::memory::most_recent_within_budget_reporting(messages, max_tokens);
246 if budgeted.dropped > 0 || elided_by_row_cap > 0 {
247 tracing::warn!(
248 thread = %thread.0,
249 dropped_by_budget = budgeted.dropped,
250 elided_by_row_cap,
251 max_tokens,
252 "short-term load could not return the whole thread; the first message and the \
253 newest turns were kept and the gap is marked in the history"
254 );
255 }
256 Ok(mark_row_cap_gap(budgeted, elided_by_row_cap))
257 }
258
259 async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError> {
260 self.db
261 .execute(move |conn| {
262 conn.execute(
263 "DELETE FROM short_term_messages WHERE thread_id = ?1",
264 rusqlite::params![&thread.0],
265 )?;
266 Ok(())
267 })
268 .await
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::connection::DbHandle;
276
277 fn user(text: &str) -> Message {
278 Message {
279 role: Role::User,
280 content: text.into(),
281 tool_calls: vec![],
282 tool_call_id: None,
283 }
284 }
285
286 async fn fresh() -> SqliteShortTerm {
287 let db = DbHandle::open(":memory:").await.unwrap();
288 SqliteShortTerm::new(db)
289 }
290
291 #[tokio::test]
295 async fn satisfies_short_term_conformance() {
296 klieo_core::conformance::short_term_memory(&fresh().await).await;
297 }
298
299 #[tokio::test]
300 async fn append_then_load_round_trips() {
301 let m = fresh().await;
302 let t = ThreadId::new("t1");
303 m.append(t.clone(), user("hello")).await.unwrap();
304 m.append(t.clone(), user("world")).await.unwrap();
305 let loaded = m.load(t, 10_000).await.unwrap();
306 assert_eq!(loaded.len(), 2);
307 assert_eq!(loaded[0].content, "hello");
308 assert_eq!(loaded[1].content, "world");
309 }
310
311 #[tokio::test]
312 async fn append_batch_preserves_order_and_continues_seq() {
313 let m = fresh().await;
314 let t = ThreadId::new("t1");
315 m.append(t.clone(), user("first")).await.unwrap();
318 m.append_batch(t.clone(), vec![user("second"), user("third")])
319 .await
320 .unwrap();
321 let loaded = m.load(t, 10_000).await.unwrap();
322 let contents: Vec<&str> = loaded.iter().map(|msg| msg.content.as_str()).collect();
323 assert_eq!(contents, vec!["first", "second", "third"]);
324 }
325
326 #[tokio::test]
327 async fn append_batch_with_empty_input_is_a_noop() {
328 let m = fresh().await;
329 let t = ThreadId::new("t1");
330 m.append_batch(t.clone(), vec![]).await.unwrap();
331 assert!(m.load(t, 10_000).await.unwrap().is_empty());
332 }
333
334 #[tokio::test]
335 async fn load_caps_rows_at_token_budget_plus_buffer() {
336 let m = fresh().await;
337 let t = ThreadId::new("t1");
338 let batch: Vec<Message> = (0..200).map(|i| user(&i.to_string())).collect();
341 m.append_batch(t.clone(), batch).await.unwrap();
342 let loaded = m.load(t, 5).await.unwrap();
343 assert!(
344 loaded.len() <= 5 + LOAD_ROW_CAP_BUFFER,
345 "load must bound the row scan; got {}",
346 loaded.len()
347 );
348 assert!(loaded.len() < 100, "cap must fire; got {}", loaded.len());
350 assert_eq!(loaded.last().unwrap().content, "199");
352 }
353
354 #[tokio::test]
355 async fn load_truncates_to_token_budget() {
356 let m = fresh().await;
357 let t = ThreadId::new("t1");
358 for i in 0..20 {
360 m.append(
361 t.clone(),
362 user(&format!("msg-{i:03}-padding-padding-padding")),
363 )
364 .await
365 .unwrap();
366 }
367 let loaded = m.load(t, 30).await.unwrap();
368 assert!(
371 loaded.len() <= 6 && !loaded.is_empty(),
372 "expected the budget to bound the middle, got {}",
373 loaded.len()
374 );
375 assert!(
376 loaded.first().unwrap().content.contains("msg-000"),
377 "the first message must survive truncation"
378 );
379 assert!(
380 loaded.last().unwrap().content.contains("msg-019"),
381 "newest message must survive truncation"
382 );
383 assert!(
384 loaded[1]
385 .content
386 .starts_with(klieo_core::memory::ELIDED_MESSAGES_MARKER_PREFIX),
387 "the omitted span must be marked, not closed silently: {:?}",
388 loaded[1].content
389 );
390 }
391
392 #[tokio::test]
396 async fn a_brief_larger_than_the_budget_still_loads() {
397 let m = fresh().await;
398 let t = ThreadId::new("t1");
399 m.append(t.clone(), user(&"x".repeat(40_000)))
400 .await
401 .unwrap();
402
403 let loaded = m.load(t, 8_000).await.unwrap();
404
405 assert_eq!(
406 loaded.len(),
407 1,
408 "a non-empty thread must never load as an empty history"
409 );
410 }
411
412 #[tokio::test]
415 async fn the_first_message_survives_a_thread_longer_than_the_row_cap() {
416 let m = fresh().await;
417 let t = ThreadId::new("t1");
418 m.append(t.clone(), user("the brief")).await.unwrap();
419 for i in 0..200 {
420 m.append(t.clone(), user(&format!("turn-{i}")))
421 .await
422 .unwrap();
423 }
424
425 let loaded = m.load(t, 10).await.unwrap();
426
427 assert_eq!(
428 loaded.first().unwrap().content,
429 "the brief",
430 "the head row must be fetched even when the row cap excludes it"
431 );
432 assert!(
433 loaded.last().unwrap().content.contains("turn-199"),
434 "and the newest turn must still be there"
435 );
436 }
437
438 #[tokio::test]
439 async fn clear_removes_thread() {
440 let m = fresh().await;
441 let t = ThreadId::new("t1");
442 m.append(t.clone(), user("hello")).await.unwrap();
443 m.clear(t.clone()).await.unwrap();
444 let loaded = m.load(t, 10_000).await.unwrap();
445 assert!(loaded.is_empty());
446 }
447
448 #[tokio::test]
449 async fn threads_are_isolated() {
450 let m = fresh().await;
451 m.append(ThreadId::new("a"), user("a-msg")).await.unwrap();
452 m.append(ThreadId::new("b"), user("b-msg")).await.unwrap();
453 let a = m.load(ThreadId::new("a"), 10_000).await.unwrap();
454 let b = m.load(ThreadId::new("b"), 10_000).await.unwrap();
455 assert_eq!(a.len(), 1);
456 assert_eq!(b.len(), 1);
457 assert_eq!(a[0].content, "a-msg");
458 assert_eq!(b[0].content, "b-msg");
459 }
460
461 #[tokio::test]
462 async fn role_round_trip_for_all_variants() {
463 let m = fresh().await;
464 let t = ThreadId::new("r");
465 for role in [Role::System, Role::User, Role::Assistant, Role::Tool] {
466 m.append(
467 t.clone(),
468 Message {
469 role,
470 content: "x".into(),
471 tool_calls: vec![],
472 tool_call_id: None,
473 },
474 )
475 .await
476 .unwrap();
477 }
478 let loaded = m.load(t, 10_000).await.unwrap();
479 assert_eq!(loaded.len(), 4);
480 assert_eq!(loaded[0].role, Role::System);
481 assert_eq!(loaded[3].role, Role::Tool);
482 }
483}