@@ -111,6 +111,32 @@ impl BrainDb {
111111 summary TEXT NOT NULL DEFAULT ''
112112 );
113113
114+ -- ── Chat FTS5 ──────────────────────────────────────────────────────────
115+ CREATE VIRTUAL TABLE IF NOT EXISTS chat_fts USING fts5(
116+ content,
117+ content=chat_history,
118+ content_rowid=id
119+ );
120+
121+ -- Triggers: keep chat_fts in sync with chat_history
122+ CREATE TRIGGER IF NOT EXISTS chat_history_ai
123+ AFTER INSERT ON chat_history BEGIN
124+ INSERT INTO chat_fts(rowid, content)
125+ VALUES (new.id, new.content);
126+ END;
127+ CREATE TRIGGER IF NOT EXISTS chat_history_ad
128+ AFTER DELETE ON chat_history BEGIN
129+ INSERT INTO chat_fts(chat_fts, rowid, content)
130+ VALUES ('delete', old.id, old.content);
131+ END;
132+ CREATE TRIGGER IF NOT EXISTS chat_history_au
133+ AFTER UPDATE ON chat_history BEGIN
134+ INSERT INTO chat_fts(chat_fts, rowid, content)
135+ VALUES ('delete', old.id, old.content);
136+ INSERT INTO chat_fts(rowid, content)
137+ VALUES (new.id, new.content);
138+ END;
139+
114140 -- ── Vault index ──────────────────────────────────────────────────────
115141 CREATE TABLE IF NOT EXISTS vault_index (
116142 filepath TEXT PRIMARY KEY,
@@ -410,6 +436,47 @@ impl BrainDb {
410436 let results: Vec < ( String , String ) > = rows. collect :: < Result < _ , _ > > ( ) ?;
411437 Ok ( results)
412438 }
439+
440+ /// BM25-ranked keyword search over `chat_fts`.
441+ ///
442+ /// Returns at most `limit` triples of `(chat_id, role, snippet)`.
443+ pub fn chat_fts_search (
444+ & self ,
445+ fts_query : & str ,
446+ limit : usize ,
447+ ) -> Result < Vec < ( String , String , String ) > , DbError > {
448+ if fts_query. trim ( ) . is_empty ( ) {
449+ return Ok ( Vec :: new ( ) ) ;
450+ }
451+
452+ let conn = self
453+ . conn
454+ . lock ( )
455+ . map_err ( |e| DbError ( format ! ( "lock: {e}" ) ) ) ?;
456+
457+ #[ allow( clippy:: cast_possible_wrap) ]
458+ let limit_i64 = limit as i64 ;
459+
460+ let mut stmt = conn. prepare (
461+ "SELECT h.chat_id, h.role,
462+ snippet(chat_fts, 0, '**', '**', '...', 10) AS snip
463+ FROM chat_fts
464+ JOIN chat_history h ON h.id = chat_fts.rowid
465+ WHERE chat_fts MATCH ?1
466+ ORDER BY bm25(chat_fts)
467+ LIMIT ?2" ,
468+ ) ?;
469+
470+ let rows = stmt. query_map ( params ! [ fts_query, limit_i64] , |row| {
471+ Ok ( (
472+ row. get :: < _ , String > ( 0 ) ?,
473+ row. get :: < _ , String > ( 1 ) ?,
474+ row. get :: < _ , String > ( 2 ) ?,
475+ ) )
476+ } ) ?;
477+
478+ rows. collect :: < Result < _ , _ > > ( ) . map_err ( DbError :: from)
479+ }
413480}
414481
415482// ---------------------------------------------------------------------------
@@ -854,6 +921,71 @@ mod tests {
854921 assert_eq ! ( summary, "日本語サマリー" ) ;
855922 }
856923
924+ // ── chat_fts: search ─────────────────────────────────────────────────────
925+
926+ #[ test]
927+ fn chat_fts_search_finds_saved_message ( ) {
928+ let ( _tmp, db) = temp_db ( ) ;
929+ db. save_session (
930+ "chat1" ,
931+ & [ StoredMessage {
932+ role : "user" . into ( ) ,
933+ content : "I want to do squats tomorrow" . into ( ) ,
934+ tool_call_id : None ,
935+ tool_calls : None ,
936+ } ] ,
937+ "" ,
938+ )
939+ . unwrap ( ) ;
940+
941+ let rows = db. chat_fts_search ( "squats" , 5 ) . unwrap ( ) ;
942+ assert_eq ! ( rows. len( ) , 1 ) ;
943+ assert_eq ! ( rows[ 0 ] . 0 , "chat1" ) ;
944+ assert_eq ! ( rows[ 0 ] . 1 , "user" ) ;
945+ assert ! ( rows[ 0 ] . 2 . contains( "squats" ) || rows[ 0 ] . 2 . contains( "**" ) ) ;
946+ }
947+
948+ #[ test]
949+ fn chat_fts_search_empty_query_returns_empty ( ) {
950+ let ( _tmp, db) = temp_db ( ) ;
951+ let rows = db. chat_fts_search ( " " , 5 ) . unwrap ( ) ;
952+ assert ! ( rows. is_empty( ) ) ;
953+ }
954+
955+ #[ test]
956+ fn chat_fts_search_no_match_returns_empty ( ) {
957+ let ( _tmp, db) = temp_db ( ) ;
958+ db. save_session (
959+ "c" ,
960+ & [ StoredMessage {
961+ role : "user" . into ( ) ,
962+ content : "hello world" . into ( ) ,
963+ tool_call_id : None ,
964+ tool_calls : None ,
965+ } ] ,
966+ "" ,
967+ )
968+ . unwrap ( ) ;
969+ let rows = db. chat_fts_search ( "squats" , 5 ) . unwrap ( ) ;
970+ assert ! ( rows. is_empty( ) ) ;
971+ }
972+
973+ #[ test]
974+ fn chat_fts_search_respects_limit ( ) {
975+ let ( _tmp, db) = temp_db ( ) ;
976+ let messages: Vec < StoredMessage > = ( 0 ..10 )
977+ . map ( |i| StoredMessage {
978+ role : "user" . into ( ) ,
979+ content : format ! ( "workout session {i} squats reps" ) ,
980+ tool_call_id : None ,
981+ tool_calls : None ,
982+ } )
983+ . collect ( ) ;
984+ db. save_session ( "bulk" , & messages, "" ) . unwrap ( ) ;
985+ let rows = db. chat_fts_search ( "squats" , 3 ) . unwrap ( ) ;
986+ assert ! ( rows. len( ) <= 3 ) ;
987+ }
988+
857989 #[ test]
858990 fn message_ordering_preserved ( ) {
859991 let ( _tmp, db) = temp_db ( ) ;
0 commit comments