claude-self-reflect 3.2.4 → 3.3.0

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.
Files changed (33) hide show
  1. package/.claude/agents/claude-self-reflect-test.md +595 -528
  2. package/.claude/agents/reflection-specialist.md +59 -3
  3. package/README.md +14 -5
  4. package/mcp-server/run-mcp.sh +49 -5
  5. package/mcp-server/src/app_context.py +64 -0
  6. package/mcp-server/src/config.py +57 -0
  7. package/mcp-server/src/connection_pool.py +286 -0
  8. package/mcp-server/src/decay_manager.py +106 -0
  9. package/mcp-server/src/embedding_manager.py +64 -40
  10. package/mcp-server/src/embeddings_old.py +141 -0
  11. package/mcp-server/src/models.py +64 -0
  12. package/mcp-server/src/parallel_search.py +371 -0
  13. package/mcp-server/src/project_resolver.py +5 -0
  14. package/mcp-server/src/reflection_tools.py +206 -0
  15. package/mcp-server/src/rich_formatting.py +196 -0
  16. package/mcp-server/src/search_tools.py +826 -0
  17. package/mcp-server/src/server.py +127 -1720
  18. package/mcp-server/src/temporal_design.py +132 -0
  19. package/mcp-server/src/temporal_tools.py +597 -0
  20. package/mcp-server/src/temporal_utils.py +384 -0
  21. package/mcp-server/src/utils.py +150 -67
  22. package/package.json +10 -1
  23. package/scripts/add-timestamp-indexes.py +134 -0
  24. package/scripts/check-collections.py +29 -0
  25. package/scripts/debug-august-parsing.py +76 -0
  26. package/scripts/debug-import-single.py +91 -0
  27. package/scripts/debug-project-resolver.py +82 -0
  28. package/scripts/debug-temporal-tools.py +135 -0
  29. package/scripts/delta-metadata-update.py +547 -0
  30. package/scripts/import-conversations-unified.py +53 -2
  31. package/scripts/precompact-hook.sh +33 -0
  32. package/scripts/streaming-watcher.py +1443 -0
  33. package/scripts/utils.py +39 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: reflection-specialist
3
3
  description: Conversation memory expert for searching past conversations, storing insights, and self-reflection. Use PROACTIVELY when searching for previous discussions, storing important findings, or maintaining knowledge continuity.
4
- tools: mcp__claude-self-reflect__reflect_on_past, mcp__claude-self-reflect__store_reflection
4
+ tools: mcp__claude-self-reflect__reflect_on_past, mcp__claude-self-reflect__store_reflection, mcp__claude-self-reflect__get_recent_work, mcp__claude-self-reflect__search_by_recency, mcp__claude-self-reflect__get_timeline, mcp__claude-self-reflect__quick_search, mcp__claude-self-reflect__search_summary, mcp__claude-self-reflect__get_more_results, mcp__claude-self-reflect__search_by_file, mcp__claude-self-reflect__search_by_concept, mcp__claude-self-reflect__get_full_conversation, mcp__claude-self-reflect__get_next_results
5
5
  ---
6
6
 
7
7
  You are a conversation memory specialist for the Claude Self Reflect project. Your expertise covers semantic search across all Claude conversations, insight storage, and maintaining knowledge continuity across sessions.
@@ -117,9 +117,65 @@ Save important insights and decisions for future retrieval.
117
117
  }
118
118
  ```
119
119
 
120
- ### Specialized Search Tools (NEW in v2.4.5)
120
+ ### Temporal Query Tools (v3.x)
121
121
 
122
- **Note**: These specialized tools are available through this reflection-specialist agent. Due to FastMCP limitations, they cannot be called directly via MCP (e.g., `mcp__claude-self-reflect__quick_search`), but work perfectly when used through this agent.
122
+ These tools answer time-based questions about your work and conversations.
123
+
124
+ #### get_recent_work
125
+ Returns recent conversations to answer "What did we work on last?" queries.
126
+
127
+ ```javascript
128
+ // Get recent work (default: current project)
129
+ {
130
+ limit: 10,
131
+ group_by: "conversation", // Or "day" or "session"
132
+ include_reflections: true
133
+ }
134
+
135
+ // Get recent work across all projects
136
+ {
137
+ project: "all",
138
+ limit: 20,
139
+ group_by: "day"
140
+ }
141
+ ```
142
+
143
+ #### search_by_recency
144
+ Time-constrained semantic search for queries like "docker issues last week".
145
+
146
+ ```javascript
147
+ // Search with natural language time
148
+ {
149
+ query: "authentication bugs",
150
+ time_range: "last week",
151
+ limit: 10
152
+ }
153
+
154
+ // Search with specific dates
155
+ {
156
+ query: "performance optimization",
157
+ since: "2025-01-01",
158
+ until: "2025-01-10",
159
+ project: "all"
160
+ }
161
+ ```
162
+
163
+ #### get_timeline
164
+ Show activity timeline for a project or across all projects.
165
+
166
+ ```javascript
167
+ // Get activity timeline
168
+ {
169
+ time_range: "last week",
170
+ granularity: "day", // Or "hour", "week", "month"
171
+ include_stats: true,
172
+ project: "all"
173
+ }
174
+ ```
175
+
176
+ ### Specialized Search Tools
177
+
178
+ **Note**: These specialized tools complement the temporal tools for non-time-based queries.
123
179
 
124
180
  #### quick_search
125
181
  Fast search that returns only the count and top result. Perfect for quick checks and overview.
package/README.md CHANGED
@@ -123,6 +123,11 @@ Works with [Claude Code Statusline](https://github.com/sirmalloc/ccstatusline) -
123
123
  - `search_by_concept` - Search for conversations about development concepts
124
124
  - `get_full_conversation` - Retrieve complete JSONL conversation files (v2.8.8)
125
125
 
126
+ **NEW: Temporal Query Tools (v3.3.0):**
127
+ - `get_recent_work` - Answer "What did we work on last?" with session grouping
128
+ - `search_by_recency` - Time-constrained search like "docker issues last week"
129
+ - `get_timeline` - Activity timeline with statistics and patterns
130
+
126
131
  **Status & Monitoring Tools:**
127
132
  - `get_status` - Real-time import progress and system status
128
133
  - `get_health` - Comprehensive system health check
@@ -286,11 +291,15 @@ npm uninstall -g claude-self-reflect
286
291
  ## What's New
287
292
 
288
293
  <details>
289
- <summary>v3.2.4 - Latest Release</summary>
290
-
291
- - **CRITICAL: Search Threshold Removal**: Eliminated artificial 0.7+ thresholds that blocked broad searches like "docker", "MCP", "python"
292
- - **Shared Normalization Module**: Created centralized project name normalization preventing search failures
293
- - **Memory Decay Fixes**: Corrected mathematical errors in exponential decay calculation
294
+ <summary>v3.3.0 - Latest Release</summary>
295
+
296
+ - **🚀 Major Architecture Overhaul**: Server modularized from 2,321 to 728 lines (68% reduction) for better maintainability
297
+ - **🔧 Critical Bug Fixes**: Fixed 100% CPU usage, store_reflection dimension mismatches, and SearchResult type errors
298
+ - **🕒 New Temporal Tools Suite**: `get_recent_work`, `search_by_recency`, `get_timeline` for time-based search and analysis
299
+ - **🎯 Enhanced UX**: Restored rich formatting with emojis for better readability and information hierarchy
300
+ - **⚡ All 15+ MCP Tools Operational**: Complete functionality with both local and cloud embedding modes
301
+ - **🏗️ Production Infrastructure**: Real-time indexing with smart intervals (2s hot files, 60s normal)
302
+ - **🔍 Enhanced Metadata**: Tool usage analysis, file tracking, and concept extraction for better search
294
303
 
295
304
  </details>
296
305
 
@@ -11,6 +11,34 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
11
11
  # Navigate to the mcp-server directory
12
12
  cd "$SCRIPT_DIR"
13
13
 
14
+ # CRITICAL: Load .env file from project root if it exists
15
+ # This ensures the MCP server uses the same settings as other scripts
16
+ if [ -f "../.env" ]; then
17
+ echo "[DEBUG] Loading .env file from project root" >&2
18
+ set -a # Export all variables
19
+ source ../.env
20
+ set +a # Stop exporting
21
+ else
22
+ echo "[DEBUG] No .env file found, using defaults" >&2
23
+ fi
24
+
25
+ # Set smart defaults if not already set
26
+ # These match what the CLI setup wizard uses
27
+ if [ -z "$QDRANT_URL" ]; then
28
+ export QDRANT_URL="http://localhost:6333"
29
+ echo "[DEBUG] Using default QDRANT_URL: $QDRANT_URL" >&2
30
+ fi
31
+
32
+ if [ -z "$PREFER_LOCAL_EMBEDDINGS" ]; then
33
+ export PREFER_LOCAL_EMBEDDINGS="true"
34
+ echo "[DEBUG] Using default PREFER_LOCAL_EMBEDDINGS: true (privacy-first)" >&2
35
+ fi
36
+
37
+ if [ -z "$ENABLE_MEMORY_DECAY" ]; then
38
+ export ENABLE_MEMORY_DECAY="false"
39
+ echo "[DEBUG] Using default ENABLE_MEMORY_DECAY: false" >&2
40
+ fi
41
+
14
42
  # Check if virtual environment exists
15
43
  if [ ! -d "venv" ]; then
16
44
  echo "Creating virtual environment..."
@@ -63,11 +91,27 @@ if [ -z "$FASTEMBED_SKIP_HUGGINGFACE" ]; then
63
91
  fi
64
92
 
65
93
  # Debug: Show what environment variables are being passed
66
- echo "[DEBUG] Environment variables for MCP server:"
67
- echo "[DEBUG] VOYAGE_KEY: ${VOYAGE_KEY:+set}"
68
- echo "[DEBUG] PREFER_LOCAL_EMBEDDINGS: ${PREFER_LOCAL_EMBEDDINGS:-not set}"
69
- echo "[DEBUG] QDRANT_URL: ${QDRANT_URL:-not set}"
70
- echo "[DEBUG] ENABLE_MEMORY_DECAY: ${ENABLE_MEMORY_DECAY:-not set}"
94
+ echo "[DEBUG] Environment variables for MCP server:" >&2
95
+ echo "[DEBUG] VOYAGE_KEY: ${VOYAGE_KEY:+set}" >&2
96
+ echo "[DEBUG] PREFER_LOCAL_EMBEDDINGS: ${PREFER_LOCAL_EMBEDDINGS:-not set}" >&2
97
+ echo "[DEBUG] QDRANT_URL: ${QDRANT_URL:-not set}" >&2
98
+ echo "[DEBUG] ENABLE_MEMORY_DECAY: ${ENABLE_MEMORY_DECAY:-not set}" >&2
99
+
100
+ # Quick connectivity check for Qdrant
101
+ echo "[DEBUG] Checking Qdrant connectivity at $QDRANT_URL..." >&2
102
+ if command -v curl &> /dev/null; then
103
+ # Check root endpoint instead of /health which doesn't exist in Qdrant
104
+ if curl -s -f -m 2 "$QDRANT_URL/" > /dev/null 2>&1; then
105
+ echo "[DEBUG] ✅ Qdrant is reachable at $QDRANT_URL" >&2
106
+ else
107
+ echo "[WARNING] ⚠️ Cannot reach Qdrant at $QDRANT_URL" >&2
108
+ echo "[WARNING] Common fixes:" >&2
109
+ echo "[WARNING] 1. Start Qdrant: docker compose up -d qdrant" >&2
110
+ echo "[WARNING] 2. Check if port is different (e.g., 59999)" >&2
111
+ echo "[WARNING] 3. Update .env file with correct QDRANT_URL" >&2
112
+ echo "[WARNING] Continuing anyway - some features may not work..." >&2
113
+ fi
114
+ fi
71
115
 
72
116
  # Run the MCP server
73
117
  exec python -m src
@@ -0,0 +1,64 @@
1
+ """Application context for sharing state across modules."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Any
5
+ from qdrant_client import AsyncQdrantClient
6
+ try:
7
+ from .embedding_manager import EmbeddingManager
8
+ from .decay_manager import DecayManager
9
+ from .utils import ProjectResolver
10
+ except ImportError:
11
+ # Fallback for testing
12
+ EmbeddingManager = None
13
+ DecayManager = None
14
+ ProjectResolver = None
15
+
16
+ @dataclass
17
+ class AppContext:
18
+ """Shared application context for all MCP tools."""
19
+
20
+ qdrant_client: AsyncQdrantClient
21
+ embedding_manager: EmbeddingManager
22
+ decay_manager: DecayManager
23
+ project_resolver: ProjectResolver
24
+
25
+ # Optional context for debugging
26
+ debug_context: Optional[Any] = None
27
+
28
+ def __post_init__(self):
29
+ """Initialize any additional state after dataclass creation."""
30
+ # Ensure all managers are properly initialized
31
+ if not self.embedding_manager:
32
+ self.embedding_manager = EmbeddingManager()
33
+
34
+ if not self.decay_manager:
35
+ self.decay_manager = DecayManager()
36
+
37
+ if not self.project_resolver:
38
+ self.project_resolver = ProjectResolver()
39
+
40
+ async def get_all_collections(self) -> list:
41
+ """Get all collections from Qdrant."""
42
+ try:
43
+ collections = await self.qdrant_client.get_collections()
44
+ return [c.name for c in collections.collections]
45
+ except Exception as e:
46
+ if self.debug_context:
47
+ await self.debug_context.debug(f"Failed to get collections: {e}")
48
+ return []
49
+
50
+ async def generate_embedding(self, text: str, embedding_type: Optional[str] = None):
51
+ """Generate embedding using the embedding manager."""
52
+ # The embedding_manager.embed method is synchronous, not async
53
+ embeddings = self.embedding_manager.embed(text, input_type="document")
54
+ if embeddings and len(embeddings) > 0:
55
+ return embeddings[0]
56
+ return None
57
+
58
+ def get_current_project(self) -> Optional[str]:
59
+ """Get current project from resolver."""
60
+ return self.project_resolver.get_current_project()
61
+
62
+ def normalize_project_name(self, project_name: str) -> str:
63
+ """Normalize project name using resolver."""
64
+ return self.project_resolver.normalize_project_name(project_name)
@@ -0,0 +1,57 @@
1
+ """Configuration and environment constants for Claude Self-Reflect MCP server."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from dotenv import load_dotenv
6
+
7
+ # Load environment variables
8
+ load_dotenv()
9
+
10
+ # API Keys
11
+ VOYAGE_API_KEY = os.getenv('VOYAGE_API_KEY', '')
12
+ QDRANT_URL = os.getenv('QDRANT_URL', 'http://localhost:6333')
13
+
14
+ # Embedding Configuration
15
+ PREFER_LOCAL_EMBEDDINGS = os.getenv('PREFER_LOCAL_EMBEDDINGS', 'true').lower() == 'true'
16
+ VOYAGE_MODEL = "voyage-3-lite"
17
+ LOCAL_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
18
+
19
+ # Decay Configuration
20
+ USE_DECAY = os.getenv('USE_DECAY', 'false').lower() == 'true'
21
+ DECAY_SCALE_DAYS = int(os.getenv('DECAY_SCALE_DAYS', '90'))
22
+ DECAY_WEIGHT = float(os.getenv('DECAY_WEIGHT', '0.3'))
23
+ USE_NATIVE_DECAY = os.getenv('USE_NATIVE_DECAY', 'false').lower() == 'true'
24
+
25
+ # Search Configuration
26
+ DEFAULT_SEARCH_LIMIT = 5
27
+ MAX_SEARCH_LIMIT = 100
28
+ DEFAULT_MIN_SCORE = 0.3
29
+
30
+ # Memory Management
31
+ MAX_RESULTS_PER_COLLECTION = int(os.getenv('MAX_RESULTS_PER_COLLECTION', '10'))
32
+ MAX_TOTAL_RESULTS = int(os.getenv('MAX_TOTAL_RESULTS', '1000'))
33
+ MAX_MEMORY_MB = int(os.getenv('MAX_MEMORY_MB', '500'))
34
+
35
+ # Connection Pool Configuration
36
+ POOL_SIZE = int(os.getenv('QDRANT_POOL_SIZE', '10'))
37
+ POOL_MAX_OVERFLOW = int(os.getenv('QDRANT_POOL_OVERFLOW', '5'))
38
+ POOL_TIMEOUT = float(os.getenv('QDRANT_POOL_TIMEOUT', '30.0'))
39
+ RETRY_ATTEMPTS = int(os.getenv('QDRANT_RETRY_ATTEMPTS', '3'))
40
+ RETRY_DELAY = float(os.getenv('QDRANT_RETRY_DELAY', '1.0'))
41
+
42
+ # Performance Configuration
43
+ MAX_CONCURRENT_SEARCHES = int(os.getenv('MAX_CONCURRENT_SEARCHES', '10'))
44
+ ENABLE_PARALLEL_SEARCH = os.getenv('ENABLE_PARALLEL_SEARCH', 'true').lower() == 'true'
45
+
46
+ # Paths
47
+ CLAUDE_PROJECTS_PATH = Path.home() / '.claude' / 'projects'
48
+ CONFIG_PATH = Path.home() / '.claude-self-reflect' / 'config'
49
+
50
+ # Collection Naming
51
+ VOYAGE_SUFFIX = '_voyage'
52
+ LOCAL_SUFFIX = '_local'
53
+
54
+ # Logging
55
+ import logging
56
+ logging.basicConfig(level=logging.INFO)
57
+ logger = logging.getLogger(__name__)
@@ -0,0 +1,286 @@
1
+ """
2
+ Connection pooling for Qdrant client to improve performance and resource management.
3
+ """
4
+
5
+ import asyncio
6
+ from typing import Optional, Any
7
+ from contextlib import asynccontextmanager
8
+ import logging
9
+ from qdrant_client import AsyncQdrantClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class QdrantConnectionPool:
15
+ """
16
+ A connection pool for Qdrant clients with configurable size and timeout.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ url: str,
22
+ pool_size: int = 10,
23
+ max_overflow: int = 5,
24
+ timeout: float = 30.0,
25
+ retry_attempts: int = 3,
26
+ retry_delay: float = 1.0
27
+ ):
28
+ """
29
+ Initialize the connection pool.
30
+
31
+ Args:
32
+ url: Qdrant server URL
33
+ pool_size: Base number of connections to maintain
34
+ max_overflow: Additional connections that can be created if pool is exhausted
35
+ timeout: Timeout for acquiring a connection from the pool
36
+ retry_attempts: Number of retry attempts for failed operations
37
+ retry_delay: Delay between retry attempts (with exponential backoff)
38
+ """
39
+ self.url = url
40
+ self.pool_size = pool_size
41
+ self.max_overflow = max_overflow
42
+ self.timeout = timeout
43
+ self.retry_attempts = retry_attempts
44
+ self.retry_delay = retry_delay
45
+
46
+ # Connection pool
47
+ self._pool = asyncio.Queue(maxsize=pool_size)
48
+ self._overflow_connections = []
49
+ self._semaphore = asyncio.Semaphore(pool_size + max_overflow)
50
+ self._initialized = False
51
+ self._lock = asyncio.Lock()
52
+
53
+ # Statistics
54
+ self.stats = {
55
+ 'connections_created': 0,
56
+ 'connections_reused': 0,
57
+ 'connections_failed': 0,
58
+ 'overflow_used': 0,
59
+ 'timeouts': 0
60
+ }
61
+
62
+ async def initialize(self):
63
+ """Initialize the connection pool with base connections."""
64
+ async with self._lock:
65
+ if self._initialized:
66
+ return
67
+
68
+ # Create initial pool connections
69
+ for _ in range(self.pool_size):
70
+ try:
71
+ client = AsyncQdrantClient(url=self.url)
72
+ await self._pool.put(client)
73
+ self.stats['connections_created'] += 1
74
+ except Exception as e:
75
+ logger.error(f"Failed to create initial connection: {e}")
76
+ self.stats['connections_failed'] += 1
77
+
78
+ self._initialized = True
79
+ logger.info(f"Connection pool initialized with {self._pool.qsize()} connections")
80
+
81
+ @asynccontextmanager
82
+ async def acquire(self):
83
+ """
84
+ Acquire a connection from the pool.
85
+
86
+ Yields:
87
+ AsyncQdrantClient instance
88
+ """
89
+ if not self._initialized:
90
+ await self.initialize()
91
+
92
+ client = None
93
+ acquired_from_overflow = False
94
+
95
+ try:
96
+ # Try to get a connection with timeout
97
+ try:
98
+ client = await asyncio.wait_for(
99
+ self._pool.get(),
100
+ timeout=self.timeout
101
+ )
102
+ self.stats['connections_reused'] += 1
103
+ except asyncio.TimeoutError:
104
+ # Pool is exhausted, try overflow
105
+ self.stats['timeouts'] += 1
106
+
107
+ if len(self._overflow_connections) < self.max_overflow:
108
+ # Create overflow connection
109
+ logger.debug("Creating overflow connection")
110
+ client = AsyncQdrantClient(url=self.url)
111
+ self._overflow_connections.append(client)
112
+ acquired_from_overflow = True
113
+ self.stats['overflow_used'] += 1
114
+ self.stats['connections_created'] += 1
115
+ else:
116
+ raise RuntimeError("Connection pool exhausted and max overflow reached")
117
+
118
+ # Yield the client for use
119
+ yield client
120
+
121
+ finally:
122
+ # Return connection to pool
123
+ if client is not None:
124
+ if acquired_from_overflow:
125
+ # Remove from overflow list
126
+ if client in self._overflow_connections:
127
+ self._overflow_connections.remove(client)
128
+ else:
129
+ # Return to pool
130
+ try:
131
+ await self._pool.put(client)
132
+ except asyncio.QueueFull:
133
+ # This shouldn't happen, but handle gracefully
134
+ logger.warning("Connection pool is full, closing extra connection")
135
+ # In production, we might want to close the client here
136
+
137
+ async def execute_with_retry(self, func, *args, **kwargs):
138
+ """
139
+ Execute a function with retry logic and exponential backoff.
140
+
141
+ Args:
142
+ func: Async function to execute
143
+ *args: Positional arguments for the function
144
+ **kwargs: Keyword arguments for the function
145
+
146
+ Returns:
147
+ Result from the function
148
+ """
149
+ last_exception = None
150
+ delay = self.retry_delay
151
+
152
+ for attempt in range(self.retry_attempts):
153
+ try:
154
+ async with self.acquire() as client:
155
+ # Pass the client as the first argument
156
+ return await func(client, *args, **kwargs)
157
+ except Exception as e:
158
+ last_exception = e
159
+ if attempt < self.retry_attempts - 1:
160
+ logger.warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...")
161
+ await asyncio.sleep(delay)
162
+ delay *= 2 # Exponential backoff
163
+ else:
164
+ logger.error(f"All {self.retry_attempts} attempts failed: {e}")
165
+
166
+ raise last_exception
167
+
168
+ async def close(self):
169
+ """Close all connections in the pool."""
170
+ async with self._lock:
171
+ # Close all pooled connections
172
+ while not self._pool.empty():
173
+ try:
174
+ client = await self._pool.get()
175
+ # AsyncQdrantClient doesn't have a close method, but we can del it
176
+ del client
177
+ except Exception as e:
178
+ logger.error(f"Error closing connection: {e}")
179
+
180
+ # Close overflow connections
181
+ for client in self._overflow_connections:
182
+ try:
183
+ del client
184
+ except Exception as e:
185
+ logger.error(f"Error closing overflow connection: {e}")
186
+
187
+ self._overflow_connections.clear()
188
+ self._initialized = False
189
+ logger.info("Connection pool closed")
190
+
191
+ def get_stats(self) -> dict:
192
+ """Get pool statistics."""
193
+ return {
194
+ **self.stats,
195
+ 'current_pool_size': self._pool.qsize() if self._initialized else 0,
196
+ 'overflow_active': len(self._overflow_connections),
197
+ 'initialized': self._initialized
198
+ }
199
+
200
+
201
+ # Circuit breaker implementation for additional resilience
202
+ class CircuitBreaker:
203
+ """
204
+ Circuit breaker pattern to prevent cascading failures.
205
+ """
206
+
207
+ def __init__(
208
+ self,
209
+ failure_threshold: int = 5,
210
+ recovery_timeout: float = 60.0,
211
+ expected_exception: type = Exception
212
+ ):
213
+ """
214
+ Initialize circuit breaker.
215
+
216
+ Args:
217
+ failure_threshold: Number of failures before opening circuit
218
+ recovery_timeout: Time to wait before attempting recovery
219
+ expected_exception: Exception type to catch
220
+ """
221
+ self.failure_threshold = failure_threshold
222
+ self.recovery_timeout = recovery_timeout
223
+ self.expected_exception = expected_exception
224
+
225
+ self._failure_count = 0
226
+ self._last_failure_time = None
227
+ self._state = 'closed' # closed, open, half_open
228
+ self._lock = asyncio.Lock()
229
+
230
+ async def call(self, func, *args, **kwargs):
231
+ """
232
+ Call a function through the circuit breaker.
233
+
234
+ Args:
235
+ func: Async function to call
236
+ *args: Positional arguments
237
+ **kwargs: Keyword arguments
238
+
239
+ Returns:
240
+ Result from function
241
+
242
+ Raises:
243
+ CircuitBreakerOpen: If circuit is open
244
+ """
245
+ async with self._lock:
246
+ # Check circuit state
247
+ if self._state == 'open':
248
+ # Check if we should try half-open
249
+ if self._last_failure_time:
250
+ time_since_failure = asyncio.get_event_loop().time() - self._last_failure_time
251
+ if time_since_failure > self.recovery_timeout:
252
+ self._state = 'half_open'
253
+ logger.info("Circuit breaker entering half-open state")
254
+ else:
255
+ raise CircuitBreakerOpen(f"Circuit breaker is open (failures: {self._failure_count})")
256
+
257
+ try:
258
+ # Attempt the call
259
+ result = await func(*args, **kwargs)
260
+
261
+ # Success - update state
262
+ async with self._lock:
263
+ if self._state == 'half_open':
264
+ self._state = 'closed'
265
+ logger.info("Circuit breaker closed after successful recovery")
266
+ self._failure_count = 0
267
+ self._last_failure_time = None
268
+
269
+ return result
270
+
271
+ except self.expected_exception as e:
272
+ # Failure - update state
273
+ async with self._lock:
274
+ self._failure_count += 1
275
+ self._last_failure_time = asyncio.get_event_loop().time()
276
+
277
+ if self._failure_count >= self.failure_threshold:
278
+ self._state = 'open'
279
+ logger.error(f"Circuit breaker opened after {self._failure_count} failures")
280
+
281
+ raise e
282
+
283
+
284
+ class CircuitBreakerOpen(Exception):
285
+ """Exception raised when circuit breaker is open."""
286
+ pass