Skip to main content

agentdb/
conversations.rs

1use crate::error::{AgentDbError, Result};
2use crate::schema::now_ms;
3use rusqlite::params;
4use rusqlite::Connection;
5use serde_json::Value;
6use std::sync::{Arc, Mutex};
7use uuid::Uuid;
8
9/// A full-text search result for a message.
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct MessageSearchResult {
12    /// ID of the matching message.
13    pub message_id: String,
14    /// ID of the conversation the message belongs to.
15    pub conversation_id: String,
16    /// BM25-ranked snippet of the matched content.
17    pub snippet: String,
18    /// BM25 rank score (lower is better; negate for descending sort).
19    pub rank: f64,
20}
21
22/// A conversation thread.
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
24pub struct Conversation {
25    /// Unique identifier for this conversation.
26    pub id: String,
27    /// Optional human-readable title.
28    pub title: Option<String>,
29    /// Arbitrary JSON payload attached to the conversation.
30    pub metadata: Option<Value>,
31    /// Unix-millisecond timestamp when the conversation was created.
32    pub created_at: i64,
33    /// Unix-millisecond timestamp of the most recent update.
34    pub updated_at: i64,
35}
36
37/// A single message within a conversation.
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39pub struct Message {
40    /// Unique identifier for this message.
41    pub id: String,
42    /// ID of the parent conversation.
43    pub conversation_id: String,
44    /// Role of the sender (e.g. `"user"`, `"assistant"`, `"system"`).
45    pub role: String,
46    /// Text content of the message.
47    pub content: String,
48    /// Arbitrary JSON payload attached to the message.
49    pub metadata: Option<Value>,
50    /// Unix-millisecond timestamp when the message was created.
51    pub created_at: i64,
52}
53
54/// Manages conversation threads and their messages.
55pub struct ConversationStore {
56    conn: Arc<Mutex<Connection>>,
57}
58
59impl ConversationStore {
60    pub(crate) fn new(conn: Arc<Mutex<Connection>>) -> Self {
61        Self { conn }
62    }
63
64    /// Create a new conversation. The `id` must be unique; use
65    /// `uuid::Uuid::new_v4().to_string()` if you do not have a stable ID.
66    pub fn create_conversation(
67        &self,
68        id: &str,
69        title: Option<&str>,
70        metadata: Option<Value>,
71    ) -> Result<()> {
72        let conn = self.conn.lock().unwrap();
73        let meta_str = metadata.as_ref().map(|m| m.to_string());
74        let now = now_ms();
75        conn.execute(
76            "INSERT INTO _adb_conversations (id, title, metadata, created_at, updated_at)
77             VALUES (?1, ?2, ?3, ?4, ?5)",
78            params![id, title, meta_str, now, now],
79        )?;
80        Ok(())
81    }
82
83    /// Append a message to an existing conversation.
84    ///
85    /// The INSERT and the updated_at bump run inside a single lock acquisition
86    /// so a crash between the two operations cannot leave a stale timestamp.
87    ///
88    /// Returns the newly generated message ID.
89    pub fn add_message(
90        &self,
91        conversation_id: &str,
92        role: &str,
93        content: &str,
94        metadata: Option<Value>,
95    ) -> Result<String> {
96        let msg_id = Uuid::new_v4().to_string();
97        let meta_str = metadata.as_ref().map(|m| m.to_string());
98        let now = now_ms();
99        let conn = self.conn.lock().unwrap();
100        conn.execute(
101            "INSERT INTO _adb_messages
102                 (id, conversation_id, role, content, metadata, created_at)
103             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
104            params![msg_id, conversation_id, role, content, meta_str, now],
105        )?;
106        conn.execute(
107            "UPDATE _adb_conversations SET updated_at = ?1 WHERE id = ?2",
108            params![now, conversation_id],
109        )?;
110        conn.execute(
111            "INSERT INTO _adb_messages_fts (message_id, conversation_id, content)
112             VALUES (?1, ?2, ?3)",
113            params![&msg_id, conversation_id, content],
114        )?;
115        Ok(msg_id)
116    }
117
118    /// Return messages for a conversation in chronological order.
119    ///
120    /// If `limit` is `Some(n)` only the most-recent `n` messages are returned
121    /// (still in ascending chronological order). Pass `None` for all messages.
122    pub fn get_messages(
123        &self,
124        conversation_id: &str,
125        limit: Option<usize>,
126    ) -> Result<Vec<Message>> {
127        let conn = self.conn.lock().unwrap();
128        let rows: Vec<Message> = match limit {
129            Some(n) => {
130                // Fetch the last n rows via a sub-query so they come back in
131                // ascending (chronological) order.
132                let mut stmt = conn.prepare(
133                    "SELECT id, conversation_id, role, content, metadata, created_at
134                     FROM (
135                         SELECT id, conversation_id, role, content, metadata, created_at
136                         FROM _adb_messages
137                         WHERE conversation_id = ?1
138                         ORDER BY created_at DESC
139                         LIMIT ?2
140                     )
141                     ORDER BY created_at ASC",
142                )?;
143                let rows = stmt.query_map(params![conversation_id, n as i64], parse_message)?;
144                rows.map(|r| r.map_err(AgentDbError::Sqlite))
145                    .collect::<Result<Vec<_>>>()?
146            }
147            None => {
148                let mut stmt = conn.prepare(
149                    "SELECT id, conversation_id, role, content, metadata, created_at
150                     FROM _adb_messages
151                     WHERE conversation_id = ?1
152                     ORDER BY created_at ASC",
153                )?;
154                let rows = stmt.query_map(params![conversation_id], parse_message)?;
155                rows.map(|r| r.map_err(AgentDbError::Sqlite))
156                    .collect::<Result<Vec<_>>>()?
157            }
158        };
159        Ok(rows)
160    }
161
162    /// List all conversations ordered by most-recently updated first.
163    pub fn list_conversations(&self) -> Result<Vec<Conversation>> {
164        let conn = self.conn.lock().unwrap();
165        let mut stmt = conn.prepare(
166            "SELECT id, title, metadata, created_at, updated_at
167             FROM _adb_conversations
168             ORDER BY updated_at DESC",
169        )?;
170        let rows = stmt.query_map([], parse_conversation)?;
171        rows.map(|r| r.map_err(AgentDbError::Sqlite)).collect()
172    }
173
174    /// Delete a conversation and all its messages (via ON DELETE CASCADE).
175    pub fn delete_conversation(&self, id: &str) -> Result<()> {
176        let conn = self.conn.lock().unwrap();
177        conn.execute(
178            "DELETE FROM _adb_messages_fts WHERE conversation_id = ?1",
179            params![id],
180        )?;
181        conn.execute("DELETE FROM _adb_conversations WHERE id = ?1", params![id])?;
182        Ok(())
183    }
184
185    /// Full-text search over all message content.
186    ///
187    /// Returns up to `top_k` results ranked by BM25 relevance.
188    /// Optionally filter to a single conversation with `conversation_id`.
189    pub fn search_messages(
190        &self,
191        query: &str,
192        top_k: usize,
193        conversation_id: Option<&str>,
194    ) -> Result<Vec<MessageSearchResult>> {
195        let conn = self.conn.lock().unwrap();
196        let rows = match conversation_id {
197            Some(cid) => {
198                let mut stmt = conn.prepare(
199                    "SELECT message_id, conversation_id,
200                            snippet(_adb_messages_fts, 2, '<b>', '</b>', '...', 10),
201                            rank
202                     FROM _adb_messages_fts
203                     WHERE _adb_messages_fts MATCH ?1
204                       AND conversation_id = ?2
205                     ORDER BY rank
206                     LIMIT ?3",
207                )?;
208                let rows = stmt.query_map(
209                    params![query, cid, top_k as i64],
210                    parse_message_search_result,
211                )?;
212                rows.map(|r| r.map_err(AgentDbError::Sqlite))
213                    .collect::<Result<Vec<_>>>()?
214            }
215            None => {
216                let mut stmt = conn.prepare(
217                    "SELECT message_id, conversation_id,
218                            snippet(_adb_messages_fts, 2, '<b>', '</b>', '...', 10),
219                            rank
220                     FROM _adb_messages_fts
221                     WHERE _adb_messages_fts MATCH ?1
222                     ORDER BY rank
223                     LIMIT ?2",
224                )?;
225                let rows =
226                    stmt.query_map(params![query, top_k as i64], parse_message_search_result)?;
227                rows.map(|r| r.map_err(AgentDbError::Sqlite))
228                    .collect::<Result<Vec<_>>>()?
229            }
230        };
231        Ok(rows)
232    }
233}
234
235// ── Row parsers ──────────────────────────────────────────────────────────────
236
237fn parse_message_search_result(row: &rusqlite::Row) -> rusqlite::Result<MessageSearchResult> {
238    Ok(MessageSearchResult {
239        message_id: row.get(0)?,
240        conversation_id: row.get(1)?,
241        snippet: row.get(2)?,
242        rank: row.get(3)?,
243    })
244}
245
246fn parse_conversation(row: &rusqlite::Row) -> rusqlite::Result<Conversation> {
247    let meta_str: Option<String> = row.get(2)?;
248    Ok(Conversation {
249        id: row.get(0)?,
250        title: row.get(1)?,
251        metadata: meta_str
252            .as_deref()
253            .and_then(|s| serde_json::from_str(s).ok()),
254        created_at: row.get(3)?,
255        updated_at: row.get(4)?,
256    })
257}
258
259fn parse_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
260    let meta_str: Option<String> = row.get(4)?;
261    Ok(Message {
262        id: row.get(0)?,
263        conversation_id: row.get(1)?,
264        role: row.get(2)?,
265        content: row.get(3)?,
266        metadata: meta_str
267            .as_deref()
268            .and_then(|s| serde_json::from_str(s).ok()),
269        created_at: row.get(5)?,
270    })
271}