claude-self-reflect 3.3.0 → 4.0.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.
@@ -4,6 +4,7 @@ import os
4
4
  import json
5
5
  import logging
6
6
  import time
7
+ import html
7
8
  from typing import Optional, List, Dict, Any
8
9
  from datetime import datetime, timezone
9
10
  from pathlib import Path
@@ -82,10 +83,20 @@ class SearchTools:
82
83
  # Generate embedding for query
83
84
  embedding_manager = self.get_embedding_manager()
84
85
 
85
- # Determine embedding type based on collection name
86
- embedding_type = 'voyage' if collection_name.endswith('_voyage') else 'local'
86
+ # Determine embedding type based on collection name (v3 and v4 compatible)
87
+ # v4 format: csr_project_mode_dims (e.g., csr_project_cloud_1024d)
88
+ # v3 format: project_suffix (e.g., project_voyage)
89
+ if '_cloud_' in collection_name or collection_name.endswith('_1024d') or collection_name.endswith('_voyage'):
90
+ embedding_type = 'voyage'
91
+ else:
92
+ embedding_type = 'local'
87
93
  query_embedding = await embedding_manager.generate_embedding(query, force_type=embedding_type)
88
-
94
+
95
+ # FIX: Validate embedding before search
96
+ if query_embedding is None:
97
+ logger.warning(f"Embedding generation failed for query in {collection_name}")
98
+ return []
99
+
89
100
  # Search the collection
90
101
  search_results = await self.qdrant_client.search(
91
102
  collection_name=collection_name,
@@ -131,9 +142,9 @@ class SearchTools:
131
142
  # Apply exponential decay
132
143
  decay_factor = pow(2, -age / self.decay_scale_days)
133
144
 
134
- # Adjust score
145
+ # Adjust score - FIX: Maintain comparable scale
135
146
  original_score = result['score']
136
- result['score'] = original_score * (1 - self.decay_weight) + decay_factor * self.decay_weight
147
+ result['score'] = original_score * ((1 - self.decay_weight) + self.decay_weight * decay_factor)
137
148
  result['original_score'] = original_score
138
149
  result['decay_factor'] = decay_factor
139
150
 
@@ -168,20 +179,23 @@ class SearchTools:
168
179
  if include_raw:
169
180
  output += f"**Raw Payload:**\n```json\n{json.dumps(result.get('payload', {}), indent=2)}\n```\n\n"
170
181
  else:
171
- # XML format (default)
172
- output = f"<search_results>\n<query>{query}</query>\n<count>{len(results)}</count>\n"
182
+ # XML format (default) with proper escaping
183
+ def _esc(x): return html.escape(str(x), quote=False)
184
+
185
+ output = f"<search_results>\n<query>{_esc(query)}</query>\n<count>{len(results)}</count>\n"
173
186
  for i, result in enumerate(results, 1):
174
187
  output += f"<result index=\"{i}\">\n"
175
188
  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"
189
+ output += f" <timestamp>{_esc(result.get('timestamp', 'N/A'))}</timestamp>\n"
190
+ output += f" <conversation_id>{_esc(result.get('conversation_id', 'N/A'))}</conversation_id>\n"
178
191
  if not brief:
179
192
  # Handle both 'content' and 'excerpt' fields
180
- content = result.get('content', result.get('excerpt', ''))
193
+ content = result.get('content', result.get('excerpt', result.get('text', '')))
181
194
  truncated = content[:500] + ('...' if len(content) > 500 else '')
182
195
  output += f" <content><![CDATA[{truncated}]]></content>\n"
183
196
  if include_raw:
184
- output += f" <raw_payload>{json.dumps(result.get('payload', {}))}</raw_payload>\n"
197
+ # Use CDATA for large JSON payloads
198
+ output += f" <raw_payload><![CDATA[{json.dumps(result.get('payload', {}), ensure_ascii=False)}]]></raw_payload>\n"
185
199
  output += "</result>\n"
186
200
  output += "</search_results>"
187
201
 
@@ -238,12 +252,14 @@ class SearchTools:
238
252
  ]
239
253
  await ctx.debug(f"Filtered to {len(filtered_collections)} collections from {len(all_collections)} total")
240
254
  else:
241
- # Use all collections except reflections
255
+ # Use all collections INCLUDING reflections (with decay)
242
256
  collections_response = await self.qdrant_client.get_collections()
243
257
  collections = collections_response.collections
258
+ # Include both conversation collections and reflection collections
244
259
  filtered_collections = [
245
- c for c in collections
246
- if not c.name.startswith('reflections')
260
+ c for c in collections
261
+ if (c.name.endswith('_local') or c.name.endswith('_voyage') or
262
+ c.name.startswith('reflections'))
247
263
  ]
248
264
  await ctx.debug(f"Searching across {len(filtered_collections)} collections")
249
265
 
@@ -358,12 +374,14 @@ class SearchTools:
358
374
  if c.name in collection_names
359
375
  ]
360
376
  else:
361
- # Use all collections except reflections
377
+ # Use all collections INCLUDING reflections (with decay)
362
378
  collections_response = await self.qdrant_client.get_collections()
363
379
  collections = collections_response.collections
380
+ # Include both conversation collections and reflection collections
364
381
  filtered_collections = [
365
- c for c in collections
366
- if not c.name.startswith('reflections')
382
+ c for c in collections
383
+ if (c.name.endswith('_local') or c.name.endswith('_voyage') or
384
+ c.name.startswith('reflections'))
367
385
  ]
368
386
 
369
387
  # Quick PARALLEL count across collections
@@ -394,22 +412,29 @@ class SearchTools:
394
412
  top_result = max(all_results, key=lambda x: x.get('score', 0)) if all_results else None
395
413
  top_score = top_result.get('score', 0) if top_result else 0
396
414
 
397
- # Format quick search response
415
+ # Format quick search response with proper XML escaping
416
+ def _esc(x): return html.escape(str(x), quote=False)
417
+
398
418
  if not top_result:
399
419
  return "<quick_search><count>0</count><message>No matches found</message></quick_search>"
400
-
420
+
421
+ # Get preview text and ensure we have content fallbacks
422
+ preview_text = top_result.get('excerpt', top_result.get('content', top_result.get('text', '')))[:200]
423
+
401
424
  return f"""<quick_search>
402
- <count>{collections_with_matches} collections with matches</count>
425
+ <count>{collections_with_matches}</count>
426
+ <collections_with_matches>{collections_with_matches}</collections_with_matches>
403
427
  <top_result>
404
428
  <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>
429
+ <timestamp>{_esc(top_result.get('timestamp', 'N/A'))}</timestamp>
430
+ <preview><![CDATA[{preview_text}...]]></preview>
407
431
  </top_result>
408
432
  </quick_search>"""
409
-
433
+
410
434
  except Exception as e:
411
435
  logger.error(f"Quick search failed: {e}", exc_info=True)
412
- return f"<quick_search><error>Quick search failed: {str(e)}</error></quick_search>"
436
+ def _esc(x): return html.escape(str(x), quote=False)
437
+ return f"<quick_search><error>Quick search failed: {_esc(str(e))}</error></quick_search>"
413
438
 
414
439
  async def search_summary(
415
440
  self,
@@ -439,12 +464,14 @@ class SearchTools:
439
464
  if c.name in collection_names
440
465
  ]
441
466
  else:
442
- # Use all collections except reflections
467
+ # Use all collections INCLUDING reflections (with decay)
443
468
  collections_response = await self.qdrant_client.get_collections()
444
469
  collections = collections_response.collections
470
+ # Include both conversation collections and reflection collections
445
471
  filtered_collections = [
446
- c for c in collections
447
- if not c.name.startswith('reflections')
472
+ c for c in collections
473
+ if (c.name.endswith('_local') or c.name.endswith('_voyage') or
474
+ c.name.startswith('reflections'))
448
475
  ]
449
476
 
450
477
  # Gather results for summary using PARALLEL search
@@ -534,12 +561,14 @@ class SearchTools:
534
561
  if c.name in collection_names
535
562
  ]
536
563
  else:
537
- # Use all collections except reflections
564
+ # Use all collections INCLUDING reflections (with decay)
538
565
  collections_response = await self.qdrant_client.get_collections()
539
566
  collections = collections_response.collections
567
+ # Include both conversation collections and reflection collections
540
568
  filtered_collections = [
541
- c for c in collections
542
- if not c.name.startswith('reflections')
569
+ c for c in collections
570
+ if (c.name.endswith('_local') or c.name.endswith('_voyage') or
571
+ c.name.startswith('reflections'))
543
572
  ]
544
573
 
545
574
  # Gather all results using PARALLEL search
@@ -606,61 +635,102 @@ class SearchTools:
606
635
  project: Optional[str] = None
607
636
  ) -> str:
608
637
  """Search for conversations that analyzed a specific file."""
609
-
638
+
610
639
  await ctx.debug(f"Searching for file: {file_path}, project={project}")
611
-
640
+
612
641
  try:
613
- # Normalize file path
614
- normalized_path = str(Path(file_path).resolve())
615
-
642
+ # Create multiple path variants to match how paths are stored
643
+ # Import uses normalize_file_path which replaces /Users/ with ~/
644
+ path_variants = set()
645
+
646
+ # Original path
647
+ path_variants.add(file_path)
648
+
649
+ # Basename only
650
+ path_variants.add(os.path.basename(file_path))
651
+
652
+ # Try to resolve if it's a valid path
653
+ try:
654
+ resolved_path = str(Path(file_path).resolve())
655
+ path_variants.add(resolved_path)
656
+
657
+ # Convert resolved path to ~/ format (matching how import stores it)
658
+ home_dir = str(Path.home())
659
+ if resolved_path.startswith(home_dir):
660
+ tilde_path = resolved_path.replace(home_dir, '~', 1)
661
+ path_variants.add(tilde_path)
662
+
663
+ # Also try with /Users/ replaced by ~/
664
+ if '/Users/' in resolved_path:
665
+ path_variants.add(resolved_path.replace('/Users/', '~/', 1))
666
+ except:
667
+ pass
668
+
669
+ # If path starts with ~, also try expanded version
670
+ if file_path.startswith('~'):
671
+ expanded = os.path.expanduser(file_path)
672
+ path_variants.add(expanded)
673
+
674
+ # Convert all to forward slashes for consistency
675
+ path_variants = {p.replace('\\', '/') for p in path_variants if p}
676
+
677
+ await ctx.debug(f"Searching with path variants: {list(path_variants)}")
678
+
616
679
  # Search for file mentions in metadata
617
680
  collections_response = await self.qdrant_client.get_collections()
618
681
  collections = collections_response.collections
619
-
620
- # Define async function to search a single collection
682
+
683
+ # Define async function to search a single collection using scroll
621
684
  async def search_collection(collection_name: str):
622
685
  try:
623
- # Search by payload filter
624
- search_results = await self.qdrant_client.search(
686
+ from qdrant_client import models
687
+
688
+ # Use scroll with proper filter for metadata-only search
689
+ results, _ = await self.qdrant_client.scroll(
625
690
  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
- }
691
+ scroll_filter=models.Filter(
692
+ should=[
693
+ models.FieldCondition(
694
+ key="files_analyzed",
695
+ match=models.MatchValue(value=path_variant)
696
+ )
697
+ for path_variant in path_variants
634
698
  ]
635
- }
699
+ ),
700
+ limit=limit,
701
+ with_payload=True
636
702
  )
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
703
+
704
+ formatted_results = []
705
+ for point in results:
706
+ formatted_results.append({
707
+ 'conversation_id': point.payload.get('conversation_id'),
708
+ 'timestamp': point.payload.get('timestamp'),
709
+ 'content': point.payload.get('content', point.payload.get('text', '')),
710
+ 'files_analyzed': point.payload.get('files_analyzed', []),
711
+ 'score': 1.0 # No score in scroll, use 1.0 for found items
646
712
  })
647
- return results
648
-
713
+ return formatted_results
714
+
649
715
  except Exception as e:
650
716
  await ctx.debug(f"Error searching {collection_name}: {e}")
651
717
  return []
652
718
 
653
- # Use asyncio.gather for PARALLEL search across all collections
719
+ # SECURITY FIX: Use proper concurrency limiting
654
720
  import asyncio
721
+ from .security_patches import ConcurrencyLimiter
722
+
655
723
  search_tasks = [search_collection(c.name) for c in collections]
656
-
657
- # Limit concurrent searches to avoid overload
658
- batch_size = 20
724
+
725
+ # Use semaphore-based limiting instead of batching
659
726
  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:
727
+ batch_results = await ConcurrencyLimiter.limited_gather(search_tasks, limit=10)
728
+ for results in batch_results:
729
+ if isinstance(results, Exception):
730
+ logger.error(f"Search task failed: {type(results).__name__}: {results}")
731
+ await ctx.debug(f"Search task error: {results}")
732
+ continue
733
+ if results:
664
734
  all_results.extend(results)
665
735
 
666
736
  # Format results
@@ -743,7 +813,7 @@ def register_search_tools(
743
813
  project_resolver # Pass the resolver
744
814
  )
745
815
 
746
- @mcp.tool()
816
+ @mcp.tool(name="csr_reflect_on_past")
747
817
  async def reflect_on_past(
748
818
  ctx: Context,
749
819
  query: str = Field(description="The search query to find semantically similar conversations"),
@@ -756,29 +826,45 @@ def register_search_tools(
756
826
  include_raw: bool = Field(default=False, description="Include raw Qdrant payload data for debugging (increases response size)"),
757
827
  response_format: str = Field(default="xml", description="Response format: 'xml' or 'markdown'")
758
828
  ) -> str:
759
- """Search for relevant past conversations using semantic search with optional time decay."""
829
+ """Search past Claude conversations semantically to find relevant context.
830
+
831
+ WHEN TO USE: User asks 'what did we discuss about X?', 'find conversations about Y',
832
+ mentions 'remember when' or 'last time', debugging issues that may have been solved before,
833
+ or finding implementation patterns used in the project.
834
+
835
+ This is the PRIMARY tool for conversation memory - use it liberally!"""
760
836
  return await tools.reflect_on_past(ctx, query, limit, min_score, use_decay, project, mode, brief, include_raw, response_format)
761
837
 
762
- @mcp.tool()
838
+ @mcp.tool(name="csr_quick_check")
763
839
  async def quick_search(
764
840
  ctx: Context,
765
841
  query: str = Field(description="The search query to find semantically similar conversations"),
766
842
  min_score: float = Field(default=0.3, description="Minimum similarity score (0-1)"),
767
843
  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
844
  ) -> str:
769
- """Quick search that returns only the count and top result for fast overview."""
845
+ """Quick check if a topic was discussed before (returns count + top match only).
846
+
847
+ WHEN TO USE: User asks 'have we discussed X?' or 'is there anything about Y?',
848
+ need a yes/no answer about topic existence, checking if a problem was encountered before.
849
+
850
+ Much faster than full search - use for existence checks!"""
770
851
  return await tools.quick_search(ctx, query, min_score, project)
771
852
 
772
- @mcp.tool()
853
+ @mcp.tool(name="csr_search_insights")
773
854
  async def search_summary(
774
855
  ctx: Context,
775
856
  query: str = Field(description="The search query to find semantically similar conversations"),
776
857
  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
858
  ) -> str:
778
- """Get aggregated insights from search results without individual result details."""
859
+ """Get aggregated insights and patterns from search results.
860
+
861
+ WHEN TO USE: User wants patterns or trends, analyzing topic evolution,
862
+ understanding common themes, getting high-level view without details.
863
+
864
+ Provides analysis, not just search results!"""
779
865
  return await tools.search_summary(ctx, query, project)
780
866
 
781
- @mcp.tool()
867
+ @mcp.tool(name="csr_get_more")
782
868
  async def get_more_results(
783
869
  ctx: Context,
784
870
  query: str = Field(description="The original search query"),
@@ -787,20 +873,30 @@ def register_search_tools(
787
873
  min_score: float = Field(default=0.3, description="Minimum similarity score (0-1)"),
788
874
  project: Optional[str] = Field(default=None, description="Search specific project only")
789
875
  ) -> str:
790
- """Get additional search results after an initial search (pagination support)."""
876
+ """Get additional search results for paginated exploration.
877
+
878
+ WHEN TO USE: User says 'show me more' after a search, initial results weren't sufficient,
879
+ deep diving into a topic, user wants comprehensive coverage.
880
+
881
+ Use after initial search when more context is needed!"""
791
882
  return await tools.get_more_results(ctx, query, offset, limit, min_score, project)
792
883
 
793
- @mcp.tool()
884
+ @mcp.tool(name="csr_search_by_file")
794
885
  async def search_by_file(
795
886
  ctx: Context,
796
887
  file_path: str = Field(description="The file path to search for in conversations"),
797
888
  limit: int = Field(default=10, description="Maximum number of results to return"),
798
889
  project: Optional[str] = Field(default=None, description="Search specific project only. Use 'all' to search across all projects.")
799
890
  ) -> str:
800
- """Search for conversations that analyzed a specific file."""
891
+ """Find all conversations that analyzed or modified a specific file.
892
+
893
+ WHEN TO USE: User asks 'when did we modify X file?', investigating file history,
894
+ understanding why changes were made, finding discussions about specific code files.
895
+
896
+ Perfect for code archaeology and understanding file evolution!"""
801
897
  return await tools.search_by_file(ctx, file_path, limit, project)
802
898
 
803
- @mcp.tool()
899
+ @mcp.tool(name="csr_search_by_concept")
804
900
  async def search_by_concept(
805
901
  ctx: Context,
806
902
  concept: str = Field(description="The concept to search for (e.g., 'security', 'docker', 'testing')"),
@@ -808,7 +904,12 @@ def register_search_tools(
808
904
  project: Optional[str] = Field(default=None, description="Search specific project only. Use 'all' to search across all projects."),
809
905
  include_files: bool = Field(default=True, description="Include file information in results")
810
906
  ) -> str:
811
- """Search for conversations about a specific development concept."""
907
+ """Search for conversations about specific development concepts or themes.
908
+
909
+ WHEN TO USE: User asks about broad topics like 'security', 'testing', 'performance',
910
+ looking for all discussions on a technical theme, gathering knowledge about a concept.
911
+
912
+ Ideal for thematic analysis and knowledge gathering!"""
812
913
  return await tools.search_by_concept(ctx, concept, limit, project, include_files)
813
914
 
814
915
  @mcp.tool()