claude-self-reflect 6.0.5 → 7.1.8

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,259 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Ralph SessionStart Hook - Searches CSR for relevant past sessions.
4
+
5
+ Triggered at session start. Uses existing CSR infrastructure to search
6
+ for relevant past Ralph sessions and injects context.
7
+
8
+ Enhanced Features (v7.1+):
9
+ - Error-centric search (find solutions to current blockers)
10
+ - Anti-pattern injection (surface failed approaches first)
11
+ - Winning strategy prioritization
12
+ - Multi-signal search (task + errors + patterns)
13
+
14
+ Input (stdin): JSON with session_id, transcript_path, source
15
+ Output (stdout): Context message for Claude
16
+ Exit code: 0 = success, 2 = blocking error
17
+
18
+ Attribution:
19
+ Search patterns inspired by https://github.com/frankbria/ralph-claude-code
20
+ """
21
+
22
+ import sys
23
+ import json
24
+ import logging
25
+ from pathlib import Path
26
+ from datetime import datetime
27
+
28
+ # Add project root to path
29
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
30
+
31
+ from src.runtime.hooks.ralph_state import (
32
+ load_state,
33
+ save_state,
34
+ RalphState,
35
+ is_ralph_session,
36
+ load_ralph_session_state,
37
+ )
38
+
39
+ logging.basicConfig(level=logging.INFO)
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def get_project_root() -> Path:
44
+ """Dynamically determine project root (works for any installation)."""
45
+ # This file is at: <project_root>/src/runtime/hooks/session_start_hook.py
46
+ return Path(__file__).parent.parent.parent.parent
47
+
48
+
49
+ def _error_signature(error: str) -> str:
50
+ """Extract error signature for deduplication (matches RalphState method)."""
51
+ import re
52
+ sig = re.sub(r'line \d+', 'line N', error)
53
+ sig = re.sub(r'/[\w/.-]+/', '/.../', sig)
54
+ sig = re.sub(r'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}', 'TIMESTAMP', sig)
55
+ return sig[:100]
56
+
57
+
58
+ def search_past_sessions(task: str, errors: list = None, limit: int = 3) -> dict:
59
+ """Enhanced search: task + errors + anti-patterns."""
60
+ results = {
61
+ 'similar_tasks': [],
62
+ 'similar_errors': [],
63
+ 'anti_patterns': [],
64
+ 'winning_strategies': []
65
+ }
66
+
67
+ try:
68
+ # Import CSR standalone client (dynamic path for any installation)
69
+ project_root = get_project_root()
70
+ mcp_server_path = project_root / "mcp-server" / "src"
71
+ if str(mcp_server_path) not in sys.path:
72
+ sys.path.insert(0, str(mcp_server_path))
73
+ from standalone_client import CSRStandaloneClient
74
+ client = CSRStandaloneClient()
75
+
76
+ # 1. Task-based search (existing behavior)
77
+ task_results = client.search(
78
+ query=f"ralph session: {task}",
79
+ limit=2,
80
+ min_score=0.5
81
+ )
82
+ results['similar_tasks'] = task_results
83
+
84
+ # 2. NEW: Error-based search (if we have current errors)
85
+ if errors:
86
+ for error in errors[:2]: # Top 2 errors only
87
+ sig = _error_signature(error)
88
+ error_results = client.search(
89
+ query=f"error blocked solved: {sig}",
90
+ limit=1,
91
+ min_score=0.6
92
+ )
93
+ results['similar_errors'].extend(error_results)
94
+
95
+ # 3. NEW: Anti-patterns search (failed approaches from incomplete sessions)
96
+ anti_results = client.search(
97
+ query=f"failed approach don't retry: {task}",
98
+ limit=2,
99
+ min_score=0.5
100
+ )
101
+ # Filter for incomplete/abandoned sessions
102
+ results['anti_patterns'] = [
103
+ r for r in anti_results
104
+ if r.get('metadata', {}).get('outcome') in ('INCOMPLETE', 'ABANDONED')
105
+ ]
106
+
107
+ # 4. NEW: Winning strategies (successful sessions)
108
+ winners = client.search(
109
+ query=f"successful solution completed: {task}",
110
+ limit=1,
111
+ min_score=0.6
112
+ )
113
+ results['winning_strategies'] = [
114
+ r for r in winners
115
+ if r.get('metadata', {}).get('outcome') == 'COMPLETED'
116
+ ]
117
+
118
+ return results
119
+
120
+ except ImportError:
121
+ logger.warning("CSR standalone client not available, skipping memory search")
122
+ return results
123
+ except Exception as e:
124
+ logger.error(f"Error searching CSR: {e}")
125
+ return results
126
+
127
+
128
+ def format_past_sessions(results: dict) -> str:
129
+ """Format search results with anti-patterns FIRST for fast loop efficiency."""
130
+ # Handle legacy list format
131
+ if isinstance(results, list):
132
+ results = {'similar_tasks': results}
133
+
134
+ has_content = any([
135
+ results.get('anti_patterns'),
136
+ results.get('winning_strategies'),
137
+ results.get('similar_errors'),
138
+ results.get('similar_tasks')
139
+ ])
140
+
141
+ if not has_content:
142
+ return ""
143
+
144
+ output = ["# Ralph Memory (from CSR)\n"]
145
+ output.append("Use these insights to avoid repeating mistakes.\n")
146
+
147
+ # 1. ANTI-PATTERNS FIRST (most important for fast loops)
148
+ if results.get('anti_patterns'):
149
+ output.append("## DON'T RETRY THESE")
150
+ for r in results['anti_patterns']:
151
+ # Extract failed approaches from metadata
152
+ failed = r.get('metadata', {}).get('failed_approaches', [])
153
+ if failed:
154
+ for f in failed[:3]:
155
+ output.append(f"- {f}")
156
+ else:
157
+ # Fallback to content preview
158
+ content = r.get('content', r.get('preview', ''))[:150]
159
+ output.append(f"- {content}...")
160
+
161
+ # 2. WINNING STRATEGIES (proven approaches)
162
+ if results.get('winning_strategies'):
163
+ output.append("\n## PROVEN APPROACHES")
164
+ for r in results['winning_strategies']:
165
+ strategies = r.get('metadata', {}).get('successful_strategies', [])
166
+ if strategies:
167
+ for s in strategies[:3]:
168
+ output.append(f"- {s}")
169
+ else:
170
+ content = r.get('content', r.get('preview', ''))[:150]
171
+ output.append(f"- {content}...")
172
+
173
+ # 3. PAST ERROR SOLUTIONS
174
+ if results.get('similar_errors'):
175
+ output.append("\n## PAST ERROR SOLUTIONS")
176
+ for r in results['similar_errors']:
177
+ score = r.get('score', 0)
178
+ content = r.get('content', r.get('preview', ''))[:200]
179
+ output.append(f"- (score: {score:.2f}) {content}...")
180
+
181
+ # 4. SIMILAR TASKS (context, less actionable)
182
+ if results.get('similar_tasks'):
183
+ output.append("\n## RELATED SESSIONS")
184
+ for i, r in enumerate(results['similar_tasks'][:2], 1):
185
+ score = r.get('score', 0)
186
+ outcome = r.get('metadata', {}).get('outcome', 'Unknown')
187
+ content = r.get('content', r.get('preview', ''))[:150]
188
+ output.append(f"- [{outcome}] (score: {score:.2f}) {content}...")
189
+
190
+ # Extract key learnings
191
+ if learnings := r.get('metadata', {}).get('learnings'):
192
+ for learning in learnings[:2]:
193
+ output.append(f" - Learning: {learning}")
194
+
195
+ return "\n".join(output)
196
+
197
+
198
+ def main():
199
+ """Main hook entry point."""
200
+ # Read hook input from stdin (official protocol)
201
+ try:
202
+ input_data = json.load(sys.stdin)
203
+ except (json.JSONDecodeError, EOFError):
204
+ input_data = {}
205
+
206
+ session_id = input_data.get('session_id', '')
207
+ source = input_data.get('source', 'startup')
208
+
209
+ logger.info(f"SessionStart hook triggered: source={source}, session_id={session_id[:20] if session_id else 'none'}...")
210
+
211
+ # Check if this is a Ralph session
212
+ if not is_ralph_session():
213
+ logger.info("No Ralph session detected (checked .claude/ralph-loop.local.md and .ralph_state.md)")
214
+ sys.exit(0)
215
+
216
+ # Load current state (supports both ralph-wiggum and custom formats)
217
+ state = load_ralph_session_state()
218
+ if not state:
219
+ logger.warning("Could not load Ralph state")
220
+ sys.exit(0)
221
+
222
+ # Search for past sessions with similar task AND current errors
223
+ logger.info(f"Searching CSR for past sessions related to: {state.task[:50]}...")
224
+ results = search_past_sessions(
225
+ task=state.task,
226
+ errors=state.blocking_errors if hasattr(state, 'blocking_errors') else None
227
+ )
228
+
229
+ # Count total results
230
+ total_results = sum([
231
+ len(results.get('similar_tasks', [])),
232
+ len(results.get('similar_errors', [])),
233
+ len(results.get('anti_patterns', [])),
234
+ len(results.get('winning_strategies', []))
235
+ ])
236
+
237
+ if total_results > 0:
238
+ # Write context file for Claude to read
239
+ context = format_past_sessions(results)
240
+ past_sessions_path = Path('.ralph_past_sessions.md')
241
+ past_sessions_path.write_text(context)
242
+
243
+ # Log breakdown
244
+ logger.info(f"Found {total_results} relevant results:")
245
+ logger.info(f" - Anti-patterns: {len(results.get('anti_patterns', []))}")
246
+ logger.info(f" - Winning strategies: {len(results.get('winning_strategies', []))}")
247
+ logger.info(f" - Error matches: {len(results.get('similar_errors', []))}")
248
+ logger.info(f" - Similar tasks: {len(results.get('similar_tasks', []))}")
249
+
250
+ print(f"# Loaded {total_results} relevant past sessions")
251
+ print(f"# See .ralph_past_sessions.md for details")
252
+ else:
253
+ logger.info("No relevant past sessions found")
254
+
255
+ sys.exit(0)
256
+
257
+
258
+ if __name__ == '__main__':
259
+ main()
@@ -2,9 +2,21 @@
2
2
  # PreCompact hook for Claude Self-Reflect
3
3
  # Place this in ~/.claude/hooks/precompact or source it from there
4
4
 
5
- # Configuration
6
- CLAUDE_REFLECT_DIR="${CLAUDE_REFLECT_DIR:-$HOME/claude-self-reflect}"
7
- VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
5
+ # Determine project root dynamically from this script's location
6
+ # This file is at: <project_root>/src/runtime/precompact-hook.sh
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ DEFAULT_PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
9
+
10
+ # Configuration (allows override via environment)
11
+ CLAUDE_REFLECT_DIR="${CLAUDE_REFLECT_DIR:-$DEFAULT_PROJECT_ROOT}"
12
+ # Support both .venv and venv directories
13
+ if [ -d "$CLAUDE_REFLECT_DIR/.venv" ]; then
14
+ VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
15
+ elif [ -d "$CLAUDE_REFLECT_DIR/venv" ]; then
16
+ VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/venv}"
17
+ else
18
+ VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
19
+ fi
8
20
  IMPORT_TIMEOUT="${IMPORT_TIMEOUT:-30}"
9
21
 
10
22
  # Check if Claude Self-Reflect is installed
@@ -29,5 +41,50 @@ timeout $IMPORT_TIMEOUT bash -c "
29
41
  echo "Quick import timed out after ${IMPORT_TIMEOUT}s" >&2
30
42
  }
31
43
 
44
+ # ============================================================
45
+ # RALPH MEMORY INTEGRATION (Added for Memory-Augmented Ralph)
46
+ # ============================================================
47
+
48
+ # If Ralph session, backup state to CSR before compaction
49
+ # Check both ralph-wiggum format and custom format
50
+ RALPH_STATE_FILE=""
51
+ if [ -f ".claude/ralph-loop.local.md" ]; then
52
+ RALPH_STATE_FILE=".claude/ralph-loop.local.md"
53
+ elif [ -f ".ralph_state.md" ]; then
54
+ RALPH_STATE_FILE=".ralph_state.md"
55
+ fi
56
+
57
+ if [ -n "$RALPH_STATE_FILE" ]; then
58
+ echo "📝 Backing up Ralph state to CSR..." >&2
59
+
60
+ RALPH_STATE_FILE="$RALPH_STATE_FILE" CLAUDE_REFLECT_DIR="$CLAUDE_REFLECT_DIR" python3 << 'PYTHON' 2>/dev/null || echo "Warning: Could not backup Ralph state" >&2
61
+ import sys
62
+ import os
63
+ from pathlib import Path
64
+ from datetime import datetime
65
+
66
+ # Dynamic path resolution
67
+ project_root = os.environ.get('CLAUDE_REFLECT_DIR', str(Path.home() / 'claude-self-reflect'))
68
+ sys.path.insert(0, str(Path(project_root) / 'mcp-server' / 'src'))
69
+
70
+ try:
71
+ from standalone_client import CSRStandaloneClient
72
+
73
+ ralph_state_file = os.environ.get('RALPH_STATE_FILE', '.ralph_state.md')
74
+ state_content = Path(ralph_state_file).read_text()
75
+ client = CSRStandaloneClient()
76
+
77
+ client.store_reflection(
78
+ content=f"Pre-compaction Ralph state backup ({datetime.now().isoformat()}):\n\n{state_content}",
79
+ tags=["ralph_state", "pre_compact_backup"]
80
+ )
81
+ print("✅ Ralph state backed up to CSR", file=sys.stderr)
82
+ except ImportError:
83
+ print("CSR client not available, skipping backup", file=sys.stderr)
84
+ except Exception as e:
85
+ print(f"Backup failed: {e}", file=sys.stderr)
86
+ PYTHON
87
+ fi
88
+
32
89
  # Always exit successfully to not block compacting
33
90
  exit 0
@@ -0,0 +1,73 @@
1
+ """
2
+ Qdrant connection utilities with retry logic.
3
+ """
4
+
5
+ import time
6
+ import logging
7
+ from typing import Optional
8
+ from qdrant_client import QdrantClient
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def connect_to_qdrant_with_retry(
14
+ url: str,
15
+ api_key: Optional[str] = None,
16
+ max_retries: int = 5,
17
+ initial_delay: float = 1.0
18
+ ) -> QdrantClient:
19
+ """
20
+ Connect to Qdrant with exponential backoff retry logic.
21
+
22
+ Args:
23
+ url: Qdrant URL
24
+ api_key: Optional API key for authentication
25
+ max_retries: Maximum number of retry attempts (default: 5)
26
+ initial_delay: Initial delay in seconds, doubles each retry (default: 1.0)
27
+
28
+ Returns:
29
+ Connected QdrantClient instance
30
+
31
+ Raises:
32
+ Exception: If all retries fail
33
+
34
+ Example:
35
+ >>> client = connect_to_qdrant_with_retry(
36
+ ... url="http://localhost:6333",
37
+ ... api_key="optional-api-key"
38
+ ... )
39
+ ✅ Connected to Qdrant at http://localhost:6333
40
+ """
41
+ delay = initial_delay
42
+
43
+ for attempt in range(max_retries):
44
+ try:
45
+ # Initialize client
46
+ if api_key:
47
+ client = QdrantClient(url=url, api_key=api_key)
48
+ else:
49
+ client = QdrantClient(url=url)
50
+
51
+ # Test connection by fetching collections
52
+ client.get_collections()
53
+
54
+ except Exception as e:
55
+ if attempt < max_retries - 1:
56
+ logger.warning(
57
+ f"⚠️ Qdrant connection attempt {attempt + 1}/{max_retries} failed: {e}"
58
+ )
59
+ logger.info(f" Retrying in {delay}s...")
60
+ time.sleep(delay)
61
+ delay *= 2 # Exponential backoff
62
+ else:
63
+ logger.exception(
64
+ f"Failed to connect to Qdrant after {max_retries} attempts"
65
+ )
66
+ raise
67
+ else:
68
+ # Connection successful
69
+ logger.info(f"✅ Connected to Qdrant at {url}")
70
+ return client
71
+
72
+ # Should never reach here due to raise in except block
73
+ raise Exception(f"Failed to connect to Qdrant at {url} after {max_retries} attempts")
@@ -336,6 +336,10 @@ class UnifiedStateManager:
336
336
  except Exception as e:
337
337
  raise ValueError(f"Invalid path: {file_path}: {e}")
338
338
 
339
+ # Detect if running in Docker by checking for Docker-mounted paths
340
+ docker_paths = ["/logs", "/config", "/app/data"]
341
+ in_docker = any(Path(dp).exists() for dp in docker_paths)
342
+
339
343
  # Docker to local path mappings
340
344
  path_mappings = [
341
345
  ("/logs/", "/.claude/projects/"),
@@ -343,8 +347,15 @@ class UnifiedStateManager:
343
347
  ("/app/data/", "/.claude/projects/")
344
348
  ]
345
349
 
346
- # Apply Docker mappings if needed
350
+ # In Docker: validate against Docker paths BEFORE transformation
351
+ # This ensures security validation uses paths that actually exist
347
352
  path_str = str(resolved)
353
+ original_path_str = path_str # Keep original for Docker validation
354
+
355
+ # Check if this is a Docker path that we should validate directly
356
+ is_docker_path = in_docker and any(path_str.startswith(dp) for dp in docker_paths)
357
+
358
+ # Apply Docker mappings for storage consistency (not for validation)
348
359
  for docker_path, local_path in path_mappings:
349
360
  if path_str.startswith(docker_path):
350
361
  home = str(Path.home())
@@ -359,21 +370,35 @@ class UnifiedStateManager:
359
370
  ]
360
371
 
361
372
  # Add Docker paths if they exist
362
- for docker_path in ["/logs", "/config", "/app/data"]:
373
+ for docker_path in docker_paths:
363
374
  docker_base = Path(docker_path)
364
375
  if docker_base.exists():
365
376
  allowed_bases.append(docker_base)
366
377
 
367
378
  # Check if path is within allowed directories
368
379
  path_allowed = False
369
- for base in allowed_bases:
370
- try:
371
- if base.exists():
372
- resolved.relative_to(base)
373
- path_allowed = True
374
- break
375
- except ValueError:
376
- continue
380
+
381
+ # In Docker: validate original path against Docker mounts
382
+ if is_docker_path:
383
+ original_resolved = Path(original_path_str).resolve()
384
+ for base in allowed_bases:
385
+ try:
386
+ if base.exists():
387
+ original_resolved.relative_to(base)
388
+ path_allowed = True
389
+ break
390
+ except ValueError:
391
+ continue
392
+ else:
393
+ # Local environment: validate transformed path
394
+ for base in allowed_bases:
395
+ try:
396
+ if base.exists():
397
+ resolved.relative_to(base)
398
+ path_allowed = True
399
+ break
400
+ except ValueError:
401
+ continue
377
402
 
378
403
  # Allow test paths when running tests
379
404
  if not path_allowed: