diff --git a/src/openhuman/memory_store/unified/events.rs b/src/openhuman/memory_store/unified/events.rs index 6bf993e45..c03bf0762 100644 --- a/src/openhuman/memory_store/unified/events.rs +++ b/src/openhuman/memory_store/unified/events.rs @@ -224,6 +224,17 @@ pub fn event_search_fts( limit: usize, ) -> anyhow::Result> { let conn = conn.lock(); + let trimmed = query.trim(); + if trimmed.is_empty() { + tracing::debug!("[events] FTS search skipped — empty query"); + return Ok(Vec::new()); + } + let phrase_query = super::fts5::sanitize_fts_query(trimmed); + if phrase_query.is_empty() { + tracing::debug!("[events] FTS search skipped — sanitised query is empty"); + return Ok(Vec::new()); + } + let mut stmt = conn.prepare( "SELECT el.event_id, el.segment_id, el.session_id, el.namespace, el.event_type, el.content, el.subject, el.timestamp_ref, @@ -235,13 +246,12 @@ pub fn event_search_fts( LIMIT ?3", )?; let rows = stmt - .query_map(params![query, namespace, limit as i64], |row| { + .query_map(params![phrase_query, namespace, limit as i64], |row| { row_to_event(row) })? .collect::, _>>()?; tracing::debug!( - "[events] FTS search '{}' (ns={}) returned {} results", - query, + "[events] FTS search ns={} returned {} results", namespace, rows.len() ); diff --git a/src/openhuman/memory_store/unified/events_tests.rs b/src/openhuman/memory_store/unified/events_tests.rs index 72156871d..cc96354c7 100644 --- a/src/openhuman/memory_store/unified/events_tests.rs +++ b/src/openhuman/memory_store/unified/events_tests.rs @@ -211,6 +211,32 @@ fn event_fts_matches_subject_field() { assert_eq!(by_subject[0].event_id, "evt-subj"); } +#[test] +fn event_fts_sanitises_punctuation_safely() { + let conn = setup_db(); + let event = EventRecord { + event_id: "evt-punct".into(), + segment_id: "seg-1".into(), + session_id: "s1".into(), + namespace: "global".into(), + event_type: EventType::Decision, + content: "We decided to use Rust for backend deployment".into(), + subject: Some("backend deployment".into()), + timestamp_ref: None, + confidence: 0.85, + embedding: None, + source_turn_ids: None, + created_at: 1000.0, + }; + event_insert(&conn, &event).unwrap(); + + let results = event_search_fts(&conn, "global", "\"Rust\",(backend)?", 5) + .expect("punctuated user query should not trip FTS5 syntax errors"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].event_id, "evt-punct"); +} + #[test] fn event_embeddings_are_scoped_by_model_signature() { let conn = setup_db(); diff --git a/src/openhuman/memory_store/unified/fts5.rs b/src/openhuman/memory_store/unified/fts5.rs index 09242fdb5..11952aa37 100644 --- a/src/openhuman/memory_store/unified/fts5.rs +++ b/src/openhuman/memory_store/unified/fts5.rs @@ -154,6 +154,17 @@ pub fn episodic_search( limit: usize, ) -> anyhow::Result> { let conn = conn.lock(); + let trimmed = query.trim(); + if trimmed.is_empty() { + tracing::debug!("[fts5] search skipped — empty query"); + return Ok(Vec::new()); + } + let phrase_query = sanitize_fts_query(trimmed); + if phrase_query.is_empty() { + tracing::debug!("[fts5] search skipped — sanitised query is empty"); + return Ok(Vec::new()); + } + let mut stmt = conn.prepare( "SELECT el.id, el.session_id, el.timestamp, el.role, el.content, el.lesson, el.tool_calls_json, el.cost_microdollars @@ -165,7 +176,7 @@ pub fn episodic_search( )?; let rows = stmt - .query_map(rusqlite::params![query, limit as i64], |row| { + .query_map(rusqlite::params![phrase_query, limit as i64], |row| { Ok(EpisodicEntry { id: row.get(0)?, session_id: row.get(1)?, @@ -179,7 +190,7 @@ pub fn episodic_search( })? .collect::, _>>()?; - tracing::debug!("[fts5] search '{}' returned {} results", query, rows.len()); + tracing::debug!("[fts5] search returned {} results", rows.len()); Ok(rows) } @@ -281,17 +292,19 @@ pub fn episodic_cross_session_search( Ok(rows) } -/// Best-effort FTS5 query sanitiser: strip characters that break the -/// MATCH grammar (quotes, parens, asterisks) and wrap each surviving -/// whitespace-delimited token in double quotes so the FTS engine treats -/// it as a literal phrase. Returns an empty string when nothing usable -/// survives — the caller short-circuits to "no hits". -fn sanitize_fts_query(query: &str) -> String { +/// Best-effort FTS5 query sanitiser: split user text on punctuation and +/// symbols that break the MATCH grammar, then quote each surviving token +/// so FTS5 treats it as literal text. Returns an empty string when +/// nothing usable survives — callers short-circuit to "no hits". +pub(super) fn sanitize_fts_query(query: &str) -> String { let cleaned: String = query .chars() - .map(|c| match c { - '"' | '(' | ')' | '*' | ':' => ' ', - other => other, + .map(|c| { + if c.is_alphanumeric() || c == '_' { + c + } else { + ' ' + } }) .collect(); let tokens: Vec = cleaned @@ -539,6 +552,21 @@ mod tests { assert!(hits[0].content.contains("Postgres")); } + #[test] + fn episodic_search_sanitises_punctuation_safely() { + let conn = setup_db(); + insert_turn(&conn, "session-a", 1000.0, "Postgres deployment notes"); + + let hits = episodic_search(&conn, "\"Postgres\",(deployment)?", 10) + .expect("punctuated user query should not trip FTS5 syntax errors"); + + assert!( + !hits.is_empty(), + "punctuated query whose surviving tokens match the indexed row must still surface it" + ); + assert!(hits[0].content.contains("Postgres")); + } + #[test] fn cross_session_search_does_not_panic_on_pure_punctuation() { let conn = setup_db();