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.
- package/.claude/agents/claude-self-reflect-test.md +595 -528
- package/.claude/agents/reflection-specialist.md +59 -3
- package/README.md +14 -5
- package/mcp-server/run-mcp.sh +49 -5
- package/mcp-server/src/app_context.py +64 -0
- package/mcp-server/src/config.py +57 -0
- package/mcp-server/src/connection_pool.py +286 -0
- package/mcp-server/src/decay_manager.py +106 -0
- package/mcp-server/src/embedding_manager.py +64 -40
- package/mcp-server/src/embeddings_old.py +141 -0
- package/mcp-server/src/models.py +64 -0
- package/mcp-server/src/parallel_search.py +371 -0
- package/mcp-server/src/project_resolver.py +5 -0
- package/mcp-server/src/reflection_tools.py +206 -0
- package/mcp-server/src/rich_formatting.py +196 -0
- package/mcp-server/src/search_tools.py +826 -0
- package/mcp-server/src/server.py +127 -1720
- package/mcp-server/src/temporal_design.py +132 -0
- package/mcp-server/src/temporal_tools.py +597 -0
- package/mcp-server/src/temporal_utils.py +384 -0
- package/mcp-server/src/utils.py +150 -67
- package/package.json +10 -1
- package/scripts/add-timestamp-indexes.py +134 -0
- package/scripts/check-collections.py +29 -0
- package/scripts/debug-august-parsing.py +76 -0
- package/scripts/debug-import-single.py +91 -0
- package/scripts/debug-project-resolver.py +82 -0
- package/scripts/debug-temporal-tools.py +135 -0
- package/scripts/delta-metadata-update.py +547 -0
- package/scripts/import-conversations-unified.py +53 -2
- package/scripts/precompact-hook.sh +33 -0
- package/scripts/streaming-watcher.py +1443 -0
- package/scripts/utils.py +39 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parallel search implementation for Qdrant collections.
|
|
3
|
+
This module implements asyncio.gather-based parallel searching to improve performance.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import time
|
|
8
|
+
from typing import List, Dict, Any, Optional, Tuple
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
async def search_single_collection(
|
|
15
|
+
collection_name: str,
|
|
16
|
+
query: str,
|
|
17
|
+
query_embeddings: Dict[str, List[float]],
|
|
18
|
+
qdrant_client: Any,
|
|
19
|
+
ctx: Any,
|
|
20
|
+
limit: int,
|
|
21
|
+
min_score: float,
|
|
22
|
+
should_use_decay: bool,
|
|
23
|
+
target_project: str,
|
|
24
|
+
generate_embedding_func: Any,
|
|
25
|
+
constants: Dict[str, Any]
|
|
26
|
+
) -> Tuple[str, List[Any], Dict[str, Any]]:
|
|
27
|
+
"""
|
|
28
|
+
Search a single collection and return results.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (collection_name, results, timing_info)
|
|
32
|
+
"""
|
|
33
|
+
collection_timing = {'name': collection_name, 'start': time.time()}
|
|
34
|
+
results = []
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
# Determine embedding type for this collection
|
|
38
|
+
embedding_type_for_collection = 'voyage' if collection_name.endswith('_voyage') else 'local'
|
|
39
|
+
logger.debug(f"Collection {collection_name} needs {embedding_type_for_collection} embedding")
|
|
40
|
+
|
|
41
|
+
# Generate or retrieve cached embedding for this type
|
|
42
|
+
if embedding_type_for_collection not in query_embeddings:
|
|
43
|
+
try:
|
|
44
|
+
query_embeddings[embedding_type_for_collection] = await generate_embedding_func(
|
|
45
|
+
query, force_type=embedding_type_for_collection
|
|
46
|
+
)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
await ctx.debug(f"Failed to generate {embedding_type_for_collection} embedding: {e}")
|
|
49
|
+
collection_timing['error'] = str(e)
|
|
50
|
+
collection_timing['end'] = time.time()
|
|
51
|
+
return collection_name, results, collection_timing
|
|
52
|
+
|
|
53
|
+
query_embedding = query_embeddings[embedding_type_for_collection]
|
|
54
|
+
|
|
55
|
+
# Check if this is a reflections collection
|
|
56
|
+
is_reflection_collection = collection_name.startswith('reflections_')
|
|
57
|
+
|
|
58
|
+
# Import necessary models
|
|
59
|
+
from qdrant_client import models
|
|
60
|
+
|
|
61
|
+
# Determine which decay method to use
|
|
62
|
+
USE_NATIVE_DECAY = constants.get('USE_NATIVE_DECAY', False)
|
|
63
|
+
NATIVE_DECAY_AVAILABLE = constants.get('NATIVE_DECAY_AVAILABLE', False)
|
|
64
|
+
DECAY_SCALE_DAYS = constants.get('DECAY_SCALE_DAYS', 90)
|
|
65
|
+
DECAY_WEIGHT = constants.get('DECAY_WEIGHT', 0.3)
|
|
66
|
+
|
|
67
|
+
if should_use_decay and USE_NATIVE_DECAY and NATIVE_DECAY_AVAILABLE:
|
|
68
|
+
# Use native Qdrant decay with newer API
|
|
69
|
+
await ctx.debug(f"Using NATIVE Qdrant decay (new API) for {collection_name}")
|
|
70
|
+
|
|
71
|
+
half_life_seconds = DECAY_SCALE_DAYS * 24 * 60 * 60
|
|
72
|
+
|
|
73
|
+
# Build query using Qdrant's Fusion and RankFusion
|
|
74
|
+
fusion_query = models.Fusion(
|
|
75
|
+
fusion=models.RankFusion.RRF,
|
|
76
|
+
queries=[
|
|
77
|
+
# Semantic similarity query
|
|
78
|
+
models.NearestQuery(
|
|
79
|
+
nearest=query_embedding,
|
|
80
|
+
score_threshold=min_score
|
|
81
|
+
),
|
|
82
|
+
# Time decay query using context pair
|
|
83
|
+
models.ContextQuery(
|
|
84
|
+
context=[
|
|
85
|
+
models.ContextPair(
|
|
86
|
+
positive=models.DiscoverQuery(
|
|
87
|
+
target=query_embedding,
|
|
88
|
+
context=[
|
|
89
|
+
models.ContextPair(
|
|
90
|
+
positive=models.DatetimeRange(
|
|
91
|
+
gt=datetime.now().isoformat(),
|
|
92
|
+
lt=(datetime.now().timestamp() + half_life_seconds)
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Execute search with native decay
|
|
104
|
+
search_results = await qdrant_client.query_points(
|
|
105
|
+
collection_name=collection_name,
|
|
106
|
+
query=fusion_query,
|
|
107
|
+
limit=limit,
|
|
108
|
+
with_payload=True
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Process results
|
|
112
|
+
for point in search_results.points:
|
|
113
|
+
# Process each point and add to results
|
|
114
|
+
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
115
|
+
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
116
|
+
|
|
117
|
+
point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
|
|
118
|
+
|
|
119
|
+
# Apply project filtering
|
|
120
|
+
if target_project != 'all' and not is_reflection_collection:
|
|
121
|
+
if point_project != target_project:
|
|
122
|
+
normalized_target = target_project.replace('-', '_')
|
|
123
|
+
normalized_point = point_project.replace('-', '_')
|
|
124
|
+
if not (normalized_point == normalized_target or
|
|
125
|
+
point_project.endswith(f"/{target_project}") or
|
|
126
|
+
point_project.endswith(f"-{target_project}") or
|
|
127
|
+
normalized_point.endswith(f"_{normalized_target}") or
|
|
128
|
+
normalized_point.endswith(f"/{normalized_target}")):
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Create SearchResult
|
|
132
|
+
search_result = {
|
|
133
|
+
'id': str(point.id),
|
|
134
|
+
'score': point.score,
|
|
135
|
+
'timestamp': clean_timestamp,
|
|
136
|
+
'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
|
|
137
|
+
'excerpt': (point.payload.get('text', '')[:350] + '...'
|
|
138
|
+
if len(point.payload.get('text', '')) > 350
|
|
139
|
+
else point.payload.get('text', '')),
|
|
140
|
+
'project_name': point_project,
|
|
141
|
+
'payload': point.payload
|
|
142
|
+
}
|
|
143
|
+
results.append(search_result)
|
|
144
|
+
|
|
145
|
+
else:
|
|
146
|
+
# Standard search without native decay or client-side decay
|
|
147
|
+
search_results = await qdrant_client.search(
|
|
148
|
+
collection_name=collection_name,
|
|
149
|
+
query_vector=query_embedding,
|
|
150
|
+
limit=limit * 3 if should_use_decay else limit, # Get more results for client-side decay
|
|
151
|
+
score_threshold=min_score if not should_use_decay else 0.0,
|
|
152
|
+
with_payload=True
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Debug: Log search results
|
|
156
|
+
logger.debug(f"Search of {collection_name} returned {len(search_results)} results")
|
|
157
|
+
|
|
158
|
+
if should_use_decay and not USE_NATIVE_DECAY:
|
|
159
|
+
# Apply client-side decay
|
|
160
|
+
await ctx.debug(f"Using CLIENT-SIDE decay for {collection_name}")
|
|
161
|
+
decay_results = []
|
|
162
|
+
|
|
163
|
+
for point in search_results:
|
|
164
|
+
try:
|
|
165
|
+
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
166
|
+
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
167
|
+
|
|
168
|
+
# Calculate age and decay
|
|
169
|
+
if 'timestamp' in point.payload:
|
|
170
|
+
try:
|
|
171
|
+
point_time = datetime.fromisoformat(clean_timestamp)
|
|
172
|
+
if point_time.tzinfo is None:
|
|
173
|
+
from zoneinfo import ZoneInfo
|
|
174
|
+
point_time = point_time.replace(tzinfo=ZoneInfo('UTC'))
|
|
175
|
+
|
|
176
|
+
now = datetime.now(ZoneInfo('UTC'))
|
|
177
|
+
age_ms = (now - point_time).total_seconds() * 1000
|
|
178
|
+
|
|
179
|
+
# Exponential decay with configurable half-life
|
|
180
|
+
half_life_ms = DECAY_SCALE_DAYS * 24 * 60 * 60 * 1000
|
|
181
|
+
decay_factor = 0.5 ** (age_ms / half_life_ms)
|
|
182
|
+
|
|
183
|
+
# Apply multiplicative decay
|
|
184
|
+
adjusted_score = point.score * ((1 - DECAY_WEIGHT) + DECAY_WEIGHT * decay_factor)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
await ctx.debug(f"Error calculating decay: {e}")
|
|
187
|
+
adjusted_score = point.score
|
|
188
|
+
else:
|
|
189
|
+
adjusted_score = point.score
|
|
190
|
+
|
|
191
|
+
# Only include if above min_score after decay
|
|
192
|
+
if adjusted_score >= min_score:
|
|
193
|
+
decay_results.append((adjusted_score, point))
|
|
194
|
+
|
|
195
|
+
except Exception as e:
|
|
196
|
+
await ctx.debug(f"Error applying decay to point: {e}")
|
|
197
|
+
decay_results.append((point.score, point))
|
|
198
|
+
|
|
199
|
+
# Sort by adjusted score and take top results
|
|
200
|
+
decay_results.sort(key=lambda x: x[0], reverse=True)
|
|
201
|
+
|
|
202
|
+
# Convert to SearchResult format
|
|
203
|
+
for adjusted_score, point in decay_results[:limit]:
|
|
204
|
+
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
205
|
+
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
206
|
+
|
|
207
|
+
point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
|
|
208
|
+
|
|
209
|
+
# Apply project filtering
|
|
210
|
+
if target_project != 'all' and not is_reflection_collection:
|
|
211
|
+
if point_project != target_project:
|
|
212
|
+
normalized_target = target_project.replace('-', '_')
|
|
213
|
+
normalized_point = point_project.replace('-', '_')
|
|
214
|
+
if not (normalized_point == normalized_target or
|
|
215
|
+
point_project.endswith(f"/{target_project}") or
|
|
216
|
+
point_project.endswith(f"-{target_project}") or
|
|
217
|
+
normalized_point.endswith(f"_{normalized_target}") or
|
|
218
|
+
normalized_point.endswith(f"/{normalized_target}")):
|
|
219
|
+
logger.debug(f"Filtering out point: project '{point_project}' != target '{target_project}'")
|
|
220
|
+
continue
|
|
221
|
+
logger.debug(f"Keeping point: project '{point_project}' matches target '{target_project}'")
|
|
222
|
+
|
|
223
|
+
# Create SearchResult
|
|
224
|
+
search_result = {
|
|
225
|
+
'id': str(point.id),
|
|
226
|
+
'score': adjusted_score,
|
|
227
|
+
'timestamp': clean_timestamp,
|
|
228
|
+
'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
|
|
229
|
+
'excerpt': (point.payload.get('text', '')[:350] + '...'
|
|
230
|
+
if len(point.payload.get('text', '')) > 350
|
|
231
|
+
else point.payload.get('text', '')),
|
|
232
|
+
'project_name': point_project,
|
|
233
|
+
'payload': point.payload
|
|
234
|
+
}
|
|
235
|
+
results.append(search_result)
|
|
236
|
+
else:
|
|
237
|
+
# Process standard search results without decay
|
|
238
|
+
logger.debug(f"Processing {len(search_results)} results from {collection_name}")
|
|
239
|
+
for point in search_results:
|
|
240
|
+
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
241
|
+
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
242
|
+
|
|
243
|
+
point_project = point.payload.get('project', collection_name.replace('conv_', '').replace('_voyage', '').replace('_local', ''))
|
|
244
|
+
|
|
245
|
+
# Apply project filtering
|
|
246
|
+
if target_project != 'all' and not is_reflection_collection:
|
|
247
|
+
if point_project != target_project:
|
|
248
|
+
normalized_target = target_project.replace('-', '_')
|
|
249
|
+
normalized_point = point_project.replace('-', '_')
|
|
250
|
+
if not (normalized_point == normalized_target or
|
|
251
|
+
point_project.endswith(f"/{target_project}") or
|
|
252
|
+
point_project.endswith(f"-{target_project}") or
|
|
253
|
+
normalized_point.endswith(f"_{normalized_target}") or
|
|
254
|
+
normalized_point.endswith(f"/{normalized_target}")):
|
|
255
|
+
logger.debug(f"Filtering out point: project '{point_project}' != target '{target_project}'")
|
|
256
|
+
continue
|
|
257
|
+
logger.debug(f"Keeping point: project '{point_project}' matches target '{target_project}'")
|
|
258
|
+
|
|
259
|
+
# Create SearchResult as dictionary (consistent with other branches)
|
|
260
|
+
search_result = {
|
|
261
|
+
'id': str(point.id),
|
|
262
|
+
'score': point.score,
|
|
263
|
+
'timestamp': clean_timestamp,
|
|
264
|
+
'role': point.payload.get('start_role', point.payload.get('role', 'unknown')),
|
|
265
|
+
'excerpt': (point.payload.get('text', '')[:350] + '...'
|
|
266
|
+
if len(point.payload.get('text', '')) > 350
|
|
267
|
+
else point.payload.get('text', '')),
|
|
268
|
+
'project_name': point_project,
|
|
269
|
+
'conversation_id': point.payload.get('conversation_id'),
|
|
270
|
+
'base_conversation_id': point.payload.get('base_conversation_id'),
|
|
271
|
+
'collection_name': collection_name,
|
|
272
|
+
'raw_payload': point.payload,
|
|
273
|
+
'code_patterns': point.payload.get('code_patterns'),
|
|
274
|
+
'files_analyzed': point.payload.get('files_analyzed'),
|
|
275
|
+
'tools_used': list(point.payload.get('tools_used', [])) if isinstance(point.payload.get('tools_used'), set) else point.payload.get('tools_used'),
|
|
276
|
+
'concepts': point.payload.get('concepts')
|
|
277
|
+
}
|
|
278
|
+
results.append(search_result)
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
await ctx.debug(f"Error searching {collection_name}: {str(e)}")
|
|
282
|
+
collection_timing['error'] = str(e)
|
|
283
|
+
|
|
284
|
+
collection_timing['end'] = time.time()
|
|
285
|
+
logger.debug(f"Collection {collection_name} returning {len(results)} results after filtering")
|
|
286
|
+
return collection_name, results, collection_timing
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def parallel_search_collections(
|
|
290
|
+
collections_to_search: List[str],
|
|
291
|
+
query: str,
|
|
292
|
+
qdrant_client: Any,
|
|
293
|
+
ctx: Any,
|
|
294
|
+
limit: int,
|
|
295
|
+
min_score: float,
|
|
296
|
+
should_use_decay: bool,
|
|
297
|
+
target_project: str,
|
|
298
|
+
generate_embedding_func: Any,
|
|
299
|
+
constants: Dict[str, Any],
|
|
300
|
+
max_concurrent: int = 10
|
|
301
|
+
) -> Tuple[List[Any], List[Dict[str, Any]]]:
|
|
302
|
+
"""
|
|
303
|
+
Search multiple collections in parallel using asyncio.gather.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
collections_to_search: List of collection names to search
|
|
307
|
+
query: Search query
|
|
308
|
+
qdrant_client: Qdrant client instance
|
|
309
|
+
ctx: Context for debugging
|
|
310
|
+
limit: Maximum results per collection
|
|
311
|
+
min_score: Minimum similarity score
|
|
312
|
+
should_use_decay: Whether to apply time decay
|
|
313
|
+
target_project: Project filter ('all' or specific project)
|
|
314
|
+
generate_embedding_func: Function to generate embeddings
|
|
315
|
+
constants: Dictionary of constants (USE_NATIVE_DECAY, etc.)
|
|
316
|
+
max_concurrent: Maximum concurrent searches
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Tuple of (all_results, collection_timings)
|
|
320
|
+
"""
|
|
321
|
+
await ctx.debug(f"Starting parallel search across {len(collections_to_search)} collections")
|
|
322
|
+
|
|
323
|
+
# Shared cache for embeddings
|
|
324
|
+
query_embeddings = {}
|
|
325
|
+
|
|
326
|
+
# Create semaphore to limit concurrent operations
|
|
327
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
328
|
+
|
|
329
|
+
async def search_with_semaphore(collection_name: str) -> Tuple[str, List[Any], Dict[str, Any]]:
|
|
330
|
+
"""Search with concurrency limit"""
|
|
331
|
+
async with semaphore:
|
|
332
|
+
return await search_single_collection(
|
|
333
|
+
collection_name=collection_name,
|
|
334
|
+
query=query,
|
|
335
|
+
query_embeddings=query_embeddings,
|
|
336
|
+
qdrant_client=qdrant_client,
|
|
337
|
+
ctx=ctx,
|
|
338
|
+
limit=limit,
|
|
339
|
+
min_score=min_score,
|
|
340
|
+
should_use_decay=should_use_decay,
|
|
341
|
+
target_project=target_project,
|
|
342
|
+
generate_embedding_func=generate_embedding_func,
|
|
343
|
+
constants=constants
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Launch all searches in parallel
|
|
347
|
+
search_tasks = [
|
|
348
|
+
search_with_semaphore(collection_name)
|
|
349
|
+
for collection_name in collections_to_search
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
# Wait for all searches to complete
|
|
353
|
+
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
|
354
|
+
|
|
355
|
+
# Process results
|
|
356
|
+
all_results = []
|
|
357
|
+
collection_timings = []
|
|
358
|
+
|
|
359
|
+
for result in search_results:
|
|
360
|
+
if isinstance(result, Exception):
|
|
361
|
+
# Handle exceptions from gather
|
|
362
|
+
logger.error(f"Search task failed: {result}")
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
collection_name, results, timing = result
|
|
366
|
+
all_results.extend(results)
|
|
367
|
+
collection_timings.append(timing)
|
|
368
|
+
|
|
369
|
+
await ctx.debug(f"Parallel search complete: {len(all_results)} total results")
|
|
370
|
+
|
|
371
|
+
return all_results, collection_timings
|
|
@@ -69,6 +69,11 @@ class ProjectResolver:
|
|
|
69
69
|
Returns:
|
|
70
70
|
List of collection names that match the project
|
|
71
71
|
"""
|
|
72
|
+
# Special case: 'all' returns all conversation collections
|
|
73
|
+
if user_project_name == 'all':
|
|
74
|
+
collection_names = self._get_collection_names()
|
|
75
|
+
return collection_names # Return all conv_ collections
|
|
76
|
+
|
|
72
77
|
if user_project_name in self._cache:
|
|
73
78
|
# Check if cache entry is still valid
|
|
74
79
|
if time() - self._cache_ttl.get(user_project_name, 0) < self._cache_duration:
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Reflection tools for Claude Self Reflect MCP server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional, List, Dict, Any
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
from qdrant_client import AsyncQdrantClient
|
|
15
|
+
from qdrant_client.models import PointStruct, VectorParams, Distance
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ReflectionTools:
|
|
21
|
+
"""Handles reflection storage and conversation retrieval operations."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
qdrant_client: AsyncQdrantClient,
|
|
26
|
+
qdrant_url: str,
|
|
27
|
+
get_embedding_manager,
|
|
28
|
+
normalize_project_name
|
|
29
|
+
):
|
|
30
|
+
"""Initialize reflection tools with dependencies."""
|
|
31
|
+
self.qdrant_client = qdrant_client
|
|
32
|
+
self.qdrant_url = qdrant_url
|
|
33
|
+
self.get_embedding_manager = get_embedding_manager
|
|
34
|
+
self.normalize_project_name = normalize_project_name
|
|
35
|
+
|
|
36
|
+
async def store_reflection(
|
|
37
|
+
self,
|
|
38
|
+
ctx: Context,
|
|
39
|
+
content: str,
|
|
40
|
+
tags: List[str] = []
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Store an important insight or reflection for future reference."""
|
|
43
|
+
|
|
44
|
+
await ctx.debug(f"Storing reflection with {len(tags)} tags")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Determine collection name based on embedding type
|
|
48
|
+
embedding_manager = self.get_embedding_manager()
|
|
49
|
+
embedding_type = "local" if embedding_manager.prefer_local else "voyage"
|
|
50
|
+
collection_name = f"reflections_{embedding_type}"
|
|
51
|
+
|
|
52
|
+
# Ensure reflections collection exists
|
|
53
|
+
try:
|
|
54
|
+
await self.qdrant_client.get_collection(collection_name)
|
|
55
|
+
await ctx.debug(f"Using existing {collection_name} collection")
|
|
56
|
+
except Exception:
|
|
57
|
+
# Collection doesn't exist, create it
|
|
58
|
+
await ctx.debug(f"Creating {collection_name} collection")
|
|
59
|
+
|
|
60
|
+
# Determine embedding dimensions
|
|
61
|
+
embedding_dim = embedding_manager.get_vector_dimension()
|
|
62
|
+
|
|
63
|
+
await self.qdrant_client.create_collection(
|
|
64
|
+
collection_name=collection_name,
|
|
65
|
+
vectors_config=VectorParams(
|
|
66
|
+
size=embedding_dim,
|
|
67
|
+
distance=Distance.COSINE
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
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()
|
|
77
|
+
|
|
78
|
+
# Prepare metadata
|
|
79
|
+
metadata = {
|
|
80
|
+
"content": content,
|
|
81
|
+
"tags": tags,
|
|
82
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
83
|
+
"type": "reflection"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Store in Qdrant
|
|
87
|
+
await self.qdrant_client.upsert(
|
|
88
|
+
collection_name=collection_name,
|
|
89
|
+
points=[
|
|
90
|
+
PointStruct(
|
|
91
|
+
id=reflection_id,
|
|
92
|
+
vector=embedding,
|
|
93
|
+
payload=metadata
|
|
94
|
+
)
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
await ctx.debug(f"Stored reflection with ID {reflection_id}")
|
|
99
|
+
|
|
100
|
+
return f"""Reflection stored successfully.
|
|
101
|
+
ID: {reflection_id}
|
|
102
|
+
Tags: {', '.join(tags) if tags else 'none'}
|
|
103
|
+
Timestamp: {metadata['timestamp']}"""
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"Failed to store reflection: {e}", exc_info=True)
|
|
107
|
+
return f"Failed to store reflection: {str(e)}"
|
|
108
|
+
|
|
109
|
+
async def get_full_conversation(
|
|
110
|
+
self,
|
|
111
|
+
ctx: Context,
|
|
112
|
+
conversation_id: str,
|
|
113
|
+
project: Optional[str] = None
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Get the full JSONL conversation file path for a conversation ID.
|
|
116
|
+
This allows agents to read complete conversations instead of truncated excerpts."""
|
|
117
|
+
|
|
118
|
+
await ctx.debug(f"Getting full conversation for ID: {conversation_id}, project: {project}")
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Base path for conversations
|
|
122
|
+
base_path = Path.home() / '.claude' / 'projects'
|
|
123
|
+
|
|
124
|
+
# If project is specified, try to find it in that project
|
|
125
|
+
if project:
|
|
126
|
+
# Normalize project name for path matching
|
|
127
|
+
project_normalized = self.normalize_project_name(project)
|
|
128
|
+
|
|
129
|
+
# Look for project directories that match
|
|
130
|
+
for project_dir in base_path.glob('*'):
|
|
131
|
+
if project_normalized in project_dir.name.lower():
|
|
132
|
+
# Look for JSONL files in this project
|
|
133
|
+
for jsonl_file in project_dir.glob('*.jsonl'):
|
|
134
|
+
# Check if filename matches conversation_id (with or without .jsonl)
|
|
135
|
+
if conversation_id in jsonl_file.stem or conversation_id == jsonl_file.stem:
|
|
136
|
+
await ctx.debug(f"Found conversation by filename in {jsonl_file}")
|
|
137
|
+
return f"""<conversation_file>
|
|
138
|
+
<conversation_id>{conversation_id}</conversation_id>
|
|
139
|
+
<file_path>{str(jsonl_file)}</file_path>
|
|
140
|
+
<project>{project_dir.name}</project>
|
|
141
|
+
<message>Use the Read tool with this file path to read the complete conversation.</message>
|
|
142
|
+
</conversation_file>"""
|
|
143
|
+
|
|
144
|
+
# If not found in specific project or no project specified, search all
|
|
145
|
+
await ctx.debug("Searching all projects for conversation")
|
|
146
|
+
for project_dir in base_path.glob('*'):
|
|
147
|
+
for jsonl_file in project_dir.glob('*.jsonl'):
|
|
148
|
+
# Check if filename matches conversation_id (with or without .jsonl)
|
|
149
|
+
if conversation_id in jsonl_file.stem or conversation_id == jsonl_file.stem:
|
|
150
|
+
await ctx.debug(f"Found conversation by filename in {jsonl_file}")
|
|
151
|
+
return f"""<conversation_file>
|
|
152
|
+
<conversation_id>{conversation_id}</conversation_id>
|
|
153
|
+
<file_path>{str(jsonl_file)}</file_path>
|
|
154
|
+
<project>{project_dir.name}</project>
|
|
155
|
+
<message>Use the Read tool with this file path to read the complete conversation.</message>
|
|
156
|
+
</conversation_file>"""
|
|
157
|
+
|
|
158
|
+
# Not found
|
|
159
|
+
return f"""<conversation_file>
|
|
160
|
+
<error>Conversation ID '{conversation_id}' not found in any project.</error>
|
|
161
|
+
<suggestion>The conversation may not have been imported yet, or the ID may be incorrect.</suggestion>
|
|
162
|
+
</conversation_file>"""
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.error(f"Failed to get conversation file: {e}", exc_info=True)
|
|
166
|
+
return f"""<conversation_file>
|
|
167
|
+
<error>Failed to locate conversation: {str(e)}</error>
|
|
168
|
+
</conversation_file>"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def register_reflection_tools(
|
|
172
|
+
mcp,
|
|
173
|
+
qdrant_client: AsyncQdrantClient,
|
|
174
|
+
qdrant_url: str,
|
|
175
|
+
get_embedding_manager,
|
|
176
|
+
normalize_project_name
|
|
177
|
+
):
|
|
178
|
+
"""Register reflection tools with the MCP server."""
|
|
179
|
+
|
|
180
|
+
tools = ReflectionTools(
|
|
181
|
+
qdrant_client,
|
|
182
|
+
qdrant_url,
|
|
183
|
+
get_embedding_manager,
|
|
184
|
+
normalize_project_name
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@mcp.tool()
|
|
188
|
+
async def store_reflection(
|
|
189
|
+
ctx: Context,
|
|
190
|
+
content: str = Field(description="The insight or reflection to store"),
|
|
191
|
+
tags: List[str] = Field(default=[], description="Tags to categorize this reflection")
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Store an important insight or reflection for future reference."""
|
|
194
|
+
return await tools.store_reflection(ctx, content, tags)
|
|
195
|
+
|
|
196
|
+
@mcp.tool()
|
|
197
|
+
async def get_full_conversation(
|
|
198
|
+
ctx: Context,
|
|
199
|
+
conversation_id: str = Field(description="The conversation ID from search results (cid)"),
|
|
200
|
+
project: Optional[str] = Field(default=None, description="Optional project name to help locate the file")
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Get the full JSONL conversation file path for a conversation ID.
|
|
203
|
+
This allows agents to read complete conversations instead of truncated excerpts."""
|
|
204
|
+
return await tools.get_full_conversation(ctx, conversation_id, project)
|
|
205
|
+
|
|
206
|
+
logger.info("Reflection tools registered successfully")
|