claude-self-reflect 2.5.17 → 2.5.19

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,305 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Force metadata recovery script for Claude Self-Reflect.
4
+ Fixes conversations that were marked as updated but don't actually have metadata.
5
+ This addresses the point ID mismatch bug in delta-metadata-update.py.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import json
11
+ import hashlib
12
+ import re
13
+ import asyncio
14
+ from datetime import datetime
15
+ from typing import List, Dict, Any, Set, Optional
16
+ import logging
17
+ from pathlib import Path
18
+
19
+ from qdrant_client import QdrantClient
20
+
21
+ # Configuration
22
+ QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
23
+ LOGS_DIR = os.getenv("LOGS_DIR", os.path.expanduser("~/.claude/projects"))
24
+ DRY_RUN = os.getenv("DRY_RUN", "false").lower() == "true"
25
+ BATCH_SIZE = int(os.getenv("BATCH_SIZE", "100"))
26
+
27
+ # Set up logging
28
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Initialize Qdrant client
32
+ client = QdrantClient(url=QDRANT_URL, timeout=30)
33
+
34
+ def normalize_path(path: str) -> str:
35
+ """Normalize file paths for consistency."""
36
+ if not path:
37
+ return ""
38
+ path = path.replace("/Users/", "~/").replace("\\Users\\", "~\\")
39
+ path = path.replace("\\", "/")
40
+ path = re.sub(r'/+', '/', path)
41
+ return path
42
+
43
+ def extract_concepts(text: str) -> Set[str]:
44
+ """Extract high-level concepts from text."""
45
+ concepts = set()
46
+
47
+ concept_patterns = {
48
+ 'security': r'(security|vulnerability|CVE|injection|auth)',
49
+ 'docker': r'(docker|container|compose|kubernetes)',
50
+ 'testing': r'(test|pytest|unittest|coverage)',
51
+ 'api': r'(API|REST|GraphQL|endpoint)',
52
+ 'database': r'(database|SQL|query|migration|qdrant)',
53
+ 'debugging': r'(debug|error|exception|traceback)',
54
+ 'git': r'(git|commit|branch|merge|pull request)',
55
+ 'mcp': r'(MCP|claude-self-reflect|tool|agent)',
56
+ 'embeddings': r'(embedding|vector|semantic|similarity)',
57
+ }
58
+
59
+ text_lower = text.lower()
60
+ for concept, pattern in concept_patterns.items():
61
+ if re.search(pattern, text_lower, re.IGNORECASE):
62
+ concepts.add(concept)
63
+
64
+ return concepts
65
+
66
+ def extract_metadata_from_jsonl(jsonl_path: str) -> Dict[str, Any]:
67
+ """Extract metadata from a JSONL conversation file."""
68
+ metadata = {
69
+ "files_analyzed": [],
70
+ "files_edited": [],
71
+ "tools_used": set(),
72
+ "concepts": set(),
73
+ "text_sample": ""
74
+ }
75
+
76
+ try:
77
+ with open(jsonl_path, 'r', encoding='utf-8') as f:
78
+ line_count = 0
79
+ for line in f:
80
+ line_count += 1
81
+ if line_count > 200: # Limit processing
82
+ break
83
+
84
+ if not line.strip():
85
+ continue
86
+
87
+ try:
88
+ data = json.loads(line)
89
+ if 'message' in data and data['message']:
90
+ msg = data['message']
91
+
92
+ # Extract text for concept analysis
93
+ if msg.get('content'):
94
+ if isinstance(msg['content'], str):
95
+ metadata['text_sample'] += msg['content'][:500] + "\n"
96
+
97
+ # Extract tool usage
98
+ if msg.get('role') == 'assistant' and msg.get('content'):
99
+ content = msg['content']
100
+ if isinstance(content, list):
101
+ for item in content:
102
+ if isinstance(item, dict) and item.get('type') == 'tool_use':
103
+ tool_name = item.get('name', '')
104
+ metadata['tools_used'].add(tool_name)
105
+
106
+ inputs = item.get('input', {})
107
+
108
+ if tool_name == 'Read' and 'file_path' in inputs:
109
+ metadata['files_analyzed'].append(
110
+ normalize_path(inputs['file_path'])
111
+ )
112
+ elif tool_name in ['Edit', 'Write'] and 'file_path' in inputs:
113
+ metadata['files_edited'].append(
114
+ normalize_path(inputs['file_path'])
115
+ )
116
+
117
+ except json.JSONDecodeError:
118
+ continue
119
+
120
+ except Exception as e:
121
+ logger.error(f"Error reading {jsonl_path}: {e}")
122
+
123
+ # Extract concepts from collected text
124
+ if metadata['text_sample']:
125
+ metadata['concepts'] = extract_concepts(metadata['text_sample'][:5000])
126
+
127
+ # Convert sets to lists and limit
128
+ metadata['tools_used'] = list(metadata['tools_used'])[:20]
129
+ metadata['concepts'] = list(metadata['concepts'])[:15]
130
+ metadata['files_analyzed'] = list(set(metadata['files_analyzed']))[:20]
131
+ metadata['files_edited'] = list(set(metadata['files_edited']))[:10]
132
+
133
+ del metadata['text_sample'] # Don't store in Qdrant
134
+
135
+ return metadata
136
+
137
+ async def find_conversations_without_metadata(collection_name: str) -> List[str]:
138
+ """Find all unique conversation IDs that don't have metadata."""
139
+ conversations_without_metadata = set()
140
+
141
+ offset = None
142
+ total_checked = 0
143
+
144
+ while True:
145
+ points, next_offset = client.scroll(
146
+ collection_name=collection_name,
147
+ limit=BATCH_SIZE,
148
+ offset=offset,
149
+ with_payload=True,
150
+ with_vectors=False
151
+ )
152
+
153
+ if not points:
154
+ break
155
+
156
+ for point in points:
157
+ # Check if metadata is missing
158
+ if not point.payload.get('concepts') or not point.payload.get('has_file_metadata'):
159
+ conv_id = point.payload.get('conversation_id')
160
+ if conv_id:
161
+ conversations_without_metadata.add(conv_id)
162
+
163
+ total_checked += len(points)
164
+ offset = next_offset
165
+
166
+ if offset is None:
167
+ break
168
+
169
+ logger.info(f" Checked {total_checked} points, found {len(conversations_without_metadata)} conversations without metadata")
170
+ return list(conversations_without_metadata)
171
+
172
+ async def update_conversation_points(collection_name: str, conversation_id: str, metadata: Dict[str, Any]) -> int:
173
+ """Update all points for a conversation with metadata."""
174
+ updated_count = 0
175
+
176
+ # Get all points in the collection
177
+ offset = None
178
+ while True:
179
+ points, next_offset = client.scroll(
180
+ collection_name=collection_name,
181
+ limit=BATCH_SIZE,
182
+ offset=offset,
183
+ with_payload=True,
184
+ with_vectors=False
185
+ )
186
+
187
+ if not points:
188
+ break
189
+
190
+ # Find and update points for this conversation
191
+ for point in points:
192
+ if point.payload.get('conversation_id') == conversation_id:
193
+ if not DRY_RUN:
194
+ # Merge metadata with existing payload
195
+ updated_payload = {**point.payload, **metadata}
196
+ updated_payload['has_file_metadata'] = True
197
+ updated_payload['metadata_updated_at'] = datetime.now().isoformat()
198
+
199
+ client.set_payload(
200
+ collection_name=collection_name,
201
+ payload=updated_payload,
202
+ points=[point.id],
203
+ wait=False
204
+ )
205
+
206
+ updated_count += 1
207
+
208
+ offset = next_offset
209
+ if offset is None:
210
+ break
211
+
212
+ return updated_count
213
+
214
+ async def process_collection(collection_name: str):
215
+ """Process a single collection to add missing metadata."""
216
+ logger.info(f"\nProcessing collection: {collection_name}")
217
+
218
+ # Find conversations without metadata
219
+ conversations_without_metadata = await find_conversations_without_metadata(collection_name)
220
+
221
+ if not conversations_without_metadata:
222
+ logger.info(f" ✓ All conversations have metadata")
223
+ return 0
224
+
225
+ logger.info(f" Found {len(conversations_without_metadata)} conversations needing metadata")
226
+
227
+ # Process each conversation
228
+ success_count = 0
229
+ failed_count = 0
230
+
231
+ for conv_id in conversations_without_metadata[:10]: # Limit for testing
232
+ # Find the JSONL file
233
+ jsonl_pattern = f"**/{conv_id}.jsonl"
234
+ jsonl_files = list(Path(LOGS_DIR).glob(jsonl_pattern))
235
+
236
+ if not jsonl_files:
237
+ logger.warning(f" Cannot find JSONL for {conv_id}")
238
+ failed_count += 1
239
+ continue
240
+
241
+ jsonl_file = jsonl_files[0]
242
+ logger.info(f" Processing {conv_id}")
243
+
244
+ # Extract metadata
245
+ metadata = extract_metadata_from_jsonl(str(jsonl_file))
246
+
247
+ if not metadata['concepts'] and not metadata['files_analyzed']:
248
+ logger.warning(f" No metadata extracted from {conv_id}")
249
+ failed_count += 1
250
+ continue
251
+
252
+ # Update points
253
+ updated_points = await update_conversation_points(collection_name, conv_id, metadata)
254
+
255
+ if updated_points > 0:
256
+ logger.info(f" ✓ Updated {updated_points} points with {len(metadata['concepts'])} concepts")
257
+ success_count += 1
258
+ else:
259
+ logger.warning(f" No points updated for {conv_id}")
260
+ failed_count += 1
261
+
262
+ logger.info(f" Collection complete: {success_count} fixed, {failed_count} failed")
263
+ return success_count
264
+
265
+ async def main():
266
+ """Main recovery process."""
267
+ logger.info("=== Force Metadata Recovery ===")
268
+ logger.info(f"Qdrant URL: {QDRANT_URL}")
269
+ logger.info(f"Dry run: {DRY_RUN}")
270
+
271
+ # Get all collections
272
+ collections = client.get_collections().collections
273
+
274
+ # Focus on collections with potential issues
275
+ priority_collections = []
276
+ other_collections = []
277
+
278
+ for collection in collections:
279
+ name = collection.name
280
+ if name.startswith('conv_'):
281
+ other_collections.append(name)
282
+
283
+ logger.info(f"Found {len(priority_collections)} priority collections")
284
+ logger.info(f"Found {len(other_collections)} other collections")
285
+
286
+ # Process priority collections first
287
+ total_fixed = 0
288
+
289
+ for collection_name in priority_collections:
290
+ fixed = await process_collection(collection_name)
291
+ total_fixed += fixed
292
+
293
+ # Process a sample of other collections
294
+ for collection_name in other_collections[:5]:
295
+ fixed = await process_collection(collection_name)
296
+ total_fixed += fixed
297
+
298
+ logger.info(f"\n=== Recovery Complete ===")
299
+ logger.info(f"Total conversations fixed: {total_fixed}")
300
+
301
+ if DRY_RUN:
302
+ logger.info("This was a DRY RUN - no actual updates were made")
303
+
304
+ if __name__ == "__main__":
305
+ asyncio.run(main())