1use std::path::{Path, PathBuf};
4
5use async_openai::types::chat::{
6 ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
7 ChatCompletionRequestToolMessage, ChatCompletionRequestUserMessage,
8 ChatCompletionRequestUserMessageContent,
9};
10use rusqlite::{params, types::ToSqlOutput, Connection, Result as SqliteResult};
11use serde::{Deserialize, Serialize};
12
13use crate::datetime::current_timestamp;
14use crate::error::Result;
15
16const ROBIT_DIR: &str = ".robit";
17const MEMORY_DIR: &str = "memory";
18const DB_FILE: &str = "robit.db";
19
20pub fn resolve_db_path(working_dir: &Path, global_storage: bool) -> Result<PathBuf> {
22 if global_storage {
23 let home = dirs::home_dir().ok_or_else(|| {
24 crate::error::AgentError::InternalError("Cannot determine home directory".to_string())
25 })?;
26 Ok(home.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
27 } else {
28 Ok(working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
29 }
30}
31
32#[derive(Debug, Clone, Serialize)]
34pub struct SessionInfo {
35 pub id: String,
36 pub chat_id: Option<String>,
38 pub title: String,
39 pub model: String,
40 pub source: String,
42 pub status: String, pub created_at: String,
44 pub updated_at: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MessageData {
50 pub id: i64,
51 pub role: String,
52 pub content: String,
53 pub tool_name: Option<String>,
54 pub tool_call_id: Option<String>,
55 pub tool_info: Option<serde_json::Value>,
56 pub created_at: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolCallInfoData {
62 pub tool_call_id: String,
63 pub name: String,
64 pub arguments: String,
65 pub status: String,
66 pub output: Option<String>,
67 pub requires_confirm: bool,
68}
69
70const CURRENT_SCHEMA_VERSION: i32 = 5;
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub enum MemoryType {
80 Fact,
82 Preference,
84 Note,
86 Task,
88 Custom(String),
90}
91
92impl MemoryType {
93 pub fn as_str(&self) -> &str {
94 match self {
95 MemoryType::Fact => "fact",
96 MemoryType::Preference => "preference",
97 MemoryType::Note => "note",
98 MemoryType::Task => "task",
99 MemoryType::Custom(s) => s,
100 }
101 }
102
103 pub fn from_str(s: &str) -> Self {
104 match s.to_lowercase().as_str() {
105 "fact" => MemoryType::Fact,
106 "preference" => MemoryType::Preference,
107 "note" => MemoryType::Note,
108 "task" => MemoryType::Task,
109 _ => MemoryType::Custom(s.to_string()),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Memory {
117 pub id: String,
119 pub session_id: Option<String>,
121 pub chat_id: Option<String>,
123 pub memory_type: MemoryType,
125 pub title: String,
127 pub content: String,
129 pub tags: Vec<String>,
131 pub is_active: bool,
133 pub created_at: String,
135 pub updated_at: String,
137}
138
139#[derive(Debug, Clone, Default)]
141pub struct MemoryFilter {
142 pub memory_type: Option<MemoryType>,
144 pub tags: Option<Vec<String>>,
146 pub session_id: Option<String>,
148 pub chat_id: Option<String>,
150 pub since: Option<String>,
152 pub only_active: bool,
154}
155
156impl Memory {
157 pub fn new(
159 title: String,
160 content: String,
161 memory_type: MemoryType,
162 tags: Vec<String>,
163 ) -> Self {
164 let now = current_timestamp();
165 Memory {
166 id: uuid::Uuid::new_v4().to_string(),
167 session_id: None,
168 chat_id: None,
169 memory_type,
170 title,
171 content,
172 tags,
173 is_active: true,
174 created_at: now.clone(),
175 updated_at: now,
176 }
177 }
178
179 pub fn with_session_id(mut self, session_id: String) -> Self {
181 self.session_id = Some(session_id);
182 self
183 }
184
185 pub fn with_chat_id(mut self, chat_id: String) -> Self {
187 self.chat_id = Some(chat_id);
188 self
189 }
190}
191
192pub fn init_db(conn: &Connection) -> SqliteResult<()> {
198 ensure_meta_table(conn)?;
199
200 let version = read_schema_version(conn)?;
201
202 if version == 0 {
203 create_all_tables(conn)?;
205 write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
206 tracing::info!(
207 "Database initialized at schema v{}",
208 CURRENT_SCHEMA_VERSION
209 );
210 return Ok(());
211 }
212
213 migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
214 Ok(())
215}
216
217fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
225 match conn.query_row(
226 "SELECT value FROM _schema_meta WHERE key = 'version'",
227 [],
228 |row| row.get::<_, String>(0),
229 ) {
230 Ok(v) => v.parse().map_err(|_| {
231 rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
232 }),
233 Err(rusqlite::Error::QueryReturnedNoRows) => {
234 if sessions_table_exists(conn)? {
236 Ok(1) } else {
238 Ok(0) }
240 }
241 Err(e) => Err(e),
242 }
243}
244
245fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
247 let count: i64 = conn.query_row(
248 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
249 [],
250 |row| row.get(0),
251 )?;
252 Ok(count > 0)
253}
254
255fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
257 conn.execute_batch(
258 "CREATE TABLE IF NOT EXISTS sessions (
259 id TEXT PRIMARY KEY,
260 chat_id TEXT,
261 title TEXT NOT NULL,
262 model TEXT NOT NULL,
263 source TEXT NOT NULL DEFAULT 'gui',
264 created_at TEXT NOT NULL,
265 updated_at TEXT NOT NULL,
266 is_active INTEGER DEFAULT 1
267 );
268
269 CREATE TABLE IF NOT EXISTS messages (
270 id INTEGER PRIMARY KEY AUTOINCREMENT,
271 session_id TEXT NOT NULL REFERENCES sessions(id),
272 role TEXT NOT NULL,
273 content TEXT NOT NULL,
274 tool_name TEXT,
275 tool_call_id TEXT,
276 tool_info TEXT,
277 tokens INTEGER,
278 created_at TEXT NOT NULL
279 );
280
281 CREATE INDEX IF NOT EXISTS idx_messages_session
282 ON messages(session_id);
283 CREATE INDEX IF NOT EXISTS idx_messages_created
284 ON messages(session_id, created_at);
285 CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
286 ON sessions(chat_id) WHERE chat_id IS NOT NULL AND is_active = 1;
287
288 CREATE TABLE IF NOT EXISTS memories (
289 id TEXT PRIMARY KEY,
290 session_id TEXT,
291 chat_id TEXT,
292 memory_type TEXT NOT NULL,
293 title TEXT NOT NULL,
294 content TEXT NOT NULL,
295 tags TEXT,
296 is_active INTEGER DEFAULT 1,
297 created_at TEXT NOT NULL,
298 updated_at TEXT NOT NULL,
299
300 FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL
301 );
302
303 CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);
304 CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at DESC);
305 CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
306 CREATE INDEX IF NOT EXISTS idx_memories_chat ON memories(chat_id);
307 CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(is_active) WHERE is_active = 1;
308
309 -- FTS5 virtual table for full-text search on messages
310 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
311 content,
312 content='messages',
313 content_rowid='id',
314 tokenize='unicode61'
315 );
316
317 -- Triggers to keep FTS index in sync with messages table
318 CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
319 INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
320 END;
321
322 CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
323 INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
324 END;
325
326 CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE OF content ON messages BEGIN
327 INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
328 INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
329 END;",
330 )?;
331 Ok(())
332}
333
334fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
336 let mut current = from;
337 while current < to {
338 tracing::info!("Migrating database: v{} → v{}", current, current + 1);
339 match current {
340 1 => migrate_v1_to_v2(conn)?,
341 2 => migrate_v2_to_v3(conn)?,
342 3 => migrate_v3_to_v4(conn)?,
343 4 => migrate_v4_to_v5(conn)?,
344 other => {
345 return Err(rusqlite::Error::InvalidParameterName(format!(
346 "Unknown schema version: {}",
347 other
348 )))
349 }
350 }
351 current += 1;
352 write_schema_version(conn, current)?;
353 tracing::info!("Database migrated to v{}", current);
354 }
355 Ok(())
356}
357
358fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
361 let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", []);
362 let _ = conn.execute(
363 "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
364 [],
365 );
366 let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", []);
367 conn.execute_batch(
368 "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
369 ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
370 )?;
371 Ok(())
372}
373
374fn migrate_v2_to_v3(conn: &Connection) -> SqliteResult<()> {
376 conn.execute_batch(
377 "CREATE TABLE IF NOT EXISTS memories (
378 id TEXT PRIMARY KEY,
379 session_id TEXT,
380 chat_id TEXT,
381 memory_type TEXT NOT NULL,
382 title TEXT NOT NULL,
383 content TEXT NOT NULL,
384 tags TEXT,
385 is_active INTEGER DEFAULT 1,
386 created_at TEXT NOT NULL,
387 updated_at TEXT NOT NULL,
388
389 FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL
390 );
391
392 CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);
393 CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at DESC);
394 CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
395 CREATE INDEX IF NOT EXISTS idx_memories_chat ON memories(chat_id);
396 CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(is_active) WHERE is_active = 1;",
397 )?;
398 Ok(())
399}
400
401fn migrate_v3_to_v4(conn: &Connection) -> SqliteResult<()> {
403 conn.execute_batch(
404 "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
405 content,
406 content='messages',
407 content_rowid='id',
408 tokenize='unicode61'
409 );
410
411 CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
412 INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
413 END;
414
415 CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
416 INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
417 END;
418
419 CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE OF content ON messages BEGIN
420 INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
421 INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
422 END;
423
424 -- Backfill existing messages into the FTS index
425 INSERT INTO messages_fts(rowid, content)
426 SELECT id, content FROM messages;",
427 )?;
428 Ok(())
429}
430
431fn migrate_v4_to_v5(conn: &Connection) -> SqliteResult<()> {
439 conn.execute_batch(
442 "DROP INDEX IF EXISTS idx_sessions_chat_id;
443 CREATE UNIQUE INDEX idx_sessions_chat_id
444 ON sessions(chat_id) WHERE chat_id IS NOT NULL AND is_active = 1;",
445 )?;
446 Ok(())
447}
448
449fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
454 conn.execute_batch(
455 "CREATE TABLE IF NOT EXISTS _schema_meta (
456 key TEXT PRIMARY KEY,
457 value TEXT NOT NULL
458 )",
459 )
460}
461
462fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
463 conn.execute(
464 "INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
465 rusqlite::params![version.to_string()],
466 )?;
467 Ok(())
468}
469
470pub fn insert_session(
480 conn: &Connection,
481 id: &str,
482 chat_id: Option<&str>,
483 title: &str,
484 model: &str,
485 source: &str,
486) -> SqliteResult<()> {
487 let now = current_timestamp();
488 conn.execute(
489 "INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
490 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
491 params![id, chat_id, title, model, source, now, now],
492 )?;
493 Ok(())
494}
495
496pub fn list_sessions(
501 conn: &Connection,
502 source_filter: Option<&str>,
503) -> SqliteResult<Vec<SessionInfo>> {
504 let sql = if source_filter.is_some() {
505 "SELECT id, chat_id, title, model, source, created_at, updated_at \
506 FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
507 } else {
508 "SELECT id, chat_id, title, model, source, created_at, updated_at \
509 FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
510 };
511 let mut stmt = conn.prepare(sql)?;
512 let rows = if let Some(source) = source_filter {
513 stmt.query_map(params![source], map_session_row)?
514 } else {
515 stmt.query_map([], map_session_row)?
516 };
517 rows.collect()
518}
519
520pub fn find_session_by_chat_id(
524 conn: &Connection,
525 chat_id: &str,
526) -> SqliteResult<Option<SessionInfo>> {
527 let mut stmt = conn.prepare(
528 "SELECT id, chat_id, title, model, source, created_at, updated_at \
529 FROM sessions WHERE chat_id = ?1 AND is_active = 1",
530 )?;
531 let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
532 match rows.next() {
533 Some(Ok(session)) => Ok(Some(session)),
534 _ => Ok(None),
535 }
536}
537
538pub fn list_all_sessions_by_chat_id(
540 conn: &Connection,
541 chat_id: &str,
542) -> SqliteResult<Vec<SessionInfo>> {
543 let mut stmt = conn.prepare(
544 "SELECT id, chat_id, title, model, source, created_at, updated_at \
545 FROM sessions WHERE chat_id = ?1 ORDER BY updated_at DESC",
546 )?;
547 let rows = stmt.query_map(params![chat_id], map_session_row)?;
548 rows.collect()
549}
550
551pub fn activate_session(
553 conn: &Connection,
554 session_id: &str,
555 chat_id: &str,
556) -> SqliteResult<()> {
557 let now = current_timestamp();
558 conn.execute(
560 "UPDATE sessions SET is_active = 0 WHERE chat_id = ?1",
561 params![chat_id],
562 )?;
563 conn.execute(
565 "UPDATE sessions SET is_active = 1, updated_at = ?1 WHERE id = ?2",
566 params![now, session_id],
567 )?;
568 Ok(())
569}
570
571pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
573 let mut stmt = conn.prepare(
574 "SELECT id, chat_id, title, model, source, created_at, updated_at \
575 FROM sessions WHERE id = ?1 AND is_active = 1",
576 )?;
577 let mut rows = stmt.query_map(params![id], map_session_row)?;
578 match rows.next() {
579 Some(Ok(session)) => Ok(Some(session)),
580 _ => Ok(None),
581 }
582}
583
584fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
586 Ok(SessionInfo {
587 id: row.get(0)?,
588 chat_id: row.get(1)?,
589 title: row.get(2)?,
590 model: row.get(3)?,
591 source: row.get(4)?,
592 status: "idle".to_string(),
593 created_at: row.get(5)?,
594 updated_at: row.get(6)?,
595 })
596}
597
598pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
600 let now = current_timestamp();
601 conn.execute(
602 "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
603 params![title, now, id],
604 )?;
605 Ok(())
606}
607
608pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
610 let now = current_timestamp();
611 conn.execute(
612 "UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
613 params![now, id],
614 )?;
615 Ok(())
616}
617
618pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
620 conn.execute(
621 "UPDATE sessions SET is_active = 0 WHERE id = ?1",
622 params![id],
623 )?;
624 Ok(())
625}
626
627pub fn insert_message(
629 conn: &Connection,
630 session_id: &str,
631 role: &str,
632 content: &str,
633 tool_name: Option<&str>,
634 tool_call_id: Option<&str>,
635 tool_info: Option<&str>,
636) -> SqliteResult<i64> {
637 let now = current_timestamp();
638 conn.execute(
639 "INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
640 params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
641 )?;
642 Ok(conn.last_insert_rowid())
643}
644
645pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
647 let mut stmt = conn.prepare(
648 "SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
649 )?;
650 let rows = stmt.query_map(params![session_id], |row| {
651 let tool_info_str: Option<String> = row.get(5)?;
652 let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
653 Ok(MessageData {
654 id: row.get(0)?,
655 role: row.get(1)?,
656 content: row.get(2)?,
657 tool_name: row.get(3)?,
658 tool_call_id: row.get(4)?,
659 tool_info,
660 created_at: row.get(6)?,
661 })
662 })?;
663 rows.collect()
664}
665
666pub fn update_tool_message(
672 conn: &Connection,
673 session_id: &str,
674 tool_call_id: &str,
675 content: &str,
676 tool_info: &str,
677) -> SqliteResult<()> {
678 conn.execute(
679 "UPDATE messages SET content = ?1, tool_info = ?2 WHERE session_id = ?3 AND tool_call_id = ?4",
680 params![content, tool_info, session_id, tool_call_id],
681 )?;
682 Ok(())
683}
684
685pub fn message_to_chat_message(data: &MessageData) -> Result<ChatCompletionRequestMessage> {
691 match data.role.as_str() {
692 "user" => Ok(ChatCompletionRequestMessage::User(
693 ChatCompletionRequestUserMessage {
694 content: ChatCompletionRequestUserMessageContent::Text(data.content.clone()),
695 name: None,
696 }
697 .into(),
698 )),
699 "assistant" => {
700 let tool_calls = if let Some(tool_info) = &data.tool_info {
702 if let serde_json::Value::Object(obj) = tool_info {
703 if let Some(serde_json::Value::Array(arr)) = obj.get("tool_calls") {
705 use async_openai::types::chat::{
706 ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
707 };
708 let mut calls = Vec::new();
709 for call_val in arr {
710 if let Ok(call) =
711 serde_json::from_value::<ChatCompletionMessageToolCall>(
712 call_val.clone(),
713 )
714 {
715 calls.push(ChatCompletionMessageToolCalls::Function(call));
716 }
717 }
718 if !calls.is_empty() {
719 Some(calls)
720 } else {
721 None
722 }
723 } else {
724 None
725 }
726 } else {
727 None
728 }
729 } else {
730 None
731 };
732
733 let content = if data.content.is_empty() {
734 None
735 } else {
736 Some(data.content.clone().into())
737 };
738
739 if content.is_none() && tool_calls.is_none() {
741 tracing::warn!("Skipping invalid assistant message: both content and tool_calls are None (message id: {:?})", data.id);
743 return Err(crate::error::AgentError::InternalError("Invalid assistant message".to_string()));
744 }
745
746 Ok(ChatCompletionRequestMessage::Assistant(
747 ChatCompletionRequestAssistantMessage {
748 content,
749 name: None,
750 tool_calls,
751 refusal: None,
752 audio: None,
753 #[allow(deprecated)]
754 function_call: None,
755 }
756 .into(),
757 ))
758 }
759 "tool" => {
760 let tool_call_id = data.tool_call_id.clone().unwrap_or_default();
761 Ok(ChatCompletionRequestMessage::Tool(
762 ChatCompletionRequestToolMessage {
763 content: data.content.clone().into(),
764 tool_call_id,
765 }
766 .into(),
767 ))
768 }
769 _ => Err(crate::error::AgentError::InternalError(format!(
771 "Unknown role: {}",
772 data.role
773 ))),
774 }
775}
776
777#[derive(Debug, Clone, Serialize)]
783pub struct MessageSearchResult {
784 pub message_id: i64,
785 pub session_id: String,
786 pub session_title: String,
787 pub role: String,
788 pub content_snippet: String,
789 pub created_at: String,
790}
791
792#[derive(Debug, Clone, Default)]
794pub struct MessageSearchFilter<'a> {
795 pub session_id: Option<&'a str>,
797 pub role: Option<&'a str>,
799 pub since: Option<&'a str>,
801 pub until: Option<&'a str>,
803}
804
805pub fn search_messages(
812 conn: &Connection,
813 query: &str,
814 filter: &MessageSearchFilter,
815 limit: usize,
816) -> SqliteResult<Vec<MessageSearchResult>> {
817 if query.trim().is_empty() {
818 return Ok(Vec::new());
819 }
820
821 let mut conditions: Vec<String> = Vec::new();
822 let mut params: Vec<ToSqlOutput> = Vec::new();
823
824 params.push(ToSqlOutput::from(query));
826
827 if let Some(session_id) = filter.session_id {
829 conditions.push("m.session_id = ?".to_string());
830 params.push(ToSqlOutput::from(session_id));
831 }
832
833 if let Some(role) = filter.role {
835 conditions.push("m.role = ?".to_string());
836 params.push(ToSqlOutput::from(role));
837 }
838
839 if let Some(since) = filter.since {
841 conditions.push("m.created_at >= ?".to_string());
842 params.push(ToSqlOutput::from(since));
843 }
844
845 if let Some(until) = filter.until {
847 conditions.push("m.created_at <= ?".to_string());
848 params.push(ToSqlOutput::from(until));
849 }
850
851 let where_extra = if conditions.is_empty() {
852 String::new()
853 } else {
854 format!(" AND {}", conditions.join(" AND "))
855 };
856
857 let sql = format!(
858 "SELECT
859 m.id,
860 m.session_id,
861 s.title,
862 m.role,
863 snippet(messages_fts, 0, '<b>', '</b>', '...', 16),
864 m.created_at
865 FROM messages_fts
866 JOIN messages m ON m.id = messages_fts.rowid
867 JOIN sessions s ON s.id = m.session_id
868 WHERE messages_fts MATCH ?1{}
869 ORDER BY bm25(messages_fts)
870 LIMIT {}",
871 where_extra,
872 limit
873 );
874
875 let mut stmt = conn.prepare(&sql)?;
876 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
877 let rows = stmt.query_map(param_refs.as_slice(), |row| {
878 Ok(MessageSearchResult {
879 message_id: row.get(0)?,
880 session_id: row.get(1)?,
881 session_title: row.get(2)?,
882 role: row.get(3)?,
883 content_snippet: row.get(4)?,
884 created_at: row.get(5)?,
885 })
886 })?;
887
888 rows.collect()
889}
890
891pub fn load_chat_messages(
893 conn: &Connection,
894 session_id: &str,
895) -> Result<Vec<ChatCompletionRequestMessage>> {
896 let messages = get_messages(conn, session_id)?;
897 tracing::debug!(
898 "load_chat_messages: session_id={}, loaded {} messages from DB",
899 session_id,
900 messages.len()
901 );
902
903 let mut result = Vec::with_capacity(messages.len());
904 for (idx, msg) in messages.iter().enumerate() {
905 match message_to_chat_message(&msg) {
906 Ok(chat_msg) => {
907 let is_valid = match &chat_msg {
909 ChatCompletionRequestMessage::Assistant(assistant_msg) => {
910 assistant_msg.content.is_some() || assistant_msg.tool_calls.is_some()
911 }
912 _ => true,
913 };
914
915 if is_valid {
916 tracing::trace!(
917 "load_chat_messages: message {}: role={}, content_len={}",
918 idx,
919 msg.role,
920 msg.content.len()
921 );
922 result.push(chat_msg);
923 } else {
924 tracing::warn!(
925 "Skipping invalid assistant message {} (has neither content nor tool_calls)",
926 idx
927 );
928 }
929 }
930 Err(e) => {
931 tracing::warn!("Skipping invalid message {}: {}", idx, e);
932 }
933 }
934 }
935 tracing::debug!("load_chat_messages: successfully converted {} messages", result.len());
936 Ok(result)
937}
938
939pub fn insert_memory(conn: &Connection, memory: &Memory) -> SqliteResult<()> {
945 let tags_str = if memory.tags.is_empty() {
946 None
947 } else {
948 Some(memory.tags.join(","))
949 };
950
951 conn.execute(
952 "INSERT INTO memories (
953 id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
954 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
955 params![
956 memory.id,
957 memory.session_id,
958 memory.chat_id,
959 memory.memory_type.as_str(),
960 memory.title,
961 memory.content,
962 tags_str,
963 memory.is_active,
964 memory.created_at,
965 memory.updated_at,
966 ],
967 )?;
968 Ok(())
969}
970
971pub fn update_memory(conn: &Connection, memory: &Memory) -> SqliteResult<()> {
973 let tags_str = if memory.tags.is_empty() {
974 None
975 } else {
976 Some(memory.tags.join(","))
977 };
978
979 conn.execute(
980 "UPDATE memories SET
981 title = ?1,
982 content = ?2,
983 tags = ?3,
984 memory_type = ?4,
985 updated_at = ?5
986 WHERE id = ?6",
987 params![
988 memory.title,
989 memory.content,
990 tags_str,
991 memory.memory_type.as_str(),
992 current_timestamp(),
993 memory.id,
994 ],
995 )?;
996 Ok(())
997}
998
999pub fn deactivate_memory(conn: &Connection, memory_id: &str) -> SqliteResult<()> {
1001 conn.execute(
1002 "UPDATE memories SET is_active = 0, updated_at = ?1 WHERE id = ?2",
1003 params![current_timestamp(), memory_id],
1004 )?;
1005 Ok(())
1006}
1007
1008pub fn delete_memory_permanently(conn: &Connection, memory_id: &str) -> SqliteResult<()> {
1010 conn.execute("DELETE FROM memories WHERE id = ?1", params![memory_id])?;
1011 Ok(())
1012}
1013
1014pub fn get_memory(conn: &Connection, memory_id: &str) -> SqliteResult<Option<Memory>> {
1016 let mut stmt = conn.prepare(
1017 "SELECT id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
1018 FROM memories WHERE id = ?1",
1019 )?;
1020
1021 let mut rows = stmt.query_map(params![memory_id], map_memory_row)?;
1022 rows.next().transpose()
1023}
1024
1025pub fn find_memories_by_title(
1027 conn: &Connection,
1028 title_part: &str,
1029 filter: &MemoryFilter,
1030 limit: Option<usize>,
1031) -> SqliteResult<Vec<Memory>> {
1032 let (sql, params) = build_memory_query(Some(title_part), filter, limit);
1033 let mut stmt = conn.prepare(&sql)?;
1034
1035 let rows = stmt.query_map(rusqlite::params_from_iter(params), map_memory_row)?;
1036 rows.collect()
1037}
1038
1039pub fn list_memories(
1041 conn: &Connection,
1042 filter: &MemoryFilter,
1043 limit: Option<usize>,
1044) -> SqliteResult<Vec<Memory>> {
1045 let (sql, params) = build_memory_query(None, filter, limit);
1046 let mut stmt = conn.prepare(&sql)?;
1047
1048 let rows = stmt.query_map(rusqlite::params_from_iter(params), map_memory_row)?;
1049 rows.collect()
1050}
1051
1052pub fn recall_memories(
1054 conn: &Connection,
1055 query: &str,
1056 filter: &MemoryFilter,
1057 limit: usize,
1058) -> SqliteResult<Vec<Memory>> {
1059 let mut results = find_memories_by_title(conn, query, filter, Some(limit))?;
1061
1062 if results.len() >= limit {
1064 results.truncate(limit);
1065 return Ok(results);
1066 }
1067
1068 let remaining = limit - results.len();
1070 let (sql, params) = build_recall_query(query, filter, remaining);
1071 let mut stmt = conn.prepare(&sql)?;
1072
1073 let rows = stmt.query_map(rusqlite::params_from_iter(params), map_memory_row)?;
1074 for row in rows {
1075 let memory = row?;
1076 if !results.iter().any(|m| m.id == memory.id) {
1077 results.push(memory);
1078 }
1079 }
1080
1081 results.truncate(limit);
1082 Ok(results)
1083}
1084
1085fn map_memory_row(row: &rusqlite::Row) -> SqliteResult<Memory> {
1090 let tags_str: Option<String> = row.get(6)?;
1091 let tags = tags_str
1092 .map(|s| {
1093 s.split(',')
1094 .map(|t| t.trim().to_string())
1095 .filter(|t| !t.is_empty())
1096 .collect()
1097 })
1098 .unwrap_or_default();
1099
1100 Ok(Memory {
1101 id: row.get(0)?,
1102 session_id: row.get(1)?,
1103 chat_id: row.get(2)?,
1104 memory_type: MemoryType::from_str(&row.get::<_, String>(3)?),
1105 title: row.get(4)?,
1106 content: row.get(5)?,
1107 tags,
1108 is_active: row.get::<_, i32>(7)? != 0,
1109 created_at: row.get(8)?,
1110 updated_at: row.get(9)?,
1111 })
1112}
1113
1114fn build_memory_query<'a>(
1115 title_search: Option<&'a str>,
1116 filter: &'a MemoryFilter,
1117 limit: Option<usize>,
1118) -> (String, Vec<rusqlite::types::ToSqlOutput<'a>>) {
1119 let mut conditions = Vec::new();
1120 let mut params: Vec<rusqlite::types::ToSqlOutput> = Vec::new();
1121
1122 if filter.only_active {
1124 conditions.push("is_active = 1".to_string());
1125 }
1126
1127 if let Some(memory_type) = &filter.memory_type {
1129 conditions.push("memory_type = ?".to_string());
1130 params.push(memory_type.as_str().into());
1131 }
1132
1133 if let Some(session_id) = &filter.session_id {
1135 conditions.push("session_id = ?".to_string());
1136 params.push(session_id.as_str().into());
1137 }
1138
1139 if let Some(chat_id) = &filter.chat_id {
1141 conditions.push("chat_id = ?".to_string());
1142 params.push(chat_id.as_str().into());
1143 }
1144
1145 if let Some(since) = &filter.since {
1147 conditions.push("created_at >= ?".to_string());
1148 params.push(since.as_str().into());
1149 }
1150
1151 if let Some(title) = title_search {
1153 conditions.push("title LIKE ?".to_string());
1154 params.push(format!("%{}%", title).into());
1155 }
1156
1157 if let Some(tags) = &filter.tags {
1159 if !tags.is_empty() {
1160 let tag_conditions: Vec<_> = tags.iter().map(|_| "tags LIKE ?").collect();
1161 conditions.push(format!("({})", tag_conditions.join(" OR ")));
1162 for tag in tags {
1163 params.push(format!("%{}%", tag).into());
1164 }
1165 }
1166 }
1167
1168 let where_clause = if conditions.is_empty() {
1169 String::new()
1170 } else {
1171 format!("WHERE {}", conditions.join(" AND "))
1172 };
1173
1174 let limit_clause = limit.map(|l| format!("LIMIT {}", l)).unwrap_or_default();
1175
1176 let sql = format!(
1177 "SELECT id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
1178 FROM memories
1179 {}
1180 ORDER BY created_at DESC
1181 {}",
1182 where_clause, limit_clause
1183 );
1184
1185 (sql, params)
1186}
1187
1188fn build_recall_query<'a>(
1189 query: &'a str,
1190 filter: &'a MemoryFilter,
1191 limit: usize,
1192) -> (String, Vec<rusqlite::types::ToSqlOutput<'a>>) {
1193 let mut conditions = Vec::new();
1194 let mut params: Vec<rusqlite::types::ToSqlOutput> = Vec::new();
1195
1196 if filter.only_active {
1198 conditions.push("is_active = 1".to_string());
1199 }
1200
1201 if let Some(memory_type) = &filter.memory_type {
1203 conditions.push("memory_type = ?".to_string());
1204 params.push(memory_type.as_str().into());
1205 }
1206
1207 if let Some(session_id) = &filter.session_id {
1209 conditions.push("session_id = ?".to_string());
1210 params.push(session_id.as_str().into());
1211 }
1212
1213 if let Some(chat_id) = &filter.chat_id {
1215 conditions.push("chat_id = ?".to_string());
1216 params.push(chat_id.as_str().into());
1217 }
1218
1219 conditions.push("(title LIKE ? OR content LIKE ? OR tags LIKE ?)".to_string());
1221 let pattern = format!("%{}%", query);
1222 params.push(pattern.clone().into());
1223 params.push(pattern.clone().into());
1224 params.push(pattern.into());
1225
1226 let where_clause = format!("WHERE {}", conditions.join(" AND "));
1227
1228 let sql = format!(
1229 "SELECT id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
1230 FROM memories
1231 {}
1232 ORDER BY created_at DESC
1233 LIMIT {}",
1234 where_clause, limit
1235 );
1236
1237 (sql, params)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243
1244 #[test]
1245 fn resolves_local_db_path() {
1246 let working_dir = PathBuf::from("project");
1247 let path = resolve_db_path(&working_dir, false).unwrap();
1248 assert_eq!(
1249 path,
1250 working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
1251 );
1252 }
1253
1254 #[test]
1255 fn session_crud() {
1256 let conn = Connection::open_in_memory().unwrap();
1257 init_db(&conn).unwrap();
1258
1259 insert_session(
1260 &conn,
1261 "test-123",
1262 None,
1263 "Test Session",
1264 "deepseek/deepseek-chat",
1265 "gui",
1266 )
1267 .unwrap();
1268
1269 let sessions = list_sessions(&conn, None).unwrap();
1270 assert_eq!(sessions.len(), 1);
1271 assert_eq!(sessions[0].id, "test-123");
1272 assert_eq!(sessions[0].title, "Test Session");
1273 assert_eq!(sessions[0].source, "gui");
1274 assert_eq!(sessions[0].chat_id, None);
1275 assert_eq!(sessions[0].status, "idle");
1276
1277 let session = get_session(&conn, "test-123").unwrap().unwrap();
1278 assert_eq!(session.title, "Test Session");
1279 assert_eq!(session.source, "gui");
1280
1281 update_session_title(&conn, "test-123", "Updated Title").unwrap();
1282 let updated = get_session(&conn, "test-123").unwrap().unwrap();
1283 assert_eq!(updated.title, "Updated Title");
1284
1285 delete_session(&conn, "test-123").unwrap();
1286 assert!(get_session(&conn, "test-123").unwrap().is_none());
1287 assert!(list_sessions(&conn, None).unwrap().is_empty());
1288 }
1289
1290 #[test]
1291 fn message_operations() {
1292 let conn = Connection::open_in_memory().unwrap();
1293 init_db(&conn).unwrap();
1294
1295 insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
1296 let user_id = insert_message(
1297 &conn,
1298 "session-msg",
1299 "user",
1300 "Hello Robit",
1301 None,
1302 None,
1303 None,
1304 )
1305 .unwrap();
1306 let assistant_id = insert_message(
1307 &conn,
1308 "session-msg",
1309 "assistant",
1310 "Hello! How can I help?",
1311 None,
1312 None,
1313 None,
1314 )
1315 .unwrap();
1316
1317 let messages = get_messages(&conn, "session-msg").unwrap();
1318 assert_eq!(messages.len(), 2);
1319 assert_eq!(messages[0].id, user_id);
1320 assert_eq!(messages[0].role, "user");
1321 assert_eq!(messages[0].content, "Hello Robit");
1322 assert_eq!(messages[1].id, assistant_id);
1323 assert_eq!(messages[1].role, "assistant");
1324 assert_eq!(messages[1].content, "Hello! How can I help?");
1325 }
1326
1327 #[test]
1328 fn empty_sessions() {
1329 let conn = Connection::open_in_memory().unwrap();
1330 init_db(&conn).unwrap();
1331
1332 let sessions = list_sessions(&conn, None).unwrap();
1333 assert_eq!(sessions.len(), 0);
1334 }
1335
1336 #[test]
1337 fn get_nonexistent_session() {
1338 let conn = Connection::open_in_memory().unwrap();
1339 init_db(&conn).unwrap();
1340
1341 let session = get_session(&conn, "nonexistent").unwrap();
1342 assert!(session.is_none());
1343 }
1344
1345 #[test]
1346 fn tool_message_update() {
1347 let conn = Connection::open_in_memory().unwrap();
1348 init_db(&conn).unwrap();
1349
1350 insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
1351 let initial = serde_json::json!({
1352 "tool_call_id": "tool-1",
1353 "name": "bash",
1354 "arguments": "{}",
1355 "status": "pending",
1356 "requires_confirm": true
1357 })
1358 .to_string();
1359 insert_message(
1360 &conn,
1361 "session-tool",
1362 "tool",
1363 "{}",
1364 Some("bash"),
1365 Some("tool-1"),
1366 Some(&initial),
1367 )
1368 .unwrap();
1369
1370 let updated = serde_json::json!({
1371 "tool_call_id": "tool-1",
1372 "status": "success",
1373 "output": "done"
1374 })
1375 .to_string();
1376 update_tool_message(&conn, "session-tool", "tool-1", "done", &updated).unwrap();
1377
1378 let messages = get_messages(&conn, "session-tool").unwrap();
1379 assert_eq!(messages.len(), 1);
1380 assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
1381 assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
1382 assert_eq!(messages[0].content, "done");
1384 assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
1385 assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
1386 }
1387
1388 #[test]
1389 fn chat_id_lookup_and_source_filter() {
1390 let conn = Connection::open_in_memory().unwrap();
1391 init_db(&conn).unwrap();
1392
1393 insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
1394 insert_session(
1395 &conn,
1396 "qq-1",
1397 Some("group:abc"),
1398 "技术讨论群",
1399 "model",
1400 "qq",
1401 )
1402 .unwrap();
1403 insert_session(
1404 &conn,
1405 "qq-2",
1406 Some("private:xyz"),
1407 "私聊",
1408 "model",
1409 "qq",
1410 )
1411 .unwrap();
1412
1413 let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
1415 assert_eq!(found.id, "qq-1");
1416 assert_eq!(found.source, "qq");
1417 assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
1418
1419 assert!(find_session_by_chat_id(&conn, "does-not-exist")
1421 .unwrap()
1422 .is_none());
1423
1424 let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
1426 assert_eq!(qq_sessions.len(), 2);
1427 assert!(qq_sessions.iter().all(|s| s.source == "qq"));
1428
1429 let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
1430 assert_eq!(gui_sessions.len(), 1);
1431 assert_eq!(gui_sessions[0].id, "gui-1");
1432
1433 assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
1435 }
1436
1437 #[test]
1438 fn chat_id_unique_per_chat() {
1439 let conn = Connection::open_in_memory().unwrap();
1440 init_db(&conn).unwrap();
1441
1442 insert_session(
1443 &conn,
1444 "qq-1",
1445 Some("group:abc"),
1446 "First",
1447 "model",
1448 "qq",
1449 )
1450 .unwrap();
1451 let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
1453 assert!(err.is_err());
1454 }
1455
1456 #[test]
1457 fn archived_session_allows_new_session_same_chat() {
1458 let conn = Connection::open_in_memory().unwrap();
1459 init_db(&conn).unwrap();
1460
1461 insert_session(&conn, "qq-1", Some("group:abc"), "First", "model", "qq").unwrap();
1462 delete_session(&conn, "qq-1").unwrap();
1464 insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq").unwrap();
1465
1466 let err = insert_session(&conn, "qq-3", Some("group:abc"), "Third", "model", "qq");
1468 assert!(err.is_err());
1469
1470 let active = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
1472 assert_eq!(active.id, "qq-2");
1473 }
1474
1475 #[test]
1476 fn migrates_v4_chat_id_index_to_active_only() {
1477 let conn = Connection::open_in_memory().unwrap();
1478 ensure_meta_table(&conn).unwrap();
1481 conn.execute_batch(
1482 "CREATE TABLE sessions (
1483 id TEXT PRIMARY KEY,
1484 chat_id TEXT,
1485 title TEXT NOT NULL,
1486 model TEXT NOT NULL,
1487 source TEXT NOT NULL DEFAULT 'gui',
1488 created_at TEXT NOT NULL,
1489 updated_at TEXT NOT NULL,
1490 is_active INTEGER DEFAULT 1
1491 );
1492 CREATE UNIQUE INDEX idx_sessions_chat_id
1493 ON sessions(chat_id) WHERE chat_id IS NOT NULL;
1494 INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at, is_active)
1495 VALUES ('old-1', 'group:abc', 'Old', 'model', 'qq', '2020-01-01', '2020-01-01', 0);",
1496 )
1497 .unwrap();
1498 write_schema_version(&conn, 4).unwrap();
1499
1500 init_db(&conn).unwrap();
1501 assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
1502
1503 insert_session(&conn, "new-1", Some("group:abc"), "New", "model", "qq").unwrap();
1505 }
1506
1507 #[test]
1508 fn migrates_legacy_v1_database() {
1509 let conn = Connection::open_in_memory().unwrap();
1510 conn.execute_batch(
1512 "CREATE TABLE sessions (
1513 id TEXT PRIMARY KEY,
1514 title TEXT NOT NULL,
1515 model TEXT NOT NULL,
1516 created_at TEXT NOT NULL,
1517 updated_at TEXT NOT NULL,
1518 is_active INTEGER DEFAULT 1
1519 );
1520 CREATE TABLE messages (
1521 id INTEGER PRIMARY KEY AUTOINCREMENT,
1522 session_id TEXT NOT NULL REFERENCES sessions(id),
1523 role TEXT NOT NULL,
1524 content TEXT NOT NULL,
1525 tool_name TEXT,
1526 tool_call_id TEXT,
1527 tokens INTEGER,
1528 created_at TEXT NOT NULL
1529 );",
1530 )
1531 .unwrap();
1532 conn.execute(
1533 "INSERT INTO sessions (id, title, model, created_at, updated_at) \
1534 VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
1535 [],
1536 )
1537 .unwrap();
1538
1539 init_db(&conn).unwrap();
1543
1544 let v: i32 = read_schema_version(&conn).unwrap();
1546 assert_eq!(v, CURRENT_SCHEMA_VERSION);
1547
1548 let session = get_session(&conn, "legacy-1").unwrap().unwrap();
1550 assert_eq!(session.title, "Legacy");
1551 assert_eq!(session.source, "gui");
1552 assert_eq!(session.chat_id, None);
1553 }
1554
1555 #[test]
1556 fn init_db_is_idempotent() {
1557 let conn = Connection::open_in_memory().unwrap();
1558 init_db(&conn).unwrap();
1559 init_db(&conn).unwrap();
1561 assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
1562 }
1563
1564 fn setup_search_test(conn: &Connection) {
1569 init_db(conn).unwrap();
1570 insert_session(conn, "sess-1", None, "Session One", "model", "gui").unwrap();
1571 insert_session(conn, "sess-2", None, "Session Two", "model", "gui").unwrap();
1572
1573 insert_message(conn, "sess-1", "user", "Hello, how do I write Rust code?", None, None, None).unwrap();
1574 insert_message(conn, "sess-1", "assistant", "To write Rust code, start with cargo new.", None, None, None).unwrap();
1575 insert_message(conn, "sess-1", "user", "What about Python?", None, None, None).unwrap();
1576 insert_message(conn, "sess-1", "assistant", "Python is also a great language.", None, None, None).unwrap();
1577
1578 insert_message(conn, "sess-2", "user", "How to deploy a Rust application?", None, None, None).unwrap();
1579 insert_message(conn, "sess-2", "assistant", "You can deploy Rust apps with Docker.", None, None, None).unwrap();
1580 }
1581
1582 #[test]
1583 fn search_messages_basic() {
1584 let conn = Connection::open_in_memory().unwrap();
1585 setup_search_test(&conn);
1586
1587 let filter = MessageSearchFilter {
1588 session_id: None,
1589 role: None,
1590 since: None,
1591 until: None,
1592 };
1593 let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1594 assert!(results.len() >= 2, "Expected at least 2 results for 'Rust', got {}", results.len());
1595
1596 assert!(results[0].content_snippet.contains("<b>"));
1598 }
1599
1600 #[test]
1601 fn search_messages_session_filter() {
1602 let conn = Connection::open_in_memory().unwrap();
1603 setup_search_test(&conn);
1604
1605 let filter = MessageSearchFilter {
1606 session_id: Some("sess-1"),
1607 role: None,
1608 since: None,
1609 until: None,
1610 };
1611 let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1612 assert_eq!(results.len(), 2);
1613
1614 let filter2 = MessageSearchFilter {
1615 session_id: Some("sess-2"),
1616 role: None,
1617 since: None,
1618 until: None,
1619 };
1620 let results2 = search_messages(&conn, "Rust", &filter2, 10).unwrap();
1621 assert_eq!(results2.len(), 2);
1622 }
1623
1624 #[test]
1625 fn search_messages_role_filter() {
1626 let conn = Connection::open_in_memory().unwrap();
1627 setup_search_test(&conn);
1628
1629 let filter = MessageSearchFilter {
1630 session_id: Some("sess-1"),
1631 role: Some("user"),
1632 since: None,
1633 until: None,
1634 };
1635 let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1636 assert_eq!(results.len(), 1);
1637 assert_eq!(results[0].role, "user");
1638 }
1639
1640 #[test]
1641 fn search_messages_empty_query() {
1642 let conn = Connection::open_in_memory().unwrap();
1643 setup_search_test(&conn);
1644
1645 let filter = MessageSearchFilter::default();
1646 let results = search_messages(&conn, "", &filter, 10).unwrap();
1647 assert!(results.is_empty());
1648 }
1649
1650 #[test]
1651 fn search_messages_no_results() {
1652 let conn = Connection::open_in_memory().unwrap();
1653 setup_search_test(&conn);
1654
1655 let filter = MessageSearchFilter::default();
1656 let results = search_messages(&conn, "nonexistent_keyword_xyz", &filter, 10).unwrap();
1657 assert!(results.is_empty());
1658 }
1659
1660 #[test]
1661 fn search_messages_limit() {
1662 let conn = Connection::open_in_memory().unwrap();
1663 setup_search_test(&conn);
1664
1665 let filter = MessageSearchFilter {
1666 session_id: None,
1667 role: None,
1668 since: None,
1669 until: None,
1670 };
1671 let results = search_messages(&conn, "Rust", &filter, 2).unwrap();
1672 assert_eq!(results.len(), 2);
1673 }
1674
1675 #[test]
1676 fn search_messages_cross_session_has_session_title() {
1677 let conn = Connection::open_in_memory().unwrap();
1678 setup_search_test(&conn);
1679
1680 let filter = MessageSearchFilter {
1681 session_id: None,
1682 role: None,
1683 since: None,
1684 until: None,
1685 };
1686 let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1687 for r in &results {
1689 assert!(!r.session_title.is_empty());
1690 }
1691 }
1692
1693 #[test]
1694 fn migration_v3_to_v4_backfill() {
1695 let conn = Connection::open_in_memory().unwrap();
1696
1697 conn.execute_batch(
1699 "CREATE TABLE sessions (
1700 id TEXT PRIMARY KEY,
1701 chat_id TEXT,
1702 title TEXT NOT NULL,
1703 model TEXT NOT NULL,
1704 source TEXT NOT NULL DEFAULT 'gui',
1705 created_at TEXT NOT NULL,
1706 updated_at TEXT NOT NULL,
1707 is_active INTEGER DEFAULT 1
1708 );
1709
1710 CREATE TABLE messages (
1711 id INTEGER PRIMARY KEY AUTOINCREMENT,
1712 session_id TEXT NOT NULL REFERENCES sessions(id),
1713 role TEXT NOT NULL,
1714 content TEXT NOT NULL,
1715 tool_name TEXT,
1716 tool_call_id TEXT,
1717 tool_info TEXT,
1718 tokens INTEGER,
1719 created_at TEXT NOT NULL
1720 );
1721
1722 CREATE TABLE memories (
1723 id TEXT PRIMARY KEY,
1724 session_id TEXT,
1725 chat_id TEXT,
1726 memory_type TEXT NOT NULL,
1727 title TEXT NOT NULL,
1728 content TEXT NOT NULL,
1729 tags TEXT,
1730 is_active INTEGER DEFAULT 1,
1731 created_at TEXT NOT NULL,
1732 updated_at TEXT NOT NULL
1733 );
1734
1735 CREATE TABLE _schema_meta (
1736 key TEXT PRIMARY KEY,
1737 value TEXT NOT NULL
1738 );
1739
1740 INSERT INTO _schema_meta (key, value) VALUES ('version', '3');
1741
1742 INSERT INTO sessions (id, title, model, source, created_at, updated_at)
1743 VALUES ('old-sess', 'Old Session', 'model', 'gui', '2025-01-01', '2025-01-01');
1744
1745 INSERT INTO messages (session_id, role, content, created_at)
1746 VALUES ('old-sess', 'user', 'This is a legacy message about testing', '2025-01-01');",
1747 )
1748 .unwrap();
1749
1750 init_db(&conn).unwrap();
1752
1753 assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
1754
1755 let filter = MessageSearchFilter {
1757 session_id: Some("old-sess"),
1758 role: None,
1759 since: None,
1760 until: None,
1761 };
1762 let results = search_messages(&conn, "legacy", &filter, 10).unwrap();
1763 assert_eq!(results.len(), 1);
1764 assert_eq!(results[0].session_title, "Old Session");
1765 }
1766}