claude-self-reflect 3.2.4 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.claude/agents/claude-self-reflect-test.md +595 -528
  2. package/.claude/agents/reflection-specialist.md +59 -3
  3. package/README.md +14 -5
  4. package/mcp-server/run-mcp.sh +49 -5
  5. package/mcp-server/src/app_context.py +64 -0
  6. package/mcp-server/src/config.py +57 -0
  7. package/mcp-server/src/connection_pool.py +286 -0
  8. package/mcp-server/src/decay_manager.py +106 -0
  9. package/mcp-server/src/embedding_manager.py +64 -40
  10. package/mcp-server/src/embeddings_old.py +141 -0
  11. package/mcp-server/src/models.py +64 -0
  12. package/mcp-server/src/parallel_search.py +371 -0
  13. package/mcp-server/src/project_resolver.py +5 -0
  14. package/mcp-server/src/reflection_tools.py +206 -0
  15. package/mcp-server/src/rich_formatting.py +196 -0
  16. package/mcp-server/src/search_tools.py +826 -0
  17. package/mcp-server/src/server.py +127 -1720
  18. package/mcp-server/src/temporal_design.py +132 -0
  19. package/mcp-server/src/temporal_tools.py +597 -0
  20. package/mcp-server/src/temporal_utils.py +384 -0
  21. package/mcp-server/src/utils.py +150 -67
  22. package/package.json +10 -1
  23. package/scripts/add-timestamp-indexes.py +134 -0
  24. package/scripts/check-collections.py +29 -0
  25. package/scripts/debug-august-parsing.py +76 -0
  26. package/scripts/debug-import-single.py +91 -0
  27. package/scripts/debug-project-resolver.py +82 -0
  28. package/scripts/debug-temporal-tools.py +135 -0
  29. package/scripts/delta-metadata-update.py +547 -0
  30. package/scripts/import-conversations-unified.py +53 -2
  31. package/scripts/precompact-hook.sh +33 -0
  32. package/scripts/streaming-watcher.py +1443 -0
  33. package/scripts/utils.py +39 -0
@@ -0,0 +1,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")