use crate::connection::DbHandle;
use async_trait::async_trait;
use chrono::Utc;
use klieo_core::error::MemoryError;
use klieo_core::ids::ThreadId;
use klieo_core::llm::{Message, Role, ToolCall};
use klieo_core::memory::ShortTermMemory;
const LOAD_ROW_CAP_BUFFER: usize = 64;
type StoredRow = (i64, String, String, String, Option<String>);
pub struct SqliteShortTerm {
db: DbHandle,
}
impl SqliteShortTerm {
pub(crate) fn new(db: DbHandle) -> Self {
Self { db }
}
async fn newest_window_with_head(
&self,
thread: &ThreadId,
max_tokens: usize,
) -> Result<Vec<StoredRow>, MemoryError> {
let row_cap =
i64::try_from(max_tokens.saturating_add(LOAD_ROW_CAP_BUFFER)).unwrap_or(i64::MAX);
let thread_id = thread.0.clone();
let mut rows: Vec<StoredRow> = self
.db
.execute(move |conn| {
let mut stmt = conn.prepare(
"SELECT * FROM (\
SELECT seq, role, content, tool_calls, tool_call_id \
FROM short_term_messages WHERE thread_id = ?1 ORDER BY seq DESC LIMIT ?2\
) \
UNION \
SELECT * FROM (\
SELECT seq, role, content, tool_calls, tool_call_id \
FROM short_term_messages WHERE thread_id = ?1 ORDER BY seq ASC LIMIT 1\
)",
)?;
let iter = stmt.query_map(rusqlite::params![&thread_id, row_cap], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
))
})?;
iter.collect::<Result<Vec<_>, _>>()
})
.await?;
rows.sort_by_key(|(seq, ..)| *seq);
Ok(rows)
}
}
fn elided_between_rows(rows: &[StoredRow]) -> usize {
rows.windows(2)
.map(|pair| {
let (previous, next) = (pair[0].0, pair[1].0);
usize::try_from((next - previous - 1).max(0)).unwrap_or(0)
})
.sum()
}
fn to_messages(rows: Vec<StoredRow>) -> Result<Vec<Message>, MemoryError> {
rows.into_iter()
.map(|(_, role, content, tool_calls_json, tool_call_id)| {
let tool_calls: Vec<ToolCall> = serde_json::from_str(&tool_calls_json)
.map_err(|e| MemoryError::Serialization(e.to_string()))?;
Ok(Message {
role: role_from_str(&role)?,
content,
tool_calls,
tool_call_id,
})
})
.collect()
}
fn mark_row_cap_gap(
budgeted: klieo_core::memory::BudgetedHistory,
elided_by_row_cap: usize,
) -> Vec<Message> {
let mut kept = budgeted.kept;
if elided_by_row_cap == 0 || budgeted.dropped > 0 || kept.len() < 2 {
return kept;
}
kept.insert(
1,
Message::system(format!(
"{}{elided_by_row_cap} earlier message(s) omitted to fit max_history_tokens; the \
first message and the most recent turns are intact]",
klieo_core::memory::ELIDED_MESSAGES_MARKER_PREFIX
)),
);
kept
}
fn role_to_str(r: Role) -> &'static str {
match r {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
_ => "user",
}
}
fn role_from_str(s: &str) -> Result<Role, MemoryError> {
match s {
"system" => Ok(Role::System),
"user" => Ok(Role::User),
"assistant" => Ok(Role::Assistant),
"tool" => Ok(Role::Tool),
other => Err(MemoryError::Serialization(format!("unknown role: {other}"))),
}
}
#[async_trait]
impl ShortTermMemory for SqliteShortTerm {
async fn append(&self, thread: ThreadId, msg: Message) -> Result<(), MemoryError> {
let tool_calls_json = serde_json::to_string(&msg.tool_calls)
.map_err(|e| MemoryError::Serialization(e.to_string()))?;
let role = role_to_str(msg.role);
let now = Utc::now().to_rfc3339();
self.db
.execute(move |conn| {
let tx = conn.transaction()?;
let next_seq: i64 = tx
.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
rusqlite::params![&thread.0],
|r| r.get(0),
)?;
tx.execute(
"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)",
rusqlite::params![
&thread.0,
next_seq,
role,
&msg.content,
&tool_calls_json,
&msg.tool_call_id,
&now,
],
)?;
tx.commit()?;
Ok(())
})
.await
}
async fn append_batch(
&self,
thread: ThreadId,
messages: Vec<Message>,
) -> Result<(), MemoryError> {
if messages.is_empty() {
return Ok(());
}
let mut rows: Vec<(&'static str, String, String, Option<String>)> =
Vec::with_capacity(messages.len());
for msg in &messages {
let tool_calls_json = serde_json::to_string(&msg.tool_calls)
.map_err(|e| MemoryError::Serialization(e.to_string()))?;
rows.push((
role_to_str(msg.role),
msg.content.clone(),
tool_calls_json,
msg.tool_call_id.clone(),
));
}
let now = Utc::now().to_rfc3339();
self.db
.execute(move |conn| {
let tx = conn.transaction()?;
let start_seq: i64 = tx.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM short_term_messages WHERE thread_id = ?1",
rusqlite::params![&thread.0],
|r| r.get(0),
)?;
for (seq, (role, content, tool_calls_json, tool_call_id)) in (start_seq..).zip(&rows) {
tx.execute(
"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)",
rusqlite::params![
&thread.0, seq, role, content, tool_calls_json, tool_call_id, &now,
],
)?;
}
tx.commit()?;
Ok(())
})
.await
}
async fn load(&self, thread: ThreadId, max_tokens: usize) -> Result<Vec<Message>, MemoryError> {
let rows = self.newest_window_with_head(&thread, max_tokens).await?;
let elided_by_row_cap = elided_between_rows(&rows);
let messages = to_messages(rows)?;
let budgeted =
klieo_core::memory::most_recent_within_budget_reporting(messages, max_tokens);
if budgeted.dropped > 0 || elided_by_row_cap > 0 {
tracing::warn!(
thread = %thread.0,
dropped_by_budget = budgeted.dropped,
elided_by_row_cap,
max_tokens,
"short-term load could not return the whole thread; the first message and the \
newest turns were kept and the gap is marked in the history"
);
}
Ok(mark_row_cap_gap(budgeted, elided_by_row_cap))
}
async fn clear(&self, thread: ThreadId) -> Result<(), MemoryError> {
self.db
.execute(move |conn| {
conn.execute(
"DELETE FROM short_term_messages WHERE thread_id = ?1",
rusqlite::params![&thread.0],
)?;
Ok(())
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::connection::DbHandle;
fn user(text: &str) -> Message {
Message {
role: Role::User,
content: text.into(),
tool_calls: vec![],
tool_call_id: None,
}
}
async fn fresh() -> SqliteShortTerm {
let db = DbHandle::open(":memory:").await.unwrap();
SqliteShortTerm::new(db)
}
#[tokio::test]
async fn satisfies_short_term_conformance() {
klieo_core::conformance::short_term_memory(&fresh().await).await;
}
#[tokio::test]
async fn append_then_load_round_trips() {
let m = fresh().await;
let t = ThreadId::new("t1");
m.append(t.clone(), user("hello")).await.unwrap();
m.append(t.clone(), user("world")).await.unwrap();
let loaded = m.load(t, 10_000).await.unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].content, "hello");
assert_eq!(loaded[1].content, "world");
}
#[tokio::test]
async fn append_batch_preserves_order_and_continues_seq() {
let m = fresh().await;
let t = ThreadId::new("t1");
m.append(t.clone(), user("first")).await.unwrap();
m.append_batch(t.clone(), vec![user("second"), user("third")])
.await
.unwrap();
let loaded = m.load(t, 10_000).await.unwrap();
let contents: Vec<&str> = loaded.iter().map(|msg| msg.content.as_str()).collect();
assert_eq!(contents, vec!["first", "second", "third"]);
}
#[tokio::test]
async fn append_batch_with_empty_input_is_a_noop() {
let m = fresh().await;
let t = ThreadId::new("t1");
m.append_batch(t.clone(), vec![]).await.unwrap();
assert!(m.load(t, 10_000).await.unwrap().is_empty());
}
#[tokio::test]
async fn load_caps_rows_at_token_budget_plus_buffer() {
let m = fresh().await;
let t = ThreadId::new("t1");
let batch: Vec<Message> = (0..200).map(|i| user(&i.to_string())).collect();
m.append_batch(t.clone(), batch).await.unwrap();
let loaded = m.load(t, 5).await.unwrap();
assert!(
loaded.len() <= 5 + LOAD_ROW_CAP_BUFFER,
"load must bound the row scan; got {}",
loaded.len()
);
assert!(loaded.len() < 100, "cap must fire; got {}", loaded.len());
assert_eq!(loaded.last().unwrap().content, "199");
}
#[tokio::test]
async fn load_truncates_to_token_budget() {
let m = fresh().await;
let t = ThreadId::new("t1");
for i in 0..20 {
m.append(
t.clone(),
user(&format!("msg-{i:03}-padding-padding-padding")),
)
.await
.unwrap();
}
let loaded = m.load(t, 30).await.unwrap();
assert!(
loaded.len() <= 6 && !loaded.is_empty(),
"expected the budget to bound the middle, got {}",
loaded.len()
);
assert!(
loaded.first().unwrap().content.contains("msg-000"),
"the first message must survive truncation"
);
assert!(
loaded.last().unwrap().content.contains("msg-019"),
"newest message must survive truncation"
);
assert!(
loaded[1]
.content
.starts_with(klieo_core::memory::ELIDED_MESSAGES_MARKER_PREFIX),
"the omitted span must be marked, not closed silently: {:?}",
loaded[1].content
);
}
#[tokio::test]
async fn a_brief_larger_than_the_budget_still_loads() {
let m = fresh().await;
let t = ThreadId::new("t1");
m.append(t.clone(), user(&"x".repeat(40_000)))
.await
.unwrap();
let loaded = m.load(t, 8_000).await.unwrap();
assert_eq!(
loaded.len(),
1,
"a non-empty thread must never load as an empty history"
);
}
#[tokio::test]
async fn the_first_message_survives_a_thread_longer_than_the_row_cap() {
let m = fresh().await;
let t = ThreadId::new("t1");
m.append(t.clone(), user("the brief")).await.unwrap();
for i in 0..200 {
m.append(t.clone(), user(&format!("turn-{i}")))
.await
.unwrap();
}
let loaded = m.load(t, 10).await.unwrap();
assert_eq!(
loaded.first().unwrap().content,
"the brief",
"the head row must be fetched even when the row cap excludes it"
);
assert!(
loaded.last().unwrap().content.contains("turn-199"),
"and the newest turn must still be there"
);
}
#[tokio::test]
async fn clear_removes_thread() {
let m = fresh().await;
let t = ThreadId::new("t1");
m.append(t.clone(), user("hello")).await.unwrap();
m.clear(t.clone()).await.unwrap();
let loaded = m.load(t, 10_000).await.unwrap();
assert!(loaded.is_empty());
}
#[tokio::test]
async fn threads_are_isolated() {
let m = fresh().await;
m.append(ThreadId::new("a"), user("a-msg")).await.unwrap();
m.append(ThreadId::new("b"), user("b-msg")).await.unwrap();
let a = m.load(ThreadId::new("a"), 10_000).await.unwrap();
let b = m.load(ThreadId::new("b"), 10_000).await.unwrap();
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
assert_eq!(a[0].content, "a-msg");
assert_eq!(b[0].content, "b-msg");
}
#[tokio::test]
async fn role_round_trip_for_all_variants() {
let m = fresh().await;
let t = ThreadId::new("r");
for role in [Role::System, Role::User, Role::Assistant, Role::Tool] {
m.append(
t.clone(),
Message {
role,
content: "x".into(),
tool_calls: vec![],
tool_call_id: None,
},
)
.await
.unwrap();
}
let loaded = m.load(t, 10_000).await.unwrap();
assert_eq!(loaded.len(), 4);
assert_eq!(loaded[0].role, Role::System);
assert_eq!(loaded[3].role, Role::Tool);
}
}