claude-self-reflect 7.0.0 → 7.1.9

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.
@@ -8,6 +8,7 @@ from typing import Optional, List, Dict, Any
8
8
  from datetime import datetime, timezone
9
9
  from pathlib import Path
10
10
  import uuid
11
+ from xml.sax.saxutils import escape as xml_escape
11
12
 
12
13
  from fastmcp import Context
13
14
  from pydantic import Field
@@ -207,13 +208,108 @@ Timestamp: {metadata['timestamp']}"""
207
208
  <error>Conversation ID '{conversation_id}' not found in any project.</error>
208
209
  <suggestion>The conversation may not have been imported yet, or the ID may be incorrect.</suggestion>
209
210
  </conversation_file>"""
210
-
211
+
211
212
  except Exception as e:
212
213
  logger.error(f"Failed to get conversation file: {e}", exc_info=True)
213
214
  return f"""<conversation_file>
214
215
  <error>Failed to locate conversation: {str(e)}</error>
215
216
  </conversation_file>"""
216
217
 
218
+ async def get_session_learnings(
219
+ self,
220
+ ctx: Context,
221
+ session_id: str,
222
+ limit: int = 50
223
+ ) -> str:
224
+ """Get all learnings from a specific Ralph session.
225
+
226
+ This enables iteration-level memory: retrieve what was learned
227
+ in previous iterations of the SAME Ralph loop session.
228
+ """
229
+ from qdrant_client import models
230
+
231
+ await ctx.debug(f"Getting learnings for session: {session_id}")
232
+
233
+ try:
234
+ # Check runtime preference from environment
235
+ prefer_local = os.getenv('PREFER_LOCAL_EMBEDDINGS', 'true').lower() == 'true'
236
+ embedding_type = "local" if prefer_local else "voyage"
237
+ collection_name = f"reflections_{embedding_type}"
238
+
239
+ # Filter by session tag - matches reflections stored with session_{id} tag
240
+ session_filter = models.Filter(
241
+ must=[
242
+ models.FieldCondition(
243
+ key="tags",
244
+ match=models.MatchAny(any=[f"session_{session_id}"])
245
+ )
246
+ ]
247
+ )
248
+
249
+ results, _ = await self.qdrant_client.scroll(
250
+ collection_name=collection_name,
251
+ scroll_filter=session_filter,
252
+ limit=limit,
253
+ with_payload=True,
254
+ )
255
+
256
+ if not results:
257
+ await ctx.debug(f"No learnings found for session {session_id}")
258
+ return f"""<session_learnings>
259
+ <session_id>{session_id}</session_id>
260
+ <count>0</count>
261
+ <message>No learnings stored yet for this session. Use store_reflection() with tags=['session_{session_id}', 'iteration_N', 'ralph_iteration'] to store iteration learnings.</message>
262
+ </session_learnings>"""
263
+
264
+ # Format results
265
+ learnings = []
266
+ for point in results:
267
+ payload = point.payload or {}
268
+ tags = payload.get("tags", [])
269
+ # Extract iteration number from tags if present
270
+ iteration = "unknown"
271
+ for tag in tags:
272
+ if tag.startswith("iteration_"):
273
+ iteration = tag.replace("iteration_", "")
274
+ break
275
+
276
+ learnings.append({
277
+ "content": payload.get("content", ""),
278
+ "iteration": iteration,
279
+ "timestamp": payload.get("timestamp", ""),
280
+ "tags": tags
281
+ })
282
+
283
+ # Sort by timestamp (oldest first for chronological order)
284
+ learnings.sort(key=lambda x: x.get("timestamp", ""))
285
+
286
+ await ctx.debug(f"Found {len(learnings)} learnings for session {session_id}")
287
+
288
+ # Format as XML for structured output (escape special chars for safety)
289
+ learnings_xml = "\n".join([
290
+ f"""<learning iteration="{xml_escape(str(l['iteration']))}">
291
+ <timestamp>{xml_escape(str(l['timestamp']))}</timestamp>
292
+ <content>{xml_escape(str(l['content']))}</content>
293
+ <tags>{xml_escape(', '.join(str(t) for t in l['tags']))}</tags>
294
+ </learning>"""
295
+ for l in learnings
296
+ ])
297
+
298
+ return f"""<session_learnings>
299
+ <session_id>{session_id}</session_id>
300
+ <count>{len(learnings)}</count>
301
+ <learnings>
302
+ {learnings_xml}
303
+ </learnings>
304
+ </session_learnings>"""
305
+
306
+ except Exception as e:
307
+ logger.error(f"Failed to get session learnings: {e}", exc_info=True)
308
+ return f"""<session_learnings>
309
+ <session_id>{session_id}</session_id>
310
+ <error>Failed to retrieve learnings: {str(e)}</error>
311
+ </session_learnings>"""
312
+
217
313
 
218
314
  def register_reflection_tools(
219
315
  mcp,
@@ -249,5 +345,21 @@ def register_reflection_tools(
249
345
  """Get the full JSONL conversation file path for a conversation ID.
250
346
  This allows agents to read complete conversations instead of truncated excerpts."""
251
347
  return await tools.get_full_conversation(ctx, conversation_id, project)
252
-
348
+
349
+ @mcp.tool()
350
+ async def get_session_learnings(
351
+ ctx: Context,
352
+ session_id: str = Field(description="Ralph session ID to get learnings from (e.g., 'ralph_20260104_224757_iter1')"),
353
+ limit: int = Field(default=50, description="Maximum number of learnings to return")
354
+ ) -> str:
355
+ """Get all learnings from a specific Ralph session.
356
+
357
+ This enables iteration-level memory: retrieve what was learned
358
+ in previous iterations of the SAME Ralph loop session.
359
+
360
+ Use this at the START of each Ralph iteration to see what previous
361
+ iterations learned. Then use store_reflection() with session tags
362
+ to save learnings at the END of each iteration."""
363
+ return await tools.get_session_learnings(ctx, session_id, limit)
364
+
253
365
  logger.info("Reflection tools registered successfully")
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CSR Standalone Client - For use outside the MCP server context.
4
+
5
+ This client provides search and store_reflection functionality
6
+ for hooks and scripts that need to interact with CSR without
7
+ going through the MCP protocol.
8
+
9
+ Usage:
10
+ from mcp_server.src.standalone_client import CSRStandaloneClient
11
+
12
+ client = CSRStandaloneClient()
13
+ results = client.search("docker issues", limit=5)
14
+ client.store_reflection("Key insight here", tags=["insight"])
15
+ """
16
+
17
+ import os
18
+ import uuid
19
+ import hashlib
20
+ import logging
21
+ from typing import List, Dict, Any, Optional
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class CSRStandaloneClient:
29
+ """Standalone CSR client for hooks and scripts."""
30
+
31
+ def __init__(
32
+ self,
33
+ qdrant_url: str = None,
34
+ qdrant_api_key: str = None,
35
+ prefer_local: bool = None
36
+ ):
37
+ """Initialize the standalone client.
38
+
39
+ Args:
40
+ qdrant_url: Qdrant server URL (default: from env or localhost:6333)
41
+ qdrant_api_key: Qdrant API key (default: from env)
42
+ prefer_local: Use local embeddings (default: from env or True)
43
+ """
44
+ self.qdrant_url = qdrant_url or os.getenv('QDRANT_URL', 'http://localhost:6333')
45
+ self.qdrant_api_key = qdrant_api_key or os.getenv('QDRANT_API_KEY')
46
+
47
+ if prefer_local is None:
48
+ self.prefer_local = os.getenv('PREFER_LOCAL_EMBEDDINGS', 'true').lower() == 'true'
49
+ else:
50
+ self.prefer_local = prefer_local
51
+
52
+ self._client = None
53
+ self._embedding_manager = None
54
+
55
+ def _get_client(self):
56
+ """Get or create Qdrant client (synchronous)."""
57
+ if self._client is None:
58
+ from qdrant_client import QdrantClient
59
+
60
+ # Parse URL for host/port
61
+ import urllib.parse
62
+ parsed = urllib.parse.urlparse(self.qdrant_url)
63
+ host = parsed.hostname or 'localhost'
64
+ port = parsed.port or 6333
65
+
66
+ self._client = QdrantClient(
67
+ host=host,
68
+ port=port,
69
+ api_key=self.qdrant_api_key if self.qdrant_api_key else None,
70
+ timeout=30
71
+ )
72
+ return self._client
73
+
74
+ def _get_embedding_manager(self):
75
+ """Get or create embedding manager."""
76
+ if self._embedding_manager is None:
77
+ # Try to use the project's embedding manager
78
+ try:
79
+ from .embedding_manager import EmbeddingManager
80
+ self._embedding_manager = EmbeddingManager()
81
+ except ImportError:
82
+ # Fallback: create minimal embedding functionality
83
+ self._embedding_manager = self._create_fallback_embeddings()
84
+ return self._embedding_manager
85
+
86
+ def _create_fallback_embeddings(self):
87
+ """Create fallback embedding functionality using fastembed."""
88
+ class FallbackEmbeddings:
89
+ def __init__(self):
90
+ self._model = None
91
+
92
+ def get_model(self):
93
+ if self._model is None:
94
+ try:
95
+ from fastembed import TextEmbedding
96
+ self._model = TextEmbedding("BAAI/bge-small-en-v1.5")
97
+ except ImportError:
98
+ raise ImportError("fastembed not installed. Run: pip install fastembed")
99
+ return self._model
100
+
101
+ def embed(self, text: str) -> List[float]:
102
+ model = self.get_model()
103
+ embeddings = list(model.embed([text]))
104
+ return list(embeddings[0])
105
+
106
+ @property
107
+ def dimension(self) -> int:
108
+ return 384
109
+
110
+ return FallbackEmbeddings()
111
+
112
+ def search(
113
+ self,
114
+ query: str,
115
+ limit: int = 5,
116
+ min_score: float = 0.3,
117
+ project: str = None
118
+ ) -> List[Dict[str, Any]]:
119
+ """Search for relevant conversations.
120
+
121
+ Args:
122
+ query: Search query
123
+ limit: Maximum results to return
124
+ min_score: Minimum similarity score
125
+ project: Project name filter (optional)
126
+
127
+ Returns:
128
+ List of search results with content and metadata
129
+ """
130
+ client = self._get_client()
131
+ embeddings = self._get_embedding_manager()
132
+
133
+ # Generate query embedding
134
+ query_vector = embeddings.embed(query)
135
+
136
+ # Find searchable collections
137
+ collections = client.get_collections().collections
138
+ searchable = [
139
+ c.name for c in collections
140
+ if self._is_searchable_collection(c.name)
141
+ ]
142
+
143
+ if not searchable:
144
+ logger.warning("No searchable collections found")
145
+ return []
146
+
147
+ # Filter by project if specified
148
+ if project and project != 'all':
149
+ project_norm = self._normalize_project_name(project)
150
+ searchable = [c for c in searchable if project_norm in c]
151
+
152
+ # Prioritize reflections collections (where Ralph state is stored)
153
+ reflections = [c for c in searchable if c.startswith('reflections')]
154
+ others = [c for c in searchable if not c.startswith('reflections')]
155
+ searchable = reflections + others
156
+
157
+ results = []
158
+ for collection_name in searchable[:8]: # Search up to 8 collections
159
+ try:
160
+ search_results = client.search(
161
+ collection_name=collection_name,
162
+ query_vector=query_vector,
163
+ limit=limit,
164
+ score_threshold=min_score
165
+ )
166
+
167
+ for hit in search_results:
168
+ payload = hit.payload or {}
169
+ results.append({
170
+ 'score': hit.score,
171
+ 'content': payload.get('content', ''),
172
+ 'preview': payload.get('preview', payload.get('content', '')[:200]),
173
+ 'metadata': {
174
+ 'collection': collection_name,
175
+ 'conversation_id': payload.get('conversation_id', ''),
176
+ 'timestamp': payload.get('timestamp', ''),
177
+ 'project': payload.get('project', ''),
178
+ }
179
+ })
180
+ except Exception as e:
181
+ logger.debug(f"Error searching {collection_name}: {e}")
182
+ continue
183
+
184
+ # Sort by score and limit
185
+ results.sort(key=lambda x: x['score'], reverse=True)
186
+ return results[:limit]
187
+
188
+ def store_reflection(
189
+ self,
190
+ content: str,
191
+ tags: List[str] = None,
192
+ collection: str = None
193
+ ) -> str:
194
+ """Store a reflection/insight.
195
+
196
+ Args:
197
+ content: The reflection content
198
+ tags: Optional tags for categorization
199
+ collection: Optional custom collection name (for hooks to use separate storage)
200
+
201
+ Returns:
202
+ ID of stored reflection
203
+ """
204
+ tags = tags or []
205
+ client = self._get_client()
206
+ embeddings = self._get_embedding_manager()
207
+
208
+ # Determine collection name
209
+ # Hooks can specify a custom collection to keep their data separate
210
+ if collection:
211
+ collection_name = collection
212
+ else:
213
+ collection_name = f"reflections_{'local' if self.prefer_local else 'voyage'}"
214
+
215
+ # Ensure collection exists
216
+ try:
217
+ client.get_collection(collection_name)
218
+ except Exception:
219
+ # Create collection
220
+ from qdrant_client.models import VectorParams, Distance
221
+ client.create_collection(
222
+ collection_name=collection_name,
223
+ vectors_config=VectorParams(
224
+ size=embeddings.dimension,
225
+ distance=Distance.COSINE
226
+ )
227
+ )
228
+
229
+ # Generate embedding
230
+ vector = embeddings.embed(content)
231
+
232
+ # Generate ID
233
+ reflection_id = hashlib.sha256(
234
+ f"{content}{datetime.now().isoformat()}".encode()
235
+ ).hexdigest()[:16]
236
+
237
+ # Store
238
+ from qdrant_client.models import PointStruct
239
+ client.upsert(
240
+ collection_name=collection_name,
241
+ points=[
242
+ PointStruct(
243
+ id=str(uuid.uuid4()),
244
+ vector=vector,
245
+ payload={
246
+ "content": content,
247
+ "tags": tags,
248
+ "timestamp": datetime.now(timezone.utc).isoformat(),
249
+ "reflection_id": reflection_id,
250
+ "type": "reflection"
251
+ }
252
+ )
253
+ ]
254
+ )
255
+
256
+ logger.info(f"Stored reflection: {reflection_id}")
257
+ return reflection_id
258
+
259
+ def _is_searchable_collection(self, name: str) -> bool:
260
+ """Check if collection is searchable."""
261
+ return (
262
+ name.endswith('_local')
263
+ or name.endswith('_voyage')
264
+ or name.endswith('_384d')
265
+ or name.endswith('_1024d')
266
+ or '_cloud_' in name
267
+ or name.startswith('reflections')
268
+ or name.startswith('csr_')
269
+ )
270
+
271
+ def _normalize_project_name(self, name: str) -> str:
272
+ """Normalize project name for collection matching."""
273
+ import re
274
+ # Convert to lowercase, replace special chars
275
+ normalized = name.lower()
276
+ normalized = re.sub(r'[^a-z0-9]', '_', normalized)
277
+ normalized = re.sub(r'_+', '_', normalized)
278
+ return normalized.strip('_')
279
+
280
+ def get_session_learnings(
281
+ self,
282
+ session_id: str,
283
+ limit: int = 50,
284
+ collection: str = None
285
+ ) -> List[Dict[str, Any]]:
286
+ """Get all learnings from a specific Ralph session.
287
+
288
+ This enables iteration-level memory: retrieve what was learned
289
+ in previous iterations of the SAME Ralph loop session.
290
+
291
+ Args:
292
+ session_id: The session ID (e.g., "ralph_20260104_224757_iter1")
293
+ limit: Maximum number of reflections to return
294
+ collection: Optional custom collection (for hook-stored data)
295
+
296
+ Returns:
297
+ List of reflection payloads from this session, each containing:
298
+ - content: The reflection text
299
+ - tags: List of tags (includes iteration info)
300
+ - timestamp: When it was stored
301
+ """
302
+ from qdrant_client import models
303
+
304
+ client = self._get_client()
305
+ if collection:
306
+ collection_name = collection
307
+ else:
308
+ collection_name = f"reflections_{'local' if self.prefer_local else 'voyage'}"
309
+
310
+ # Filter by session tag - matches reflections stored with session_{id} tag
311
+ session_filter = models.Filter(
312
+ must=[
313
+ models.FieldCondition(
314
+ key="tags",
315
+ match=models.MatchAny(any=[f"session_{session_id}"])
316
+ )
317
+ ]
318
+ )
319
+
320
+ try:
321
+ results, _ = client.scroll(
322
+ collection_name=collection_name,
323
+ scroll_filter=session_filter,
324
+ limit=limit,
325
+ with_payload=True,
326
+ )
327
+
328
+ return [
329
+ {
330
+ "content": point.payload.get("content", ""),
331
+ "tags": point.payload.get("tags", []),
332
+ "timestamp": point.payload.get("timestamp", ""),
333
+ }
334
+ for point in results
335
+ ]
336
+ except Exception as e:
337
+ logger.error(f"Error getting session learnings: {e}")
338
+ return []
339
+
340
+ def test_connection(self) -> bool:
341
+ """Test if CSR is accessible.
342
+
343
+ Returns:
344
+ True if connection successful
345
+ """
346
+ try:
347
+ client = self._get_client()
348
+ client.get_collections()
349
+ return True
350
+ except Exception as e:
351
+ logger.error(f"Connection test failed: {e}")
352
+ return False
353
+
354
+
355
+ # Convenience function for quick searches
356
+ def quick_search(query: str, limit: int = 3) -> List[Dict[str, Any]]:
357
+ """Quick search without creating client instance."""
358
+ client = CSRStandaloneClient()
359
+ return client.search(query, limit=limit)
360
+
361
+
362
+ if __name__ == "__main__":
363
+ # Test the client
364
+ import sys
365
+
366
+ client = CSRStandaloneClient()
367
+
368
+ if client.test_connection():
369
+ print("✓ CSR connection successful")
370
+
371
+ if len(sys.argv) > 1:
372
+ query = " ".join(sys.argv[1:])
373
+ results = client.search(query, limit=3)
374
+ print(f"\nResults for '{query}':")
375
+ for i, r in enumerate(results, 1):
376
+ print(f"\n{i}. Score: {r['score']:.2f}")
377
+ print(f" {r['preview'][:100]}...")
378
+ else:
379
+ print("✗ CSR connection failed")
380
+ sys.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-self-reflect",
3
- "version": "7.0.0",
3
+ "version": "7.1.9",
4
4
  "description": "Give Claude perfect memory of all your conversations - Installation wizard for Python MCP server",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,21 @@
1
+ """Ralph memory hooks for Claude Code integration."""
2
+
3
+ from .ralph_state import (
4
+ RalphState,
5
+ load_state,
6
+ save_state,
7
+ is_ralph_session,
8
+ get_ralph_state_path,
9
+ load_ralph_session_state,
10
+ parse_ralph_wiggum_state,
11
+ )
12
+
13
+ __all__ = [
14
+ 'RalphState',
15
+ 'load_state',
16
+ 'save_state',
17
+ 'is_ralph_session',
18
+ 'get_ralph_state_path',
19
+ 'load_ralph_session_state',
20
+ 'parse_ralph_wiggum_state',
21
+ ]