5252from services .workspace_resolver import infer_invalid_workspace_aliases
5353from utils .cli_chat_reader import list_cli_projects , messages_to_bubbles , traverse_blobs
5454from utils .exclusion_rules import build_searchable_text , is_excluded_by_rules
55+ from utils .text_extract import extract_text_from_bubble
5556from utils .path_helpers import (
5657 get_workspace_display_name ,
5758 to_epoch_ms ,
@@ -79,6 +80,8 @@ def resolve_search_since_ms(
7980 if all_history :
8081 return None
8182 days = since_days if since_days is not None else DEFAULT_SEARCH_WINDOW_DAYS
83+ if days > 36_500 :
84+ days = 36_500
8285 if days <= 0 :
8386 return None
8487 ref = now or datetime .now (timezone .utc )
@@ -152,18 +155,70 @@ def _quick_bubble_text(raw_value: object) -> str:
152155 """Extract searchable text from a bubble KV value without full model validation."""
153156 try :
154157 if isinstance (raw_value , (bytes , bytearray )):
155- raw_value = raw_value .decode ("utf-8" , errors = "replace" )
156- obj = json .loads (raw_value )
158+ text_value = raw_value .decode ("utf-8" , errors = "replace" )
159+ elif isinstance (raw_value , str ):
160+ text_value = raw_value
161+ else :
162+ return ""
163+ obj = json .loads (text_value )
157164 if isinstance (obj , dict ):
158- for key in ("text" , "rawText" , "content" ):
159- val = obj .get (key )
160- if isinstance (val , str ) and val :
161- return val
165+ text = extract_text_from_bubble (obj )
166+ if text :
167+ return text
162168 except (json .JSONDecodeError , TypeError , ValueError ):
163169 pass
164170 return ""
165171
166172
173+ def _model_config_from_composer_dict (cd : dict [str , Any ]) -> dict [str , Any ]:
174+ raw = cd .get ("modelConfig" )
175+ return raw if isinstance (raw , dict ) else {}
176+
177+
178+ def _all_bubble_texts_for_composer (
179+ conn : sqlite3 .Connection ,
180+ composer_id : str ,
181+ ) -> list [str ]:
182+ """Load all bubble texts for one composer (exclusion-rule checks)."""
183+ texts : list [str ] = []
184+ try :
185+ rows = conn .execute (
186+ "SELECT value FROM cursorDiskKV"
187+ " WHERE key LIKE ? AND value IS NOT NULL" ,
188+ (f"bubbleId:{ composer_id } :%" ,),
189+ ).fetchall ()
190+ except sqlite3 .Error :
191+ return texts
192+ for row in rows :
193+ text = _quick_bubble_text (row ["value" ])
194+ if text :
195+ texts .append (text )
196+ return texts
197+
198+
199+ def _composer_exclusion_text (
200+ * ,
201+ project_name : str ,
202+ title : str ,
203+ model_names : list [str ] | None ,
204+ model_config : dict [str , Any ],
205+ cd : dict [str , Any ],
206+ all_bubble_texts : list [str ],
207+ ) -> str :
208+ return _build_exclusion_searchable (
209+ project_name = project_name ,
210+ chat_title = title ,
211+ model_names = model_names ,
212+ content_parts = all_bubble_texts or None ,
213+ metadata_parts = [
214+ _json_dump_safe (model_config ),
215+ _json_dump_safe (cd .get ("conversationSummary" )),
216+ _json_dump_safe (cd .get ("usage" )),
217+ _json_dump_safe (cd .get ("requestMetadata" )),
218+ ],
219+ )
220+
221+
167222def _index_bubble_texts_matching_query (
168223 conn : sqlite3 .Connection ,
169224 query_lower : str ,
@@ -219,28 +274,6 @@ def _composer_row_raw_text(row: sqlite3.Row) -> str:
219274 return str (raw )
220275
221276
222- def _light_composer_exclusion_text (
223- * ,
224- project_name : str ,
225- title : str ,
226- model_names : list [str ] | None ,
227- model_config : dict [str , Any ],
228- cd : dict [str , Any ],
229- ) -> str :
230- """Exclusion text from title/metadata only (no bubble scan)."""
231- return _build_exclusion_searchable (
232- project_name = project_name ,
233- chat_title = title ,
234- model_names = model_names ,
235- metadata_parts = [
236- _json_dump_safe (model_config ),
237- _json_dump_safe (cd .get ("conversationSummary" )),
238- _json_dump_safe (cd .get ("usage" )),
239- _json_dump_safe (cd .get ("requestMetadata" )),
240- ],
241- )
242-
243-
244277def _extract_snippet (text : str , query : str , query_lower : str ) -> str :
245278 """Return a context window around the first match of *query* in *text*.
246279
@@ -444,6 +477,7 @@ def _search_global_storage_via_index(
444477) -> list [SearchResult ] | None :
445478 """Search using local FTS index. Returns ``None`` to fall back to live scan."""
446479 from services .search_index import (
480+ query_all_composer_bubble_texts ,
447481 query_composer_bubble_hits ,
448482 query_composer_rows_in_window ,
449483 query_composer_title_hits ,
@@ -486,13 +520,16 @@ def _search_global_storage_via_index(
486520 candidate_ids .add (composer_id )
487521
488522 for composer_id in candidate_ids :
489- row = composers_in_window .get (composer_id ) if since_ms is not None else search_pool .get (composer_id )
490- if row is None :
491- row = query_composer_rows_in_window (None ).get (composer_id )
492- if row is None :
523+ composer_row : sqlite3 .Row | None = (
524+ composers_in_window .get (composer_id ) if since_ms is not None
525+ else search_pool .get (composer_id )
526+ )
527+ if composer_row is None :
528+ composer_row = query_composer_rows_in_window (None ).get (composer_id )
529+ if composer_row is None :
493530 continue
494531
495- raw_text = row ["raw_json" ] or ""
532+ raw_text = composer_row ["raw_json" ] or ""
496533 try :
497534 cd = json .loads (raw_text )
498535 except (json .JSONDecodeError , TypeError , ValueError ) as exc :
@@ -512,7 +549,7 @@ def _search_global_storage_via_index(
512549 if not headers :
513550 continue
514551
515- title = row ["title" ] or cd .get ("name" ) or ""
552+ title = composer_row ["title" ] or cd .get ("name" ) or ""
516553 if not isinstance (title , str ):
517554 title = str (title ) if title else ""
518555
@@ -525,7 +562,7 @@ def _search_global_storage_via_index(
525562 ws_name = ws_id_to_name .get (ws_id )
526563 project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id )
527564
528- model_config = cd . get ( "modelConfig" ) if isinstance ( cd . get ( "modelConfig" ), dict ) else {}
565+ model_config = _model_config_from_composer_dict ( cd )
529566 model_name = model_config .get ("modelName" )
530567 model_names = (
531568 [str (model_name )] if model_name and model_name != "default" else None
@@ -536,15 +573,6 @@ def _search_global_storage_via_index(
536573 bubble_texts : list [str ] = []
537574
538575 if title_match :
539- exclusion_text = _light_composer_exclusion_text (
540- project_name = project_name ,
541- title = title ,
542- model_names = model_names ,
543- model_config = model_config ,
544- cd = cd ,
545- )
546- if is_excluded_by_rules (rules , exclusion_text ):
547- continue
548576 has_match , matching_text = True , title
549577 else :
550578 has_match , matching_text = _find_match (title , [], query_lower , query )
@@ -560,20 +588,20 @@ def _search_global_storage_via_index(
560588 if not has_match :
561589 continue
562590
563- exclusion_text = _build_exclusion_searchable (
564- project_name = project_name ,
565- chat_title = title ,
566- model_names = model_names ,
567- content_parts = bubble_texts or None ,
568- metadata_parts = [
569- _json_dump_safe ( model_config ) ,
570- _json_dump_safe ( cd . get ( "conversationSummary" )) ,
571- _json_dump_safe ( cd . get ( "usage" )),
572- _json_dump_safe ( cd . get ( "requestMetadata" )),
573- ],
574- )
575- if is_excluded_by_rules ( rules , exclusion_text ) :
576- continue
591+ all_bubble_texts = query_all_composer_bubble_texts ( composer_id )
592+ exclusion_text = _composer_exclusion_text (
593+ project_name = project_name ,
594+ title = title ,
595+ model_names = model_names ,
596+ model_config = model_config ,
597+ cd = cd ,
598+ all_bubble_texts = all_bubble_texts ,
599+ )
600+ if is_excluded_by_rules ( rules , exclusion_text ):
601+ continue
602+
603+ if not title_match :
604+ bubble_texts = bubble_texts or bubble_texts_by_composer . get ( composer_id , [])
577605
578606 if not title :
579607 for text in bubble_texts :
@@ -709,7 +737,7 @@ def _search_global_storage_live_scan(
709737 ws_name = ws_id_to_name .get (ws_id )
710738 project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id )
711739
712- model_config = cd . get ( "modelConfig" ) if isinstance ( cd . get ( "modelConfig" ), dict ) else {}
740+ model_config = _model_config_from_composer_dict ( cd )
713741 model_name = model_config .get ("modelName" )
714742 model_names = (
715743 [str (model_name )] if model_name and model_name != "default" else None
@@ -719,15 +747,6 @@ def _search_global_storage_live_scan(
719747 bubble_texts : list [str ] = []
720748
721749 if title_match :
722- exclusion_text = _light_composer_exclusion_text (
723- project_name = project_name ,
724- title = title ,
725- model_names = model_names ,
726- model_config = model_config ,
727- cd = cd ,
728- )
729- if is_excluded_by_rules (rules , exclusion_text ):
730- continue
731750 has_match , matching_text = True , title
732751 else :
733752 has_match , matching_text = _find_match (
@@ -745,20 +764,20 @@ def _search_global_storage_live_scan(
745764 if not has_match :
746765 continue
747766
748- exclusion_text = _build_exclusion_searchable (
749- project_name = project_name ,
750- chat_title = title ,
751- model_names = model_names ,
752- content_parts = bubble_texts or None ,
753- metadata_parts = [
754- _json_dump_safe ( model_config ) ,
755- _json_dump_safe ( cd . get ( "conversationSummary" )) ,
756- _json_dump_safe ( cd . get ( "usage" )),
757- _json_dump_safe ( cd . get ( "requestMetadata" )),
758- ],
759- )
760- if is_excluded_by_rules ( rules , exclusion_text ) :
761- continue
767+ all_bubble_texts = _all_bubble_texts_for_composer ( conn , composer_id )
768+ exclusion_text = _composer_exclusion_text (
769+ project_name = project_name ,
770+ title = title ,
771+ model_names = model_names ,
772+ model_config = model_config ,
773+ cd = cd ,
774+ all_bubble_texts = all_bubble_texts ,
775+ )
776+ if is_excluded_by_rules ( rules , exclusion_text ):
777+ continue
778+
779+ if not title_match :
780+ bubble_texts = bubble_texts or bubble_texts_by_composer . get ( composer_id , [])
762781
763782 if not title :
764783 for text in bubble_texts :
@@ -965,7 +984,12 @@ def search_cli_sessions(
965984 meta = session .get ("meta" , {})
966985 session_id = session ["session_id" ]
967986 created_ms : int = to_epoch_ms (meta .get ("createdAt" ))
968- if not _timestamp_in_search_window (created_ms , since_ms ):
987+ try :
988+ session_ts = int (os .path .getmtime (session ["db_path" ]) * 1000 )
989+ except OSError :
990+ session_ts = 0
991+ effective_ms = max (created_ms or 0 , session_ts )
992+ if not _timestamp_in_search_window (effective_ms , since_ms ):
969993 continue
970994 session_name : str = meta .get ("name" ) or f"Session { session_id [:8 ]} "
971995 title_match = bool (session_name and query_lower in session_name .lower ())
@@ -982,7 +1006,7 @@ def search_cli_sessions(
9821006 "workspaceFolder" : ws_name ,
9831007 "chatId" : session_id ,
9841008 "chatTitle" : session_name ,
985- "timestamp" : created_ms ,
1009+ "timestamp" : effective_ms ,
9861010 "matchingText" : session_name ,
9871011 "type" : "cli_agent" ,
9881012 "source" : "cli" ,
@@ -1007,7 +1031,7 @@ def search_cli_sessions(
10071031 session ["db_path" ],
10081032 )
10091033
1010- bubbles = messages_to_bubbles (messages , created_ms )
1034+ bubbles = messages_to_bubbles (messages , effective_ms )
10111035 if not bubbles :
10121036 continue
10131037
@@ -1047,7 +1071,7 @@ def search_cli_sessions(
10471071 "workspaceFolder" : ws_name ,
10481072 "chatId" : session_id ,
10491073 "chatTitle" : title ,
1050- "timestamp" : created_ms ,
1074+ "timestamp" : effective_ms ,
10511075 "matchingText" : matching_text ,
10521076 "type" : "cli_agent" ,
10531077 "source" : "cli" ,
0 commit comments