claude-self-reflect 3.2.4 → 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 (33) hide show
  1. package/.claude/agents/claude-self-reflect-test.md +595 -528
  2. package/.claude/agents/reflection-specialist.md +59 -3
  3. package/README.md +14 -5
  4. package/mcp-server/run-mcp.sh +49 -5
  5. package/mcp-server/src/app_context.py +64 -0
  6. package/mcp-server/src/config.py +57 -0
  7. package/mcp-server/src/connection_pool.py +286 -0
  8. package/mcp-server/src/decay_manager.py +106 -0
  9. package/mcp-server/src/embedding_manager.py +64 -40
  10. package/mcp-server/src/embeddings_old.py +141 -0
  11. package/mcp-server/src/models.py +64 -0
  12. package/mcp-server/src/parallel_search.py +371 -0
  13. package/mcp-server/src/project_resolver.py +5 -0
  14. package/mcp-server/src/reflection_tools.py +206 -0
  15. package/mcp-server/src/rich_formatting.py +196 -0
  16. package/mcp-server/src/search_tools.py +826 -0
  17. package/mcp-server/src/server.py +127 -1720
  18. package/mcp-server/src/temporal_design.py +132 -0
  19. package/mcp-server/src/temporal_tools.py +597 -0
  20. package/mcp-server/src/temporal_utils.py +384 -0
  21. package/mcp-server/src/utils.py +150 -67
  22. package/package.json +10 -1
  23. package/scripts/add-timestamp-indexes.py +134 -0
  24. package/scripts/check-collections.py +29 -0
  25. package/scripts/debug-august-parsing.py +76 -0
  26. package/scripts/debug-import-single.py +91 -0
  27. package/scripts/debug-project-resolver.py +82 -0
  28. package/scripts/debug-temporal-tools.py +135 -0
  29. package/scripts/delta-metadata-update.py +547 -0
  30. package/scripts/import-conversations-unified.py +53 -2
  31. package/scripts/precompact-hook.sh +33 -0
  32. package/scripts/streaming-watcher.py +1443 -0
  33. package/scripts/utils.py +39 -0
@@ -0,0 +1,826 @@
1
+ """Search tools for Claude Self Reflect MCP server."""
2
+
3
+ import os
4
+ import json
5
+ import logging
6
+ import time
7
+ from typing import Optional, List, Dict, Any
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from fastmcp import Context
12
+ from pydantic import Field
13
+ from qdrant_client import AsyncQdrantClient
14
+ from qdrant_client.models import PointStruct
15
+
16
+ from .parallel_search import parallel_search_collections
17
+ from .rich_formatting import format_search_results_rich
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class SearchTools:
23
+ """Handles all search operations for the MCP server."""
24
+
25
+ def __init__(
26
+ self,
27
+ qdrant_client: AsyncQdrantClient,
28
+ qdrant_url: str,
29
+ get_embedding_manager,
30
+ normalize_project_name,
31
+ enable_memory_decay: bool,
32
+ decay_weight: float,
33
+ decay_scale_days: float,
34
+ use_native_decay: bool,
35
+ native_decay_available: bool,
36
+ decay_manager=None,
37
+ project_resolver=None # Add project resolver
38
+ ):
39
+ """Initialize search tools with dependencies."""
40
+ self.qdrant_client = qdrant_client
41
+ self.qdrant_url = qdrant_url
42
+ self.get_embedding_manager = get_embedding_manager
43
+ self.normalize_project_name = normalize_project_name
44
+ self.enable_memory_decay = enable_memory_decay
45
+ self.decay_weight = decay_weight
46
+ self.decay_scale_days = decay_scale_days
47
+ self.use_native_decay = use_native_decay
48
+ self.native_decay_available = native_decay_available
49
+ self.decay_manager = decay_manager
50
+ self.project_resolver = project_resolver
51
+
52
+ # Helper functions will be implemented as methods
53
+
54
+ def get_project_from_cwd(self, cwd: str) -> Optional[str]:
55
+ """Extract project name from current working directory."""
56
+ from pathlib import Path
57
+
58
+ path_parts = Path(cwd).parts
59
+ if 'projects' in path_parts:
60
+ idx = path_parts.index('projects')
61
+ if idx + 1 < len(path_parts):
62
+ return path_parts[idx + 1]
63
+ elif '.claude' in path_parts:
64
+ # If we're in a .claude directory, go up to find project
65
+ for i, part in enumerate(path_parts):
66
+ if part == '.claude' and i > 0:
67
+ return path_parts[i - 1]
68
+
69
+ # If still no project detected, use the last directory name
70
+ return Path(cwd).name
71
+
72
+ async def perform_search(
73
+ self,
74
+ ctx: Context,
75
+ query: str,
76
+ collection_name: str,
77
+ limit: int,
78
+ min_score: float
79
+ ) -> List[Dict[str, Any]]:
80
+ """Perform semantic search on a single collection."""
81
+ try:
82
+ # Generate embedding for query
83
+ embedding_manager = self.get_embedding_manager()
84
+
85
+ # Determine embedding type based on collection name
86
+ embedding_type = 'voyage' if collection_name.endswith('_voyage') else 'local'
87
+ query_embedding = await embedding_manager.generate_embedding(query, force_type=embedding_type)
88
+
89
+ # Search the collection
90
+ search_results = await self.qdrant_client.search(
91
+ collection_name=collection_name,
92
+ query_vector=query_embedding,
93
+ limit=limit,
94
+ score_threshold=min_score
95
+ )
96
+
97
+ # Convert results to dict format
98
+ results = []
99
+ for result in search_results:
100
+ results.append({
101
+ 'conversation_id': result.payload.get('conversation_id'),
102
+ 'timestamp': result.payload.get('timestamp'),
103
+ 'content': result.payload.get('content', ''),
104
+ 'score': result.score,
105
+ 'collection': collection_name,
106
+ 'payload': result.payload
107
+ })
108
+
109
+ return results
110
+
111
+ except Exception as e:
112
+ await ctx.debug(f"Error searching {collection_name}: {e}")
113
+ return []
114
+
115
+ def apply_decay_to_results(self, results: List[Dict], current_time: datetime) -> List[Dict]:
116
+ """Apply time-based decay to search results."""
117
+ if not self.enable_memory_decay:
118
+ return results
119
+
120
+ for result in results:
121
+ try:
122
+ # Parse timestamp
123
+ timestamp_str = result.get('timestamp')
124
+ if timestamp_str:
125
+ from datetime import datetime, timezone
126
+ timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
127
+
128
+ # Calculate age in days
129
+ age = (current_time - timestamp).total_seconds() / 86400
130
+
131
+ # Apply exponential decay
132
+ decay_factor = pow(2, -age / self.decay_scale_days)
133
+
134
+ # Adjust score
135
+ original_score = result['score']
136
+ result['score'] = original_score * (1 - self.decay_weight) + decay_factor * self.decay_weight
137
+ result['original_score'] = original_score
138
+ result['decay_factor'] = decay_factor
139
+
140
+ except Exception as e:
141
+ logger.warning(f"Error applying decay to result: {e}")
142
+
143
+ return results
144
+
145
+ def format_search_results(
146
+ self,
147
+ results: List[Dict],
148
+ query: str,
149
+ brief: bool = False,
150
+ include_raw: bool = False,
151
+ response_format: str = "xml"
152
+ ) -> str:
153
+ """Format search results for display."""
154
+ if not results:
155
+ return "<search_results><message>No matching conversations found</message></search_results>"
156
+
157
+ if response_format == "markdown":
158
+ output = f"# Search Results for: {query}\n\n"
159
+ for i, result in enumerate(results, 1):
160
+ output += f"## Result {i}\n"
161
+ output += f"**Score:** {result['score']:.3f}\n"
162
+ output += f"**Timestamp:** {result.get('timestamp', 'N/A')}\n"
163
+ output += f"**Conversation ID:** {result.get('conversation_id', 'N/A')}\n\n"
164
+ if not brief:
165
+ # Handle both 'content' and 'excerpt' fields
166
+ content = result.get('content', result.get('excerpt', ''))
167
+ output += f"**Content:**\n```\n{content[:500]}{'...' if len(content) > 500 else ''}\n```\n\n"
168
+ if include_raw:
169
+ output += f"**Raw Payload:**\n```json\n{json.dumps(result.get('payload', {}), indent=2)}\n```\n\n"
170
+ else:
171
+ # XML format (default)
172
+ output = f"<search_results>\n<query>{query}</query>\n<count>{len(results)}</count>\n"
173
+ for i, result in enumerate(results, 1):
174
+ output += f"<result index=\"{i}\">\n"
175
+ output += f" <score>{result['score']:.3f}</score>\n"
176
+ output += f" <timestamp>{result.get('timestamp', 'N/A')}</timestamp>\n"
177
+ output += f" <conversation_id>{result.get('conversation_id', 'N/A')}</conversation_id>\n"
178
+ if not brief:
179
+ # Handle both 'content' and 'excerpt' fields
180
+ content = result.get('content', result.get('excerpt', ''))
181
+ truncated = content[:500] + ('...' if len(content) > 500 else '')
182
+ output += f" <content><![CDATA[{truncated}]]></content>\n"
183
+ if include_raw:
184
+ output += f" <raw_payload>{json.dumps(result.get('payload', {}))}</raw_payload>\n"
185
+ output += "</result>\n"
186
+ output += "</search_results>"
187
+
188
+ return output
189
+
190
+ async def reflect_on_past(
191
+ self,
192
+ ctx: Context,
193
+ query: str,
194
+ limit: int = 5,
195
+ min_score: float = 0.3,
196
+ use_decay: int = -1,
197
+ project: Optional[str] = None,
198
+ mode: str = "full",
199
+ brief: bool = False,
200
+ include_raw: bool = False,
201
+ response_format: str = "xml"
202
+ ) -> str:
203
+ """Search for relevant past conversations using semantic search with optional time decay."""
204
+
205
+ await ctx.debug(f"Searching for: {query}, project={project}, mode={mode}, decay={use_decay}")
206
+
207
+ try:
208
+ # Track timing for performance metrics
209
+ start_time = time.time()
210
+ timing_info = {}
211
+
212
+ # Determine project scope
213
+ target_project = project
214
+ if project is None:
215
+ cwd = os.environ.get('MCP_CLIENT_CWD', os.getcwd())
216
+ target_project = self.get_project_from_cwd(cwd)
217
+ await ctx.debug(f"Inferred project from CWD: {target_project}")
218
+
219
+ # Handle special cases
220
+ if mode == "quick":
221
+ return await self.quick_search(ctx, query, min_score, target_project)
222
+ elif mode == "summary":
223
+ return await self.search_summary(ctx, query, target_project)
224
+
225
+ # Get relevant collections based on project
226
+ await ctx.debug(f"Project resolver: {self.project_resolver is not None}, Target project: '{target_project}'")
227
+ if self.project_resolver and target_project and target_project != 'all':
228
+ # Use ProjectResolver to find matching collections
229
+ collection_names = self.project_resolver.find_collections_for_project(target_project)
230
+ await ctx.debug(f"ProjectResolver found {len(collection_names)} collections for '{target_project}'")
231
+
232
+ # Get collection objects
233
+ collections_response = await self.qdrant_client.get_collections()
234
+ all_collections = collections_response.collections
235
+ filtered_collections = [
236
+ c for c in all_collections
237
+ if c.name in collection_names
238
+ ]
239
+ await ctx.debug(f"Filtered to {len(filtered_collections)} collections from {len(all_collections)} total")
240
+ else:
241
+ # Use all collections except reflections
242
+ collections_response = await self.qdrant_client.get_collections()
243
+ collections = collections_response.collections
244
+ filtered_collections = [
245
+ c for c in collections
246
+ if not c.name.startswith('reflections')
247
+ ]
248
+ await ctx.debug(f"Searching across {len(filtered_collections)} collections")
249
+
250
+ if not filtered_collections:
251
+ return "<search_results><message>No collections found for the specified project</message></search_results>"
252
+
253
+ # Perform PARALLEL search across collections to avoid freeze
254
+ collection_names = [c.name for c in filtered_collections]
255
+ await ctx.debug(f"Starting parallel search across {len(collection_names)} collections")
256
+
257
+ # Create embedding function wrapper for parallel search
258
+ embedding_manager = self.get_embedding_manager()
259
+ async def generate_embedding_func(text: str, force_type: str = 'local'):
260
+ return await embedding_manager.generate_embedding(text, force_type=force_type)
261
+
262
+ # Track embedding generation timing
263
+ timing_info['embedding_start'] = time.time()
264
+
265
+ # Use parallel search to avoid sequential processing freeze
266
+ all_results, search_timing = await parallel_search_collections(
267
+ collections_to_search=collection_names,
268
+ query=query,
269
+ qdrant_client=self.qdrant_client,
270
+ ctx=ctx,
271
+ limit=limit * 2, # Get more results initially
272
+ min_score=min_score,
273
+ should_use_decay=use_decay == 1,
274
+ target_project=target_project,
275
+ generate_embedding_func=generate_embedding_func,
276
+ constants={'DECAY_SCALE_DAYS': self.decay_scale_days},
277
+ max_concurrent=10 # Limit concurrent searches to avoid overload
278
+ )
279
+
280
+ # Update timing info with search timing
281
+ timing_info['embedding_end'] = time.time() # Embeddings are generated inside parallel_search
282
+ timing_info['search_all_start'] = timing_info.get('embedding_start', time.time())
283
+ timing_info['search_all_end'] = time.time()
284
+ # search_timing is a list of collection timings, not a dict
285
+
286
+ await ctx.debug(f"Parallel search completed with {len(all_results)} total results")
287
+
288
+ # Debug: Log some details about results
289
+ if all_results:
290
+ await ctx.debug(f"Top result score: {all_results[0]['score']:.4f}")
291
+ else:
292
+ await ctx.debug(f"No results found. Timing info: {timing_info}")
293
+
294
+ if not all_results:
295
+ return "<search_results><message>No matching conversations found</message></search_results>"
296
+
297
+ # Sort and limit results
298
+ all_results.sort(key=lambda x: x['score'], reverse=True)
299
+ final_results = all_results[:limit]
300
+
301
+ # Use rich formatting for default XML format
302
+ if response_format == "xml" and not brief:
303
+ # Try to get indexing status for rich display
304
+ indexing_status = None
305
+ # TODO: Add indexing status retrieval here if needed
306
+
307
+ return format_search_results_rich(
308
+ results=final_results,
309
+ query=query,
310
+ target_project=target_project,
311
+ collections_searched=len(collection_names),
312
+ timing_info=timing_info,
313
+ start_time=start_time,
314
+ brief=brief,
315
+ include_raw=include_raw,
316
+ indexing_status=indexing_status
317
+ )
318
+ else:
319
+ # Fall back to standard formatting for markdown or brief mode
320
+ return self.format_search_results(
321
+ final_results,
322
+ query,
323
+ brief=brief,
324
+ include_raw=include_raw,
325
+ response_format=response_format
326
+ )
327
+
328
+ except Exception as e:
329
+ logger.error(f"Search failed: {e}", exc_info=True)
330
+ return f"<search_results><error>Search failed: {str(e)}</error></search_results>"
331
+
332
+ async def quick_search(
333
+ self,
334
+ ctx: Context,
335
+ query: str,
336
+ min_score: float = 0.3,
337
+ project: Optional[str] = None
338
+ ) -> str:
339
+ """Quick search that returns only the count and top result for fast overview."""
340
+
341
+ await ctx.debug(f"Quick search for: {query}, project={project}")
342
+
343
+ try:
344
+ # Determine project scope
345
+ target_project = project
346
+ if project is None:
347
+ cwd = os.environ.get('MCP_CLIENT_CWD', os.getcwd())
348
+ target_project = self.get_project_from_cwd(cwd)
349
+
350
+ # Get collections based on project
351
+ if self.project_resolver and target_project and target_project != 'all':
352
+ # Use ProjectResolver to find matching collections
353
+ collection_names = self.project_resolver.find_collections_for_project(target_project)
354
+ collections_response = await self.qdrant_client.get_collections()
355
+ all_collections = collections_response.collections
356
+ filtered_collections = [
357
+ c for c in all_collections
358
+ if c.name in collection_names
359
+ ]
360
+ else:
361
+ # Use all collections except reflections
362
+ collections_response = await self.qdrant_client.get_collections()
363
+ collections = collections_response.collections
364
+ filtered_collections = [
365
+ c for c in collections
366
+ if not c.name.startswith('reflections')
367
+ ]
368
+
369
+ # Quick PARALLEL count across collections
370
+ collection_names = [c.name for c in filtered_collections]
371
+
372
+ # Create embedding function wrapper
373
+ embedding_manager = self.get_embedding_manager()
374
+ async def generate_embedding_func(text: str, force_type: str = 'local'):
375
+ return await embedding_manager.generate_embedding(text, force_type=force_type)
376
+
377
+ # Use parallel search for quick check
378
+ all_results, _ = await parallel_search_collections(
379
+ collections_to_search=collection_names,
380
+ query=query,
381
+ qdrant_client=self.qdrant_client,
382
+ ctx=ctx,
383
+ limit=1, # Only need top result from each collection
384
+ min_score=min_score,
385
+ should_use_decay=False, # Quick search doesn't use decay
386
+ target_project=target_project,
387
+ generate_embedding_func=generate_embedding_func,
388
+ constants={'DECAY_SCALE_DAYS': self.decay_scale_days},
389
+ max_concurrent=20 # Higher concurrency for quick search
390
+ )
391
+
392
+ # Count collections with results and find top result
393
+ collections_with_matches = len(set(r.get('collection_name', r.get('collection', '')) for r in all_results))
394
+ top_result = max(all_results, key=lambda x: x.get('score', 0)) if all_results else None
395
+ top_score = top_result.get('score', 0) if top_result else 0
396
+
397
+ # Format quick search response
398
+ if not top_result:
399
+ return "<quick_search><count>0</count><message>No matches found</message></quick_search>"
400
+
401
+ return f"""<quick_search>
402
+ <count>{collections_with_matches} collections with matches</count>
403
+ <top_result>
404
+ <score>{top_result['score']:.3f}</score>
405
+ <timestamp>{top_result.get('timestamp', 'N/A')}</timestamp>
406
+ <preview>{top_result.get('excerpt', top_result.get('content', ''))[:200]}...</preview>
407
+ </top_result>
408
+ </quick_search>"""
409
+
410
+ except Exception as e:
411
+ logger.error(f"Quick search failed: {e}", exc_info=True)
412
+ return f"<quick_search><error>Quick search failed: {str(e)}</error></quick_search>"
413
+
414
+ async def search_summary(
415
+ self,
416
+ ctx: Context,
417
+ query: str,
418
+ project: Optional[str] = None
419
+ ) -> str:
420
+ """Get aggregated insights from search results without individual result details."""
421
+
422
+ await ctx.debug(f"Getting search summary for: {query}, project={project}")
423
+
424
+ try:
425
+ # Determine project scope
426
+ target_project = project
427
+ if project is None:
428
+ cwd = os.environ.get('MCP_CLIENT_CWD', os.getcwd())
429
+ target_project = self.get_project_from_cwd(cwd)
430
+
431
+ # Get collections based on project
432
+ if self.project_resolver and target_project and target_project != 'all':
433
+ # Use ProjectResolver to find matching collections
434
+ collection_names = self.project_resolver.find_collections_for_project(target_project)
435
+ collections_response = await self.qdrant_client.get_collections()
436
+ all_collections = collections_response.collections
437
+ filtered_collections = [
438
+ c for c in all_collections
439
+ if c.name in collection_names
440
+ ]
441
+ else:
442
+ # Use all collections except reflections
443
+ collections_response = await self.qdrant_client.get_collections()
444
+ collections = collections_response.collections
445
+ filtered_collections = [
446
+ c for c in collections
447
+ if not c.name.startswith('reflections')
448
+ ]
449
+
450
+ # Gather results for summary using PARALLEL search
451
+ collection_names = [c.name for c in filtered_collections]
452
+
453
+ # Create embedding function wrapper
454
+ embedding_manager = self.get_embedding_manager()
455
+ async def generate_embedding_func(text: str, force_type: str = 'local'):
456
+ return await embedding_manager.generate_embedding(text, force_type=force_type)
457
+
458
+ # Use parallel search for summary
459
+ all_results, _ = await parallel_search_collections(
460
+ collections_to_search=collection_names,
461
+ query=query,
462
+ qdrant_client=self.qdrant_client,
463
+ ctx=ctx,
464
+ limit=10, # Get more results for summary
465
+ min_score=0.0, # Get all results for aggregation
466
+ should_use_decay=False, # Summary doesn't use decay
467
+ target_project=target_project,
468
+ generate_embedding_func=generate_embedding_func,
469
+ constants={'DECAY_SCALE_DAYS': self.decay_scale_days},
470
+ max_concurrent=15 # Balanced concurrency
471
+ )
472
+
473
+ if not all_results:
474
+ return "<search_summary><message>No matches found</message></search_summary>"
475
+
476
+ # Sort and get top results
477
+ all_results.sort(key=lambda x: x['score'], reverse=True)
478
+ top_results = all_results[:10]
479
+
480
+ # Analyze patterns
481
+ avg_score = sum(r['score'] for r in top_results) / len(top_results)
482
+ collections_found = len(set(r.get('collection_name', r.get('collection', '')) for r in top_results))
483
+
484
+ # Extract common concepts if available
485
+ all_concepts = []
486
+ for r in top_results:
487
+ if 'payload' in r and 'concepts' in r['payload']:
488
+ all_concepts.extend(r['payload']['concepts'])
489
+
490
+ from collections import Counter
491
+ concept_counts = Counter(all_concepts).most_common(5)
492
+
493
+ return f"""<search_summary>
494
+ <query>{query}</query>
495
+ <total_matches>{len(all_results)}</total_matches>
496
+ <average_score>{avg_score:.3f}</average_score>
497
+ <collections_matched>{collections_found}</collections_matched>
498
+ <top_concepts>{', '.join([c[0] for c in concept_counts]) if concept_counts else 'N/A'}</top_concepts>
499
+ <insight>Found {len(all_results)} matches across {collections_found} collections with average relevance of {avg_score:.3f}</insight>
500
+ </search_summary>"""
501
+
502
+ except Exception as e:
503
+ logger.error(f"Search summary failed: {e}", exc_info=True)
504
+ return f"<search_summary><error>Search summary failed: {str(e)}</error></search_summary>"
505
+
506
+ async def get_more_results(
507
+ self,
508
+ ctx: Context,
509
+ query: str,
510
+ offset: int = 3,
511
+ limit: int = 3,
512
+ min_score: float = 0.3,
513
+ project: Optional[str] = None
514
+ ) -> str:
515
+ """Get additional search results after an initial search (pagination support)."""
516
+
517
+ await ctx.debug(f"Getting more results for: {query}, offset={offset}, limit={limit}")
518
+
519
+ try:
520
+ # Determine project scope
521
+ target_project = project
522
+ if project is None:
523
+ cwd = os.environ.get('MCP_CLIENT_CWD', os.getcwd())
524
+ target_project = self.get_project_from_cwd(cwd)
525
+
526
+ # Get collections based on project
527
+ if self.project_resolver and target_project and target_project != 'all':
528
+ # Use ProjectResolver to find matching collections
529
+ collection_names = self.project_resolver.find_collections_for_project(target_project)
530
+ collections_response = await self.qdrant_client.get_collections()
531
+ all_collections = collections_response.collections
532
+ filtered_collections = [
533
+ c for c in all_collections
534
+ if c.name in collection_names
535
+ ]
536
+ else:
537
+ # Use all collections except reflections
538
+ collections_response = await self.qdrant_client.get_collections()
539
+ collections = collections_response.collections
540
+ filtered_collections = [
541
+ c for c in collections
542
+ if not c.name.startswith('reflections')
543
+ ]
544
+
545
+ # Gather all results using PARALLEL search
546
+ collection_names = [c.name for c in filtered_collections]
547
+
548
+ # Create embedding function wrapper
549
+ embedding_manager = self.get_embedding_manager()
550
+ async def generate_embedding_func(text: str, force_type: str = 'local'):
551
+ return await embedding_manager.generate_embedding(text, force_type=force_type)
552
+
553
+ # Use parallel search for pagination
554
+ all_results, _ = await parallel_search_collections(
555
+ collections_to_search=collection_names,
556
+ query=query,
557
+ qdrant_client=self.qdrant_client,
558
+ ctx=ctx,
559
+ limit=offset + limit, # Get more results than needed to handle offset
560
+ min_score=min_score,
561
+ should_use_decay=False, # Pagination doesn't use decay
562
+ target_project=target_project,
563
+ generate_embedding_func=generate_embedding_func,
564
+ constants={'DECAY_SCALE_DAYS': self.decay_scale_days},
565
+ max_concurrent=10 # Standard concurrency
566
+ )
567
+
568
+ if not all_results:
569
+ return "<more_results><message>No more results found</message></more_results>"
570
+
571
+ # Sort all results by score
572
+ all_results.sort(key=lambda x: x['score'], reverse=True)
573
+
574
+ # Apply offset and limit
575
+ paginated_results = all_results[offset:offset + limit]
576
+
577
+ if not paginated_results:
578
+ return f"<more_results><message>No results at offset {offset}</message></more_results>"
579
+
580
+ # Format paginated results
581
+ output = f"<more_results>\n<query>{query}</query>\n"
582
+ output += f"<offset>{offset}</offset>\n"
583
+ output += f"<limit>{limit}</limit>\n"
584
+ output += f"<total_available>{len(all_results)}</total_available>\n"
585
+ output += f"<results_returned>{len(paginated_results)}</results_returned>\n"
586
+
587
+ for i, result in enumerate(paginated_results, 1):
588
+ output += f"<result index=\"{offset + i}\">\n"
589
+ output += f" <score>{result['score']:.3f}</score>\n"
590
+ output += f" <timestamp>{result.get('timestamp', 'N/A')}</timestamp>\n"
591
+ output += f" <preview>{result.get('content', '')[:200]}...</preview>\n"
592
+ output += "</result>\n"
593
+
594
+ output += "</more_results>"
595
+ return output
596
+
597
+ except Exception as e:
598
+ logger.error(f"Get more results failed: {e}", exc_info=True)
599
+ return f"<more_results><error>Failed to get more results: {str(e)}</error></more_results>"
600
+
601
+ async def search_by_file(
602
+ self,
603
+ ctx: Context,
604
+ file_path: str,
605
+ limit: int = 10,
606
+ project: Optional[str] = None
607
+ ) -> str:
608
+ """Search for conversations that analyzed a specific file."""
609
+
610
+ await ctx.debug(f"Searching for file: {file_path}, project={project}")
611
+
612
+ try:
613
+ # Normalize file path
614
+ normalized_path = str(Path(file_path).resolve())
615
+
616
+ # Search for file mentions in metadata
617
+ collections_response = await self.qdrant_client.get_collections()
618
+ collections = collections_response.collections
619
+
620
+ # Define async function to search a single collection
621
+ async def search_collection(collection_name: str):
622
+ try:
623
+ # Search by payload filter
624
+ search_results = await self.qdrant_client.search(
625
+ collection_name=collection_name,
626
+ query_vector=[0] * 384, # Dummy vector for metadata search
627
+ limit=limit,
628
+ query_filter={
629
+ "must": [
630
+ {
631
+ "key": "files_analyzed",
632
+ "match": {"any": [normalized_path]}
633
+ }
634
+ ]
635
+ }
636
+ )
637
+
638
+ results = []
639
+ for result in search_results:
640
+ results.append({
641
+ 'conversation_id': result.payload.get('conversation_id'),
642
+ 'timestamp': result.payload.get('timestamp'),
643
+ 'content': result.payload.get('content', ''),
644
+ 'files_analyzed': result.payload.get('files_analyzed', []),
645
+ 'score': result.score
646
+ })
647
+ return results
648
+
649
+ except Exception as e:
650
+ await ctx.debug(f"Error searching {collection_name}: {e}")
651
+ return []
652
+
653
+ # Use asyncio.gather for PARALLEL search across all collections
654
+ import asyncio
655
+ search_tasks = [search_collection(c.name) for c in collections]
656
+
657
+ # Limit concurrent searches to avoid overload
658
+ batch_size = 20
659
+ all_results = []
660
+ for i in range(0, len(search_tasks), batch_size):
661
+ batch = search_tasks[i:i+batch_size]
662
+ batch_results = await asyncio.gather(*batch)
663
+ for results in batch_results:
664
+ all_results.extend(results)
665
+
666
+ # Format results
667
+ if not all_results:
668
+ return f"<file_search><message>No conversations found analyzing {file_path}</message></file_search>"
669
+
670
+ return self.format_search_results(all_results, f"file:{file_path}")
671
+
672
+ except Exception as e:
673
+ logger.error(f"File search failed: {e}", exc_info=True)
674
+ return f"<file_search><error>File search failed: {str(e)}</error></file_search>"
675
+
676
+ async def search_by_concept(
677
+ self,
678
+ ctx: Context,
679
+ concept: str,
680
+ limit: int = 10,
681
+ project: Optional[str] = None,
682
+ include_files: bool = True
683
+ ) -> str:
684
+ """Search for conversations about a specific development concept."""
685
+
686
+ await ctx.debug(f"Searching for concept: {concept}, project={project}")
687
+
688
+ try:
689
+ # Search using concept as query with semantic search
690
+ results = await self.reflect_on_past(
691
+ ctx, concept, limit=limit, project=project
692
+ )
693
+
694
+ # Enhance results with concept-specific formatting
695
+ # This is a simplified version - actual implementation would analyze concepts
696
+ return results
697
+
698
+ except Exception as e:
699
+ logger.error(f"Concept search failed: {e}", exc_info=True)
700
+ return f"<concept_search><error>Concept search failed: {str(e)}</error></concept_search>"
701
+
702
+ async def get_next_results(
703
+ self,
704
+ ctx: Context,
705
+ query: str,
706
+ offset: int = 3,
707
+ limit: int = 3,
708
+ min_score: float = 0.3,
709
+ project: Optional[str] = None
710
+ ) -> str:
711
+ """Get additional search results after an initial search (pagination support)."""
712
+ # This is an alias for get_more_results
713
+ return await self.get_more_results(ctx, query, offset, limit, min_score, project)
714
+
715
+
716
+ def register_search_tools(
717
+ mcp,
718
+ qdrant_client: AsyncQdrantClient,
719
+ qdrant_url: str,
720
+ get_embedding_manager,
721
+ normalize_project_name,
722
+ enable_memory_decay: bool,
723
+ decay_weight: float,
724
+ decay_scale_days: float,
725
+ use_native_decay: bool,
726
+ native_decay_available: bool,
727
+ decay_manager=None,
728
+ project_resolver=None # Add project resolver
729
+ ):
730
+ """Register search tools with the MCP server."""
731
+
732
+ tools = SearchTools(
733
+ qdrant_client,
734
+ qdrant_url,
735
+ get_embedding_manager,
736
+ normalize_project_name,
737
+ enable_memory_decay,
738
+ decay_weight,
739
+ decay_scale_days,
740
+ use_native_decay,
741
+ native_decay_available,
742
+ decay_manager,
743
+ project_resolver # Pass the resolver
744
+ )
745
+
746
+ @mcp.tool()
747
+ async def reflect_on_past(
748
+ ctx: Context,
749
+ query: str = Field(description="The search query to find semantically similar conversations"),
750
+ limit: int = Field(default=5, description="Maximum number of results to return"),
751
+ min_score: float = Field(default=0.3, description="Minimum similarity score (0-1)"),
752
+ use_decay: int = Field(default=-1, description="Apply time-based decay: 1=enable, 0=disable, -1=use environment default (accepts int or str)"),
753
+ project: Optional[str] = Field(default=None, description="Search specific project only. If not provided, searches current project based on working directory. Use 'all' to search across all projects."),
754
+ mode: str = Field(default="full", description="Search mode: 'full' (all results with details), 'quick' (count + top result only), 'summary' (aggregated insights without individual results)"),
755
+ brief: bool = Field(default=False, description="Brief mode: returns minimal information for faster response"),
756
+ include_raw: bool = Field(default=False, description="Include raw Qdrant payload data for debugging (increases response size)"),
757
+ response_format: str = Field(default="xml", description="Response format: 'xml' or 'markdown'")
758
+ ) -> str:
759
+ """Search for relevant past conversations using semantic search with optional time decay."""
760
+ return await tools.reflect_on_past(ctx, query, limit, min_score, use_decay, project, mode, brief, include_raw, response_format)
761
+
762
+ @mcp.tool()
763
+ async def quick_search(
764
+ ctx: Context,
765
+ query: str = Field(description="The search query to find semantically similar conversations"),
766
+ min_score: float = Field(default=0.3, description="Minimum similarity score (0-1)"),
767
+ project: Optional[str] = Field(default=None, description="Search specific project only. If not provided, searches current project based on working directory. Use 'all' to search across all projects.")
768
+ ) -> str:
769
+ """Quick search that returns only the count and top result for fast overview."""
770
+ return await tools.quick_search(ctx, query, min_score, project)
771
+
772
+ @mcp.tool()
773
+ async def search_summary(
774
+ ctx: Context,
775
+ query: str = Field(description="The search query to find semantically similar conversations"),
776
+ project: Optional[str] = Field(default=None, description="Search specific project only. If not provided, searches current project based on working directory. Use 'all' to search across all projects.")
777
+ ) -> str:
778
+ """Get aggregated insights from search results without individual result details."""
779
+ return await tools.search_summary(ctx, query, project)
780
+
781
+ @mcp.tool()
782
+ async def get_more_results(
783
+ ctx: Context,
784
+ query: str = Field(description="The original search query"),
785
+ offset: int = Field(default=3, description="Number of results to skip (for pagination)"),
786
+ limit: int = Field(default=3, description="Number of additional results to return"),
787
+ min_score: float = Field(default=0.3, description="Minimum similarity score (0-1)"),
788
+ project: Optional[str] = Field(default=None, description="Search specific project only")
789
+ ) -> str:
790
+ """Get additional search results after an initial search (pagination support)."""
791
+ return await tools.get_more_results(ctx, query, offset, limit, min_score, project)
792
+
793
+ @mcp.tool()
794
+ async def search_by_file(
795
+ ctx: Context,
796
+ file_path: str = Field(description="The file path to search for in conversations"),
797
+ limit: int = Field(default=10, description="Maximum number of results to return"),
798
+ project: Optional[str] = Field(default=None, description="Search specific project only. Use 'all' to search across all projects.")
799
+ ) -> str:
800
+ """Search for conversations that analyzed a specific file."""
801
+ return await tools.search_by_file(ctx, file_path, limit, project)
802
+
803
+ @mcp.tool()
804
+ async def search_by_concept(
805
+ ctx: Context,
806
+ concept: str = Field(description="The concept to search for (e.g., 'security', 'docker', 'testing')"),
807
+ limit: int = Field(default=10, description="Maximum number of results to return"),
808
+ project: Optional[str] = Field(default=None, description="Search specific project only. Use 'all' to search across all projects."),
809
+ include_files: bool = Field(default=True, description="Include file information in results")
810
+ ) -> str:
811
+ """Search for conversations about a specific development concept."""
812
+ return await tools.search_by_concept(ctx, concept, limit, project, include_files)
813
+
814
+ @mcp.tool()
815
+ async def get_next_results(
816
+ ctx: Context,
817
+ query: str = Field(description="The original search query"),
818
+ offset: int = Field(default=3, description="Number of results to skip (for pagination)"),
819
+ limit: int = Field(default=3, description="Number of additional results to return"),
820
+ min_score: float = Field(default=0.3, description="Minimum similarity score (0-1)"),
821
+ project: Optional[str] = Field(default=None, description="Search specific project only")
822
+ ) -> str:
823
+ """Get additional search results after an initial search (pagination support)."""
824
+ return await tools.get_next_results(ctx, query, offset, limit, min_score, project)
825
+
826
+ logger.info("Search tools registered successfully")