claude-self-reflect 3.2.3 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude/agents/claude-self-reflect-test.md +595 -528
  2. package/.claude/agents/documentation-writer.md +1 -1
  3. package/.claude/agents/qdrant-specialist.md +2 -2
  4. package/.claude/agents/reflection-specialist.md +61 -5
  5. package/.claude/agents/search-optimizer.md +9 -7
  6. package/README.md +16 -9
  7. package/mcp-server/pyproject.toml +1 -1
  8. package/mcp-server/run-mcp.sh +49 -5
  9. package/mcp-server/src/app_context.py +64 -0
  10. package/mcp-server/src/config.py +57 -0
  11. package/mcp-server/src/connection_pool.py +286 -0
  12. package/mcp-server/src/decay_manager.py +106 -0
  13. package/mcp-server/src/embedding_manager.py +64 -40
  14. package/mcp-server/src/embeddings_old.py +141 -0
  15. package/mcp-server/src/models.py +64 -0
  16. package/mcp-server/src/parallel_search.py +371 -0
  17. package/mcp-server/src/project_resolver.py +33 -46
  18. package/mcp-server/src/reflection_tools.py +206 -0
  19. package/mcp-server/src/rich_formatting.py +196 -0
  20. package/mcp-server/src/search_tools.py +826 -0
  21. package/mcp-server/src/server.py +140 -1715
  22. package/mcp-server/src/temporal_design.py +132 -0
  23. package/mcp-server/src/temporal_tools.py +597 -0
  24. package/mcp-server/src/temporal_utils.py +384 -0
  25. package/mcp-server/src/utils.py +150 -67
  26. package/package.json +11 -1
  27. package/scripts/add-timestamp-indexes.py +134 -0
  28. package/scripts/check-collections.py +29 -0
  29. package/scripts/debug-august-parsing.py +76 -0
  30. package/scripts/debug-import-single.py +91 -0
  31. package/scripts/debug-project-resolver.py +82 -0
  32. package/scripts/debug-temporal-tools.py +135 -0
  33. package/scripts/delta-metadata-update.py +547 -0
  34. package/scripts/import-conversations-unified.py +65 -6
  35. package/scripts/importer/utils/project_normalizer.py +22 -9
  36. package/scripts/precompact-hook.sh +33 -0
  37. package/scripts/streaming-watcher.py +1443 -0
  38. package/scripts/utils.py +39 -0
  39. package/shared/__init__.py +5 -0
  40. package/shared/normalization.py +54 -0
@@ -0,0 +1,141 @@
1
+ """Embedding generation module for Claude Self-Reflect MCP server."""
2
+
3
+ import os
4
+ import voyageai
5
+ from typing import Dict, List, Optional, Any
6
+ from fastembed import TextEmbedding
7
+ from config import (
8
+ VOYAGE_API_KEY,
9
+ VOYAGE_MODEL,
10
+ LOCAL_MODEL,
11
+ PREFER_LOCAL_EMBEDDINGS,
12
+ logger
13
+ )
14
+
15
+ class EmbeddingManager:
16
+ """Manages embedding generation for both local and Voyage AI models."""
17
+
18
+ def __init__(self):
19
+ self.local_model = None
20
+ self.voyage_client = None
21
+ self.embedding_cache = {}
22
+
23
+ # Initialize based on preference
24
+ if PREFER_LOCAL_EMBEDDINGS or not VOYAGE_API_KEY:
25
+ self._init_local_model()
26
+
27
+ if VOYAGE_API_KEY:
28
+ self._init_voyage_client()
29
+
30
+ def _init_local_model(self):
31
+ """Initialize local FastEmbed model."""
32
+ try:
33
+ self.local_model = TextEmbedding(
34
+ model_name=LOCAL_MODEL,
35
+ cache_dir=str(os.path.expanduser("~/.cache/fastembed"))
36
+ )
37
+ logger.info(f"Initialized local embedding model: {LOCAL_MODEL}")
38
+ except Exception as e:
39
+ logger.error(f"Failed to initialize local model: {e}")
40
+
41
+ def _init_voyage_client(self):
42
+ """Initialize Voyage AI client."""
43
+ try:
44
+ self.voyage_client = voyageai.Client(api_key=VOYAGE_API_KEY)
45
+ logger.info("Initialized Voyage AI client")
46
+ except Exception as e:
47
+ logger.error(f"Failed to initialize Voyage client: {e}")
48
+
49
+ async def generate_embedding(
50
+ self,
51
+ text: str,
52
+ embedding_type: Optional[str] = None
53
+ ) -> Optional[List[float]]:
54
+ """Generate embedding for text using specified or default model."""
55
+
56
+ # Use cache if available
57
+ cache_key = f"{embedding_type or 'default'}:{text[:100]}"
58
+ if cache_key in self.embedding_cache:
59
+ return self.embedding_cache[cache_key]
60
+
61
+ # Determine which model to use
62
+ use_local = True
63
+ if embedding_type:
64
+ use_local = 'local' in embedding_type
65
+ elif not PREFER_LOCAL_EMBEDDINGS and self.voyage_client:
66
+ use_local = False
67
+
68
+ try:
69
+ if use_local and self.local_model:
70
+ # Generate local embedding
71
+ embeddings = list(self.local_model.embed([text]))
72
+ if embeddings:
73
+ embedding = list(embeddings[0])
74
+ self.embedding_cache[cache_key] = embedding
75
+ return embedding
76
+
77
+ elif self.voyage_client:
78
+ # Generate Voyage embedding
79
+ result = self.voyage_client.embed(
80
+ [text],
81
+ model=VOYAGE_MODEL,
82
+ input_type="document"
83
+ )
84
+ if result.embeddings:
85
+ embedding = result.embeddings[0]
86
+ self.embedding_cache[cache_key] = embedding
87
+ return embedding
88
+
89
+ except Exception as e:
90
+ logger.error(f"Failed to generate embedding: {e}")
91
+
92
+ return None
93
+
94
+ async def generate_embeddings_batch(
95
+ self,
96
+ texts: List[str],
97
+ embedding_type: Optional[str] = None
98
+ ) -> Dict[str, List[float]]:
99
+ """Generate embeddings for multiple texts efficiently."""
100
+ results = {}
101
+
102
+ # Determine which model to use
103
+ use_local = True
104
+ if embedding_type:
105
+ use_local = 'local' in embedding_type
106
+ elif not PREFER_LOCAL_EMBEDDINGS and self.voyage_client:
107
+ use_local = False
108
+
109
+ try:
110
+ if use_local and self.local_model:
111
+ # Batch process with local model
112
+ embeddings = list(self.local_model.embed(texts))
113
+ for text, embedding in zip(texts, embeddings):
114
+ results[text] = list(embedding)
115
+
116
+ elif self.voyage_client:
117
+ # Batch process with Voyage
118
+ result = self.voyage_client.embed(
119
+ texts,
120
+ model=VOYAGE_MODEL,
121
+ input_type="document"
122
+ )
123
+ for text, embedding in zip(texts, result.embeddings):
124
+ results[text] = embedding
125
+
126
+ except Exception as e:
127
+ logger.error(f"Failed to generate batch embeddings: {e}")
128
+
129
+ return results
130
+
131
+ def get_embedding_dimension(self, embedding_type: str = "local") -> int:
132
+ """Get the dimension of embeddings for a given type."""
133
+ if "local" in embedding_type:
134
+ return 384 # all-MiniLM-L6-v2 dimension
135
+ else:
136
+ return 1024 # voyage-3-lite dimension
137
+
138
+ def clear_cache(self):
139
+ """Clear the embedding cache."""
140
+ self.embedding_cache.clear()
141
+ logger.info("Cleared embedding cache")
@@ -0,0 +1,64 @@
1
+ """Pydantic models for Claude Self-Reflect MCP server."""
2
+
3
+ from typing import Optional, List, Dict, Any, Set
4
+ from datetime import datetime
5
+ from pydantic import BaseModel, Field
6
+
7
+ class SearchResult(BaseModel):
8
+ """Model for search results."""
9
+ id: str
10
+ score: float
11
+ timestamp: str
12
+ role: str
13
+ excerpt: str
14
+ project_name: str
15
+ conversation_id: Optional[str] = None
16
+ base_conversation_id: Optional[str] = None
17
+ collection_name: str
18
+ raw_payload: Optional[Dict[str, Any]] = None
19
+ code_patterns: Optional[Dict[str, List[str]]] = None
20
+ files_analyzed: Optional[List[str]] = None
21
+ files_edited: Optional[List[str]] = None
22
+ tools_used: Optional[List[str]] = None
23
+ concepts: Optional[List[str]] = None
24
+
25
+ class ConversationGroup(BaseModel):
26
+ """Model for grouped conversations."""
27
+ conversation_id: str
28
+ base_conversation_id: str
29
+ timestamp: datetime
30
+ message_count: int
31
+ excerpts: List[str]
32
+ files: Set[str] = Field(default_factory=set)
33
+ tools: Set[str] = Field(default_factory=set)
34
+ concepts: Set[str] = Field(default_factory=set)
35
+
36
+ class WorkSession(BaseModel):
37
+ """Model for work sessions."""
38
+ start_time: datetime
39
+ end_time: datetime
40
+ conversations: List[ConversationGroup]
41
+ total_messages: int
42
+ files_touched: Set[str] = Field(default_factory=set)
43
+ tools_used: Set[str] = Field(default_factory=set)
44
+ concepts: Set[str] = Field(default_factory=set)
45
+
46
+ class ActivityStats(BaseModel):
47
+ """Model for activity statistics."""
48
+ total_conversations: int
49
+ total_messages: int
50
+ unique_files: int
51
+ unique_tools: int
52
+ peak_hour: Optional[str] = None
53
+ peak_day: Optional[str] = None
54
+
55
+ class TimelineEntry(BaseModel):
56
+ """Model for timeline entries."""
57
+ period: str
58
+ start_time: datetime
59
+ end_time: datetime
60
+ conversation_count: int
61
+ message_count: int
62
+ files: Set[str] = Field(default_factory=set)
63
+ tools: Set[str] = Field(default_factory=set)
64
+ concepts: Set[str] = Field(default_factory=set)
@@ -0,0 +1,371 @@
1
+ """
2
+ Parallel search implementation for Qdrant collections.
3
+ This module implements asyncio.gather-based parallel searching to improve performance.
4
+ """
5
+
6
+ import asyncio
7
+ import time
8
+ from typing import List, Dict, Any, Optional, Tuple
9
+ from datetime import datetime
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ async def search_single_collection(
15
+ collection_name: str,
16
+ query: str,
17
+ query_embeddings: Dict[str, List[float]],
18
+ qdrant_client: Any,
19
+ ctx: Any,
20
+ limit: int,
21
+ min_score: float,
22
+ should_use_decay: bool,
23
+ target_project: str,
24
+ generate_embedding_func: Any,
25
+ constants: Dict[str, Any]
26
+ ) -> Tuple[str, List[Any], Dict[str, Any]]:
27
+ """
28
+ Search a single collection and return results.
29
+
30
+ Returns:
31
+ Tuple of (collection_name, results, timing_info)
32
+ """
33
+ collection_timing = {'name': collection_name, 'start': time.time()}
34
+ results = []
35
+
36
+ try:
37
+ # Determine embedding type for this collection
38
+ embedding_type_for_collection = 'voyage' if collection_name.endswith('_voyage') else 'local'
39
+ logger.debug(f"Collection {collection_name} needs {embedding_type_for_collection} embedding")
40
+
41
+ # Generate or retrieve cached embedding for this type
42
+ if embedding_type_for_collection not in query_embeddings:
43
+ try:
44
+ query_embeddings[embedding_type_for_collection] = await generate_embedding_func(
45
+ query, force_type=embedding_type_for_collection
46
+ )
47
+ except Exception as e:
48
+ await ctx.debug(f"Failed to generate {embedding_type_for_collection} embedding: {e}")
49
+ collection_timing['error'] = str(e)
50
+ collection_timing['end'] = time.time()
51
+ return collection_name, results, collection_timing
52
+
53
+ query_embedding = query_embeddings[embedding_type_for_collection]
54
+
55
+ # Check if this is a reflections collection
56
+ is_reflection_collection = collection_name.startswith('reflections_')
57
+
58
+ # Import necessary models
59
+ from qdrant_client import models
60
+
61
+ # Determine which decay method to use
62
+ USE_NATIVE_DECAY = constants.get('USE_NATIVE_DECAY', False)
63
+ NATIVE_DECAY_AVAILABLE = constants.get('NATIVE_DECAY_AVAILABLE', False)
64
+ DECAY_SCALE_DAYS = constants.get('DECAY_SCALE_DAYS', 90)
65
+ DECAY_WEIGHT = constants.get('DECAY_WEIGHT', 0.3)
66
+
67
+ if should_use_decay and USE_NATIVE_DECAY and NATIVE_DECAY_AVAILABLE:
68
+ # Use native Qdrant decay with newer API
69
+ await ctx.debug(f"Using NATIVE Qdrant decay (new API) for {collection_name}")
70
+
71
+ half_life_seconds = DECAY_SCALE_DAYS * 24 * 60 * 60
72
+
73
+ # Build query using Qdrant's Fusion and RankFusion
74
+ fusion_query = models.Fusion(
75
+ fusion=models.RankFusion.RRF,
76
+ queries=[
77
+ # Semantic similarity query
78
+ models.NearestQuery(
79
+ nearest=query_embedding,
80
+ score_threshold=min_score
81
+ ),
82
+ # Time decay query using context pair
83
+ models.ContextQuery(
84
+ context=[
85
+ models.ContextPair(
86
+ positive=models.DiscoverQuery(
87
+ target=query_embedding,
88
+ context=[
89
+ models.ContextPair(
90
+ positive=models.DatetimeRange(
91
+ gt=datetime.now().isoformat(),
92
+ lt=(datetime.now().timestamp() + half_life_seconds)
93
+ )
94
+ )
95
+ ]
96
+ )
97
+ )
98
+ ]
99
+ )
100
+ ]
101
+ )
102
+
103
+ # Execute search with native decay
104
+ search_results = await qdrant_client.query_points(
105
+ collection_name=collection_name,
106
+ query=fusion_query,
107
+ limit=limit,
108
+ with_payload=True
109
+ )
110
+
111
+ # Process results
112
+ for point in search_results.points:
113
+ # Process each point and add to results
114
+ raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
115
+ clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
116
+
117
+ point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
118
+
119
+ # Apply project filtering
120
+ if target_project != 'all' and not is_reflection_collection:
121
+ if point_project != target_project:
122
+ normalized_target = target_project.replace('-', '_')
123
+ normalized_point = point_project.replace('-', '_')
124
+ if not (normalized_point == normalized_target or
125
+ point_project.endswith(f"/{target_project}") or
126
+ point_project.endswith(f"-{target_project}") or
127
+ normalized_point.endswith(f"_{normalized_target}") or
128
+ normalized_point.endswith(f"/{normalized_target}")):
129
+ continue
130
+
131
+ # Create SearchResult
132
+ search_result = {
133
+ 'id': str(point.id),
134
+ 'score': point.score,
135
+ 'timestamp': clean_timestamp,
136
+ 'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
137
+ 'excerpt': (point.payload.get('text', '')[:350] + '...'
138
+ if len(point.payload.get('text', '')) > 350
139
+ else point.payload.get('text', '')),
140
+ 'project_name': point_project,
141
+ 'payload': point.payload
142
+ }
143
+ results.append(search_result)
144
+
145
+ else:
146
+ # Standard search without native decay or client-side decay
147
+ search_results = await qdrant_client.search(
148
+ collection_name=collection_name,
149
+ query_vector=query_embedding,
150
+ limit=limit * 3 if should_use_decay else limit, # Get more results for client-side decay
151
+ score_threshold=min_score if not should_use_decay else 0.0,
152
+ with_payload=True
153
+ )
154
+
155
+ # Debug: Log search results
156
+ logger.debug(f"Search of {collection_name} returned {len(search_results)} results")
157
+
158
+ if should_use_decay and not USE_NATIVE_DECAY:
159
+ # Apply client-side decay
160
+ await ctx.debug(f"Using CLIENT-SIDE decay for {collection_name}")
161
+ decay_results = []
162
+
163
+ for point in search_results:
164
+ try:
165
+ raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
166
+ clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
167
+
168
+ # Calculate age and decay
169
+ if 'timestamp' in point.payload:
170
+ try:
171
+ point_time = datetime.fromisoformat(clean_timestamp)
172
+ if point_time.tzinfo is None:
173
+ from zoneinfo import ZoneInfo
174
+ point_time = point_time.replace(tzinfo=ZoneInfo('UTC'))
175
+
176
+ now = datetime.now(ZoneInfo('UTC'))
177
+ age_ms = (now - point_time).total_seconds() * 1000
178
+
179
+ # Exponential decay with configurable half-life
180
+ half_life_ms = DECAY_SCALE_DAYS * 24 * 60 * 60 * 1000
181
+ decay_factor = 0.5 ** (age_ms / half_life_ms)
182
+
183
+ # Apply multiplicative decay
184
+ adjusted_score = point.score * ((1 - DECAY_WEIGHT) + DECAY_WEIGHT * decay_factor)
185
+ except Exception as e:
186
+ await ctx.debug(f"Error calculating decay: {e}")
187
+ adjusted_score = point.score
188
+ else:
189
+ adjusted_score = point.score
190
+
191
+ # Only include if above min_score after decay
192
+ if adjusted_score >= min_score:
193
+ decay_results.append((adjusted_score, point))
194
+
195
+ except Exception as e:
196
+ await ctx.debug(f"Error applying decay to point: {e}")
197
+ decay_results.append((point.score, point))
198
+
199
+ # Sort by adjusted score and take top results
200
+ decay_results.sort(key=lambda x: x[0], reverse=True)
201
+
202
+ # Convert to SearchResult format
203
+ for adjusted_score, point in decay_results[:limit]:
204
+ raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
205
+ clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
206
+
207
+ point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
208
+
209
+ # Apply project filtering
210
+ if target_project != 'all' and not is_reflection_collection:
211
+ if point_project != target_project:
212
+ normalized_target = target_project.replace('-', '_')
213
+ normalized_point = point_project.replace('-', '_')
214
+ if not (normalized_point == normalized_target or
215
+ point_project.endswith(f"/{target_project}") or
216
+ point_project.endswith(f"-{target_project}") or
217
+ normalized_point.endswith(f"_{normalized_target}") or
218
+ normalized_point.endswith(f"/{normalized_target}")):
219
+ logger.debug(f"Filtering out point: project '{point_project}' != target '{target_project}'")
220
+ continue
221
+ logger.debug(f"Keeping point: project '{point_project}' matches target '{target_project}'")
222
+
223
+ # Create SearchResult
224
+ search_result = {
225
+ 'id': str(point.id),
226
+ 'score': adjusted_score,
227
+ 'timestamp': clean_timestamp,
228
+ 'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
229
+ 'excerpt': (point.payload.get('text', '')[:350] + '...'
230
+ if len(point.payload.get('text', '')) > 350
231
+ else point.payload.get('text', '')),
232
+ 'project_name': point_project,
233
+ 'payload': point.payload
234
+ }
235
+ results.append(search_result)
236
+ else:
237
+ # Process standard search results without decay
238
+ logger.debug(f"Processing {len(search_results)} results from {collection_name}")
239
+ for point in search_results:
240
+ raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
241
+ clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
242
+
243
+ point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
244
+
245
+ # Apply project filtering
246
+ if target_project != 'all' and not is_reflection_collection:
247
+ if point_project != target_project:
248
+ normalized_target = target_project.replace('-', '_')
249
+ normalized_point = point_project.replace('-', '_')
250
+ if not (normalized_point == normalized_target or
251
+ point_project.endswith(f"/{target_project}") or
252
+ point_project.endswith(f"-{target_project}") or
253
+ normalized_point.endswith(f"_{normalized_target}") or
254
+ normalized_point.endswith(f"/{normalized_target}")):
255
+ logger.debug(f"Filtering out point: project '{point_project}' != target '{target_project}'")
256
+ continue
257
+ logger.debug(f"Keeping point: project '{point_project}' matches target '{target_project}'")
258
+
259
+ # Create SearchResult as dictionary (consistent with other branches)
260
+ search_result = {
261
+ 'id': str(point.id),
262
+ 'score': point.score,
263
+ 'timestamp': clean_timestamp,
264
+ 'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
265
+ 'excerpt': (point.payload.get('text', '')[:350] + '...'
266
+ if len(point.payload.get('text', '')) > 350
267
+ else point.payload.get('text', '')),
268
+ 'project_name': point_project,
269
+ 'conversation_id': point.payload.get('conversation_id'),
270
+ 'base_conversation_id': point.payload.get('base_conversation_id'),
271
+ 'collection_name': collection_name,
272
+ 'raw_payload': point.payload,
273
+ 'code_patterns': point.payload.get('code_patterns'),
274
+ 'files_analyzed': point.payload.get('files_analyzed'),
275
+ 'tools_used': list(point.payload.get('tools_used', [])) if isinstance(point.payload.get('tools_used'), set) else point.payload.get('tools_used'),
276
+ 'concepts': point.payload.get('concepts')
277
+ }
278
+ results.append(search_result)
279
+
280
+ except Exception as e:
281
+ await ctx.debug(f"Error searching {collection_name}: {str(e)}")
282
+ collection_timing['error'] = str(e)
283
+
284
+ collection_timing['end'] = time.time()
285
+ logger.debug(f"Collection {collection_name} returning {len(results)} results after filtering")
286
+ return collection_name, results, collection_timing
287
+
288
+
289
+ async def parallel_search_collections(
290
+ collections_to_search: List[str],
291
+ query: str,
292
+ qdrant_client: Any,
293
+ ctx: Any,
294
+ limit: int,
295
+ min_score: float,
296
+ should_use_decay: bool,
297
+ target_project: str,
298
+ generate_embedding_func: Any,
299
+ constants: Dict[str, Any],
300
+ max_concurrent: int = 10
301
+ ) -> Tuple[List[Any], List[Dict[str, Any]]]:
302
+ """
303
+ Search multiple collections in parallel using asyncio.gather.
304
+
305
+ Args:
306
+ collections_to_search: List of collection names to search
307
+ query: Search query
308
+ qdrant_client: Qdrant client instance
309
+ ctx: Context for debugging
310
+ limit: Maximum results per collection
311
+ min_score: Minimum similarity score
312
+ should_use_decay: Whether to apply time decay
313
+ target_project: Project filter ('all' or specific project)
314
+ generate_embedding_func: Function to generate embeddings
315
+ constants: Dictionary of constants (USE_NATIVE_DECAY, etc.)
316
+ max_concurrent: Maximum concurrent searches
317
+
318
+ Returns:
319
+ Tuple of (all_results, collection_timings)
320
+ """
321
+ await ctx.debug(f"Starting parallel search across {len(collections_to_search)} collections")
322
+
323
+ # Shared cache for embeddings
324
+ query_embeddings = {}
325
+
326
+ # Create semaphore to limit concurrent operations
327
+ semaphore = asyncio.Semaphore(max_concurrent)
328
+
329
+ async def search_with_semaphore(collection_name: str) -> Tuple[str, List[Any], Dict[str, Any]]:
330
+ """Search with concurrency limit"""
331
+ async with semaphore:
332
+ return await search_single_collection(
333
+ collection_name=collection_name,
334
+ query=query,
335
+ query_embeddings=query_embeddings,
336
+ qdrant_client=qdrant_client,
337
+ ctx=ctx,
338
+ limit=limit,
339
+ min_score=min_score,
340
+ should_use_decay=should_use_decay,
341
+ target_project=target_project,
342
+ generate_embedding_func=generate_embedding_func,
343
+ constants=constants
344
+ )
345
+
346
+ # Launch all searches in parallel
347
+ search_tasks = [
348
+ search_with_semaphore(collection_name)
349
+ for collection_name in collections_to_search
350
+ ]
351
+
352
+ # Wait for all searches to complete
353
+ search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
354
+
355
+ # Process results
356
+ all_results = []
357
+ collection_timings = []
358
+
359
+ for result in search_results:
360
+ if isinstance(result, Exception):
361
+ # Handle exceptions from gather
362
+ logger.error(f"Search task failed: {result}")
363
+ continue
364
+
365
+ collection_name, results, timing = result
366
+ all_results.extend(results)
367
+ collection_timings.append(timing)
368
+
369
+ await ctx.debug(f"Parallel search complete: {len(all_results)} total results")
370
+
371
+ return all_results, collection_timings