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.
- package/.claude/agents/csr-validator.md +87 -1
- package/.env.example +15 -0
- package/README.md +70 -1
- package/docker-compose.yaml +18 -9
- package/installer/setup-wizard-docker.js +87 -6
- package/installer/update-manager.js +88 -1
- package/mcp-server/src/reflection_tools.py +114 -2
- package/mcp-server/src/standalone_client.py +380 -0
- package/package.json +1 -1
- package/src/runtime/hooks/__init__.py +21 -0
- package/src/runtime/hooks/iteration_hook.py +196 -0
- package/src/runtime/hooks/ralph_state.py +402 -0
- package/src/runtime/hooks/session_end_hook.py +254 -0
- package/src/runtime/hooks/session_start_hook.py +259 -0
- package/src/runtime/precompact-hook.sh +82 -3
- package/src/runtime/unified_state_manager.py +35 -10
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ralph SessionEnd Hook - Stores session narrative to CSR.
|
|
4
|
+
|
|
5
|
+
Triggered at session end. Parses .ralph_state.md, determines outcome,
|
|
6
|
+
and stores narrative with metadata for future sessions.
|
|
7
|
+
|
|
8
|
+
Enhanced Features (v7.1+):
|
|
9
|
+
- Structured status block extraction
|
|
10
|
+
- Rich metadata storage (work type, confidence, error signatures)
|
|
11
|
+
- Output trend tracking for circuit breaker patterns
|
|
12
|
+
|
|
13
|
+
Input (stdin): JSON with session_id, transcript_path, reason
|
|
14
|
+
Output: None (cannot block session end)
|
|
15
|
+
|
|
16
|
+
Attribution:
|
|
17
|
+
Status block format inspired by https://github.com/frankbria/ralph-claude-code
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
# Add project root to path
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
|
28
|
+
|
|
29
|
+
from src.runtime.hooks.ralph_state import load_state, is_ralph_session, load_ralph_session_state
|
|
30
|
+
|
|
31
|
+
logging.basicConfig(level=logging.INFO)
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_status_block(content: str) -> dict:
|
|
36
|
+
"""Extract ---RALPH_STATUS--- block if present.
|
|
37
|
+
|
|
38
|
+
Format:
|
|
39
|
+
---RALPH_STATUS---
|
|
40
|
+
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
|
|
41
|
+
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION
|
|
42
|
+
EXIT_SIGNAL: true | false
|
|
43
|
+
---END_RALPH_STATUS---
|
|
44
|
+
"""
|
|
45
|
+
import re
|
|
46
|
+
match = re.search(
|
|
47
|
+
r'---RALPH_STATUS---\n(.+?)\n---END_RALPH_STATUS---',
|
|
48
|
+
content, re.DOTALL
|
|
49
|
+
)
|
|
50
|
+
if not match:
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
status = {}
|
|
54
|
+
for line in match.group(1).strip().split('\n'):
|
|
55
|
+
if ':' in line:
|
|
56
|
+
key, val = line.split(':', 1)
|
|
57
|
+
key = key.strip().lower().replace(' ', '_')
|
|
58
|
+
val = val.strip()
|
|
59
|
+
# Convert boolean strings
|
|
60
|
+
if val.lower() in ('true', 'false'):
|
|
61
|
+
val = val.lower() == 'true'
|
|
62
|
+
# Convert numeric strings
|
|
63
|
+
elif val.isdigit():
|
|
64
|
+
val = int(val)
|
|
65
|
+
status[key] = val
|
|
66
|
+
return status
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_project_root() -> Path:
|
|
70
|
+
"""Dynamically determine project root (works for any installation)."""
|
|
71
|
+
# This file is at: <project_root>/src/runtime/hooks/session_end_hook.py
|
|
72
|
+
return Path(__file__).parent.parent.parent.parent
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def store_session_narrative(state, session_id: str, reason: str) -> bool:
|
|
76
|
+
"""Store session narrative to CSR with rich metadata."""
|
|
77
|
+
try:
|
|
78
|
+
# Import CSR standalone client (dynamic path for any installation)
|
|
79
|
+
project_root = get_project_root()
|
|
80
|
+
mcp_server_path = project_root / "mcp-server" / "src"
|
|
81
|
+
if str(mcp_server_path) not in sys.path:
|
|
82
|
+
sys.path.insert(0, str(mcp_server_path))
|
|
83
|
+
from standalone_client import CSRStandaloneClient
|
|
84
|
+
client = CSRStandaloneClient()
|
|
85
|
+
|
|
86
|
+
# Determine outcome
|
|
87
|
+
if state.completion_promise_met:
|
|
88
|
+
outcome = "COMPLETED"
|
|
89
|
+
elif reason in ('clear', 'logout'):
|
|
90
|
+
outcome = "ABANDONED"
|
|
91
|
+
else:
|
|
92
|
+
outcome = "INCOMPLETE"
|
|
93
|
+
|
|
94
|
+
# Get enhanced fields if available (new RalphState fields)
|
|
95
|
+
work_type = getattr(state, 'work_type', '') or 'UNKNOWN'
|
|
96
|
+
exit_confidence = getattr(state, 'exit_confidence', 0)
|
|
97
|
+
error_signatures = getattr(state, 'error_signatures', {})
|
|
98
|
+
# Handle output_declining as either method or boolean safely
|
|
99
|
+
output_declining_attr = getattr(state, 'output_declining', None)
|
|
100
|
+
output_declining = output_declining_attr() if callable(output_declining_attr) else False
|
|
101
|
+
|
|
102
|
+
# Generate narrative with enhanced metadata
|
|
103
|
+
narrative = f"""# Ralph Session Complete
|
|
104
|
+
|
|
105
|
+
## Metadata
|
|
106
|
+
- Session ID: {state.session_id}
|
|
107
|
+
- End Reason: {reason}
|
|
108
|
+
- Timestamp: {datetime.now().isoformat()}
|
|
109
|
+
- Total Iterations: {state.iteration}
|
|
110
|
+
- Work Type: {work_type}
|
|
111
|
+
- Exit Confidence: {exit_confidence}%
|
|
112
|
+
- Output Trend: {"DECLINING" if output_declining else "STABLE"}
|
|
113
|
+
|
|
114
|
+
## Task
|
|
115
|
+
{state.task}
|
|
116
|
+
|
|
117
|
+
## Outcome: {outcome}
|
|
118
|
+
Completion Promise: `{state.completion_promise}`
|
|
119
|
+
Promise Met: {state.completion_promise_met}
|
|
120
|
+
|
|
121
|
+
## Final Approach
|
|
122
|
+
{state.current_approach}
|
|
123
|
+
|
|
124
|
+
## What Worked
|
|
125
|
+
{chr(10).join(f'- {s}' for s in state.successful_strategies) or '- (none recorded)'}
|
|
126
|
+
|
|
127
|
+
## What Failed (Don't Retry These)
|
|
128
|
+
{chr(10).join(f'- {f}' for f in state.failed_approaches) or '- (none recorded)'}
|
|
129
|
+
|
|
130
|
+
## Blocking Errors Encountered
|
|
131
|
+
{chr(10).join(f'- {e}' for e in state.blocking_errors) or '- (none recorded)'}
|
|
132
|
+
|
|
133
|
+
## Error Signatures (Deduplicated)
|
|
134
|
+
{chr(10).join(f'- `{sig}` (x{count})' for sig, count in error_signatures.items()) or '- (none)'}
|
|
135
|
+
|
|
136
|
+
## Key Learnings
|
|
137
|
+
{chr(10).join(f'- {l}' for l in state.learnings) or '- (none recorded)'}
|
|
138
|
+
|
|
139
|
+
## Files Modified
|
|
140
|
+
{chr(10).join(f'- {f}' for f in state.files_modified) or '- (none recorded)'}
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
# Store with outcome-aware tags
|
|
144
|
+
# IMPORTANT: __csr_hook_auto__ is a distinct signature that identifies
|
|
145
|
+
# hook-stored reflections vs manually-stored ones during development
|
|
146
|
+
tags = [
|
|
147
|
+
"__csr_hook_auto__", # Hook signature - don't manually use this tag!
|
|
148
|
+
"ralph_session",
|
|
149
|
+
f"session_{state.session_id}",
|
|
150
|
+
f"outcome_{outcome.lower()}",
|
|
151
|
+
f"iterations_{state.iteration}",
|
|
152
|
+
f"work_type_{work_type.lower()}"
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
# Rich metadata for better search filtering
|
|
156
|
+
metadata = {
|
|
157
|
+
"outcome": outcome,
|
|
158
|
+
"iterations": state.iteration,
|
|
159
|
+
"work_type": work_type,
|
|
160
|
+
"exit_confidence": exit_confidence,
|
|
161
|
+
"output_declining": output_declining,
|
|
162
|
+
"error_signatures": list(error_signatures.keys()),
|
|
163
|
+
"failed_approaches": state.failed_approaches,
|
|
164
|
+
"successful_strategies": state.successful_strategies,
|
|
165
|
+
"learnings": state.learnings,
|
|
166
|
+
"files_modified": state.files_modified,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Note: CSR store_reflection may not support metadata yet,
|
|
170
|
+
# but we include the rich info in the narrative for searchability
|
|
171
|
+
# Use hook-specific collection so random agents don't pollute it
|
|
172
|
+
client.store_reflection(
|
|
173
|
+
content=narrative,
|
|
174
|
+
tags=tags,
|
|
175
|
+
collection="csr_hook_sessions_local" # Separate from user reflections
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
logger.info(f"Stored session narrative: {outcome}, {state.iteration} iterations, confidence={exit_confidence}%")
|
|
179
|
+
|
|
180
|
+
# If successful, also store the winning strategy separately
|
|
181
|
+
if outcome == "COMPLETED" and state.successful_strategies:
|
|
182
|
+
success_summary = f"""Successful Ralph approach for '{state.task[:100]}':
|
|
183
|
+
Approach: {state.current_approach}
|
|
184
|
+
Key strategies: {', '.join(state.successful_strategies[:5])}
|
|
185
|
+
Exit confidence: {exit_confidence}%
|
|
186
|
+
Iterations: {state.iteration}
|
|
187
|
+
"""
|
|
188
|
+
client.store_reflection(
|
|
189
|
+
content=success_summary,
|
|
190
|
+
tags=["__csr_hook_auto__", "ralph_success", "winning_strategy", f"work_type_{work_type.lower()}"],
|
|
191
|
+
collection="csr_hook_sessions_local"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
except ImportError:
|
|
197
|
+
logger.warning("CSR standalone client not available")
|
|
198
|
+
return False
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.error(f"Error storing narrative: {e}")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cleanup_session_files():
|
|
205
|
+
"""Clean up temporary session files."""
|
|
206
|
+
files_to_remove = [
|
|
207
|
+
Path('.ralph_past_sessions.md'),
|
|
208
|
+
Path('.ralph_memories.md')
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
for f in files_to_remove:
|
|
212
|
+
if f.exists():
|
|
213
|
+
try:
|
|
214
|
+
f.unlink()
|
|
215
|
+
logger.info(f"Cleaned up: {f}")
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.warning(f"Could not remove {f}: {e}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main():
|
|
221
|
+
"""Main hook entry point."""
|
|
222
|
+
# Read hook input from stdin
|
|
223
|
+
try:
|
|
224
|
+
input_data = json.load(sys.stdin)
|
|
225
|
+
except (json.JSONDecodeError, EOFError):
|
|
226
|
+
input_data = {}
|
|
227
|
+
|
|
228
|
+
session_id = input_data.get('session_id', 'unknown')
|
|
229
|
+
reason = input_data.get('reason', 'other')
|
|
230
|
+
|
|
231
|
+
logger.info(f"SessionEnd hook triggered: reason={reason}")
|
|
232
|
+
|
|
233
|
+
# Check if this is a Ralph session
|
|
234
|
+
if not is_ralph_session():
|
|
235
|
+
sys.exit(0)
|
|
236
|
+
|
|
237
|
+
# Load state (supports both ralph-wiggum and custom formats)
|
|
238
|
+
state = load_ralph_session_state()
|
|
239
|
+
if not state:
|
|
240
|
+
logger.warning("Could not load Ralph state for narrative storage")
|
|
241
|
+
sys.exit(0)
|
|
242
|
+
|
|
243
|
+
# Store narrative to CSR
|
|
244
|
+
store_session_narrative(state, session_id, reason)
|
|
245
|
+
|
|
246
|
+
# Note: Don't clean up .ralph_state.md - it may be needed for resume
|
|
247
|
+
# Only clean up helper files
|
|
248
|
+
cleanup_session_files()
|
|
249
|
+
|
|
250
|
+
sys.exit(0)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == '__main__':
|
|
254
|
+
main()
|
|
@@ -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,28 @@
|
|
|
2
2
|
# PreCompact hook for Claude Self-Reflect
|
|
3
3
|
# Place this in ~/.claude/hooks/precompact or source it from there
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
|
|
13
|
+
# SECURITY: Validate CLAUDE_REFLECT_DIR to prevent directory traversal
|
|
14
|
+
if [[ "$CLAUDE_REFLECT_DIR" =~ \.\. ]]; then
|
|
15
|
+
echo "Error: CLAUDE_REFLECT_DIR contains directory traversal" >&2
|
|
16
|
+
exit 0 # Exit gracefully, don't block compacting
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
# Support both .venv and venv directories
|
|
20
|
+
if [ -d "$CLAUDE_REFLECT_DIR/.venv" ]; then
|
|
21
|
+
VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
|
|
22
|
+
elif [ -d "$CLAUDE_REFLECT_DIR/venv" ]; then
|
|
23
|
+
VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/venv}"
|
|
24
|
+
else
|
|
25
|
+
VENV_PATH="${VENV_PATH:-$CLAUDE_REFLECT_DIR/.venv}"
|
|
26
|
+
fi
|
|
8
27
|
IMPORT_TIMEOUT="${IMPORT_TIMEOUT:-30}"
|
|
9
28
|
|
|
10
29
|
# Check if Claude Self-Reflect is installed
|
|
@@ -19,6 +38,17 @@ if [ ! -d "$VENV_PATH" ]; then
|
|
|
19
38
|
exit 0 # Exit gracefully
|
|
20
39
|
fi
|
|
21
40
|
|
|
41
|
+
# SECURITY: Validate VENV_PATH to prevent command injection
|
|
42
|
+
if [[ ! "$VENV_PATH" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
|
|
43
|
+
echo "Error: Invalid VENV_PATH contains unsafe characters" >&2
|
|
44
|
+
exit 0 # Exit gracefully, don't block compacting
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
if [ ! -x "$VENV_PATH/bin/python3" ]; then
|
|
48
|
+
echo "Error: Python not found at $VENV_PATH/bin/python3" >&2
|
|
49
|
+
exit 0 # Exit gracefully
|
|
50
|
+
fi
|
|
51
|
+
|
|
22
52
|
# Run quick import with timeout
|
|
23
53
|
echo "Updating conversation memory..." >&2
|
|
24
54
|
timeout $IMPORT_TIMEOUT bash -c "
|
|
@@ -29,5 +59,54 @@ timeout $IMPORT_TIMEOUT bash -c "
|
|
|
29
59
|
echo "Quick import timed out after ${IMPORT_TIMEOUT}s" >&2
|
|
30
60
|
}
|
|
31
61
|
|
|
62
|
+
# ============================================================
|
|
63
|
+
# RALPH MEMORY INTEGRATION (Added for Memory-Augmented Ralph)
|
|
64
|
+
# ============================================================
|
|
65
|
+
|
|
66
|
+
# If Ralph session, backup state to CSR before compaction
|
|
67
|
+
# Check both ralph-wiggum format and custom format
|
|
68
|
+
RALPH_STATE_FILE=""
|
|
69
|
+
if [ -f ".claude/ralph-loop.local.md" ]; then
|
|
70
|
+
RALPH_STATE_FILE=".claude/ralph-loop.local.md"
|
|
71
|
+
elif [ -f ".ralph_state.md" ]; then
|
|
72
|
+
RALPH_STATE_FILE=".ralph_state.md"
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
if [ -n "$RALPH_STATE_FILE" ]; then
|
|
76
|
+
echo "📝 Backing up Ralph state to CSR..." >&2
|
|
77
|
+
|
|
78
|
+
RALPH_STATE_FILE="$RALPH_STATE_FILE" CLAUDE_REFLECT_DIR="$CLAUDE_REFLECT_DIR" "$VENV_PATH/bin/python3" << 'PYTHON' || echo "Warning: Could not backup Ralph state" >&2
|
|
79
|
+
import sys
|
|
80
|
+
import os
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
from datetime import datetime
|
|
83
|
+
|
|
84
|
+
# Dynamic path resolution
|
|
85
|
+
project_root = os.environ.get('CLAUDE_REFLECT_DIR', str(Path.home() / 'claude-self-reflect'))
|
|
86
|
+
sys.path.insert(0, str(Path(project_root) / 'mcp-server' / 'src'))
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
import re
|
|
90
|
+
from standalone_client import CSRStandaloneClient
|
|
91
|
+
|
|
92
|
+
ralph_state_file = os.environ.get('RALPH_STATE_FILE', '.ralph_state.md')
|
|
93
|
+
state_content = Path(ralph_state_file).read_text()
|
|
94
|
+
# SECURITY: Sanitize project name to prevent injection
|
|
95
|
+
project_name = re.sub(r'[^a-zA-Z0-9_-]', '_', Path.cwd().name)
|
|
96
|
+
client = CSRStandaloneClient()
|
|
97
|
+
|
|
98
|
+
client.store_reflection(
|
|
99
|
+
content=f"Pre-compaction Ralph state backup ({datetime.now().isoformat()}):\n\nProject: {project_name}\n\n{state_content}",
|
|
100
|
+
tags=["__csr_hook_auto__", f"project_{project_name}", "ralph_state", "pre_compact_backup"],
|
|
101
|
+
collection="csr_hook_sessions_local"
|
|
102
|
+
)
|
|
103
|
+
print(f"✅ Ralph state ({project_name}) backed up to CSR", file=sys.stderr)
|
|
104
|
+
except ImportError:
|
|
105
|
+
print("CSR client not available, skipping backup", file=sys.stderr)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
print(f"Backup failed: {e}", file=sys.stderr)
|
|
108
|
+
PYTHON
|
|
109
|
+
fi
|
|
110
|
+
|
|
32
111
|
# Always exit successfully to not block compacting
|
|
33
112
|
exit 0
|
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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:
|