surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
When hookify rules use `field: transcript`, the entire transcript file was read into memory. For long-running sessions or ralph loops, transcripts can grow to gigabytes, causing OOM kills. This fix adds: - 10MB max size limit for transcript reading - For larger files, only the tail (most recent content) is read - Warning at 5MB to alert users of large transcripts - Proper error handling consolidated into a reusable function This addresses memory issues reported in monorepo where CC was consuming 30-40GB before getting OOM killed during startup/resume.
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """Rule evaluation engine for hookify plugin.""" |
| 3 | 3 | |
| 4 | +import os | |
| 4 | 5 | import re |
| 5 | 6 | import sys |
| 6 | 7 | from functools import lru_cache |
| @@ -9,7 +10,14 @@ | ||
| 9 | 10 | # Import from local module |
| 10 | 11 | from hookify.core.config_loader import Rule, Condition |
| 11 | 12 | |
| 13 | +# Maximum transcript size to load into memory (10MB default) | |
| 14 | +# For larger files, only the tail is read to prevent OOM | |
| 15 | +MAX_TRANSCRIPT_SIZE_BYTES = 10 * 1024 * 1024 # 10MB | |
| 12 | 16 | |
| 17 | +# Size threshold for warning about large transcripts | |
| 18 | +TRANSCRIPT_WARNING_SIZE_BYTES = 5 * 1024 * 1024 # 5MB | |
| 19 | + | |
| 20 | + | |
| 13 | 21 | # Cache compiled regexes (max 128 patterns) |
| 14 | 22 | @lru_cache(maxsize=128) |
| 15 | 23 | def compile_regex(pattern: str) -> re.Pattern: |
| @@ -24,6 +32,58 @@ | ||
| 24 | 32 | return re.compile(pattern, re.IGNORECASE) |
| 25 | 33 | |
| 26 | 34 | |
| 35 | +def read_transcript_safely(transcript_path: str) -> str: | |
| 36 | + """Read transcript file with size limits to prevent OOM. | |
| 37 | + | |
| 38 | + For large transcripts (>10MB), only reads the tail of the file | |
| 39 | + to prevent memory exhaustion. This preserves the most recent | |
| 40 | + conversation context which is typically what rules care about. | |
| 41 | + | |
| 42 | + Args: | |
| 43 | + transcript_path: Path to the transcript file | |
| 44 | + | |
| 45 | + Returns: | |
| 46 | + Transcript content as string, possibly truncated for large files | |
| 47 | + """ | |
| 48 | + try: | |
| 49 | + file_size = os.path.getsize(transcript_path) | |
| 50 | + | |
| 51 | + # Warn about large transcripts | |
| 52 | + if file_size > TRANSCRIPT_WARNING_SIZE_BYTES: | |
| 53 | + size_mb = file_size / (1024 * 1024) | |
| 54 | + print(f"Warning: Large transcript ({size_mb:.1f}MB): {transcript_path}", file=sys.stderr) | |
| 55 | + | |
| 56 | + # For files within limit, read normally | |
| 57 | + if file_size <= MAX_TRANSCRIPT_SIZE_BYTES: | |
| 58 | + with open(transcript_path, 'r') as f: | |
| 59 | + return f.read() | |
| 60 | + | |
| 61 | + # For large files, read only the tail to prevent OOM | |
| 62 | + size_mb = file_size / (1024 * 1024) | |
| 63 | + limit_mb = MAX_TRANSCRIPT_SIZE_BYTES / (1024 * 1024) | |
| 64 | + print(f"Warning: Transcript too large ({size_mb:.1f}MB), reading last {limit_mb:.0f}MB only", file=sys.stderr) | |
| 65 | + | |
| 66 | + with open(transcript_path, 'r') as f: | |
| 67 | + # Seek to position near end, leaving room for MAX_TRANSCRIPT_SIZE_BYTES | |
| 68 | + f.seek(file_size - MAX_TRANSCRIPT_SIZE_BYTES) | |
| 69 | + # Skip partial line at seek position | |
| 70 | + f.readline() | |
| 71 | + return f.read() | |
| 72 | + | |
| 73 | + except FileNotFoundError: | |
| 74 | + print(f"Warning: Transcript file not found: {transcript_path}", file=sys.stderr) | |
| 75 | + return '' | |
| 76 | + except PermissionError: | |
| 77 | + print(f"Warning: Permission denied reading transcript: {transcript_path}", file=sys.stderr) | |
| 78 | + return '' | |
| 79 | + except (IOError, OSError) as e: | |
| 80 | + print(f"Warning: Error reading transcript {transcript_path}: {e}", file=sys.stderr) | |
| 81 | + return '' | |
| 82 | + except UnicodeDecodeError as e: | |
| 83 | + print(f"Warning: Encoding error in transcript {transcript_path}: {e}", file=sys.stderr) | |
| 84 | + return '' | |
| 85 | + | |
| 86 | + | |
| 27 | 87 | class RuleEngine: |
| 28 | 88 | """Evaluates rules against hook input data.""" |
| 29 | 89 | |
| @@ -205,24 +265,10 @@ | ||
| 205 | 265 | if field == 'reason': |
| 206 | 266 | return input_data.get('reason', '') |
| 207 | 267 | elif field == 'transcript': |
| 208 | - # Read transcript file if path provided | |
| 268 | + # Read transcript file with size limits to prevent OOM | |
| 209 | 269 | transcript_path = input_data.get('transcript_path') |
| 210 | 270 | if transcript_path: |
| 211 | - try: | |
| 212 | - with open(transcript_path, 'r') as f: | |
| 213 | - return f.read() | |
| 214 | - except FileNotFoundError: | |
| 215 | - print(f"Warning: Transcript file not found: {transcript_path}", file=sys.stderr) | |
| 216 | - return '' | |
| 217 | - except PermissionError: | |
| 218 | - print(f"Warning: Permission denied reading transcript: {transcript_path}", file=sys.stderr) | |
| 219 | - return '' | |
| 220 | - except (IOError, OSError) as e: | |
| 221 | - print(f"Warning: Error reading transcript {transcript_path}: {e}", file=sys.stderr) | |
| 222 | - return '' | |
| 223 | - except UnicodeDecodeError as e: | |
| 224 | - print(f"Warning: Encoding error in transcript {transcript_path}: {e}", file=sys.stderr) | |
| 225 | - return '' | |
| 271 | + return read_transcript_safely(transcript_path) | |
| 226 | 272 | elif field == 'user_prompt': |
| 227 | 273 | # For UserPromptSubmit events |
| 228 | 274 | return input_data.get('user_prompt', '') |