claude-self-reflect 4.0.2 → 5.0.2
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/README.md +125 -71
- package/mcp-server/src/code_reload_tool.py +207 -98
- package/mcp-server/src/parallel_search.py +22 -12
- package/mcp-server/src/rich_formatting.py +10 -6
- package/mcp-server/src/safe_getters.py +217 -0
- package/mcp-server/src/search_tools.py +48 -9
- package/package.json +1 -1
- package/scripts/auto-migrate.cjs +87 -10
package/README.md
CHANGED
|
@@ -24,24 +24,30 @@
|
|
|
24
24
|
|
|
25
25
|
Give Claude perfect memory of all your conversations. Search past discussions instantly. Never lose context again.
|
|
26
26
|
|
|
27
|
-
**100% Local by Default** • **
|
|
27
|
+
**100% Local by Default** • **20x Faster** • **Zero Configuration** • **Production Ready**
|
|
28
|
+
|
|
29
|
+
## Why This Exists
|
|
30
|
+
|
|
31
|
+
Claude starts fresh every conversation. You've solved complex bugs, designed architectures, made critical decisions - all forgotten. Until now.
|
|
28
32
|
|
|
29
33
|
## Table of Contents
|
|
30
34
|
|
|
31
|
-
- [Quick Install](
|
|
35
|
+
- [Quick Install](#quick-install)
|
|
36
|
+
- [Performance](#performance)
|
|
32
37
|
- [The Magic](#the-magic)
|
|
33
38
|
- [Before & After](#before--after)
|
|
34
39
|
- [Real Examples](#real-examples)
|
|
35
40
|
- [NEW: Real-time Indexing Status](#new-real-time-indexing-status-in-your-terminal)
|
|
36
41
|
- [Key Features](#key-features)
|
|
42
|
+
- [Code Quality Insights](#code-quality-insights)
|
|
37
43
|
- [Architecture](#architecture)
|
|
38
44
|
- [Requirements](#requirements)
|
|
39
45
|
- [Documentation](#documentation)
|
|
40
|
-
- [
|
|
46
|
+
- [Keeping Up to Date](#keeping-up-to-date)
|
|
41
47
|
- [Troubleshooting](#troubleshooting)
|
|
42
48
|
- [Contributors](#contributors)
|
|
43
49
|
|
|
44
|
-
##
|
|
50
|
+
## Quick Install
|
|
45
51
|
|
|
46
52
|
```bash
|
|
47
53
|
# Install and run automatic setup (5 minutes, everything automatic)
|
|
@@ -49,15 +55,18 @@ npm install -g claude-self-reflect
|
|
|
49
55
|
claude-self-reflect setup
|
|
50
56
|
|
|
51
57
|
# That's it! The setup will:
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
58
|
+
# - Run everything in Docker (no Python issues!)
|
|
59
|
+
# - Configure everything automatically
|
|
60
|
+
# - Install the MCP in Claude Code
|
|
61
|
+
# - Start monitoring for new conversations
|
|
62
|
+
# - Keep all data local - no API keys needed
|
|
57
63
|
```
|
|
58
64
|
|
|
65
|
+
> [!TIP]
|
|
66
|
+
> **v4.0+ Auto-Migration**: Updates from v3.x automatically migrate during npm install - no manual steps needed!
|
|
67
|
+
|
|
59
68
|
<details open>
|
|
60
|
-
<summary
|
|
69
|
+
<summary>Cloud Mode (Better Search Accuracy)</summary>
|
|
61
70
|
|
|
62
71
|
```bash
|
|
63
72
|
# Step 1: Get your free Voyage AI key
|
|
@@ -67,7 +76,35 @@ claude-self-reflect setup
|
|
|
67
76
|
npm install -g claude-self-reflect
|
|
68
77
|
claude-self-reflect setup --voyage-key=YOUR_ACTUAL_KEY_HERE
|
|
69
78
|
```
|
|
70
|
-
|
|
79
|
+
|
|
80
|
+
> [!NOTE]
|
|
81
|
+
> Cloud mode provides 1024-dimensional embeddings (vs 384 local) for more accurate semantic search but sends conversation data to Voyage AI for processing.
|
|
82
|
+
|
|
83
|
+
</details>
|
|
84
|
+
|
|
85
|
+
## Performance
|
|
86
|
+
|
|
87
|
+
<details open>
|
|
88
|
+
<summary><b>v4.0 Performance Improvements</b></summary>
|
|
89
|
+
|
|
90
|
+
| Metric | v3.x | v4.0 | Improvement |
|
|
91
|
+
|--------|------|------|-------------|
|
|
92
|
+
| **Status Check** | 119ms | 6ms | **20x faster** |
|
|
93
|
+
| **Storage Usage** | 100MB | 50MB | **50% reduction** |
|
|
94
|
+
| **Import Speed** | 10/sec | 100/sec | **10x faster** |
|
|
95
|
+
| **Memory Usage** | 500MB | 50MB | **90% reduction** |
|
|
96
|
+
| **Search Latency** | 15ms | 3ms | **5x faster** |
|
|
97
|
+
|
|
98
|
+
### How We Compare
|
|
99
|
+
|
|
100
|
+
| Feature | Claude Self-Reflect | MemGPT | LangChain Memory |
|
|
101
|
+
|---------|---------------------|---------|------------------|
|
|
102
|
+
| **Local-first** | Yes | No | Partial |
|
|
103
|
+
| **No API keys** | Yes | No | No |
|
|
104
|
+
| **Real-time indexing** | Yes 2-sec | Manual | No |
|
|
105
|
+
| **Search speed** | <3ms | ~50ms | ~100ms |
|
|
106
|
+
| **Setup time** | 5 min | 30+ min | 20+ min |
|
|
107
|
+
| **Docker required** | Yes | Python | Python |
|
|
71
108
|
|
|
72
109
|
</details>
|
|
73
110
|
|
|
@@ -82,20 +119,20 @@ claude-self-reflect setup --voyage-key=YOUR_ACTUAL_KEY_HERE
|
|
|
82
119
|
## Real Examples
|
|
83
120
|
|
|
84
121
|
```
|
|
85
|
-
You: "
|
|
86
|
-
Claude: "Found it -
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
You: "
|
|
91
|
-
Claude: "
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
You: "How did we fix that 100% CPU usage bug?"
|
|
123
|
+
Claude: "Found it - we fixed the circular reference causing 100% CPU usage
|
|
124
|
+
in the server modularization. Also fixed store_reflection dimension
|
|
125
|
+
mismatch by creating separate reflections_local and reflections_voyage."
|
|
126
|
+
|
|
127
|
+
You: "What about that Docker memory issue?"
|
|
128
|
+
Claude: "The container was limited to 2GB but only using 266MB. We found
|
|
129
|
+
the issue only happened with MAX_QUEUE_SIZE=1000 outside Docker.
|
|
130
|
+
With proper Docker limits, memory stays stable at 341MB."
|
|
131
|
+
|
|
132
|
+
You: "Have we worked with JWT authentication?"
|
|
133
|
+
Claude: "Found conversations about JWT patterns including User.authenticate
|
|
134
|
+
methods, TokenHandler classes, and concepts like token rotation,
|
|
135
|
+
PKCE, and social login integration."
|
|
99
136
|
```
|
|
100
137
|
|
|
101
138
|
## NEW: Real-time Indexing Status in Your Terminal
|
|
@@ -110,6 +147,39 @@ See your conversation indexing progress directly in your statusline:
|
|
|
110
147
|
|
|
111
148
|
Works with [Claude Code Statusline](https://github.com/sirmalloc/ccstatusline) - shows progress bars, percentages, and indexing lag in real-time! The statusline also displays MCP connection status (✓ Connected) and collection counts (28/29 indexed).
|
|
112
149
|
|
|
150
|
+
## Code Quality Insights
|
|
151
|
+
|
|
152
|
+
<details>
|
|
153
|
+
<summary><b>AST-GREP Pattern Analysis (100+ Patterns)</b></summary>
|
|
154
|
+
|
|
155
|
+
### Real-time Quality Scoring in Statusline
|
|
156
|
+
Your code quality displayed live as you work:
|
|
157
|
+
- 🟢 **A+** (95-100): Exceptional code quality
|
|
158
|
+
- 🟢 **A** (90-95): Excellent, production-ready
|
|
159
|
+
- 🟢 **B** (80-90): Good, minor improvements possible
|
|
160
|
+
- 🟡 **C** (60-80): Fair, needs refactoring
|
|
161
|
+
- 🔴 **D** (40-60): Poor, significant issues
|
|
162
|
+
- 🔴 **F** (0-40): Critical problems detected
|
|
163
|
+
|
|
164
|
+
### Pattern Categories Analyzed
|
|
165
|
+
- **Security Patterns**: SQL injection, XSS vulnerabilities, hardcoded secrets
|
|
166
|
+
- **Performance Patterns**: N+1 queries, inefficient loops, memory leaks
|
|
167
|
+
- **Error Handling**: Bare exceptions, missing error boundaries
|
|
168
|
+
- **Type Safety**: Missing type hints, unsafe casts
|
|
169
|
+
- **Async Patterns**: Missing await, promise handling
|
|
170
|
+
- **Testing Patterns**: Test coverage, assertion quality
|
|
171
|
+
|
|
172
|
+
### How It Works
|
|
173
|
+
1. **During Import**: AST elements extracted from all code blocks
|
|
174
|
+
2. **Pattern Matching**: 100+ patterns from unified registry
|
|
175
|
+
3. **Quality Scoring**: Weighted scoring normalized by lines of code
|
|
176
|
+
4. **Statusline Display**: Real-time feedback as you code
|
|
177
|
+
|
|
178
|
+
> [!TIP]
|
|
179
|
+
> Run `python scripts/session_quality_tracker.py` to analyze your current session quality!
|
|
180
|
+
|
|
181
|
+
</details>
|
|
182
|
+
|
|
113
183
|
## Key Features
|
|
114
184
|
|
|
115
185
|
<details>
|
|
@@ -128,11 +198,21 @@ Works with [Claude Code Statusline](https://github.com/sirmalloc/ccstatusline) -
|
|
|
128
198
|
- `search_by_recency` - Time-constrained search like "docker issues last week"
|
|
129
199
|
- `get_timeline` - Activity timeline with statistics and patterns
|
|
130
200
|
|
|
201
|
+
**Runtime Configuration Tools (v4.0):**
|
|
202
|
+
- `switch_embedding_mode` - Switch between local/cloud modes without restart
|
|
203
|
+
- `get_embedding_mode` - Check current embedding configuration
|
|
204
|
+
- `reload_code` - Hot reload Python code changes
|
|
205
|
+
- `reload_status` - Check reload state
|
|
206
|
+
- `clear_module_cache` - Clear Python cache
|
|
207
|
+
|
|
131
208
|
**Status & Monitoring Tools:**
|
|
132
209
|
- `get_status` - Real-time import progress and system status
|
|
133
210
|
- `get_health` - Comprehensive system health check
|
|
134
211
|
- `collection_status` - Check Qdrant collection health and stats
|
|
135
212
|
|
|
213
|
+
> [!TIP]
|
|
214
|
+
> Use `reflect_on_past --mode quick` for instant existence checks - returns count + top match only!
|
|
215
|
+
|
|
136
216
|
All tools are automatically available when the MCP server is connected to Claude Code.
|
|
137
217
|
|
|
138
218
|
</details>
|
|
@@ -175,6 +255,9 @@ Recent conversations matter more. Old ones fade. Like your brain, but reliable.
|
|
|
175
255
|
- **Graceful aging**: Old information fades naturally
|
|
176
256
|
- **Configurable**: Adjust decay rate to your needs
|
|
177
257
|
|
|
258
|
+
> [!NOTE]
|
|
259
|
+
> Memory decay ensures recent solutions are prioritized while still maintaining historical context.
|
|
260
|
+
|
|
178
261
|
</details>
|
|
179
262
|
|
|
180
263
|
<details>
|
|
@@ -186,6 +269,9 @@ Recent conversations matter more. Old ones fade. Like your brain, but reliable.
|
|
|
186
269
|
- **Memory**: 96% reduction from v2.5.15
|
|
187
270
|
- **Real-time**: HOT/WARM/COLD intelligent prioritization
|
|
188
271
|
|
|
272
|
+
> [!TIP]
|
|
273
|
+
> For best performance, keep Docker allocated 4GB+ RAM and use SSD storage.
|
|
274
|
+
|
|
189
275
|
</details>
|
|
190
276
|
|
|
191
277
|
## Architecture
|
|
@@ -213,6 +299,9 @@ Files are categorized by age and processed with priority queuing to ensure newes
|
|
|
213
299
|
|
|
214
300
|
## Requirements
|
|
215
301
|
|
|
302
|
+
> [!WARNING]
|
|
303
|
+
> **Breaking Change in v4.0**: Collections now use prefixed naming (e.g., `csr_project_local_384d`). Run migration automatically via `npm update`.
|
|
304
|
+
|
|
216
305
|
<details>
|
|
217
306
|
<summary><b>System Requirements</b></summary>
|
|
218
307
|
|
|
@@ -288,55 +377,20 @@ npm uninstall -g claude-self-reflect
|
|
|
288
377
|
|
|
289
378
|
</details>
|
|
290
379
|
|
|
291
|
-
##
|
|
292
|
-
|
|
293
|
-
<details>
|
|
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
|
|
303
|
-
|
|
304
|
-
</details>
|
|
305
|
-
|
|
306
|
-
<details>
|
|
307
|
-
<summary>v2.5.19 - Metadata Enrichment</summary>
|
|
380
|
+
## Keeping Up to Date
|
|
308
381
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
# Update to latest version
|
|
312
|
-
npm update -g claude-self-reflect
|
|
313
|
-
|
|
314
|
-
# Run setup - it will detect your existing installation
|
|
315
|
-
claude-self-reflect setup
|
|
316
|
-
# Choose "yes" when asked about metadata enrichment
|
|
317
|
-
|
|
318
|
-
# Or manually enrich metadata anytime:
|
|
319
|
-
docker compose run --rm importer python /app/scripts/delta-metadata-update-safe.py
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
### What You Get
|
|
323
|
-
- `search_by_concept("docker")` - Find conversations by topic
|
|
324
|
-
- `search_by_file("server.py")` - Find conversations that touched specific files
|
|
325
|
-
- Better search accuracy with metadata-based filtering
|
|
326
|
-
|
|
327
|
-
</details>
|
|
382
|
+
> [!TIP]
|
|
383
|
+
> **For Existing Users**: Simply run `npm update -g claude-self-reflect` to get the latest features and improvements. Updates are automatic and preserve your data.
|
|
328
384
|
|
|
329
385
|
<details>
|
|
330
|
-
<summary>
|
|
331
|
-
|
|
332
|
-
- **
|
|
333
|
-
- **
|
|
334
|
-
- **
|
|
335
|
-
- **
|
|
336
|
-
- **
|
|
337
|
-
- **
|
|
338
|
-
- **v2.5.10** - Emergency hotfix for MCP server startup
|
|
339
|
-
- **v2.5.6** - Tool Output Extraction
|
|
386
|
+
<summary>Recent Improvements</summary>
|
|
387
|
+
|
|
388
|
+
- **20x faster performance** - Status checks, search, and imports
|
|
389
|
+
- **Runtime configuration** - Switch modes without restarting
|
|
390
|
+
- **Unified state management** - Single source of truth
|
|
391
|
+
- **AST-GREP integration** - Code quality analysis
|
|
392
|
+
- **Temporal search tools** - Find recent work and time-based queries
|
|
393
|
+
- **Auto-migration** - Updates handle breaking changes automatically
|
|
340
394
|
|
|
341
395
|
[Full changelog](docs/release-history.md)
|
|
342
396
|
|
|
@@ -5,11 +5,22 @@ import sys
|
|
|
5
5
|
import importlib
|
|
6
6
|
import logging
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Dict, List, Optional
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
9
|
from fastmcp import Context
|
|
10
10
|
from pydantic import Field
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
|
+
import asyncio
|
|
14
|
+
|
|
15
|
+
# Import security module - handle both relative and absolute imports
|
|
16
|
+
try:
|
|
17
|
+
from .security_patches import ModuleWhitelist
|
|
18
|
+
except ImportError:
|
|
19
|
+
try:
|
|
20
|
+
from security_patches import ModuleWhitelist
|
|
21
|
+
except ImportError:
|
|
22
|
+
# Security module is required - fail closed, not open
|
|
23
|
+
raise RuntimeError("Security module 'security_patches' is required for code reload functionality")
|
|
13
24
|
|
|
14
25
|
logger = logging.getLogger(__name__)
|
|
15
26
|
|
|
@@ -19,20 +30,36 @@ class CodeReloader:
|
|
|
19
30
|
|
|
20
31
|
def __init__(self):
|
|
21
32
|
"""Initialize the code reloader."""
|
|
22
|
-
self.module_hashes: Dict[str, str] = {}
|
|
23
|
-
self.reload_history: List[Dict] = []
|
|
24
33
|
self.cache_dir = Path.home() / '.claude-self-reflect' / 'reload_cache'
|
|
25
34
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
self.hash_file = self.cache_dir / 'module_hashes.json'
|
|
36
|
+
self._lock = asyncio.Lock() # Thread safety for async operations
|
|
37
|
+
|
|
38
|
+
# Load persisted hashes from disk with error handling
|
|
39
|
+
if self.hash_file.exists():
|
|
40
|
+
try:
|
|
41
|
+
with open(self.hash_file, 'r') as f:
|
|
42
|
+
self.module_hashes: Dict[str, str] = json.load(f)
|
|
43
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
44
|
+
logger.error(f"Failed to load module hashes: {e}. Starting fresh.")
|
|
45
|
+
self.module_hashes: Dict[str, str] = {}
|
|
46
|
+
else:
|
|
47
|
+
self.module_hashes: Dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
self.reload_history: List[Dict] = []
|
|
50
|
+
logger.info(f"CodeReloader initialized with {len(self.module_hashes)} cached hashes")
|
|
28
51
|
|
|
29
52
|
def _get_file_hash(self, filepath: Path) -> str:
|
|
30
53
|
"""Get SHA256 hash of a file."""
|
|
31
54
|
with open(filepath, 'rb') as f:
|
|
32
55
|
return hashlib.sha256(f.read()).hexdigest()
|
|
33
56
|
|
|
34
|
-
def
|
|
35
|
-
"""Detect which modules have changed since last check.
|
|
57
|
+
def _detect_changed_modules(self) -> List[str]:
|
|
58
|
+
"""Detect which modules have changed since last check.
|
|
59
|
+
|
|
60
|
+
This method ONLY detects changes, it does NOT update the stored hashes.
|
|
61
|
+
Use _update_module_hashes() to update hashes after successful reload.
|
|
62
|
+
"""
|
|
36
63
|
changed = []
|
|
37
64
|
src_dir = Path(__file__).parent
|
|
38
65
|
|
|
@@ -43,13 +70,61 @@ class CodeReloader:
|
|
|
43
70
|
module_name = f"src.{py_file.stem}"
|
|
44
71
|
current_hash = self._get_file_hash(py_file)
|
|
45
72
|
|
|
73
|
+
# Only detect changes, DO NOT update hashes here
|
|
46
74
|
if module_name in self.module_hashes:
|
|
47
75
|
if self.module_hashes[module_name] != current_hash:
|
|
48
76
|
changed.append(module_name)
|
|
77
|
+
logger.debug(f"Change detected in {module_name}: {self.module_hashes[module_name][:8]} -> {current_hash[:8]}")
|
|
78
|
+
else:
|
|
79
|
+
# New module not seen before
|
|
80
|
+
changed.append(module_name)
|
|
81
|
+
logger.debug(f"New module detected: {module_name}")
|
|
82
|
+
|
|
83
|
+
return changed
|
|
84
|
+
|
|
85
|
+
def _update_module_hashes(self, modules: Optional[List[str]] = None) -> None:
|
|
86
|
+
"""Update the stored hashes for specified modules or all modules.
|
|
87
|
+
|
|
88
|
+
This should be called AFTER successful reload to mark modules as up-to-date.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
modules: List of module names to update. If None, updates all modules.
|
|
92
|
+
"""
|
|
93
|
+
src_dir = Path(__file__).parent
|
|
94
|
+
updated = []
|
|
95
|
+
|
|
96
|
+
for py_file in src_dir.glob("*.py"):
|
|
97
|
+
if py_file.name == "__pycache__":
|
|
98
|
+
continue
|
|
49
99
|
|
|
100
|
+
module_name = f"src.{py_file.stem}"
|
|
101
|
+
|
|
102
|
+
# If specific modules provided, only update those
|
|
103
|
+
if modules is not None and module_name not in modules:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
current_hash = self._get_file_hash(py_file)
|
|
107
|
+
old_hash = self.module_hashes.get(module_name, "new")
|
|
50
108
|
self.module_hashes[module_name] = current_hash
|
|
109
|
+
|
|
110
|
+
if old_hash != current_hash:
|
|
111
|
+
updated.append(module_name)
|
|
112
|
+
logger.debug(f"Updated hash for {module_name}: {old_hash[:8] if old_hash != 'new' else 'new'} -> {current_hash[:8]}")
|
|
51
113
|
|
|
52
|
-
|
|
114
|
+
# Persist the updated hashes to disk using atomic write
|
|
115
|
+
temp_file = Path(str(self.hash_file) + '.tmp')
|
|
116
|
+
try:
|
|
117
|
+
with open(temp_file, 'w') as f:
|
|
118
|
+
json.dump(self.module_hashes, f, indent=2)
|
|
119
|
+
# Atomic rename on POSIX systems
|
|
120
|
+
temp_file.replace(self.hash_file)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"Failed to persist module hashes: {e}")
|
|
123
|
+
if temp_file.exists():
|
|
124
|
+
temp_file.unlink() # Clean up temp file on failure
|
|
125
|
+
|
|
126
|
+
if updated:
|
|
127
|
+
logger.info(f"Updated hashes for {len(updated)} modules: {', '.join(updated)}")
|
|
53
128
|
|
|
54
129
|
async def reload_modules(
|
|
55
130
|
self,
|
|
@@ -61,93 +136,98 @@ class CodeReloader:
|
|
|
61
136
|
|
|
62
137
|
await ctx.debug("Starting code reload process...")
|
|
63
138
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
response +=
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
139
|
+
async with self._lock: # Ensure thread safety for reload operations
|
|
140
|
+
try:
|
|
141
|
+
# Track what we're reloading
|
|
142
|
+
reload_targets = []
|
|
143
|
+
|
|
144
|
+
if auto_detect:
|
|
145
|
+
# Detect changed modules (without updating hashes)
|
|
146
|
+
changed = self._detect_changed_modules()
|
|
147
|
+
if changed:
|
|
148
|
+
reload_targets.extend(changed)
|
|
149
|
+
await ctx.debug(f"Auto-detected changes in: {changed}")
|
|
150
|
+
|
|
151
|
+
if modules:
|
|
152
|
+
# Add explicitly requested modules
|
|
153
|
+
reload_targets.extend(modules)
|
|
154
|
+
|
|
155
|
+
if not reload_targets:
|
|
156
|
+
return "📊 No modules to reload. All code is up to date!"
|
|
157
|
+
|
|
158
|
+
# Perform the reload
|
|
159
|
+
reloaded = []
|
|
160
|
+
failed = []
|
|
161
|
+
|
|
162
|
+
for module_name in reload_targets:
|
|
163
|
+
try:
|
|
164
|
+
# SECURITY FIX: Validate module is in whitelist
|
|
165
|
+
if not ModuleWhitelist.is_allowed_module(module_name):
|
|
166
|
+
logger.warning(f"Module not in whitelist, skipping: {module_name}")
|
|
167
|
+
failed.append((module_name, "Module not in whitelist"))
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if module_name in sys.modules:
|
|
171
|
+
# Store old module reference for rollback
|
|
172
|
+
old_module = sys.modules[module_name]
|
|
173
|
+
|
|
174
|
+
# Reload the module
|
|
175
|
+
logger.info(f"Reloading module: {module_name}")
|
|
176
|
+
reloaded_module = importlib.reload(sys.modules[module_name])
|
|
177
|
+
|
|
178
|
+
# Update any global references if needed
|
|
179
|
+
self._update_global_references(module_name, reloaded_module)
|
|
180
|
+
|
|
181
|
+
reloaded.append(module_name)
|
|
182
|
+
await ctx.debug(f"✅ Reloaded: {module_name}")
|
|
183
|
+
else:
|
|
184
|
+
# Module not loaded yet, import it
|
|
185
|
+
importlib.import_module(module_name)
|
|
186
|
+
reloaded.append(module_name)
|
|
187
|
+
await ctx.debug(f"✅ Imported: {module_name}")
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Failed to reload {module_name}: {e}", exc_info=True)
|
|
191
|
+
failed.append((module_name, str(e)))
|
|
192
|
+
await ctx.debug(f"❌ Failed: {module_name} - {e}")
|
|
193
|
+
|
|
194
|
+
# Update hashes ONLY for successfully reloaded modules
|
|
195
|
+
if reloaded:
|
|
196
|
+
self._update_module_hashes(reloaded)
|
|
197
|
+
await ctx.debug(f"Updated hashes for {len(reloaded)} successfully reloaded modules")
|
|
198
|
+
|
|
199
|
+
# Record reload history
|
|
200
|
+
self.reload_history.append({
|
|
201
|
+
"timestamp": os.environ.get('MCP_REQUEST_ID', 'unknown'),
|
|
202
|
+
"reloaded": reloaded,
|
|
203
|
+
"failed": failed
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
# Build response
|
|
207
|
+
response = "🔄 **Code Reload Results**\n\n"
|
|
208
|
+
|
|
209
|
+
if reloaded:
|
|
210
|
+
response += f"**Successfully Reloaded ({len(reloaded)}):**\n"
|
|
211
|
+
for module in reloaded:
|
|
212
|
+
response += f"- ✅ {module}\n"
|
|
213
|
+
response += "\n"
|
|
214
|
+
|
|
215
|
+
if failed:
|
|
216
|
+
response += f"**Failed to Reload ({len(failed)}):**\n"
|
|
217
|
+
for module, error in failed:
|
|
218
|
+
response += f"- ❌ {module}: {error}\n"
|
|
219
|
+
response += "\n"
|
|
220
|
+
|
|
221
|
+
response += "**Important Notes:**\n"
|
|
222
|
+
response += "- Class instances created before reload keep old code\n"
|
|
223
|
+
response += "- New requests will use the reloaded code\n"
|
|
224
|
+
response += "- Some changes may require full restart (e.g., new tools)\n"
|
|
225
|
+
|
|
226
|
+
return response
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.error(f"Code reload failed: {e}", exc_info=True)
|
|
230
|
+
return f"❌ Code reload failed: {str(e)}"
|
|
151
231
|
|
|
152
232
|
def _update_global_references(self, module_name: str, new_module):
|
|
153
233
|
"""Update global references after module reload."""
|
|
@@ -171,8 +251,8 @@ class CodeReloader:
|
|
|
171
251
|
"""Get the current reload status and history."""
|
|
172
252
|
|
|
173
253
|
try:
|
|
174
|
-
# Check for changed files
|
|
175
|
-
changed = self.
|
|
254
|
+
# Check for changed files (WITHOUT updating hashes)
|
|
255
|
+
changed = self._detect_changed_modules()
|
|
176
256
|
|
|
177
257
|
response = "📊 **Code Reload Status**\n\n"
|
|
178
258
|
|
|
@@ -224,6 +304,24 @@ class CodeReloader:
|
|
|
224
304
|
logger.error(f"Failed to clear cache: {e}", exc_info=True)
|
|
225
305
|
return f"❌ Failed to clear cache: {str(e)}"
|
|
226
306
|
|
|
307
|
+
async def force_update_hashes(self, ctx: Context) -> str:
|
|
308
|
+
"""Force update all module hashes to current state.
|
|
309
|
+
|
|
310
|
+
This is useful when you want to mark all current code as 'baseline'
|
|
311
|
+
without actually reloading anything.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
await ctx.debug("Force updating all module hashes...")
|
|
315
|
+
|
|
316
|
+
# Update all module hashes
|
|
317
|
+
self._update_module_hashes(modules=None)
|
|
318
|
+
|
|
319
|
+
return f"✅ Force updated hashes for all {len(self.module_hashes)} tracked modules"
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logger.error(f"Failed to force update hashes: {e}", exc_info=True)
|
|
323
|
+
return f"❌ Failed to force update hashes: {str(e)}"
|
|
324
|
+
|
|
227
325
|
|
|
228
326
|
def register_code_reload_tool(mcp, get_embedding_manager):
|
|
229
327
|
"""Register the code reloading tool with the MCP server."""
|
|
@@ -257,6 +355,8 @@ def register_code_reload_tool(mcp, get_embedding_manager):
|
|
|
257
355
|
|
|
258
356
|
Shows which files have been modified since last reload and
|
|
259
357
|
the history of recent reload operations.
|
|
358
|
+
|
|
359
|
+
Note: This only checks for changes, it does not update the stored hashes.
|
|
260
360
|
"""
|
|
261
361
|
return await reloader.get_reload_status(ctx)
|
|
262
362
|
|
|
@@ -267,5 +367,14 @@ def register_code_reload_tool(mcp, get_embedding_manager):
|
|
|
267
367
|
Useful when reload isn't working due to cached bytecode.
|
|
268
368
|
"""
|
|
269
369
|
return await reloader.clear_python_cache(ctx)
|
|
370
|
+
|
|
371
|
+
@mcp.tool()
|
|
372
|
+
async def force_update_module_hashes(ctx: Context) -> str:
|
|
373
|
+
"""Force update all module hashes to mark current code as baseline.
|
|
374
|
+
|
|
375
|
+
Use this when you want to ignore current changes and treat
|
|
376
|
+
the current state as the new baseline without reloading.
|
|
377
|
+
"""
|
|
378
|
+
return await reloader.force_update_hashes(ctx)
|
|
270
379
|
|
|
271
|
-
logger.info("Code reload tools registered successfully")
|
|
380
|
+
logger.info("Code reload tools registered successfully")
|
|
@@ -8,6 +8,7 @@ import time
|
|
|
8
8
|
from typing import List, Dict, Any, Optional, Tuple
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
import logging
|
|
11
|
+
from .safe_getters import safe_get_list, safe_get_str
|
|
11
12
|
|
|
12
13
|
logger = logging.getLogger(__name__)
|
|
13
14
|
|
|
@@ -88,15 +89,20 @@ async def search_single_collection(
|
|
|
88
89
|
logger.warning(f"Search returned None for collection {collection_name}")
|
|
89
90
|
search_results = []
|
|
90
91
|
|
|
92
|
+
# Ensure search_results is iterable (additional safety check)
|
|
93
|
+
if not hasattr(search_results, '__iter__'):
|
|
94
|
+
logger.error(f"Search results not iterable for collection {collection_name}: {type(search_results)}")
|
|
95
|
+
search_results = []
|
|
96
|
+
|
|
91
97
|
# Debug: Log search results
|
|
92
|
-
logger.debug(f"Search of {collection_name} returned {len(search_results)} results")
|
|
98
|
+
logger.debug(f"Search of {collection_name} returned {len(search_results) if search_results else 0} results")
|
|
93
99
|
|
|
94
|
-
if should_use_decay and not USE_NATIVE_DECAY:
|
|
100
|
+
if should_use_decay and not USE_NATIVE_DECAY and search_results:
|
|
95
101
|
# Apply client-side decay
|
|
96
102
|
await ctx.debug(f"Using CLIENT-SIDE decay for {collection_name}")
|
|
97
103
|
decay_results = []
|
|
98
104
|
|
|
99
|
-
for point in search_results:
|
|
105
|
+
for point in (search_results or []):
|
|
100
106
|
try:
|
|
101
107
|
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
102
108
|
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
@@ -171,15 +177,15 @@ async def search_single_collection(
|
|
|
171
177
|
'collection_name': collection_name,
|
|
172
178
|
'raw_payload': point.payload, # Renamed from 'payload' for consistency
|
|
173
179
|
'code_patterns': point.payload.get('code_patterns'),
|
|
174
|
-
'files_analyzed': point.payload
|
|
175
|
-
'tools_used':
|
|
176
|
-
'concepts': point.payload
|
|
180
|
+
'files_analyzed': safe_get_list(point.payload, 'files_analyzed'),
|
|
181
|
+
'tools_used': safe_get_list(point.payload, 'tools_used'),
|
|
182
|
+
'concepts': safe_get_list(point.payload, 'concepts')
|
|
177
183
|
}
|
|
178
184
|
results.append(search_result)
|
|
179
185
|
else:
|
|
180
186
|
# Process standard search results without decay
|
|
181
|
-
logger.debug(f"Processing {len(search_results)} results from {collection_name}")
|
|
182
|
-
for point in search_results:
|
|
187
|
+
logger.debug(f"Processing {len(search_results) if search_results else 0} results from {collection_name}")
|
|
188
|
+
for point in (search_results or []):
|
|
183
189
|
raw_timestamp = point.payload.get('timestamp', datetime.now().isoformat())
|
|
184
190
|
clean_timestamp = raw_timestamp.replace('Z', '+00:00') if raw_timestamp.endswith('Z') else raw_timestamp
|
|
185
191
|
|
|
@@ -214,9 +220,9 @@ async def search_single_collection(
|
|
|
214
220
|
'collection_name': collection_name,
|
|
215
221
|
'raw_payload': point.payload,
|
|
216
222
|
'code_patterns': point.payload.get('code_patterns'),
|
|
217
|
-
'files_analyzed': point.payload
|
|
218
|
-
'tools_used':
|
|
219
|
-
'concepts': point.payload
|
|
223
|
+
'files_analyzed': safe_get_list(point.payload, 'files_analyzed'),
|
|
224
|
+
'tools_used': safe_get_list(point.payload, 'tools_used'),
|
|
225
|
+
'concepts': safe_get_list(point.payload, 'concepts')
|
|
220
226
|
}
|
|
221
227
|
results.append(search_result)
|
|
222
228
|
|
|
@@ -307,7 +313,11 @@ async def parallel_search_collections(
|
|
|
307
313
|
continue
|
|
308
314
|
|
|
309
315
|
collection_name, results, timing = result
|
|
310
|
-
|
|
316
|
+
# Handle None results safely
|
|
317
|
+
if results is not None:
|
|
318
|
+
all_results.extend(results)
|
|
319
|
+
else:
|
|
320
|
+
logger.warning(f"Collection {collection_name} returned None results")
|
|
311
321
|
collection_timings.append(timing)
|
|
312
322
|
|
|
313
323
|
await ctx.debug(f"Parallel search complete: {len(all_results)} total results")
|
|
@@ -5,6 +5,7 @@ import time
|
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from typing import List, Dict, Any, Optional
|
|
7
7
|
import logging
|
|
8
|
+
from .safe_getters import safe_get_list, safe_get_str
|
|
8
9
|
|
|
9
10
|
logger = logging.getLogger(__name__)
|
|
10
11
|
|
|
@@ -114,16 +115,19 @@ def format_search_results_rich(
|
|
|
114
115
|
concept_frequency = {}
|
|
115
116
|
|
|
116
117
|
for result in results:
|
|
117
|
-
# Count file modifications
|
|
118
|
-
|
|
118
|
+
# Count file modifications - using safe_get_list for consistency
|
|
119
|
+
files = safe_get_list(result, 'files_analyzed')
|
|
120
|
+
for file in files:
|
|
119
121
|
file_frequency[file] = file_frequency.get(file, 0) + 1
|
|
120
122
|
|
|
121
|
-
# Count tool usage
|
|
122
|
-
|
|
123
|
+
# Count tool usage - using safe_get_list for consistency
|
|
124
|
+
tools = safe_get_list(result, 'tools_used')
|
|
125
|
+
for tool in tools:
|
|
123
126
|
tool_frequency[tool] = tool_frequency.get(tool, 0) + 1
|
|
124
127
|
|
|
125
|
-
# Count concepts
|
|
126
|
-
|
|
128
|
+
# Count concepts - using safe_get_list for consistency
|
|
129
|
+
concepts = safe_get_list(result, 'concepts')
|
|
130
|
+
for concept in concepts:
|
|
127
131
|
concept_frequency[concept] = concept_frequency.get(concept, 0) + 1
|
|
128
132
|
|
|
129
133
|
# Show most frequently modified files
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Safe getter utilities for handling None values consistently."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, List, Optional, Set, Union
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def safe_get_list(
|
|
10
|
+
data: Optional[Dict[str, Any]],
|
|
11
|
+
key: str,
|
|
12
|
+
default: Optional[List] = None
|
|
13
|
+
) -> List[Any]:
|
|
14
|
+
"""
|
|
15
|
+
Safely get a list field from a dictionary, handling None and non-list values.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
data: Dictionary to get value from (can be None)
|
|
19
|
+
key: Key to retrieve
|
|
20
|
+
default: Default value if key not found or value is None
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A list, either the value, converted value, or default/empty list
|
|
24
|
+
"""
|
|
25
|
+
if data is None:
|
|
26
|
+
return default if default is not None else []
|
|
27
|
+
|
|
28
|
+
value = data.get(key)
|
|
29
|
+
|
|
30
|
+
if value is None:
|
|
31
|
+
return default if default is not None else []
|
|
32
|
+
|
|
33
|
+
# Handle sets and tuples by converting to list
|
|
34
|
+
if isinstance(value, (set, tuple)):
|
|
35
|
+
return list(value)
|
|
36
|
+
|
|
37
|
+
# If it's already a list, return it
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
# If it's not a list-like type, log warning and return empty list
|
|
42
|
+
logger.warning(
|
|
43
|
+
f"Expected list-like type for key '{key}', got {type(value).__name__}. "
|
|
44
|
+
f"Value: {repr(value)[:100]}"
|
|
45
|
+
)
|
|
46
|
+
return default if default is not None else []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def safe_get_str(
|
|
50
|
+
data: Optional[Dict[str, Any]],
|
|
51
|
+
key: str,
|
|
52
|
+
default: str = ""
|
|
53
|
+
) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Safely get a string field from a dictionary.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
data: Dictionary to get value from (can be None)
|
|
59
|
+
key: Key to retrieve
|
|
60
|
+
default: Default value if key not found or value is None
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A string, either the value or the default
|
|
64
|
+
"""
|
|
65
|
+
if data is None:
|
|
66
|
+
return default
|
|
67
|
+
|
|
68
|
+
value = data.get(key)
|
|
69
|
+
|
|
70
|
+
if value is None:
|
|
71
|
+
return default
|
|
72
|
+
|
|
73
|
+
# Convert to string if needed
|
|
74
|
+
return str(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def safe_get_dict(
|
|
78
|
+
data: Optional[Dict[str, Any]],
|
|
79
|
+
key: str,
|
|
80
|
+
default: Optional[Dict] = None
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
"""
|
|
83
|
+
Safely get a dictionary field from another dictionary.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
data: Dictionary to get value from (can be None)
|
|
87
|
+
key: Key to retrieve
|
|
88
|
+
default: Default value if key not found or value is None
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A dictionary, either the value or the default/empty dict
|
|
92
|
+
"""
|
|
93
|
+
if data is None:
|
|
94
|
+
return default if default is not None else {}
|
|
95
|
+
|
|
96
|
+
value = data.get(key)
|
|
97
|
+
|
|
98
|
+
if value is None:
|
|
99
|
+
return default if default is not None else {}
|
|
100
|
+
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
logger.warning(
|
|
105
|
+
f"Expected dict for key '{key}', got {type(value).__name__}. "
|
|
106
|
+
f"Value: {repr(value)[:100]}"
|
|
107
|
+
)
|
|
108
|
+
return default if default is not None else {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def safe_get_float(
|
|
112
|
+
data: Optional[Dict[str, Any]],
|
|
113
|
+
key: str,
|
|
114
|
+
default: float = 0.0
|
|
115
|
+
) -> float:
|
|
116
|
+
"""
|
|
117
|
+
Safely get a float field from a dictionary.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
data: Dictionary to get value from (can be None)
|
|
121
|
+
key: Key to retrieve
|
|
122
|
+
default: Default value if key not found or value is None/non-numeric
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A float, either the converted value or the default
|
|
126
|
+
"""
|
|
127
|
+
if data is None:
|
|
128
|
+
return default
|
|
129
|
+
|
|
130
|
+
value = data.get(key)
|
|
131
|
+
|
|
132
|
+
if value is None:
|
|
133
|
+
return default
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
return float(value)
|
|
137
|
+
except (TypeError, ValueError) as e:
|
|
138
|
+
logger.warning(
|
|
139
|
+
f"Could not convert key '{key}' value to float: {repr(value)[:100]}. "
|
|
140
|
+
f"Error: {e}"
|
|
141
|
+
)
|
|
142
|
+
return default
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def safe_get_int(
|
|
146
|
+
data: Optional[Dict[str, Any]],
|
|
147
|
+
key: str,
|
|
148
|
+
default: int = 0
|
|
149
|
+
) -> int:
|
|
150
|
+
"""
|
|
151
|
+
Safely get an integer field from a dictionary.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
data: Dictionary to get value from (can be None)
|
|
155
|
+
key: Key to retrieve
|
|
156
|
+
default: Default value if key not found or value is None/non-numeric
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
An integer, either the converted value or the default
|
|
160
|
+
"""
|
|
161
|
+
if data is None:
|
|
162
|
+
return default
|
|
163
|
+
|
|
164
|
+
value = data.get(key)
|
|
165
|
+
|
|
166
|
+
if value is None:
|
|
167
|
+
return default
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
return int(value)
|
|
171
|
+
except (TypeError, ValueError) as e:
|
|
172
|
+
logger.warning(
|
|
173
|
+
f"Could not convert key '{key}' value to int: {repr(value)[:100]}. "
|
|
174
|
+
f"Error: {e}"
|
|
175
|
+
)
|
|
176
|
+
return default
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def safe_get_bool(
|
|
180
|
+
data: Optional[Dict[str, Any]],
|
|
181
|
+
key: str,
|
|
182
|
+
default: bool = False
|
|
183
|
+
) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
Safely get a boolean field from a dictionary.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
data: Dictionary to get value from (can be None)
|
|
189
|
+
key: Key to retrieve
|
|
190
|
+
default: Default value if key not found or value is None
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
A boolean, either the value or the default
|
|
194
|
+
"""
|
|
195
|
+
if data is None:
|
|
196
|
+
return default
|
|
197
|
+
|
|
198
|
+
value = data.get(key)
|
|
199
|
+
|
|
200
|
+
if value is None:
|
|
201
|
+
return default
|
|
202
|
+
|
|
203
|
+
if isinstance(value, bool):
|
|
204
|
+
return value
|
|
205
|
+
|
|
206
|
+
# Handle string booleans
|
|
207
|
+
if isinstance(value, str):
|
|
208
|
+
return value.lower() in ('true', '1', 'yes', 'on')
|
|
209
|
+
|
|
210
|
+
# Handle numeric booleans
|
|
211
|
+
try:
|
|
212
|
+
return bool(int(value))
|
|
213
|
+
except (TypeError, ValueError):
|
|
214
|
+
logger.warning(
|
|
215
|
+
f"Could not convert key '{key}' value to bool: {repr(value)[:100]}"
|
|
216
|
+
)
|
|
217
|
+
return default
|
|
@@ -20,6 +20,26 @@ from .rich_formatting import format_search_results_rich
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def is_searchable_collection(name: str) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Check if collection name matches searchable patterns.
|
|
26
|
+
Supports both v3 and v4 collection naming conventions.
|
|
27
|
+
"""
|
|
28
|
+
return (
|
|
29
|
+
# v3 patterns
|
|
30
|
+
name.endswith('_local')
|
|
31
|
+
or name.endswith('_voyage')
|
|
32
|
+
# v4 patterns
|
|
33
|
+
or name.endswith('_384d') # Local v4 collections
|
|
34
|
+
or name.endswith('_1024d') # Cloud v4 collections
|
|
35
|
+
or '_cloud_' in name # Cloud v4 intermediate naming
|
|
36
|
+
# Reflections
|
|
37
|
+
or name.startswith('reflections')
|
|
38
|
+
# CSR prefixed collections
|
|
39
|
+
or name.startswith('csr_')
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
23
43
|
class SearchTools:
|
|
24
44
|
"""Handles all search operations for the MCP server."""
|
|
25
45
|
|
|
@@ -114,6 +134,11 @@ class SearchTools:
|
|
|
114
134
|
# Convert results to dict format
|
|
115
135
|
results = []
|
|
116
136
|
for result in search_results:
|
|
137
|
+
# Guard against None payload
|
|
138
|
+
if result.payload is None:
|
|
139
|
+
logger.warning(f"Result in {collection_name} has None payload, skipping")
|
|
140
|
+
continue
|
|
141
|
+
|
|
117
142
|
results.append({
|
|
118
143
|
'conversation_id': result.payload.get('conversation_id'),
|
|
119
144
|
'timestamp': result.payload.get('timestamp'),
|
|
@@ -260,19 +285,36 @@ class SearchTools:
|
|
|
260
285
|
else:
|
|
261
286
|
# Use all collections INCLUDING reflections (with decay)
|
|
262
287
|
collections_response = await self.qdrant_client.get_collections()
|
|
288
|
+
|
|
289
|
+
# Handle None response from Qdrant
|
|
290
|
+
if collections_response is None or not hasattr(collections_response, 'collections'):
|
|
291
|
+
await ctx.debug(f"WARNING: Qdrant returned None or invalid response")
|
|
292
|
+
return "<search_results><message>Unable to retrieve collections from Qdrant</message></search_results>"
|
|
293
|
+
|
|
263
294
|
collections = collections_response.collections
|
|
295
|
+
|
|
296
|
+
# Ensure collections is not None
|
|
297
|
+
if collections is None:
|
|
298
|
+
await ctx.debug(f"WARNING: collections is None!")
|
|
299
|
+
return "<search_results><message>No collections available</message></search_results>"
|
|
300
|
+
|
|
264
301
|
# Include both conversation collections and reflection collections
|
|
302
|
+
# Use module-level function for consistency
|
|
265
303
|
filtered_collections = [
|
|
266
304
|
c for c in collections
|
|
267
|
-
if (c.name
|
|
268
|
-
c.name.startswith('reflections'))
|
|
305
|
+
if is_searchable_collection(c.name)
|
|
269
306
|
]
|
|
270
307
|
await ctx.debug(f"Searching across {len(filtered_collections)} collections")
|
|
271
308
|
|
|
272
309
|
if not filtered_collections:
|
|
273
310
|
return "<search_results><message>No collections found for the specified project</message></search_results>"
|
|
274
|
-
|
|
311
|
+
|
|
275
312
|
# Perform PARALLEL search across collections to avoid freeze
|
|
313
|
+
# Ensure filtered_collections is not None before iterating
|
|
314
|
+
if filtered_collections is None:
|
|
315
|
+
await ctx.debug(f"WARNING: filtered_collections is None!")
|
|
316
|
+
return "<search_results><message>No collections available for search</message></search_results>"
|
|
317
|
+
|
|
276
318
|
collection_names = [c.name for c in filtered_collections]
|
|
277
319
|
await ctx.debug(f"Starting parallel search across {len(collection_names)} collections")
|
|
278
320
|
|
|
@@ -386,8 +428,7 @@ class SearchTools:
|
|
|
386
428
|
# Include both conversation collections and reflection collections
|
|
387
429
|
filtered_collections = [
|
|
388
430
|
c for c in collections
|
|
389
|
-
if (c.name
|
|
390
|
-
c.name.startswith('reflections'))
|
|
431
|
+
if is_searchable_collection(c.name)
|
|
391
432
|
]
|
|
392
433
|
|
|
393
434
|
# Quick PARALLEL count across collections
|
|
@@ -476,8 +517,7 @@ class SearchTools:
|
|
|
476
517
|
# Include both conversation collections and reflection collections
|
|
477
518
|
filtered_collections = [
|
|
478
519
|
c for c in collections
|
|
479
|
-
if (c.name
|
|
480
|
-
c.name.startswith('reflections'))
|
|
520
|
+
if is_searchable_collection(c.name)
|
|
481
521
|
]
|
|
482
522
|
|
|
483
523
|
# Gather results for summary using PARALLEL search
|
|
@@ -573,8 +613,7 @@ class SearchTools:
|
|
|
573
613
|
# Include both conversation collections and reflection collections
|
|
574
614
|
filtered_collections = [
|
|
575
615
|
c for c in collections
|
|
576
|
-
if (c.name
|
|
577
|
-
c.name.startswith('reflections'))
|
|
616
|
+
if is_searchable_collection(c.name)
|
|
578
617
|
]
|
|
579
618
|
|
|
580
619
|
# Gather all results using PARALLEL search
|
package/package.json
CHANGED
package/scripts/auto-migrate.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
@@ -23,7 +23,22 @@ const needsMigration = legacyFiles.some(file =>
|
|
|
23
23
|
fs.existsSync(path.join(csrConfigDir, file))
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
if
|
|
26
|
+
// Check if unified state exists and has proper structure
|
|
27
|
+
let unifiedStateValid = false;
|
|
28
|
+
if (fs.existsSync(unifiedStateFile)) {
|
|
29
|
+
try {
|
|
30
|
+
const state = JSON.parse(fs.readFileSync(unifiedStateFile, 'utf8'));
|
|
31
|
+
// Check for v5.0 structure
|
|
32
|
+
unifiedStateValid = state.version === '5.0.0' &&
|
|
33
|
+
state.files &&
|
|
34
|
+
state.collections &&
|
|
35
|
+
state.metadata;
|
|
36
|
+
} catch {
|
|
37
|
+
unifiedStateValid = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!needsMigration && unifiedStateValid) {
|
|
27
42
|
console.log('✅ Already using Unified State Management v5.0');
|
|
28
43
|
process.exit(0);
|
|
29
44
|
}
|
|
@@ -34,9 +49,12 @@ if (needsMigration) {
|
|
|
34
49
|
|
|
35
50
|
try {
|
|
36
51
|
// Check if Python is available
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
const pythonCheck = spawnSync('python3', ['--version'], {
|
|
53
|
+
stdio: 'ignore',
|
|
54
|
+
shell: false
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (pythonCheck.error || pythonCheck.status !== 0) {
|
|
40
58
|
console.log('⚠️ Python 3 not found. Migration will run when you first use the MCP server.');
|
|
41
59
|
console.log(' To run migration manually: python3 scripts/migrate-to-unified-state.py');
|
|
42
60
|
process.exit(0);
|
|
@@ -62,19 +80,78 @@ if (needsMigration) {
|
|
|
62
80
|
process.exit(0);
|
|
63
81
|
}
|
|
64
82
|
|
|
65
|
-
// Run the migration
|
|
83
|
+
// Run the migration safely using spawnSync to prevent shell injection
|
|
66
84
|
console.log(`🚀 Running migration from: ${migrationScript}`);
|
|
67
|
-
const result =
|
|
85
|
+
const result = spawnSync('python3', [migrationScript], {
|
|
68
86
|
encoding: 'utf-8',
|
|
69
|
-
stdio: 'pipe'
|
|
87
|
+
stdio: 'pipe',
|
|
88
|
+
shell: false // Explicitly disable shell to prevent injection
|
|
70
89
|
});
|
|
71
90
|
|
|
72
|
-
|
|
91
|
+
if (result.error) {
|
|
92
|
+
throw result.error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (result.status !== 0) {
|
|
96
|
+
// Categorize errors for better user guidance
|
|
97
|
+
const stderr = result.stderr || '';
|
|
98
|
+
const stdout = result.stdout || '';
|
|
99
|
+
|
|
100
|
+
if (stderr.includes('ModuleNotFoundError')) {
|
|
101
|
+
console.log('⚠️ Missing Python dependencies. The MCP server will install them on first run.');
|
|
102
|
+
console.log(' To install manually: pip install -r requirements.txt');
|
|
103
|
+
} else if (stderr.includes('PermissionError') || stderr.includes('Permission denied')) {
|
|
104
|
+
console.log('⚠️ Permission issue accessing state files.');
|
|
105
|
+
console.log(' Try running with appropriate permissions or check file ownership.');
|
|
106
|
+
} else if (stderr.includes('FileNotFoundError')) {
|
|
107
|
+
console.log('⚠️ State files not found at expected location.');
|
|
108
|
+
console.log(' This is normal for fresh installations.');
|
|
109
|
+
} else {
|
|
110
|
+
console.log('⚠️ Migration encountered an issue:');
|
|
111
|
+
console.log(stderr || stdout || `Exit code: ${result.status}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(' Your existing state files are preserved.');
|
|
115
|
+
console.log(' To run migration manually: python3 scripts/migrate-to-unified-state.py');
|
|
116
|
+
console.log(' For help: https://github.com/ramakay/claude-self-reflect/issues');
|
|
117
|
+
process.exit(0); // Exit gracefully, don't fail npm install
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result.stdout) {
|
|
121
|
+
console.log(result.stdout);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Clean up legacy files after successful migration
|
|
125
|
+
console.log('🧹 Cleaning up legacy state files...');
|
|
126
|
+
let cleanedCount = 0;
|
|
127
|
+
for (const file of legacyFiles) {
|
|
128
|
+
const filePath = path.join(csrConfigDir, file);
|
|
129
|
+
if (fs.existsSync(filePath)) {
|
|
130
|
+
try {
|
|
131
|
+
// Move to archive instead of deleting (safer)
|
|
132
|
+
const archiveDir = path.join(csrConfigDir, 'archive');
|
|
133
|
+
if (!fs.existsSync(archiveDir)) {
|
|
134
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
const archivePath = path.join(archiveDir, `migrated-${file}`);
|
|
137
|
+
fs.renameSync(filePath, archivePath);
|
|
138
|
+
cleanedCount++;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.log(` ⚠️ Could not archive ${file}: ${err.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (cleanedCount > 0) {
|
|
146
|
+
console.log(` ✓ Archived ${cleanedCount} legacy files to config/archive/`);
|
|
147
|
+
}
|
|
148
|
+
|
|
73
149
|
console.log('✅ Migration completed successfully!');
|
|
74
150
|
console.log('🎉 Now using Unified State Management v5.0 (20x faster!)');
|
|
75
151
|
|
|
76
152
|
} catch (error) {
|
|
77
|
-
|
|
153
|
+
// Handle unexpected errors
|
|
154
|
+
console.log('⚠️ Migration encountered an unexpected issue:', error.message);
|
|
78
155
|
console.log(' Your existing state files are preserved.');
|
|
79
156
|
console.log(' To run migration manually: python3 scripts/migrate-to-unified-state.py');
|
|
80
157
|
console.log(' For help: https://github.com/ramakay/claude-self-reflect/issues');
|