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.
- package/.claude/agents/claude-self-reflect-test.md +525 -11
- package/.claude/agents/quality-fixer.md +314 -0
- package/.claude/agents/reflection-specialist.md +40 -1
- package/installer/cli.js +16 -0
- package/installer/postinstall.js +14 -0
- package/installer/statusline-setup.js +289 -0
- package/mcp-server/run-mcp.sh +45 -7
- package/mcp-server/src/code_reload_tool.py +271 -0
- package/mcp-server/src/embedding_manager.py +60 -26
- package/mcp-server/src/enhanced_tool_registry.py +407 -0
- package/mcp-server/src/mode_switch_tool.py +181 -0
- package/mcp-server/src/parallel_search.py +24 -85
- package/mcp-server/src/project_resolver.py +20 -2
- package/mcp-server/src/reflection_tools.py +60 -13
- package/mcp-server/src/rich_formatting.py +103 -0
- package/mcp-server/src/search_tools.py +180 -79
- package/mcp-server/src/security_patches.py +555 -0
- package/mcp-server/src/server.py +318 -240
- package/mcp-server/src/status.py +13 -8
- package/mcp-server/src/temporal_tools.py +10 -3
- package/mcp-server/src/test_quality.py +153 -0
- package/package.json +6 -1
- package/scripts/ast_grep_final_analyzer.py +328 -0
- package/scripts/ast_grep_unified_registry.py +710 -0
- package/scripts/csr-status +511 -0
- package/scripts/import-conversations-unified.py +114 -28
- package/scripts/session_quality_tracker.py +661 -0
- package/scripts/streaming-watcher.py +140 -5
- package/scripts/update_patterns.py +334 -0
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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=
|
|
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
|
-
'
|
|
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
|
-
#
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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"
|