claude-self-reflect 3.2.4 → 3.3.1

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 (41) hide show
  1. package/.claude/agents/claude-self-reflect-test.md +992 -510
  2. package/.claude/agents/reflection-specialist.md +59 -3
  3. package/README.md +14 -5
  4. package/installer/cli.js +16 -0
  5. package/installer/postinstall.js +14 -0
  6. package/installer/statusline-setup.js +289 -0
  7. package/mcp-server/run-mcp.sh +73 -5
  8. package/mcp-server/src/app_context.py +64 -0
  9. package/mcp-server/src/config.py +57 -0
  10. package/mcp-server/src/connection_pool.py +286 -0
  11. package/mcp-server/src/decay_manager.py +106 -0
  12. package/mcp-server/src/embedding_manager.py +64 -40
  13. package/mcp-server/src/embeddings_old.py +141 -0
  14. package/mcp-server/src/models.py +64 -0
  15. package/mcp-server/src/parallel_search.py +305 -0
  16. package/mcp-server/src/project_resolver.py +5 -0
  17. package/mcp-server/src/reflection_tools.py +211 -0
  18. package/mcp-server/src/rich_formatting.py +196 -0
  19. package/mcp-server/src/search_tools.py +874 -0
  20. package/mcp-server/src/server.py +127 -1720
  21. package/mcp-server/src/temporal_design.py +132 -0
  22. package/mcp-server/src/temporal_tools.py +604 -0
  23. package/mcp-server/src/temporal_utils.py +384 -0
  24. package/mcp-server/src/utils.py +150 -67
  25. package/package.json +15 -1
  26. package/scripts/add-timestamp-indexes.py +134 -0
  27. package/scripts/ast_grep_final_analyzer.py +325 -0
  28. package/scripts/ast_grep_unified_registry.py +556 -0
  29. package/scripts/check-collections.py +29 -0
  30. package/scripts/csr-status +366 -0
  31. package/scripts/debug-august-parsing.py +76 -0
  32. package/scripts/debug-import-single.py +91 -0
  33. package/scripts/debug-project-resolver.py +82 -0
  34. package/scripts/debug-temporal-tools.py +135 -0
  35. package/scripts/delta-metadata-update.py +547 -0
  36. package/scripts/import-conversations-unified.py +157 -25
  37. package/scripts/precompact-hook.sh +33 -0
  38. package/scripts/session_quality_tracker.py +481 -0
  39. package/scripts/streaming-watcher.py +1578 -0
  40. package/scripts/update_patterns.py +334 -0
  41. package/scripts/utils.py +39 -0
@@ -0,0 +1,1578 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Claude Self-Reflect Production Streaming Watcher v3.0.0
4
+ Complete overhaul with all fixes from v2.5.17 plus enhanced monitoring
5
+
6
+ Key improvements:
7
+ 1. Production state file: csr-watcher.json (no temp/test names)
8
+ 2. Comprehensive psutil memory monitoring with detailed metrics
9
+ 3. Proper state key format handling (full paths)
10
+ 4. Container-aware configuration for Docker deployments
11
+ 5. Enhanced error recovery and queue management
12
+ 6. Real-time progress tracking toward 100% indexing
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import time
19
+ import hashlib
20
+ import re
21
+ import gc
22
+ import ctypes
23
+ import platform
24
+ from pathlib import Path
25
+ from typing import Dict, List, Optional, Any, Set, Tuple, Generator
26
+ from datetime import datetime, timedelta
27
+ from concurrent.futures import ThreadPoolExecutor
28
+ from dataclasses import dataclass, field
29
+ from enum import Enum
30
+ import logging
31
+ from collections import deque
32
+
33
+ from qdrant_client import AsyncQdrantClient, models
34
+ from qdrant_client.http.exceptions import UnexpectedResponse
35
+ from fastembed import TextEmbedding
36
+ import psutil
37
+
38
+ # Import normalize_project_name
39
+ import sys
40
+ sys.path.insert(0, str(Path(__file__).parent))
41
+ from utils import normalize_project_name
42
+
43
+ # Configure logging
44
+ logging.basicConfig(
45
+ level=logging.INFO,
46
+ format='%(asctime)s - %(levelname)s - %(message)s'
47
+ )
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Configuration from environment
51
+ @dataclass
52
+ class Config:
53
+ """Production configuration with proper defaults."""
54
+ qdrant_url: str = field(default_factory=lambda: os.getenv("QDRANT_URL", "http://localhost:6333"))
55
+ voyage_api_key: Optional[str] = field(default_factory=lambda: os.getenv("VOYAGE_API_KEY"))
56
+ prefer_local_embeddings: bool = field(default_factory=lambda: os.getenv("PREFER_LOCAL_EMBEDDINGS", "true").lower() == "true")
57
+ embedding_model: str = field(default_factory=lambda: os.getenv("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2"))
58
+
59
+ logs_dir: Path = field(default_factory=lambda: Path(os.getenv("LOGS_DIR", "~/.claude/projects")).expanduser())
60
+
61
+ # Production state file with proper naming
62
+ state_file: Path = field(default_factory=lambda: (
63
+ # Docker/cloud mode: use /config volume
64
+ Path("/config/csr-watcher.json") if os.path.exists("/.dockerenv")
65
+ # Local mode with cloud flag: separate state file
66
+ else Path("~/.claude-self-reflect/config/csr-watcher-cloud.json").expanduser()
67
+ if os.getenv("PREFER_LOCAL_EMBEDDINGS", "true").lower() == "false" and os.getenv("VOYAGE_API_KEY")
68
+ # Default local mode
69
+ else Path("~/.claude-self-reflect/config/csr-watcher.json").expanduser()
70
+ if os.getenv("STATE_FILE") is None
71
+ # User override
72
+ else Path(os.getenv("STATE_FILE")).expanduser()
73
+ ))
74
+
75
+ collection_prefix: str = "conv"
76
+ vector_size: int = 384 # FastEmbed all-MiniLM-L6-v2
77
+
78
+ # Production throttling controls (optimized for stability)
79
+ import_frequency: int = field(default_factory=lambda: int(os.getenv("IMPORT_FREQUENCY", "60"))) # Normal cycle
80
+ hot_check_interval_s: int = field(default_factory=lambda: int(os.getenv("HOT_CHECK_INTERVAL_S", "2"))) # HOT file check
81
+ batch_size: int = field(default_factory=lambda: int(os.getenv("BATCH_SIZE", "10")))
82
+ memory_limit_mb: int = field(default_factory=lambda: int(os.getenv("MEMORY_LIMIT_MB", "1024"))) # 1GB default
83
+ memory_warning_mb: int = field(default_factory=lambda: int(os.getenv("MEMORY_WARNING_MB", "500"))) # 500MB warning
84
+
85
+ # HOT/WARM/COLD configuration
86
+ hot_window_minutes: int = field(default_factory=lambda: int(os.getenv("HOT_WINDOW_MINUTES", "5"))) # Files < 5 min are HOT
87
+ warm_window_hours: int = field(default_factory=lambda: int(os.getenv("WARM_WINDOW_HOURS", "24"))) # Files < 24 hrs are WARM
88
+ max_cold_files: int = field(default_factory=lambda: int(os.getenv("MAX_COLD_FILES", "5"))) # Max COLD files per cycle
89
+ max_warm_wait_minutes: int = field(default_factory=lambda: int(os.getenv("MAX_WARM_WAIT_MINUTES", "30"))) # Starvation prevention
90
+
91
+ # CPU management
92
+ max_cpu_percent_per_core: float = field(default_factory=lambda: float(os.getenv("MAX_CPU_PERCENT_PER_CORE", "50")))
93
+ max_concurrent_embeddings: int = field(default_factory=lambda: int(os.getenv("MAX_CONCURRENT_EMBEDDINGS", "2")))
94
+ max_concurrent_qdrant: int = field(default_factory=lambda: int(os.getenv("MAX_CONCURRENT_QDRANT", "3")))
95
+
96
+ # Queue management
97
+ max_queue_size: int = field(default_factory=lambda: int(os.getenv("MAX_QUEUE_SIZE", "100")))
98
+ max_backlog_hours: int = field(default_factory=lambda: int(os.getenv("MAX_BACKLOG_HOURS", "24")))
99
+
100
+ # Reliability settings
101
+ qdrant_timeout_s: float = field(default_factory=lambda: float(os.getenv("QDRANT_TIMEOUT", "10")))
102
+ max_retries: int = field(default_factory=lambda: int(os.getenv("MAX_RETRIES", "3")))
103
+ retry_delay_s: float = field(default_factory=lambda: float(os.getenv("RETRY_DELAY", "1")))
104
+
105
+ # Collection cache settings
106
+ collection_cache_ttl: int = field(default_factory=lambda: int(os.getenv("COLLECTION_CACHE_TTL", "3600")))
107
+ collection_cache_max_size: int = field(default_factory=lambda: int(os.getenv("COLLECTION_CACHE_MAX_SIZE", "100")))
108
+
109
+
110
+ # Check if malloc_trim is available
111
+ try:
112
+ libc = ctypes.CDLL("libc.so.6")
113
+ malloc_trim = libc.malloc_trim
114
+ malloc_trim.argtypes = [ctypes.c_size_t]
115
+ malloc_trim.restype = ctypes.c_int
116
+ MALLOC_TRIM_AVAILABLE = True
117
+ except:
118
+ MALLOC_TRIM_AVAILABLE = False
119
+ logger.debug("malloc_trim not available on this platform")
120
+
121
+
122
+ def get_effective_cpus() -> float:
123
+ """Get effective CPU count considering cgroup limits."""
124
+ effective_cores_env = os.getenv("EFFECTIVE_CORES")
125
+ if effective_cores_env:
126
+ try:
127
+ return float(effective_cores_env)
128
+ except ValueError:
129
+ pass
130
+
131
+ # cgroup v2
132
+ cpu_max = Path("/sys/fs/cgroup/cpu.max")
133
+ # cgroup v1
134
+ cpu_quota = Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
135
+ cpu_period = Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
136
+
137
+ try:
138
+ if cpu_max.exists():
139
+ content = cpu_max.read_text().strip().split()
140
+ if content[0] != "max":
141
+ quota, period = int(content[0]), int(content[1])
142
+ if period > 0:
143
+ return max(1.0, quota / period)
144
+ elif cpu_quota.exists() and cpu_period.exists():
145
+ quota = int(cpu_quota.read_text())
146
+ period = int(cpu_period.read_text())
147
+ if quota > 0 and period > 0:
148
+ return max(1.0, quota / period)
149
+ except Exception:
150
+ pass
151
+
152
+ return float(psutil.cpu_count() or 1)
153
+
154
+
155
+ def extract_tool_usage_from_conversation(messages: List[Dict]) -> Dict[str, Any]:
156
+ """Extract tool usage metadata from conversation messages."""
157
+ tool_usage = {
158
+ 'files_analyzed': [],
159
+ 'files_edited': [],
160
+ 'tools_used': set()
161
+ }
162
+
163
+ for msg in messages:
164
+ content = msg.get('content', '')
165
+
166
+ if isinstance(content, str):
167
+ text = content
168
+ elif isinstance(content, list):
169
+ text_parts = []
170
+ for item in content:
171
+ if isinstance(item, str):
172
+ text_parts.append(item)
173
+ elif isinstance(item, dict):
174
+ if item.get('type') == 'text':
175
+ text_parts.append(item.get('text', ''))
176
+ elif item.get('type') == 'tool_use':
177
+ tool_name = item.get('name', '')
178
+ tool_usage['tools_used'].add(tool_name)
179
+
180
+ if 'input' in item:
181
+ tool_input = item['input']
182
+ if isinstance(tool_input, dict):
183
+ if 'file_path' in tool_input:
184
+ file_path = tool_input['file_path']
185
+ if tool_name in ['Read', 'Grep', 'Glob', 'LS']:
186
+ tool_usage['files_analyzed'].append(file_path)
187
+ elif tool_name in ['Edit', 'Write', 'MultiEdit']:
188
+ tool_usage['files_edited'].append(file_path)
189
+
190
+ if 'files' in tool_input:
191
+ files = tool_input['files']
192
+ if isinstance(files, list):
193
+ tool_usage['files_analyzed'].extend(files)
194
+ text = ' '.join(text_parts)
195
+ else:
196
+ text = str(content) if content else ''
197
+
198
+ # Extract file paths from text content
199
+ file_patterns = [
200
+ r'`([/\w\-\.]+\.\w+)`',
201
+ r'File: ([/\w\-\.]+\.\w+)',
202
+ r'(?:^|\s)(/[\w\-\./]+\.\w+)',
203
+ r'(?:^|\s)([\w\-]+\.\w+)',
204
+ ]
205
+
206
+ for pattern in file_patterns:
207
+ matches = re.findall(pattern, text[:5000])
208
+ for match in matches[:10]:
209
+ if match and not match.startswith('http'):
210
+ if any(keyword in text.lower() for keyword in ['edit', 'modify', 'update', 'write', 'create']):
211
+ tool_usage['files_edited'].append(match)
212
+ else:
213
+ tool_usage['files_analyzed'].append(match)
214
+
215
+ # Convert sets to lists and deduplicate
216
+ tool_usage['tools_used'] = list(tool_usage['tools_used'])
217
+ tool_usage['files_analyzed'] = list(set(tool_usage['files_analyzed']))[:20]
218
+ tool_usage['files_edited'] = list(set(tool_usage['files_edited']))[:20]
219
+
220
+ return tool_usage
221
+
222
+
223
+ def extract_concepts(text: str, tool_usage: Dict[str, Any]) -> List[str]:
224
+ """Extract development concepts from conversation text."""
225
+ concepts = set()
226
+
227
+ text_sample = text[:50000] if len(text) > 50000 else text
228
+
229
+ concept_patterns = {
230
+ 'docker': r'\b(?:docker|container|compose|dockerfile)\b',
231
+ 'testing': r'\b(?:test|testing|unittest|pytest)\b',
232
+ 'database': r'\b(?:database|sql|postgres|mysql|mongodb|qdrant)\b',
233
+ 'api': r'\b(?:api|rest|graphql|endpoint|mcp)\b',
234
+ 'security': r'\b(?:security|auth|authentication)\b',
235
+ 'performance': r'\b(?:performance|optimization|cache|memory)\b',
236
+ 'debugging': r'\b(?:debug|debugging|error|bug|fix)\b',
237
+ 'deployment': r'\b(?:deploy|deployment|ci\/cd)\b',
238
+ 'streaming': r'\b(?:stream|streaming|import|watcher)\b',
239
+ 'embeddings': r'\b(?:embed|embedding|vector|fastembed|voyage)\b',
240
+ }
241
+
242
+ text_lower = text_sample.lower()
243
+
244
+ for concept, pattern in concept_patterns.items():
245
+ if re.search(pattern, text_lower, re.IGNORECASE):
246
+ concepts.add(concept)
247
+
248
+ # Add concepts based on tools used
249
+ if 'Docker' in tool_usage.get('tools_used', []):
250
+ concepts.add('docker')
251
+ if 'Bash' in tool_usage.get('tools_used', []):
252
+ concepts.add('scripting')
253
+
254
+ return list(concepts)[:15]
255
+
256
+
257
+ class FreshnessLevel(Enum):
258
+ """File freshness categorization for prioritization."""
259
+ HOT = "HOT" # < 5 minutes old - near real-time processing
260
+ WARM = "WARM" # 5 minutes - 24 hours - normal processing
261
+ COLD = "COLD" # > 24 hours - batch processing
262
+ URGENT_WARM = "URGENT_WARM" # WARM files waiting > 30 minutes (starvation prevention)
263
+
264
+
265
+ class MemoryMonitor:
266
+ """Enhanced memory monitoring with psutil."""
267
+
268
+ def __init__(self, limit_mb: int, warning_mb: int):
269
+ self.process = psutil.Process()
270
+ self.limit_mb = limit_mb
271
+ self.warning_mb = warning_mb
272
+ self.start_memory = self.get_memory_info()
273
+ self.peak_memory = self.start_memory['rss_mb']
274
+ self.cleanup_count = 0
275
+ self.last_warning_time = 0
276
+
277
+ def get_memory_info(self) -> Dict[str, float]:
278
+ """Get detailed memory information."""
279
+ mem = self.process.memory_info()
280
+
281
+ # Get additional memory metrics
282
+ try:
283
+ mem_full = self.process.memory_full_info()
284
+ uss = mem_full.uss / 1024 / 1024 # Unique set size
285
+ pss = mem_full.pss / 1024 / 1024 if hasattr(mem_full, 'pss') else 0 # Proportional set size
286
+ except:
287
+ uss = 0
288
+ pss = 0
289
+
290
+ return {
291
+ 'rss_mb': mem.rss / 1024 / 1024, # Resident set size
292
+ 'vms_mb': mem.vms / 1024 / 1024, # Virtual memory size
293
+ 'uss_mb': uss, # Unique memory
294
+ 'pss_mb': pss, # Proportional memory
295
+ 'percent': self.process.memory_percent(),
296
+ 'available_mb': psutil.virtual_memory().available / 1024 / 1024
297
+ }
298
+
299
+ def check_memory(self) -> Tuple[bool, Dict[str, Any]]:
300
+ """Check memory usage and return (should_cleanup, metrics)."""
301
+ info = self.get_memory_info()
302
+ rss_mb = info['rss_mb']
303
+
304
+ # Update peak
305
+ self.peak_memory = max(self.peak_memory, rss_mb)
306
+
307
+ # Check thresholds
308
+ should_cleanup = False
309
+ alert_level = "normal"
310
+
311
+ if rss_mb > self.limit_mb:
312
+ alert_level = "critical"
313
+ should_cleanup = True
314
+ elif rss_mb > self.limit_mb * 0.85:
315
+ alert_level = "high"
316
+ should_cleanup = True
317
+ elif rss_mb > self.warning_mb:
318
+ alert_level = "warning"
319
+ # Only warn once per minute
320
+ now = time.time()
321
+ if now - self.last_warning_time > 60:
322
+ logger.warning(f"Memory usage {rss_mb:.1f}MB exceeds warning threshold {self.warning_mb}MB")
323
+ self.last_warning_time = now
324
+
325
+ return should_cleanup, {
326
+ 'current_mb': rss_mb,
327
+ 'peak_mb': self.peak_memory,
328
+ 'limit_mb': self.limit_mb,
329
+ 'warning_mb': self.warning_mb,
330
+ 'percent_of_limit': (rss_mb / self.limit_mb * 100) if self.limit_mb > 0 else 0,
331
+ 'alert_level': alert_level,
332
+ 'cleanup_count': self.cleanup_count,
333
+ 'details': info
334
+ }
335
+
336
+ async def cleanup(self) -> Dict[str, Any]:
337
+ """Perform memory cleanup and return metrics."""
338
+ before = self.get_memory_info()
339
+
340
+ # Force garbage collection
341
+ gc.collect(2) # Full collection
342
+
343
+ # Platform-specific cleanup
344
+ if MALLOC_TRIM_AVAILABLE:
345
+ malloc_trim(0)
346
+
347
+ # Give system time to reclaim
348
+ await asyncio.sleep(0.1)
349
+
350
+ after = self.get_memory_info()
351
+ self.cleanup_count += 1
352
+
353
+ freed = before['rss_mb'] - after['rss_mb']
354
+
355
+ if freed > 10: # Significant cleanup
356
+ logger.info(f"Memory cleanup freed {freed:.1f}MB (before: {before['rss_mb']:.1f}MB, after: {after['rss_mb']:.1f}MB)")
357
+
358
+ return {
359
+ 'before_mb': before['rss_mb'],
360
+ 'after_mb': after['rss_mb'],
361
+ 'freed_mb': freed,
362
+ 'cleanup_count': self.cleanup_count
363
+ }
364
+
365
+
366
+ class EmbeddingProvider:
367
+ """Base class for embedding providers."""
368
+
369
+ async def embed_documents(self, texts: List[str]) -> List[List[float]]:
370
+ raise NotImplementedError
371
+
372
+ async def close(self):
373
+ """Cleanup resources."""
374
+ pass
375
+
376
+
377
+ class FastEmbedProvider(EmbeddingProvider):
378
+ """FastEmbed provider with proper resource management."""
379
+
380
+ def __init__(self, model_name: str, max_concurrent: int = 2):
381
+ self.model = TextEmbedding(model_name)
382
+ self.executor = ThreadPoolExecutor(max_workers=1)
383
+ self.semaphore = asyncio.Semaphore(max_concurrent)
384
+ self.vector_size = 384 # all-MiniLM-L6-v2 dimensions
385
+ self.provider_type = 'local'
386
+
387
+ async def embed_documents(self, texts: List[str]) -> List[List[float]]:
388
+ """Generate embeddings with concurrency control."""
389
+ async with self.semaphore:
390
+ loop = asyncio.get_event_loop()
391
+ embeddings = await loop.run_in_executor(
392
+ self.executor,
393
+ lambda: list(self.model.embed(texts))
394
+ )
395
+ return [embedding.tolist() for embedding in embeddings]
396
+
397
+ async def close(self):
398
+ """Shutdown executor properly."""
399
+ if sys.version_info >= (3, 9):
400
+ self.executor.shutdown(wait=True, cancel_futures=True)
401
+ else:
402
+ self.executor.shutdown(wait=True)
403
+
404
+
405
+ class VoyageProvider(EmbeddingProvider):
406
+ """Voyage AI provider for cloud embeddings with retry logic."""
407
+
408
+ def __init__(self, api_key: str, model_name: str = "voyage-3", max_concurrent: int = 2):
409
+ self.api_key = api_key
410
+ self.model_name = model_name
411
+ self.vector_size = 1024 # voyage-3 dimension
412
+ self.semaphore = asyncio.Semaphore(max_concurrent)
413
+ self.base_url = "https://api.voyageai.com/v1/embeddings"
414
+ self.session = None
415
+ self.max_retries = 3
416
+ self.retry_delay = 1.0
417
+
418
+ async def _ensure_session(self):
419
+ """Ensure aiohttp session exists."""
420
+ if self.session is None:
421
+ import aiohttp
422
+ self.session = aiohttp.ClientSession()
423
+
424
+ async def embed_documents(self, texts: List[str]) -> List[List[float]]:
425
+ """Generate embeddings using Voyage AI API with retry logic."""
426
+ await self._ensure_session()
427
+
428
+ async with self.semaphore:
429
+ for attempt in range(self.max_retries):
430
+ try:
431
+ import aiohttp
432
+ headers = {
433
+ "Authorization": f"Bearer {self.api_key}",
434
+ "Content-Type": "application/json"
435
+ }
436
+
437
+ payload = {
438
+ "input": texts,
439
+ "model": self.model_name,
440
+ "input_type": "document" # For document embeddings
441
+ }
442
+
443
+ async with self.session.post(
444
+ self.base_url,
445
+ headers=headers,
446
+ json=payload,
447
+ timeout=aiohttp.ClientTimeout(total=30)
448
+ ) as response:
449
+ if response.status == 200:
450
+ data = await response.json()
451
+ # Voyage returns embeddings in data.data[].embedding
452
+ embeddings = [item["embedding"] for item in data["data"]]
453
+ return embeddings
454
+ elif response.status == 429: # Rate limit
455
+ retry_after = int(response.headers.get("Retry-After", 2))
456
+ logger.warning(f"Rate limited, retrying after {retry_after}s")
457
+ await asyncio.sleep(retry_after)
458
+ else:
459
+ error_text = await response.text()
460
+ logger.error(f"Voyage API error {response.status}: {error_text}")
461
+ if attempt < self.max_retries - 1:
462
+ await asyncio.sleep(self.retry_delay * (2 ** attempt))
463
+
464
+ except asyncio.TimeoutError:
465
+ logger.warning(f"Voyage API timeout (attempt {attempt + 1}/{self.max_retries})")
466
+ if attempt < self.max_retries - 1:
467
+ await asyncio.sleep(self.retry_delay * (2 ** attempt))
468
+ except Exception as e:
469
+ logger.error(f"Voyage API error: {e}")
470
+ if attempt < self.max_retries - 1:
471
+ await asyncio.sleep(self.retry_delay * (2 ** attempt))
472
+
473
+ raise Exception(f"Failed to get embeddings after {self.max_retries} attempts")
474
+
475
+ async def close(self):
476
+ """Close aiohttp session."""
477
+ if self.session:
478
+ await self.session.close()
479
+ self.session = None
480
+
481
+
482
+ class QdrantService:
483
+ """Qdrant service with proper backpressure and retries."""
484
+
485
+ def __init__(self, config: Config, embedding_provider: EmbeddingProvider):
486
+ self.config = config
487
+
488
+ # Security: Validate Qdrant URL for remote connections
489
+ from urllib.parse import urlparse
490
+ parsed = urlparse(config.qdrant_url)
491
+ host = (parsed.hostname or "").lower()
492
+
493
+ if config.require_tls_for_remote and host not in ("localhost", "127.0.0.1", "qdrant") and parsed.scheme != "https":
494
+ raise ValueError(f"Insecure QDRANT_URL for remote host: {config.qdrant_url} (use https:// or set QDRANT_REQUIRE_TLS_FOR_REMOTE=false)")
495
+
496
+ # Initialize with API key if provided
497
+ self.client = AsyncQdrantClient(
498
+ url=config.qdrant_url,
499
+ api_key=config.qdrant_api_key if hasattr(config, 'qdrant_api_key') else None
500
+ )
501
+ self.embedding_provider = embedding_provider
502
+ self._collection_cache: Dict[str, float] = {}
503
+ self.request_semaphore = asyncio.Semaphore(config.max_concurrent_qdrant)
504
+
505
+ async def ensure_collection(self, collection_name: str) -> None:
506
+ """Ensure collection exists with TTL cache."""
507
+ now = time.time()
508
+
509
+ if collection_name in self._collection_cache:
510
+ if now - self._collection_cache[collection_name] < self.config.collection_cache_ttl:
511
+ return
512
+
513
+ if len(self._collection_cache) >= self.config.collection_cache_max_size:
514
+ oldest = min(self._collection_cache.items(), key=lambda x: x[1])
515
+ del self._collection_cache[oldest[0]]
516
+
517
+ async with self.request_semaphore:
518
+ try:
519
+ await asyncio.wait_for(
520
+ self.client.get_collection(collection_name),
521
+ timeout=self.config.qdrant_timeout_s
522
+ )
523
+ self._collection_cache[collection_name] = now
524
+ logger.debug(f"Collection {collection_name} exists")
525
+ except (UnexpectedResponse, asyncio.TimeoutError):
526
+ # Create collection with correct vector size based on provider
527
+ vector_size = self.embedding_provider.vector_size or self.config.vector_size
528
+
529
+ try:
530
+ await asyncio.wait_for(
531
+ self.client.create_collection(
532
+ collection_name=collection_name,
533
+ vectors_config=models.VectorParams(
534
+ size=vector_size,
535
+ distance=models.Distance.COSINE
536
+ ),
537
+ optimizers_config=models.OptimizersConfigDiff(
538
+ indexing_threshold=100
539
+ )
540
+ ),
541
+ timeout=self.config.qdrant_timeout_s
542
+ )
543
+ self._collection_cache[collection_name] = now
544
+ logger.info(f"Created collection {collection_name}")
545
+ except UnexpectedResponse as e:
546
+ if "already exists" in str(e):
547
+ self._collection_cache[collection_name] = now
548
+ else:
549
+ raise
550
+
551
+ async def store_points_with_retry(
552
+ self,
553
+ collection_name: str,
554
+ points: List[models.PointStruct]
555
+ ) -> bool:
556
+ """Store points with retry logic."""
557
+ if not points:
558
+ return True
559
+
560
+ for attempt in range(self.config.max_retries):
561
+ try:
562
+ async with self.request_semaphore:
563
+ # Directly await with timeout to avoid orphaned tasks
564
+ await asyncio.wait_for(
565
+ self.client.upsert(
566
+ collection_name=collection_name,
567
+ points=points,
568
+ wait=True
569
+ ),
570
+ timeout=self.config.qdrant_timeout_s
571
+ )
572
+ logger.debug(f"Stored {len(points)} points in {collection_name}")
573
+ return True
574
+
575
+ except asyncio.TimeoutError:
576
+ # Don't cancel - let it complete in background to avoid race condition
577
+ logger.warning(f"Timeout storing points (attempt {attempt + 1}/{self.config.max_retries})")
578
+ if attempt < self.config.max_retries - 1:
579
+ await asyncio.sleep(self.config.retry_delay_s * (2 ** attempt))
580
+ except Exception as e:
581
+ logger.error(f"Error storing points: {e}")
582
+ if attempt < self.config.max_retries - 1:
583
+ await asyncio.sleep(self.config.retry_delay_s)
584
+
585
+ return False
586
+
587
+ async def close(self):
588
+ """Close client connection."""
589
+ self._collection_cache.clear()
590
+ try:
591
+ await self.client.close() # Close AsyncQdrantClient connections
592
+ except AttributeError:
593
+ pass # Older versions might not have close()
594
+
595
+
596
+ class TokenAwareChunker:
597
+ """Memory-efficient streaming chunker."""
598
+
599
+ def __init__(self, chunk_size_tokens: int = 400, chunk_overlap_tokens: int = 75):
600
+ self.chunk_size_chars = chunk_size_tokens * 4
601
+ self.chunk_overlap_chars = chunk_overlap_tokens * 4
602
+ logger.info(f"TokenAwareChunker: {chunk_size_tokens} tokens (~{self.chunk_size_chars} chars)")
603
+
604
+ def chunk_text_stream(self, text: str) -> Generator[str, None, None]:
605
+ """Stream chunks without holding all in memory."""
606
+ if not text:
607
+ return
608
+
609
+ if len(text) <= self.chunk_size_chars:
610
+ yield text
611
+ return
612
+
613
+ start = 0
614
+ while start < len(text):
615
+ end = min(start + self.chunk_size_chars, len(text))
616
+
617
+ if end < len(text):
618
+ for separator in ['. ', '.\n', '! ', '? ', '\n\n', '\n', ' ']:
619
+ last_sep = text.rfind(separator, start, end)
620
+ if last_sep > start + (self.chunk_size_chars // 2):
621
+ end = last_sep + len(separator)
622
+ break
623
+
624
+ chunk = text[start:end].strip()
625
+ if chunk:
626
+ yield chunk
627
+
628
+ if end >= len(text):
629
+ break
630
+ start = max(start + 1, end - self.chunk_overlap_chars)
631
+
632
+
633
+ class CPUMonitor:
634
+ """Non-blocking CPU monitoring with cgroup awareness."""
635
+
636
+ def __init__(self, max_cpu_per_core: float):
637
+ self.process = psutil.Process()
638
+ effective_cores = get_effective_cpus()
639
+ self.max_total_cpu = max_cpu_per_core * effective_cores
640
+ logger.info(f"CPU Monitor: {effective_cores:.1f} effective cores, {self.max_total_cpu:.1f}% limit")
641
+
642
+ self.process.cpu_percent(interval=None)
643
+ time.sleep(0.01)
644
+ self.last_check = time.time()
645
+ self.last_cpu = self.process.cpu_percent(interval=None)
646
+
647
+ def get_cpu_nowait(self) -> float:
648
+ """Get CPU without blocking."""
649
+ now = time.time()
650
+ if now - self.last_check > 1.0:
651
+ val = self.process.cpu_percent(interval=None)
652
+ if val == 0.0 and self.last_cpu == 0.0:
653
+ time.sleep(0.01)
654
+ val = self.process.cpu_percent(interval=None)
655
+ self.last_cpu = val
656
+ self.last_check = now
657
+ return self.last_cpu
658
+
659
+ def should_throttle(self) -> bool:
660
+ """Check if we should throttle based on CPU."""
661
+ return self.get_cpu_nowait() > self.max_total_cpu
662
+
663
+
664
+ class QueueManager:
665
+ """Manage file processing queue with priority support and deduplication."""
666
+
667
+ def __init__(self, max_size: int, max_age_hours: int):
668
+ self.max_size = max_size
669
+ self.max_age = timedelta(hours=max_age_hours)
670
+ # Queue stores (path, mod_time, freshness_level, priority_score)
671
+ self.queue: deque = deque()
672
+ self._queued: Set[str] = set() # Track queued files to prevent duplicates
673
+ self.processed_count = 0
674
+ self.deferred_count = 0
675
+
676
+ def add_categorized(self, items: List[Tuple[Path, datetime, FreshnessLevel, int]]) -> int:
677
+ """Add categorized files with priority handling."""
678
+ added = 0
679
+ overflow = []
680
+
681
+ for file_path, mod_time, level, priority in items:
682
+ key = str(file_path)
683
+
684
+ # Skip if already queued
685
+ if key in self._queued:
686
+ continue
687
+
688
+ if len(self.queue) >= self.max_size:
689
+ overflow.append((file_path, mod_time))
690
+ continue
691
+
692
+ # HOT and URGENT_WARM go to front of queue
693
+ if level in (FreshnessLevel.HOT, FreshnessLevel.URGENT_WARM):
694
+ self.queue.appendleft((file_path, mod_time, level, priority))
695
+ else:
696
+ self.queue.append((file_path, mod_time, level, priority))
697
+
698
+ self._queued.add(key)
699
+ added += 1
700
+
701
+ if overflow:
702
+ self.deferred_count += len(overflow)
703
+ oldest = min(overflow, key=lambda x: x[1])
704
+ logger.critical(f"QUEUE OVERFLOW: {len(overflow)} files deferred. "
705
+ f"Oldest: {oldest[0].name} ({(datetime.now() - oldest[1]).total_seconds() / 3600:.1f}h old)")
706
+
707
+ return added
708
+
709
+ def get_batch(self, batch_size: int) -> List[Tuple[Path, FreshnessLevel]]:
710
+ """Get next batch of files with their freshness levels."""
711
+ batch = []
712
+ now = datetime.now()
713
+
714
+ if self.queue:
715
+ oldest_time = self.queue[0][1]
716
+ if now - oldest_time > self.max_age:
717
+ logger.warning(f"BACKLOG: Oldest file is {(now - oldest_time).total_seconds() / 3600:.1f} hours old")
718
+
719
+ for _ in range(min(batch_size, len(self.queue))):
720
+ if self.queue:
721
+ file_path, _, level, _ = self.queue.popleft()
722
+ self._queued.discard(str(file_path))
723
+ batch.append((file_path, level))
724
+ self.processed_count += 1
725
+
726
+ return batch
727
+
728
+ def has_hot_or_urgent(self) -> bool:
729
+ """Check if queue contains HOT or URGENT_WARM files."""
730
+ return any(level in (FreshnessLevel.HOT, FreshnessLevel.URGENT_WARM)
731
+ for _, _, level, _ in self.queue)
732
+
733
+ def get_metrics(self) -> Dict[str, Any]:
734
+ """Get queue metrics."""
735
+ return {
736
+ "queue_size": len(self.queue),
737
+ "processed": self.processed_count,
738
+ "deferred": self.deferred_count,
739
+ "oldest_age_hours": self._get_oldest_age()
740
+ }
741
+
742
+ def _get_oldest_age(self) -> float:
743
+ """Get age of oldest item in hours."""
744
+ if not self.queue:
745
+ return 0
746
+ oldest_time = self.queue[0][1]
747
+ return (datetime.now() - oldest_time).total_seconds() / 3600
748
+
749
+
750
+ class IndexingProgress:
751
+ """Track progress toward 100% indexing."""
752
+
753
+ def __init__(self, logs_dir: Path):
754
+ self.logs_dir = logs_dir
755
+ self.total_files = 0
756
+ self.indexed_files = 0
757
+ self.start_time = time.time()
758
+ self.last_update = time.time()
759
+
760
+ def scan_total_files(self) -> int:
761
+ """Count total JSONL files."""
762
+ total = 0
763
+ if self.logs_dir.exists():
764
+ for project_dir in self.logs_dir.iterdir():
765
+ if project_dir.is_dir():
766
+ total += len(list(project_dir.glob("*.jsonl")))
767
+ self.total_files = total
768
+ return total
769
+
770
+ def update(self, indexed_count: int):
771
+ """Update progress."""
772
+ self.indexed_files = indexed_count
773
+ self.last_update = time.time()
774
+
775
+ def get_progress(self) -> Dict[str, Any]:
776
+ """Get progress metrics."""
777
+ # Cap percentage at 100% to handle stale state entries
778
+ percent = min(100.0, (self.indexed_files / self.total_files * 100)) if self.total_files > 0 else 0
779
+ elapsed = time.time() - self.start_time
780
+ rate = self.indexed_files / elapsed if elapsed > 0 else 0
781
+ # For ETA calculation, use remaining files (but min with 0 to avoid negative)
782
+ remaining = max(0, self.total_files - self.indexed_files)
783
+ eta = remaining / rate if rate > 0 else 0
784
+
785
+ return {
786
+ 'total_files': self.total_files,
787
+ 'indexed_files': min(self.indexed_files, self.total_files), # Cap at total
788
+ 'percent': percent,
789
+ 'rate_per_hour': rate * 3600,
790
+ 'eta_hours': eta / 3600,
791
+ 'elapsed_hours': elapsed / 3600
792
+ }
793
+
794
+
795
+ class StreamingWatcher:
796
+ """Production-ready streaming watcher with comprehensive monitoring."""
797
+
798
+ def __init__(self, config: Config):
799
+ self.config = config
800
+ self.state: Dict[str, Any] = {}
801
+ self.embedding_provider = self._create_embedding_provider()
802
+ self.qdrant_service = QdrantService(config, self.embedding_provider)
803
+ self.chunker = TokenAwareChunker()
804
+ self.cpu_monitor = CPUMonitor(config.max_cpu_percent_per_core)
805
+ self.memory_monitor = MemoryMonitor(config.memory_limit_mb, config.memory_warning_mb)
806
+ self.queue_manager = QueueManager(config.max_queue_size, config.max_backlog_hours)
807
+ self.progress = IndexingProgress(config.logs_dir)
808
+
809
+ self.stats = {
810
+ "files_processed": 0,
811
+ "chunks_processed": 0,
812
+ "failures": 0,
813
+ "start_time": time.time()
814
+ }
815
+
816
+ # Track file wait times for starvation prevention
817
+ self.file_first_seen: Dict[str, float] = {}
818
+ self.current_project: Optional[str] = self._detect_current_project()
819
+ self.last_mode: Optional[str] = None # Track mode changes for logging
820
+
821
+ self.shutdown_event = asyncio.Event()
822
+
823
+ logger.info(f"Streaming Watcher v3.0.0 with HOT/WARM/COLD prioritization")
824
+ logger.info(f"State file: {self.config.state_file}")
825
+ logger.info(f"Memory limits: {config.memory_warning_mb}MB warning, {config.memory_limit_mb}MB limit")
826
+ logger.info(f"HOT window: {config.hot_window_minutes} min, WARM window: {config.warm_window_hours} hrs")
827
+
828
+ def _detect_current_project(self) -> Optional[str]:
829
+ """Detect current project from working directory."""
830
+ try:
831
+ cwd = Path.cwd()
832
+ # Check if we're in a claude project directory
833
+ if "/.claude/projects/" in str(cwd):
834
+ # Extract project name from path
835
+ parts = str(cwd).split("/.claude/projects/")
836
+ if len(parts) > 1:
837
+ project = parts[1].split("/")[0]
838
+ logger.info(f"Detected current project: {project}")
839
+ return project
840
+ except Exception as e:
841
+ logger.debug(f"Could not detect current project: {e}")
842
+ return None
843
+
844
+ def categorize_freshness(self, file_path: Path) -> Tuple[FreshnessLevel, int]:
845
+ """
846
+ Categorize file freshness for prioritization.
847
+ Returns (FreshnessLevel, priority_score) where lower scores = higher priority.
848
+ """
849
+ now = time.time()
850
+ file_key = str(file_path)
851
+
852
+ # Track first seen time atomically
853
+ if file_key not in self.file_first_seen:
854
+ self.file_first_seen[file_key] = now
855
+ first_seen_time = self.file_first_seen[file_key]
856
+
857
+ file_age_minutes = (now - file_path.stat().st_mtime) / 60
858
+
859
+ # Check if file is from current project
860
+ is_current_project = False
861
+ if self.current_project:
862
+ file_project = normalize_project_name(str(file_path.parent))
863
+ is_current_project = (file_project == self.current_project)
864
+
865
+ # Determine base freshness level
866
+ if file_age_minutes < self.config.hot_window_minutes:
867
+ level = FreshnessLevel.HOT
868
+ base_priority = 0 # Highest priority
869
+ elif file_age_minutes < (self.config.warm_window_hours * 60):
870
+ # Check for starvation (WARM files waiting too long)
871
+ wait_minutes = (now - first_seen_time) / 60
872
+ if wait_minutes > self.config.max_warm_wait_minutes:
873
+ level = FreshnessLevel.URGENT_WARM
874
+ base_priority = 1 # Second highest priority
875
+ else:
876
+ level = FreshnessLevel.WARM
877
+ base_priority = 2 if is_current_project else 3
878
+ else:
879
+ level = FreshnessLevel.COLD
880
+ base_priority = 4 # Lowest priority
881
+
882
+ # Adjust priority score based on exact age for tie-breaking
883
+ priority_score = base_priority * 10000 + min(file_age_minutes, 9999)
884
+
885
+ return level, int(priority_score)
886
+
887
+ def _create_embedding_provider(self) -> EmbeddingProvider:
888
+ """Create embedding provider based on configuration."""
889
+ if not self.config.prefer_local_embeddings and self.config.voyage_api_key:
890
+ logger.info("Using Voyage AI for cloud embeddings")
891
+ return VoyageProvider(
892
+ api_key=self.config.voyage_api_key,
893
+ model_name="voyage-3", # Latest Voyage model with 1024 dimensions
894
+ max_concurrent=self.config.max_concurrent_embeddings
895
+ )
896
+ else:
897
+ logger.info(f"Using FastEmbed: {self.config.embedding_model}")
898
+ return FastEmbedProvider(
899
+ self.config.embedding_model,
900
+ self.config.max_concurrent_embeddings
901
+ )
902
+
903
+ async def load_state(self) -> None:
904
+ """Load persisted state with migration support."""
905
+ if self.config.state_file.exists():
906
+ try:
907
+ with open(self.config.state_file, 'r') as f:
908
+ self.state = json.load(f)
909
+
910
+ # Migrate old state format if needed
911
+ if "imported_files" in self.state:
912
+ imported_count = len(self.state["imported_files"])
913
+ logger.info(f"Loaded state with {imported_count} files")
914
+
915
+ # Ensure all entries have full paths as keys
916
+ migrated = {}
917
+ for key, value in self.state["imported_files"].items():
918
+ # Ensure key is a full path
919
+ if not key.startswith('/'):
920
+ # Try to reconstruct full path
921
+ possible_path = self.config.logs_dir / key
922
+ if possible_path.exists():
923
+ migrated[str(possible_path)] = value
924
+ else:
925
+ migrated[key] = value # Keep as is
926
+ else:
927
+ migrated[key] = value
928
+
929
+ if len(migrated) != len(self.state["imported_files"]):
930
+ logger.info(f"Migrated state format: {len(self.state['imported_files'])} -> {len(migrated)} entries")
931
+ self.state["imported_files"] = migrated
932
+
933
+ except Exception as e:
934
+ logger.error(f"Error loading state: {e}")
935
+ self.state = {}
936
+
937
+ if "imported_files" not in self.state:
938
+ self.state["imported_files"] = {}
939
+ if "high_water_mark" not in self.state:
940
+ self.state["high_water_mark"] = 0
941
+
942
+ # Update progress tracker
943
+ self.progress.update(len(self.state["imported_files"]))
944
+
945
+ async def save_state(self) -> None:
946
+ """Save state atomically."""
947
+ try:
948
+ self.config.state_file.parent.mkdir(parents=True, exist_ok=True)
949
+ temp_file = self.config.state_file.with_suffix('.tmp')
950
+
951
+ with open(temp_file, 'w') as f:
952
+ json.dump(self.state, f, indent=2)
953
+ f.flush()
954
+ os.fsync(f.fileno())
955
+
956
+ if platform.system() == 'Windows':
957
+ if self.config.state_file.exists():
958
+ self.config.state_file.unlink()
959
+ temp_file.rename(self.config.state_file)
960
+ else:
961
+ os.replace(temp_file, self.config.state_file)
962
+
963
+ # Directory fsync for stronger guarantees
964
+ try:
965
+ dir_fd = os.open(str(self.config.state_file.parent), os.O_DIRECTORY)
966
+ os.fsync(dir_fd)
967
+ os.close(dir_fd)
968
+ except:
969
+ pass
970
+
971
+ except Exception as e:
972
+ logger.error(f"Error saving state: {e}")
973
+
974
+ def get_collection_name(self, project_path: str) -> str:
975
+ """Get collection name for project."""
976
+ normalized = normalize_project_name(project_path)
977
+ project_hash = hashlib.md5(normalized.encode()).hexdigest()[:8]
978
+ suffix = "_local" if self.config.prefer_local_embeddings else "_voyage"
979
+ return f"{self.config.collection_prefix}_{project_hash}{suffix}"
980
+
981
+ def _extract_message_text(self, content: Any) -> str:
982
+ """Extract text from message content."""
983
+ if isinstance(content, str):
984
+ return content
985
+ elif isinstance(content, list):
986
+ text_parts = []
987
+ for item in content:
988
+ if isinstance(item, str):
989
+ text_parts.append(item)
990
+ elif isinstance(item, dict):
991
+ if item.get('type') == 'text':
992
+ text_parts.append(item.get('text', ''))
993
+ return ' '.join(text_parts)
994
+ return str(content) if content else ''
995
+
996
+ def _update_quality_cache(self, pattern_analysis: Dict, avg_score: float, project_name: str = None):
997
+ """Update quality cache file for statusline display - PER PROJECT."""
998
+ try:
999
+ # Determine project name from current context or use provided
1000
+ if not project_name:
1001
+ # Try to infer from analyzed files
1002
+ files = pattern_analysis.get('files', [])
1003
+ if files and len(files) > 0:
1004
+ # Extract project from first file path
1005
+ first_file = Path(files[0])
1006
+ # Walk up to find .git directory or use immediate parent
1007
+ for parent in first_file.parents:
1008
+ if (parent / '.git').exists():
1009
+ project_name = parent.name
1010
+ break
1011
+ else:
1012
+ project_name = 'unknown'
1013
+ else:
1014
+ project_name = 'unknown'
1015
+
1016
+ # Sanitize project name for filename
1017
+ safe_project_name = project_name.replace('/', '-').replace(' ', '_')
1018
+ cache_dir = Path.home() / ".claude-self-reflect" / "quality_cache"
1019
+ cache_dir.mkdir(exist_ok=True, parents=True)
1020
+ cache_file = cache_dir / f"{safe_project_name}.json"
1021
+
1022
+ # Calculate total issues from pattern analysis
1023
+ total_issues = sum(p.get('count', 0) for p in pattern_analysis.get('issues', []))
1024
+
1025
+ # Determine grade based on score
1026
+ if avg_score >= 0.95:
1027
+ grade = 'A+' if total_issues < 10 else 'A'
1028
+ elif avg_score >= 0.8:
1029
+ grade = 'B'
1030
+ elif avg_score >= 0.6:
1031
+ grade = 'C'
1032
+ else:
1033
+ grade = 'D'
1034
+
1035
+ cache_data = {
1036
+ 'status': 'success',
1037
+ 'session_id': 'watcher',
1038
+ 'timestamp': datetime.now().isoformat(),
1039
+ 'summary': {
1040
+ 'files_analyzed': len(pattern_analysis.get('files', [])),
1041
+ 'avg_quality_score': round(avg_score, 3),
1042
+ 'total_issues': total_issues,
1043
+ 'quality_grade': grade
1044
+ }
1045
+ }
1046
+
1047
+ with open(cache_file, 'w') as f:
1048
+ json.dump(cache_data, f, indent=2)
1049
+
1050
+ logger.debug(f"Updated quality cache: {grade}/{total_issues}")
1051
+
1052
+ except Exception as e:
1053
+ logger.debug(f"Failed to update quality cache: {e}")
1054
+
1055
+ async def process_file(self, file_path: Path) -> bool:
1056
+ """Process a single file."""
1057
+ try:
1058
+ # Memory check
1059
+ should_cleanup, mem_metrics = self.memory_monitor.check_memory()
1060
+ if should_cleanup:
1061
+ await self.memory_monitor.cleanup()
1062
+ _, mem_metrics = self.memory_monitor.check_memory()
1063
+ if mem_metrics['alert_level'] == 'critical':
1064
+ logger.error(f"Memory critical: {mem_metrics['current_mb']:.1f}MB, skipping {file_path}")
1065
+ return False
1066
+
1067
+ project_path = file_path.parent.name # Use just the project directory name, not full path
1068
+ collection_name = self.get_collection_name(project_path)
1069
+ conversation_id = file_path.stem
1070
+
1071
+ logger.info(f"Processing: {file_path.name} (memory: {mem_metrics['current_mb']:.1f}MB)")
1072
+
1073
+ # Read messages (defer collection creation until we know we have content)
1074
+ all_messages = []
1075
+ with open(file_path, 'r') as f:
1076
+ for line in f:
1077
+ if line.strip():
1078
+ try:
1079
+ data = json.loads(line)
1080
+ # Handle 'messages' array (standard format)
1081
+ if 'messages' in data and data['messages']:
1082
+ all_messages.extend(data['messages'])
1083
+ # Handle single 'message' object (must be dict, not string)
1084
+ elif 'message' in data and data['message']:
1085
+ if isinstance(data['message'], dict):
1086
+ all_messages.append(data['message'])
1087
+ # Skip string messages (like status messages)
1088
+ # Handle direct role/content format
1089
+ elif 'role' in data and 'content' in data:
1090
+ all_messages.append(data)
1091
+ except json.JSONDecodeError:
1092
+ continue
1093
+
1094
+ if not all_messages:
1095
+ logger.warning(f"No messages in {file_path}, marking as processed")
1096
+ # Mark file as processed with 0 chunks
1097
+ self.state["imported_files"][str(file_path)] = {
1098
+ "imported_at": datetime.now().isoformat(),
1099
+ "_parsed_time": datetime.now().timestamp(),
1100
+ "chunks": 0,
1101
+ "collection": collection_name,
1102
+ "empty_file": True
1103
+ }
1104
+ self.stats["files_processed"] += 1
1105
+ return True
1106
+
1107
+ # Extract metadata
1108
+ tool_usage = extract_tool_usage_from_conversation(all_messages)
1109
+
1110
+ # MANDATORY AST-GREP Analysis for HOT files
1111
+ pattern_analysis = {}
1112
+ avg_quality_score = 0.0
1113
+ freshness_level, _ = self.categorize_freshness(file_path)
1114
+
1115
+ # Analyze code quality for HOT files (current session)
1116
+ if freshness_level == FreshnessLevel.HOT and (tool_usage.get('files_edited') or tool_usage.get('files_analyzed')):
1117
+ try:
1118
+ # Import analyzer (lazy import to avoid startup overhead)
1119
+ from ast_grep_final_analyzer import FinalASTGrepAnalyzer
1120
+ from update_patterns import check_and_update_patterns
1121
+
1122
+ # Update patterns (24h cache, <100ms)
1123
+ check_and_update_patterns()
1124
+
1125
+ # Create analyzer
1126
+ if not hasattr(self, '_ast_analyzer'):
1127
+ self._ast_analyzer = FinalASTGrepAnalyzer()
1128
+
1129
+ # Analyze edited files from this session
1130
+ files_to_analyze = list(set(
1131
+ tool_usage.get('files_edited', [])[:5] +
1132
+ tool_usage.get('files_analyzed', [])[:5]
1133
+ ))
1134
+
1135
+ quality_scores = []
1136
+ for file_ref in files_to_analyze:
1137
+ if file_ref and any(file_ref.endswith(ext) for ext in ['.py', '.ts', '.js', '.tsx', '.jsx']):
1138
+ try:
1139
+ if os.path.exists(file_ref):
1140
+ result = self._ast_analyzer.analyze_file(file_ref)
1141
+ metrics = result['quality_metrics']
1142
+ pattern_analysis[file_ref] = {
1143
+ 'score': metrics['quality_score'],
1144
+ 'good_patterns': metrics['good_patterns_found'],
1145
+ 'bad_patterns': metrics['bad_patterns_found'],
1146
+ 'issues': metrics['total_issues']
1147
+ }
1148
+ quality_scores.append(metrics['quality_score'])
1149
+
1150
+ # Log quality issues for HOT files
1151
+ if metrics['quality_score'] < 0.6:
1152
+ logger.warning(f"⚠️ Quality issue in {os.path.basename(file_ref)}: {metrics['quality_score']:.1%} ({metrics['total_issues']} issues)")
1153
+ except Exception as e:
1154
+ logger.debug(f"Could not analyze {file_ref}: {e}")
1155
+
1156
+ if quality_scores:
1157
+ avg_quality_score = sum(quality_scores) / len(quality_scores)
1158
+ logger.info(f"📊 Session quality: {avg_quality_score:.1%} for {len(quality_scores)} files")
1159
+
1160
+ # Update quality cache for statusline - watcher handles this automatically!
1161
+ # Pass project name from current file being processed
1162
+ project_name = file_path.parent.name if file_path else None
1163
+ self._update_quality_cache(pattern_analysis, avg_quality_score, project_name)
1164
+
1165
+ except Exception as e:
1166
+ logger.debug(f"AST analysis not available: {e}")
1167
+
1168
+ # Add pattern analysis to tool_usage metadata
1169
+ if pattern_analysis:
1170
+ tool_usage['pattern_analysis'] = pattern_analysis
1171
+ tool_usage['avg_quality_score'] = round(avg_quality_score, 3)
1172
+
1173
+ # Build text
1174
+ text_parts = []
1175
+ for msg in all_messages:
1176
+ role = msg.get('role', 'unknown')
1177
+ content = msg.get('content', '')
1178
+ text = self._extract_message_text(content)
1179
+ if text:
1180
+ text_parts.append(f"{role}: {text}")
1181
+
1182
+ combined_text = "\n\n".join(text_parts)
1183
+ if not combined_text.strip():
1184
+ logger.warning(f"No textual content in {file_path}, marking as processed")
1185
+ # Mark file as processed with 0 chunks (has messages but no extractable text)
1186
+ self.state["imported_files"][str(file_path)] = {
1187
+ "imported_at": datetime.now().isoformat(),
1188
+ "_parsed_time": datetime.now().timestamp(),
1189
+ "chunks": 0,
1190
+ "collection": collection_name,
1191
+ "no_text_content": True
1192
+ }
1193
+ self.stats["files_processed"] += 1
1194
+ return True
1195
+
1196
+ concepts = extract_concepts(combined_text, tool_usage)
1197
+
1198
+ # Now we know we have content, ensure collection exists
1199
+ await self.qdrant_service.ensure_collection(collection_name)
1200
+
1201
+ # Process chunks
1202
+ chunks_processed = 0
1203
+ chunk_index = 0
1204
+
1205
+ for chunk_text in self.chunker.chunk_text_stream(combined_text):
1206
+ if self.shutdown_event.is_set():
1207
+ return False
1208
+
1209
+ # CPU throttling
1210
+ if self.cpu_monitor.should_throttle():
1211
+ await asyncio.sleep(0.5)
1212
+
1213
+ # Generate embedding
1214
+ embeddings = None
1215
+ for attempt in range(self.config.max_retries):
1216
+ try:
1217
+ embeddings = await self.embedding_provider.embed_documents([chunk_text])
1218
+ # Validate embedding dimensions
1219
+ if embeddings and len(embeddings[0]) != self.embedding_provider.vector_size:
1220
+ logger.error(f"Embedding dimension mismatch: got {len(embeddings[0])}, expected {self.embedding_provider.vector_size} for provider {self.embedding_provider.__class__.__name__}")
1221
+ self.stats["failures"] += 1
1222
+ embeddings = None # Force retry
1223
+ continue # Continue retrying, not break
1224
+ break
1225
+ except Exception as e:
1226
+ logger.warning(f"Embed failed (attempt {attempt+1}): {e}")
1227
+ if attempt < self.config.max_retries - 1:
1228
+ await asyncio.sleep(self.config.retry_delay_s * (2 ** attempt))
1229
+
1230
+ if not embeddings:
1231
+ logger.error(f"Failed to embed chunk {chunk_index}")
1232
+ self.stats["failures"] += 1
1233
+ continue
1234
+
1235
+ # Create payload
1236
+ payload = {
1237
+ "text": chunk_text[:10000],
1238
+ "conversation_id": conversation_id,
1239
+ "chunk_index": chunk_index,
1240
+ "message_count": len(all_messages),
1241
+ "project": normalize_project_name(project_path),
1242
+ "timestamp": datetime.now().isoformat(),
1243
+ "total_length": len(chunk_text),
1244
+ "chunking_version": "v3",
1245
+ "concepts": concepts,
1246
+ "files_analyzed": tool_usage['files_analyzed'],
1247
+ "files_edited": tool_usage['files_edited'],
1248
+ "tools_used": tool_usage['tools_used']
1249
+ }
1250
+
1251
+ # Create point
1252
+ point_id_str = hashlib.md5(
1253
+ f"{conversation_id}_{chunk_index}".encode()
1254
+ ).hexdigest()[:16]
1255
+ point_id = int(point_id_str, 16) % (2**63)
1256
+
1257
+ point = models.PointStruct(
1258
+ id=point_id,
1259
+ vector=embeddings[0],
1260
+ payload=payload
1261
+ )
1262
+
1263
+ # Store
1264
+ success = await self.qdrant_service.store_points_with_retry(
1265
+ collection_name,
1266
+ [point]
1267
+ )
1268
+
1269
+ if not success:
1270
+ logger.error(f"Failed to store chunk {chunk_index}")
1271
+ self.stats["failures"] += 1
1272
+ else:
1273
+ chunks_processed += 1
1274
+
1275
+ chunk_index += 1
1276
+
1277
+ # Memory check mid-file
1278
+ if chunk_index % 10 == 0:
1279
+ should_cleanup, _ = self.memory_monitor.check_memory()
1280
+ if should_cleanup:
1281
+ await self.memory_monitor.cleanup()
1282
+
1283
+ # Update state - use full path as key
1284
+ self.state["imported_files"][str(file_path)] = {
1285
+ "imported_at": datetime.now().isoformat(),
1286
+ "_parsed_time": datetime.now().timestamp(),
1287
+ "chunks": chunks_processed,
1288
+ "collection": collection_name
1289
+ }
1290
+
1291
+ self.stats["files_processed"] += 1
1292
+ self.stats["chunks_processed"] += chunks_processed
1293
+
1294
+ logger.info(f"Completed: {file_path.name} ({chunks_processed} chunks)")
1295
+ return True
1296
+
1297
+ except Exception as e:
1298
+ logger.error(f"Error processing {file_path}: {e}")
1299
+ self.stats["failures"] += 1
1300
+ return False
1301
+
1302
+ async def find_new_files(self) -> List[Tuple[Path, FreshnessLevel, int]]:
1303
+ """Find new files to process with freshness categorization."""
1304
+ if not self.config.logs_dir.exists():
1305
+ logger.warning(f"Logs dir not found: {self.config.logs_dir}")
1306
+ return []
1307
+
1308
+ categorized_files = []
1309
+ high_water_mark = self.state.get("high_water_mark", 0)
1310
+ new_high_water = high_water_mark
1311
+ now = time.time()
1312
+
1313
+ try:
1314
+ for project_dir in self.config.logs_dir.iterdir():
1315
+ if not project_dir.is_dir():
1316
+ continue
1317
+
1318
+ try:
1319
+ for jsonl_file in project_dir.glob("*.jsonl"):
1320
+ file_mtime = jsonl_file.stat().st_mtime
1321
+ new_high_water = max(new_high_water, file_mtime)
1322
+
1323
+ # Check if already processed (using full path)
1324
+ file_key = str(jsonl_file)
1325
+ if file_key in self.state["imported_files"]:
1326
+ stored = self.state["imported_files"][file_key]
1327
+ if "_parsed_time" in stored:
1328
+ if file_mtime <= stored["_parsed_time"]:
1329
+ continue
1330
+ elif "imported_at" in stored:
1331
+ import_time = datetime.fromisoformat(stored["imported_at"]).timestamp()
1332
+ stored["_parsed_time"] = import_time
1333
+ if file_mtime <= import_time:
1334
+ continue
1335
+
1336
+ # Categorize file freshness (handles first_seen tracking internally)
1337
+ freshness_level, priority_score = self.categorize_freshness(jsonl_file)
1338
+
1339
+ categorized_files.append((jsonl_file, freshness_level, priority_score))
1340
+ except Exception as e:
1341
+ logger.error(f"Error scanning project dir {project_dir}: {e}")
1342
+
1343
+ except Exception as e:
1344
+ logger.error(f"Error scanning logs dir: {e}")
1345
+
1346
+ self.state["high_water_mark"] = new_high_water
1347
+
1348
+ # Sort by priority score (lower = higher priority)
1349
+ categorized_files.sort(key=lambda x: x[2])
1350
+
1351
+ # Log categorization summary
1352
+ if categorized_files:
1353
+ hot_count = sum(1 for _, level, _ in categorized_files if level == FreshnessLevel.HOT)
1354
+ urgent_count = sum(1 for _, level, _ in categorized_files if level == FreshnessLevel.URGENT_WARM)
1355
+ warm_count = sum(1 for _, level, _ in categorized_files if level == FreshnessLevel.WARM)
1356
+ cold_count = sum(1 for _, level, _ in categorized_files if level == FreshnessLevel.COLD)
1357
+
1358
+ status_parts = []
1359
+ if hot_count: status_parts.append(f"{hot_count} 🔥HOT")
1360
+ if urgent_count: status_parts.append(f"{urgent_count} ⚠️URGENT")
1361
+ if warm_count: status_parts.append(f"{warm_count} 🌡️WARM")
1362
+ if cold_count: status_parts.append(f"{cold_count} ❄️COLD")
1363
+
1364
+ logger.info(f"Found {len(categorized_files)} new files: {', '.join(status_parts)}")
1365
+
1366
+ return categorized_files
1367
+
1368
+ async def run_continuous(self) -> None:
1369
+ """Main loop with comprehensive monitoring."""
1370
+ logger.info("=" * 60)
1371
+ logger.info("Claude Self-Reflect Streaming Watcher v3.0.0")
1372
+ logger.info("=" * 60)
1373
+ logger.info(f"State file: {self.config.state_file}")
1374
+ logger.info(f"Memory: {self.config.memory_warning_mb}MB warning, {self.config.memory_limit_mb}MB limit")
1375
+ logger.info(f"CPU limit: {self.cpu_monitor.max_total_cpu:.1f}%")
1376
+ logger.info(f"Queue size: {self.config.max_queue_size}")
1377
+ logger.info("=" * 60)
1378
+
1379
+ await self.load_state()
1380
+
1381
+ # Initial progress scan
1382
+ total_files = self.progress.scan_total_files()
1383
+ indexed_files = len(self.state.get("imported_files", {}))
1384
+ self.progress.update(indexed_files)
1385
+
1386
+ initial_progress = self.progress.get_progress()
1387
+ logger.info(f"Initial progress: {indexed_files}/{total_files} files ({initial_progress['percent']:.1f}%)")
1388
+
1389
+ try:
1390
+ cycle_count = 0
1391
+ while not self.shutdown_event.is_set():
1392
+ try:
1393
+ cycle_count += 1
1394
+
1395
+ # Find new files with categorization
1396
+ categorized_files = await self.find_new_files()
1397
+
1398
+ # Determine if we have HOT files (in new files or existing queue)
1399
+ has_hot_files = (any(level == FreshnessLevel.HOT for _, level, _ in categorized_files)
1400
+ or self.queue_manager.has_hot_or_urgent())
1401
+
1402
+ # Process files by temperature with proper priority
1403
+ files_to_process = []
1404
+ cold_count = 0
1405
+
1406
+ for file_path, level, priority in categorized_files:
1407
+ # Limit COLD files per cycle
1408
+ if level == FreshnessLevel.COLD:
1409
+ if cold_count >= self.config.max_cold_files:
1410
+ logger.debug(f"Skipping COLD file {file_path.name} (limit reached)")
1411
+ continue
1412
+ cold_count += 1
1413
+
1414
+ mod_time = datetime.fromtimestamp(file_path.stat().st_mtime)
1415
+ files_to_process.append((file_path, mod_time, level, priority))
1416
+
1417
+ if files_to_process:
1418
+ added = self.queue_manager.add_categorized(files_to_process)
1419
+ if added > 0:
1420
+ logger.info(f"Cycle {cycle_count}: Added {added} files to queue")
1421
+
1422
+ # Process batch
1423
+ batch = self.queue_manager.get_batch(self.config.batch_size)
1424
+
1425
+ for file_path, level in batch:
1426
+ if self.shutdown_event.is_set():
1427
+ break
1428
+
1429
+ # Double-check if already imported (defensive)
1430
+ file_key = str(file_path)
1431
+ try:
1432
+ file_mtime = file_path.stat().st_mtime
1433
+ except FileNotFoundError:
1434
+ logger.warning(f"File disappeared: {file_path}")
1435
+ continue
1436
+
1437
+ imported = self.state["imported_files"].get(file_key)
1438
+ if imported:
1439
+ parsed_time = imported.get("_parsed_time")
1440
+ if not parsed_time and "imported_at" in imported:
1441
+ parsed_time = datetime.fromisoformat(imported["imported_at"]).timestamp()
1442
+ if parsed_time and file_mtime <= parsed_time:
1443
+ logger.debug(f"Skipping already imported: {file_path.name}")
1444
+ continue
1445
+
1446
+ success = await self.process_file(file_path)
1447
+
1448
+ if success:
1449
+ # Clean up first_seen tracking to prevent memory leak
1450
+ self.file_first_seen.pop(file_key, None)
1451
+ await self.save_state()
1452
+ self.progress.update(len(self.state["imported_files"]))
1453
+
1454
+ # Log comprehensive metrics
1455
+ if batch or cycle_count % 6 == 0: # Every minute if idle
1456
+ queue_metrics = self.queue_manager.get_metrics()
1457
+ progress_metrics = self.progress.get_progress()
1458
+ _, mem_metrics = self.memory_monitor.check_memory()
1459
+ cpu = self.cpu_monitor.get_cpu_nowait()
1460
+
1461
+ logger.info(
1462
+ f"Progress: {progress_metrics['percent']:.1f}% "
1463
+ f"({progress_metrics['indexed_files']}/{progress_metrics['total_files']}) | "
1464
+ f"Queue: {queue_metrics['queue_size']} | "
1465
+ f"Memory: {mem_metrics['current_mb']:.1f}MB/{mem_metrics['limit_mb']}MB | "
1466
+ f"CPU: {cpu:.1f}% | "
1467
+ f"Processed: {self.stats['files_processed']} | "
1468
+ f"Failures: {self.stats['failures']}"
1469
+ )
1470
+
1471
+ # Alert on high memory
1472
+ if mem_metrics['alert_level'] in ['warning', 'high', 'critical']:
1473
+ logger.warning(
1474
+ f"Memory {mem_metrics['alert_level'].upper()}: "
1475
+ f"{mem_metrics['current_mb']:.1f}MB "
1476
+ f"({mem_metrics['percent_of_limit']:.1f}% of limit)"
1477
+ )
1478
+
1479
+ # Progress toward 100%
1480
+ if progress_metrics['percent'] >= 99.9:
1481
+ logger.info("🎉 INDEXING COMPLETE: 100% of files processed!")
1482
+ elif progress_metrics['percent'] >= 90:
1483
+ logger.info(f"📈 Nearing completion: {progress_metrics['percent']:.1f}%")
1484
+
1485
+ # Backlog alert
1486
+ if queue_metrics['oldest_age_hours'] > self.config.max_backlog_hours:
1487
+ logger.error(
1488
+ f"BACKLOG CRITICAL: Oldest file is "
1489
+ f"{queue_metrics['oldest_age_hours']:.1f} hours old"
1490
+ )
1491
+
1492
+ # Dynamic interval based on file temperature
1493
+ current_mode = "HOT" if has_hot_files else "NORMAL"
1494
+
1495
+ if current_mode != self.last_mode:
1496
+ if has_hot_files:
1497
+ logger.info(f"🔥 HOT files detected - switching to {self.config.hot_check_interval_s}s interval")
1498
+ else:
1499
+ logger.info(f"Returning to normal {self.config.import_frequency}s interval")
1500
+ self.last_mode = current_mode
1501
+
1502
+ wait_time = self.config.hot_check_interval_s if has_hot_files else self.config.import_frequency
1503
+
1504
+ # Wait with interrupt capability for HOT files
1505
+ try:
1506
+ await asyncio.wait_for(
1507
+ self.shutdown_event.wait(),
1508
+ timeout=wait_time
1509
+ )
1510
+ except asyncio.TimeoutError:
1511
+ pass # Normal timeout, continue loop
1512
+
1513
+ except Exception as e:
1514
+ logger.error(f"Error in main loop: {e}")
1515
+ await asyncio.sleep(self.config.import_frequency)
1516
+
1517
+ except asyncio.CancelledError:
1518
+ logger.info("Main task cancelled")
1519
+ raise
1520
+ finally:
1521
+ logger.info("Shutting down...")
1522
+ await self.save_state()
1523
+ await self.embedding_provider.close()
1524
+ await self.qdrant_service.close()
1525
+
1526
+ # Final metrics
1527
+ final_progress = self.progress.get_progress()
1528
+ logger.info("=" * 60)
1529
+ logger.info("Final Statistics:")
1530
+ logger.info(f"Progress: {final_progress['percent']:.1f}% complete")
1531
+ logger.info(f"Files processed: {self.stats['files_processed']}")
1532
+ logger.info(f"Chunks processed: {self.stats['chunks_processed']}")
1533
+ logger.info(f"Failures: {self.stats['failures']}")
1534
+ logger.info(f"Memory cleanups: {self.memory_monitor.cleanup_count}")
1535
+ logger.info(f"Peak memory: {self.memory_monitor.peak_memory:.1f}MB")
1536
+ logger.info("=" * 60)
1537
+ logger.info("Shutdown complete")
1538
+
1539
+ async def shutdown(self):
1540
+ """Trigger graceful shutdown."""
1541
+ logger.info("Shutdown requested")
1542
+ self.shutdown_event.set()
1543
+
1544
+
1545
+ async def main():
1546
+ """Main entry point."""
1547
+ config = Config()
1548
+ watcher = StreamingWatcher(config)
1549
+
1550
+ # Setup signal handlers
1551
+ import signal
1552
+
1553
+ loop = asyncio.get_running_loop()
1554
+
1555
+ def shutdown_handler():
1556
+ logger.info("Received shutdown signal")
1557
+ watcher.shutdown_event.set()
1558
+
1559
+ if hasattr(loop, "add_signal_handler"):
1560
+ for sig in (signal.SIGINT, signal.SIGTERM):
1561
+ loop.add_signal_handler(sig, shutdown_handler)
1562
+ else:
1563
+ # Windows fallback
1564
+ def signal_handler(sig, frame):
1565
+ logger.info(f"Received signal {sig}")
1566
+ watcher.shutdown_event.set()
1567
+
1568
+ signal.signal(signal.SIGINT, signal_handler)
1569
+ signal.signal(signal.SIGTERM, signal_handler)
1570
+
1571
+ try:
1572
+ await watcher.run_continuous()
1573
+ except (KeyboardInterrupt, asyncio.CancelledError):
1574
+ await watcher.shutdown()
1575
+
1576
+
1577
+ if __name__ == "__main__":
1578
+ asyncio.run(main())