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.
@@ -0,0 +1,181 @@
1
+ """Runtime mode switching tool for embedding models."""
2
+
3
+ import os
4
+ import logging
5
+ from typing import Literal
6
+ from fastmcp import Context
7
+ from pydantic import Field
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ModeSwitcher:
13
+ """Handles runtime switching between embedding modes."""
14
+
15
+ def __init__(self, get_embedding_manager):
16
+ """Initialize with embedding manager getter."""
17
+ self.get_embedding_manager = get_embedding_manager
18
+
19
+ async def switch_mode(
20
+ self,
21
+ ctx: Context,
22
+ mode: Literal["local", "cloud"]
23
+ ) -> str:
24
+ """Switch between local and cloud embedding modes at runtime."""
25
+
26
+ await ctx.debug(f"Switching to {mode} mode...")
27
+
28
+ try:
29
+ # Get the current embedding manager
30
+ manager = self.get_embedding_manager()
31
+
32
+ # Store current state
33
+ old_mode = manager.model_type
34
+ old_prefer_local = manager.prefer_local
35
+
36
+ # Update configuration based on requested mode
37
+ if mode == "local":
38
+ # Switch to local mode
39
+ manager.prefer_local = True
40
+ # Clear voyage key to force local
41
+ manager.voyage_key = None
42
+
43
+ # Reinitialize with local preference
44
+ if not manager.local_model:
45
+ success = manager.try_initialize_local()
46
+ if not success:
47
+ return "❌ Failed to initialize local model"
48
+
49
+ # Update default model type
50
+ manager.model_type = 'local'
51
+
52
+ await ctx.debug("Switched to LOCAL mode (FastEmbed, 384 dimensions)")
53
+
54
+ elif mode == "cloud":
55
+ # Switch to cloud mode
56
+ # First check if we have a Voyage key
57
+ voyage_key = os.getenv('VOYAGE_KEY') or os.getenv('VOYAGE_KEY-2')
58
+ if not voyage_key:
59
+ # Try to load from .env file
60
+ from pathlib import Path
61
+ from dotenv import load_dotenv
62
+ env_path = Path(__file__).parent.parent.parent / '.env'
63
+ load_dotenv(env_path, override=True)
64
+ voyage_key = os.getenv('VOYAGE_KEY') or os.getenv('VOYAGE_KEY-2')
65
+
66
+ if not voyage_key:
67
+ return "❌ Cannot switch to cloud mode: VOYAGE_KEY not found in environment or .env file"
68
+
69
+ manager.prefer_local = False
70
+ manager.voyage_key = voyage_key
71
+
72
+ # Reinitialize Voyage client
73
+ if not manager.voyage_client:
74
+ success = manager.try_initialize_voyage()
75
+ if not success:
76
+ # Restore previous state
77
+ manager.prefer_local = old_prefer_local
78
+ manager.model_type = old_mode
79
+ return "❌ Failed to initialize Voyage AI client"
80
+
81
+ # Update default model type
82
+ manager.model_type = 'voyage'
83
+
84
+ await ctx.debug("Switched to CLOUD mode (Voyage AI, 1024 dimensions)")
85
+
86
+ # Log the switch
87
+ logger.info(f"Mode switched from {old_mode} to {manager.model_type}")
88
+
89
+ # Prepare detailed response
90
+ return f"""✅ Successfully switched to {mode.upper()} mode!
91
+
92
+ **Previous Configuration:**
93
+ - Mode: {old_mode}
94
+ - Prefer Local: {old_prefer_local}
95
+
96
+ **New Configuration:**
97
+ - Mode: {manager.model_type}
98
+ - Prefer Local: {manager.prefer_local}
99
+ - Vector Dimensions: {manager.get_vector_dimension()}
100
+ - Has Voyage Key: {bool(manager.voyage_key)}
101
+
102
+ **Important Notes:**
103
+ - New reflections will go to: reflections_{manager.model_type}
104
+ - Existing collections remain unchanged
105
+ - No restart required! 🎉
106
+
107
+ **Next Steps:**
108
+ - Use `store_reflection` to test the new mode
109
+ - Use `reflect_on_past` to search across all collections"""
110
+
111
+ except Exception as e:
112
+ logger.error(f"Failed to switch mode: {e}", exc_info=True)
113
+ return f"❌ Failed to switch mode: {str(e)}"
114
+
115
+ async def get_current_mode(self, ctx: Context) -> str:
116
+ """Get the current embedding mode and configuration."""
117
+
118
+ try:
119
+ manager = self.get_embedding_manager()
120
+
121
+ # Check actual model availability
122
+ local_available = manager.local_model is not None
123
+ voyage_available = manager.voyage_client is not None
124
+
125
+ return f"""📊 Current Embedding Configuration:
126
+
127
+ **Active Mode:** {manager.model_type.upper()}
128
+ **Vector Dimensions:** {manager.get_vector_dimension()}
129
+
130
+ **Configuration:**
131
+ - Prefer Local: {manager.prefer_local}
132
+ - Has Voyage Key: {bool(manager.voyage_key)}
133
+
134
+ **Available Models:**
135
+ - Local (FastEmbed): {'✅ Initialized' if local_available else '❌ Not initialized'}
136
+ - Cloud (Voyage AI): {'✅ Initialized' if voyage_available else '❌ Not initialized'}
137
+
138
+ **Collection Names:**
139
+ - Reflections: reflections_{manager.model_type}
140
+ - Conversations: [project]_{manager.model_type}
141
+
142
+ **Environment:**
143
+ - PREFER_LOCAL_EMBEDDINGS: {os.getenv('PREFER_LOCAL_EMBEDDINGS', 'not set')}
144
+ - VOYAGE_KEY: {'set' if manager.voyage_key else 'not set'}"""
145
+
146
+ except Exception as e:
147
+ logger.error(f"Failed to get current mode: {e}", exc_info=True)
148
+ return f"❌ Failed to get current mode: {str(e)}"
149
+
150
+
151
+ def register_mode_switch_tool(mcp, get_embedding_manager):
152
+ """Register the mode switching tool with the MCP server."""
153
+
154
+ switcher = ModeSwitcher(get_embedding_manager)
155
+
156
+ @mcp.tool()
157
+ async def switch_embedding_mode(
158
+ ctx: Context,
159
+ mode: Literal["local", "cloud"] = Field(
160
+ description="Target embedding mode: 'local' for FastEmbed (384 dim), 'cloud' for Voyage AI (1024 dim)"
161
+ )
162
+ ) -> str:
163
+ """Switch between local and cloud embedding modes at runtime without restarting the MCP server.
164
+
165
+ This allows dynamic switching between:
166
+ - LOCAL mode: FastEmbed with 384 dimensions (privacy-first, no API calls)
167
+ - CLOUD mode: Voyage AI with 1024 dimensions (better quality, requires API key)
168
+
169
+ No restart required! The change takes effect immediately for all new operations.
170
+ """
171
+ return await switcher.switch_mode(ctx, mode)
172
+
173
+ @mcp.tool()
174
+ async def get_embedding_mode(ctx: Context) -> str:
175
+ """Get the current embedding mode configuration and status.
176
+
177
+ Shows which mode is active, available models, and collection naming.
178
+ """
179
+ return await switcher.get_current_mode(ctx)
180
+
181
+ logger.info("Mode switching tools registered successfully")
@@ -64,90 +64,21 @@ async def search_single_collection(
64
64
  DECAY_SCALE_DAYS = constants.get('DECAY_SCALE_DAYS', 90)
65
65
  DECAY_WEIGHT = constants.get('DECAY_WEIGHT', 0.3)
66
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
-
67
+ # NOTE: Native decay API is not available in current Qdrant, fall back to client-side
68
+ # The Fusion/RankFusion API was experimental and removed, always use client-side decay
69
+ if should_use_decay and False: # Disabled until Qdrant provides stable decay API
70
+ # This code path is intentionally disabled
71
+ pass
145
72
  else:
73
+ # SECURITY FIX: Reduce memory multiplier to prevent OOM
74
+ from .security_patches import MemoryOptimizer
75
+ safe_limit = MemoryOptimizer.calculate_safe_limit(limit, 1.5) if should_use_decay else limit
76
+
146
77
  # Standard search without native decay or client-side decay
147
78
  search_results = await qdrant_client.search(
148
79
  collection_name=collection_name,
149
80
  query_vector=query_embedding,
150
- limit=limit * 3 if should_use_decay else limit, # Get more results for client-side decay
81
+ limit=safe_limit, # Use safe limit to prevent memory explosion
151
82
  score_threshold=min_score if not should_use_decay else 0.0,
152
83
  with_payload=True
153
84
  )
@@ -220,17 +151,24 @@ async def search_single_collection(
220
151
  continue
221
152
  logger.debug(f"Keeping point: project '{point_project}' matches target '{target_project}'")
222
153
 
223
- # Create SearchResult
154
+ # Create SearchResult with consistent structure
224
155
  search_result = {
225
156
  'id': str(point.id),
226
157
  'score': adjusted_score,
227
158
  'timestamp': clean_timestamp,
228
159
  '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
160
+ 'excerpt': (point.payload.get('text', '')[:350] + '...'
161
+ if len(point.payload.get('text', '')) > 350
231
162
  else point.payload.get('text', '')),
232
163
  'project_name': point_project,
233
- 'payload': point.payload
164
+ 'conversation_id': point.payload.get('conversation_id'),
165
+ 'base_conversation_id': point.payload.get('base_conversation_id'),
166
+ 'collection_name': collection_name,
167
+ 'raw_payload': point.payload, # Renamed from 'payload' for consistency
168
+ 'code_patterns': point.payload.get('code_patterns'),
169
+ 'files_analyzed': point.payload.get('files_analyzed'),
170
+ 'tools_used': list(point.payload.get('tools_used', [])) if isinstance(point.payload.get('tools_used'), set) else point.payload.get('tools_used'),
171
+ 'concepts': point.payload.get('concepts')
234
172
  }
235
173
  results.append(search_result)
236
174
  else:
@@ -358,8 +296,9 @@ async def parallel_search_collections(
358
296
 
359
297
  for result in search_results:
360
298
  if isinstance(result, Exception):
361
- # Handle exceptions from gather
362
- logger.error(f"Search task failed: {result}")
299
+ # SECURITY FIX: Proper exception logging with context
300
+ from .security_patches import ExceptionLogger
301
+ ExceptionLogger.log_exception(result, "parallel_search_task")
363
302
  continue
364
303
 
365
304
  collection_name, results, timing = result
@@ -219,11 +219,27 @@ class ProjectResolver:
219
219
  logger.debug(f"Failed to scroll {coll_name}: {e}")
220
220
  continue
221
221
 
222
+ # Add appropriate reflection collections based on the found conversation collections
223
+ # If we found _local collections, add reflections_local
224
+ # If we found _voyage collections, add reflections_voyage
225
+ reflection_collections = set()
226
+ for coll in matching_collections:
227
+ if coll.endswith('_local') and 'reflections_local' in collection_names:
228
+ reflection_collections.add('reflections_local')
229
+ elif coll.endswith('_voyage') and 'reflections_voyage' in collection_names:
230
+ reflection_collections.add('reflections_voyage')
231
+
232
+ # Also check for the legacy 'reflections' collection
233
+ if 'reflections' in collection_names:
234
+ reflection_collections.add('reflections')
235
+
236
+ matching_collections.update(reflection_collections)
237
+
222
238
  # Cache the result with TTL
223
239
  result = list(matching_collections)
224
240
  self._cache[user_project_name] = matching_collections
225
241
  self._cache_ttl[user_project_name] = time()
226
-
242
+
227
243
  return result
228
244
 
229
245
  def _get_collection_names(self, force_refresh: bool = False) -> List[str]:
@@ -244,7 +260,9 @@ class ProjectResolver:
244
260
  # Fetch fresh collection list
245
261
  try:
246
262
  all_collections = self.client.get_collections().collections
247
- collection_names = [c.name for c in all_collections if c.name.startswith('conv_')]
263
+ # Include both conversation collections and reflection collections
264
+ collection_names = [c.name for c in all_collections
265
+ if c.name.startswith('conv_') or c.name.startswith('reflections')]
248
266
 
249
267
  # Update cache
250
268
  self._collections_cache = collection_names
@@ -44,9 +44,24 @@ class ReflectionTools:
44
44
  await ctx.debug(f"Storing reflection with {len(tags)} tags")
45
45
 
46
46
  try:
47
- # Determine collection name based on embedding type
47
+ # Check runtime preference from environment
48
+ import os
49
+ prefer_local = os.getenv('PREFER_LOCAL_EMBEDDINGS', 'true').lower() == 'true'
50
+
48
51
  embedding_manager = self.get_embedding_manager()
49
- embedding_type = "local" if embedding_manager.prefer_local else "voyage"
52
+
53
+ # Use embedding_manager's model_type which already respects preferences
54
+ embedding_type = embedding_manager.model_type
55
+
56
+ if embedding_type == "local":
57
+ await ctx.debug("Using LOCAL mode (FastEmbed, 384 dimensions)")
58
+ elif embedding_type == "voyage":
59
+ await ctx.debug("Using VOYAGE mode (Voyage AI, 1024 dimensions)")
60
+ else:
61
+ # Shouldn't happen but handle gracefully
62
+ embedding_type = "local" if embedding_manager.local_model else "voyage"
63
+ await ctx.debug(f"Using {embedding_type} mode (fallback)")
64
+
50
65
  collection_name = f"reflections_{embedding_type}"
51
66
 
52
67
  # Ensure reflections collection exists
@@ -57,8 +72,8 @@ class ReflectionTools:
57
72
  # Collection doesn't exist, create it
58
73
  await ctx.debug(f"Creating {collection_name} collection")
59
74
 
60
- # Determine embedding dimensions
61
- embedding_dim = embedding_manager.get_vector_dimension()
75
+ # Get embedding dimensions for the specific type
76
+ embedding_dim = embedding_manager.get_vector_dimension(force_type=embedding_type)
62
77
 
63
78
  await self.qdrant_client.create_collection(
64
79
  collection_name=collection_name,
@@ -67,13 +82,18 @@ class ReflectionTools:
67
82
  distance=Distance.COSINE
68
83
  )
69
84
  )
85
+
86
+ # Generate embedding with the same forced type for consistency
87
+ embedding = await embedding_manager.generate_embedding(content, force_type=embedding_type)
88
+
89
+ # Guard against failed embeddings
90
+ if not embedding:
91
+ await ctx.debug("Failed to generate embedding for reflection")
92
+ return "Failed to store reflection: embedding generation failed"
70
93
 
71
- # Generate embedding for the reflection
72
- embedding_manager = self.get_embedding_manager()
73
- embedding = await embedding_manager.generate_embedding(content)
74
-
75
- # Create unique ID
76
- reflection_id = hashlib.md5(f"{content}{datetime.now().isoformat()}".encode()).hexdigest()
94
+ # SECURITY FIX: Use SHA-256 instead of MD5
95
+ from .security_patches import SecureHashGenerator
96
+ reflection_id = SecureHashGenerator.generate_id(f"{content}{datetime.now().isoformat()}")
77
97
 
78
98
  # Prepare metadata
79
99
  metadata = {
@@ -99,6 +119,7 @@ class ReflectionTools:
99
119
 
100
120
  return f"""Reflection stored successfully.
101
121
  ID: {reflection_id}
122
+ Collection: {collection_name}
102
123
  Tags: {', '.join(tags) if tags else 'none'}
103
124
  Timestamp: {metadata['timestamp']}"""
104
125
 
@@ -120,17 +141,34 @@ Timestamp: {metadata['timestamp']}"""
120
141
  try:
121
142
  # Base path for conversations
122
143
  base_path = Path.home() / '.claude' / 'projects'
123
-
144
+
145
+ # SECURITY FIX: Validate paths to prevent traversal
146
+ from .security_patches import PathValidator
147
+ if not PathValidator.is_safe_path(base_path):
148
+ logger.error(f"Unsafe base path detected: {base_path}")
149
+ return "<conversation_file><error>Security validation failed</error></conversation_file>"
150
+
124
151
  # If project is specified, try to find it in that project
125
152
  if project:
126
153
  # Normalize project name for path matching
127
- project_normalized = self.normalize_project_name(project)
128
-
154
+ from .security_patches import InputValidator
155
+ project_normalized = InputValidator.validate_project_name(
156
+ self.normalize_project_name(project)
157
+ )
158
+
129
159
  # Look for project directories that match
130
160
  for project_dir in base_path.glob('*'):
161
+ # Validate each path before accessing
162
+ if not PathValidator.is_safe_path(project_dir):
163
+ continue
164
+
131
165
  if project_normalized in project_dir.name.lower():
132
166
  # Look for JSONL files in this project
133
167
  for jsonl_file in project_dir.glob('*.jsonl'):
168
+ # Validate file path
169
+ if not PathValidator.is_safe_path(jsonl_file):
170
+ continue
171
+
134
172
  # Check if filename matches conversation_id (with or without .jsonl)
135
173
  if conversation_id in jsonl_file.stem or conversation_id == jsonl_file.stem:
136
174
  await ctx.debug(f"Found conversation by filename in {jsonl_file}")
@@ -143,8 +181,17 @@ Timestamp: {metadata['timestamp']}"""
143
181
 
144
182
  # If not found in specific project or no project specified, search all
145
183
  await ctx.debug("Searching all projects for conversation")
184
+ from .security_patches import PathValidator
146
185
  for project_dir in base_path.glob('*'):
186
+ # SECURITY FIX: Validate each path before accessing
187
+ if not PathValidator.is_safe_path(project_dir):
188
+ continue
189
+
147
190
  for jsonl_file in project_dir.glob('*.jsonl'):
191
+ # Validate file path
192
+ if not PathValidator.is_safe_path(jsonl_file):
193
+ continue
194
+
148
195
  # Check if filename matches conversation_id (with or without .jsonl)
149
196
  if conversation_id in jsonl_file.stem or conversation_id == jsonl_file.stem:
150
197
  await ctx.debug(f"Found conversation by filename in {jsonl_file}")
@@ -103,6 +103,109 @@ def format_search_results_rich(
103
103
  result_text += f" <relevance>No conversations matched your query</relevance>\n"
104
104
  result_text += f" </result-summary>\n"
105
105
 
106
+ # Add aggregated insights section (NEW FEATURE)
107
+ if results and len(results) > 1:
108
+ result_text += " <insights>\n"
109
+ result_text += f" <!-- Processing {len(results)} results for pattern analysis -->\n"
110
+
111
+ # Aggregate file modification patterns
112
+ file_frequency = {}
113
+ tool_frequency = {}
114
+ concept_frequency = {}
115
+
116
+ for result in results:
117
+ # Count file modifications
118
+ for file in result.get('files_analyzed', []):
119
+ file_frequency[file] = file_frequency.get(file, 0) + 1
120
+
121
+ # Count tool usage
122
+ for tool in result.get('tools_used', []):
123
+ tool_frequency[tool] = tool_frequency.get(tool, 0) + 1
124
+
125
+ # Count concepts
126
+ for concept in result.get('concepts', []):
127
+ concept_frequency[concept] = concept_frequency.get(concept, 0) + 1
128
+
129
+ # Show most frequently modified files
130
+ if file_frequency:
131
+ top_files = sorted(file_frequency.items(), key=lambda x: x[1], reverse=True)[:3]
132
+ if top_files:
133
+ result_text += ' <pattern type="files">\n'
134
+ result_text += f' <title>📁 Frequently Modified Files</title>\n'
135
+ for file, count in top_files:
136
+ percentage = (count / len(results)) * 100
137
+ result_text += f' <item count="{count}" pct="{percentage:.0f}%">{file}</item>\n'
138
+ result_text += ' </pattern>\n'
139
+
140
+ # Show common tools used
141
+ if tool_frequency:
142
+ top_tools = sorted(tool_frequency.items(), key=lambda x: x[1], reverse=True)[:3]
143
+ if top_tools:
144
+ result_text += ' <pattern type="tools">\n'
145
+ result_text += f' <title>🔧 Common Tools Used</title>\n'
146
+ for tool, count in top_tools:
147
+ percentage = (count / len(results)) * 100
148
+ result_text += f' <item count="{count}" pct="{percentage:.0f}%">{tool}</item>\n'
149
+ result_text += ' </pattern>\n'
150
+
151
+ # Show related concepts
152
+ if concept_frequency:
153
+ top_concepts = sorted(concept_frequency.items(), key=lambda x: x[1], reverse=True)[:3]
154
+ if top_concepts:
155
+ result_text += ' <pattern type="concepts">\n'
156
+ result_text += f' <title>💡 Related Concepts</title>\n'
157
+ for concept, count in top_concepts:
158
+ percentage = (count / len(results)) * 100
159
+ result_text += f' <item count="{count}" pct="{percentage:.0f}%">{concept}</item>\n'
160
+ result_text += ' </pattern>\n'
161
+
162
+ # Add workflow suggestion based on patterns
163
+ if file_frequency and tool_frequency:
164
+ most_common_file = list(file_frequency.keys())[0] if file_frequency else None
165
+ most_common_tool = list(tool_frequency.keys())[0] if tool_frequency else None
166
+ if most_common_file and most_common_tool:
167
+ result_text += ' <suggestion>\n'
168
+ result_text += f' <title>💭 Pattern Detection</title>\n'
169
+ result_text += f' <text>Similar conversations often involve {most_common_tool} on {most_common_file}</text>\n'
170
+ result_text += ' </suggestion>\n'
171
+
172
+ # Always show a summary even if no clear patterns
173
+ if not file_frequency and not tool_frequency and not concept_frequency:
174
+ result_text += ' <summary>\n'
175
+ result_text += f' <title>📊 Analysis Summary</title>\n'
176
+ result_text += f' <text>Analyzed {len(results)} conversations for patterns</text>\n'
177
+
178
+ # Show temporal distribution
179
+ now = datetime.now(timezone.utc)
180
+ time_dist = {"today": 0, "week": 0, "month": 0, "older": 0}
181
+ for result in results:
182
+ timestamp_str = result.get('timestamp', '')
183
+ if timestamp_str:
184
+ try:
185
+ timestamp_clean = timestamp_str.replace('Z', '+00:00') if timestamp_str.endswith('Z') else timestamp_str
186
+ timestamp_dt = datetime.fromisoformat(timestamp_clean)
187
+ if timestamp_dt.tzinfo is None:
188
+ timestamp_dt = timestamp_dt.replace(tzinfo=timezone.utc)
189
+ days_ago = (now - timestamp_dt).days
190
+ if days_ago == 0:
191
+ time_dist["today"] += 1
192
+ elif days_ago <= 7:
193
+ time_dist["week"] += 1
194
+ elif days_ago <= 30:
195
+ time_dist["month"] += 1
196
+ else:
197
+ time_dist["older"] += 1
198
+ except:
199
+ pass
200
+
201
+ if any(time_dist.values()):
202
+ dist_str = ", ".join([f"{v} {k}" for k, v in time_dist.items() if v > 0])
203
+ result_text += f' <temporal>Time distribution: {dist_str}</temporal>\n'
204
+
205
+ result_text += ' </summary>\n'
206
+
207
+ result_text += " </insights>\n\n"
208
+
106
209
  # Add metadata
107
210
  result_text += f" <meta>\n"
108
211
  result_text += f" <q>{query}</q>\n"