@@ -145,7 +145,17 @@ enum SearchTool {
145145 ] ) ,
146146 " since " : . object( [
147147 " type " : " string " ,
148- " description " : " Time bound (ISO, relative like \" 24h \" , or natural like \" yesterday \" ) "
148+ " description " : " Time bound (ISO, relative like \" 24h \" / \" 7d \" , or natural like \" yesterday \" / \" last tuesday \" / \" 2 weeks ago \" ) "
149+ ] ) ,
150+ " match_all " : . object( [
151+ " type " : " boolean " ,
152+ " description " : " If true, require ALL words to match. If false (default), match ANY word. " ,
153+ " default " : . bool( false )
154+ ] ) ,
155+ " fuzzy " : . object( [
156+ " type " : " boolean " ,
157+ " description " : " Enable typo-tolerant matching (allows 1-2 character differences). Useful for finding messages with typos. " ,
158+ " default " : . bool( false )
149159 ] ) ,
150160 " before " : . object( [
151161 " type " : " string " ,
@@ -192,9 +202,21 @@ enum SearchTool {
192202 description: """
193203 Full-text search across messages with advanced filtering.
194204
205+ Search features:
206+ - Multi-word: " costa rica trip " matches ANY word by default
207+ - match_all: true requires ALL words to be present
208+ - fuzzy: true handles typos (costarcia → costa rica)
209+
210+ Time filters (since/before):
211+ - Relative: " 24h " , " 7d " , " 2w " , " 3m "
212+ - Natural: " yesterday " , " last tuesday " , " 2 weeks ago " , " this month "
213+ - ISO: " 2024-01-15T10:30:00Z "
214+
195215 Examples:
196- - search(query: " dinner plans " ) - find messages containing " dinner plans "
197- - search(from_person: " me " , since: " 7d " ) - my messages from last week
216+ - search(query: " costa rica trip " ) - find any of these words
217+ - search(query: " costa rica " , match_all: true) - must have both words
218+ - search(query: " volcno " , fuzzy: true) - finds " volcano " despite typo
219+ - search(from_person: " me " , since: " last monday " ) - my messages since Monday
198220 - search(has: " link " , in_chat: " chat123 " ) - links in a specific chat
199221 - search(unanswered: true) - questions I sent without replies
200222 """ ,
@@ -220,6 +242,8 @@ enum SearchTool {
220242 let includeContext = arguments ? [ " include_context " ] ? . boolValue ?? false
221243 let unanswered = arguments ? [ " unanswered " ] ? . boolValue ?? false
222244 let unansweredHours = arguments ? [ " unanswered_hours " ] ? . intValue ?? 24
245+ let matchAll = arguments ? [ " match_all " ] ? . boolValue ?? false
246+ let fuzzy = arguments ? [ " fuzzy " ] ? . boolValue ?? false
223247
224248 let result = await execute (
225249 query: query,
@@ -235,6 +259,8 @@ enum SearchTool {
235259 includeContext: includeContext,
236260 unanswered: unanswered,
237261 unansweredHours: unansweredHours,
262+ matchAll: matchAll,
263+ fuzzy: fuzzy,
238264 db: db,
239265 resolver: resolver
240266 )
@@ -266,6 +292,8 @@ enum SearchTool {
266292 includeContext: Bool = false ,
267293 unanswered: Bool = false ,
268294 unansweredHours: Int = 24 ,
295+ matchAll: Bool = false ,
296+ fuzzy: Bool = false ,
269297 db: Database = Database ( ) ,
270298 resolver: ContactResolver
271299 ) async -> Result < String , SearchError > {
@@ -326,11 +354,35 @@ enum SearchTool {
326354 }
327355
328356 // Filter by search query in Swift (since we can't search attributedBody in SQL)
329- if hasQuery, let searchTerm = query? . trimmingCharacters ( in: . whitespaces) . lowercased ( ) {
330- rows = rows. filter { row in
331- let extractedText = getMessageText ( text: row. text, attributedBody: row. attributedBody)
332- guard let text = extractedText? . lowercased ( ) else { return false }
333- return text. contains ( searchTerm)
357+ // Supports multi-word search: OR (any word) by default, AND (all words) with matchAll=true
358+ // With fuzzy=true, also matches words within 1-2 edits (handles typos)
359+ if hasQuery, let searchQuery = query? . trimmingCharacters ( in: . whitespaces) . lowercased ( ) , !searchQuery. isEmpty {
360+ // Split query into words (minimum 2 chars each to avoid noise)
361+ let searchWords = searchQuery. split ( separator: " " )
362+ . map { String ( $0) . lowercased ( ) }
363+ . filter { $0. count >= 2 }
364+
365+ if !searchWords. isEmpty {
366+ rows = rows. filter { row in
367+ let extractedText = getMessageText ( text: row. text, attributedBody: row. attributedBody)
368+ guard let text = extractedText? . lowercased ( ) else { return false }
369+
370+ // Split message text into words for fuzzy matching
371+ let textWords = text. split ( whereSeparator: { !$0. isLetter && !$0. isNumber } )
372+ . map { String ( $0) . lowercased ( ) }
373+
374+ if matchAll {
375+ // AND logic: all search words must be present
376+ return searchWords. allSatisfy { searchWord in
377+ wordMatches ( searchWord: searchWord, in: text, textWords: textWords, fuzzy: fuzzy)
378+ }
379+ } else {
380+ // OR logic: any word matches
381+ return searchWords. contains { searchWord in
382+ wordMatches ( searchWord: searchWord, in: text, textWords: textWords, fuzzy: fuzzy)
383+ }
384+ }
385+ }
334386 }
335387 }
336388
@@ -969,6 +1021,75 @@ enum SearchTool {
9691021 return " \( baseName) \( suffix) "
9701022 }
9711023
1024+ /// Check if a search word matches anywhere in the text
1025+ /// - Parameters:
1026+ /// - searchWord: The word to search for
1027+ /// - text: The full message text (for exact contains match)
1028+ /// - textWords: Individual words from the message (for fuzzy matching)
1029+ /// - fuzzy: Whether to use fuzzy/typo-tolerant matching
1030+ /// - Returns: True if the word matches
1031+ private static func wordMatches( searchWord: String , in text: String , textWords: [ String ] , fuzzy: Bool ) -> Bool {
1032+ // Always try exact substring match first (fast)
1033+ if text. contains ( searchWord) {
1034+ return true
1035+ }
1036+
1037+ // If fuzzy matching enabled, check for close matches
1038+ if fuzzy {
1039+ // Calculate max allowed distance based on word length
1040+ // Short words (3-4 chars): 1 edit, longer words: 2 edits
1041+ let maxDistance = searchWord. count <= 4 ? 1 : 2
1042+
1043+ for textWord in textWords {
1044+ // Skip words that are way too different in length
1045+ if abs ( textWord. count - searchWord. count) > maxDistance {
1046+ continue
1047+ }
1048+ // Check Levenshtein distance
1049+ if levenshteinDistance ( searchWord, textWord) <= maxDistance {
1050+ return true
1051+ }
1052+ }
1053+ }
1054+
1055+ return false
1056+ }
1057+
1058+ /// Calculate Levenshtein edit distance between two strings
1059+ /// Returns the minimum number of single-character edits (insertions, deletions, substitutions)
1060+ /// needed to transform one string into another
1061+ private static func levenshteinDistance( _ s1: String , _ s2: String ) -> Int {
1062+ let m = s1. count
1063+ let n = s2. count
1064+
1065+ // Quick checks for empty strings
1066+ if m == 0 { return n }
1067+ if n == 0 { return m }
1068+
1069+ // Convert to arrays for indexing
1070+ let chars1 = Array ( s1)
1071+ let chars2 = Array ( s2)
1072+
1073+ // Use two rows instead of full matrix for memory efficiency
1074+ var prevRow = Array ( 0 ... n)
1075+ var currRow = Array ( repeating: 0 , count: n + 1 )
1076+
1077+ for i in 1 ... m {
1078+ currRow [ 0 ] = i
1079+ for j in 1 ... n {
1080+ let cost = chars1 [ i - 1 ] == chars2 [ j - 1 ] ? 0 : 1
1081+ currRow [ j] = min (
1082+ prevRow [ j] + 1 , // deletion
1083+ currRow [ j - 1 ] + 1 , // insertion
1084+ prevRow [ j - 1 ] + cost // substitution
1085+ )
1086+ }
1087+ swap ( & prevRow, & currRow)
1088+ }
1089+
1090+ return prevRow [ n]
1091+ }
1092+
9721093 private static func getMessageText( text: String ? , attributedBody: Data ? ) -> String ? {
9731094 // First try plain text
9741095 if let text = text, !text. isEmpty {
0 commit comments