claude-self-reflect 4.0.3 → 5.0.4

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.
@@ -18,7 +18,9 @@ services:
18
18
  - "${QDRANT_PORT:-6333}:6333"
19
19
  volumes:
20
20
  - qdrant_data:/qdrant/storage
21
- - ./config/qdrant-config.yaml:/qdrant/config/config.yaml:ro
21
+ # Note: Using CONFIG_PATH variable to support global npm installs (fixes #71)
22
+ # macOS Docker Desktop restricts mounts to /Users, /Volumes, /private, /tmp
23
+ - ${CONFIG_PATH:-~/.claude-self-reflect/config}/qdrant-config.yaml:/qdrant/config/config.yaml:ro
22
24
  environment:
23
25
  - QDRANT__LOG_LEVEL=INFO
24
26
  - QDRANT__SERVICE__HTTP_PORT=6333
@@ -71,23 +71,64 @@ async function checkDocker() {
71
71
  try {
72
72
  safeExec('docker', ['info'], { stdio: 'ignore' });
73
73
  console.log('✅ Docker is installed and running');
74
-
74
+
75
75
  // Check docker compose
76
76
  try {
77
77
  safeExec('docker', ['compose', 'version'], { stdio: 'ignore' });
78
- console.log('✅ Docker Compose v2 is available');
78
+ console.log('✅ Docker Compose is available');
79
79
  return true;
80
80
  } catch {
81
- console.log('❌ Docker Compose v2 not found');
82
- console.log(' Please update Docker Desktop to the latest version');
81
+ console.log('❌ Docker Compose not found');
82
+ console.log(' Please update Docker Desktop to include Compose v2');
83
83
  return false;
84
84
  }
85
85
  } catch {
86
- console.log('❌ Docker is not running or not installed');
87
- console.log('\n📋 Please install Docker:');
88
- console.log(' macOS/Windows: https://docker.com/products/docker-desktop');
89
- console.log(' • Linux: https://docs.docker.com/engine/install/');
90
- console.log('\n After installation, make sure Docker is running and try again.');
86
+ console.log('❌ Docker is not running or not installed\n');
87
+ console.log('📋 Claude Self-Reflect requires Docker Desktop');
88
+ console.log(' (Includes Docker Engine + Compose - everything you need)\n');
89
+
90
+ const platform = process.platform;
91
+ const arch = process.arch;
92
+
93
+ if (platform === 'darwin') {
94
+ const archType = arch === 'arm64' ? 'Apple Silicon (M1/M2/M3/M4)' : 'Intel';
95
+ console.log(`🍎 macOS (${archType}) Installation:\n`);
96
+
97
+ if (arch === 'arm64') {
98
+ console.log(' Download: https://desktop.docker.com/mac/main/arm64/Docker.dmg');
99
+ } else {
100
+ console.log(' Download: https://desktop.docker.com/mac/main/amd64/Docker.dmg');
101
+ }
102
+
103
+ console.log(' 1. Open the downloaded Docker.dmg');
104
+ console.log(' 2. Drag Docker.app to Applications folder');
105
+ console.log(' 3. Launch Docker Desktop from Applications');
106
+ console.log(' 4. Wait for Docker to start (whale icon in menu bar)');
107
+ console.log(' 5. Re-run: claude-self-reflect setup\n');
108
+
109
+ } else if (platform === 'win32') {
110
+ console.log('🪟 Windows Installation:\n');
111
+ console.log(' Download: https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe');
112
+ console.log(' 1. Run the installer');
113
+ console.log(' 2. Follow installation prompts');
114
+ console.log(' 3. Restart computer if prompted');
115
+ console.log(' 4. Launch Docker Desktop');
116
+ console.log(' 5. Re-run: claude-self-reflect setup\n');
117
+
118
+ } else {
119
+ console.log('🐧 Linux Installation:\n');
120
+ console.log(' Install Docker Engine (includes Compose):');
121
+ console.log(' • Ubuntu/Debian: https://docs.docker.com/engine/install/ubuntu/');
122
+ console.log(' • Fedora: https://docs.docker.com/engine/install/fedora/');
123
+ console.log(' • Arch: https://wiki.archlinux.org/title/docker');
124
+ console.log(' • CentOS: https://docs.docker.com/engine/install/centos/\n');
125
+ }
126
+
127
+ console.log('ℹ️ Docker Desktop is free for:');
128
+ console.log(' • Personal use');
129
+ console.log(' • Small businesses (<250 employees, <$10M revenue)');
130
+ console.log(' • Education and open source projects\n');
131
+
91
132
  return false;
92
133
  }
93
134
  }
@@ -123,6 +164,20 @@ async function configureEnvironment() {
123
164
  } catch {
124
165
  // No old config directory, nothing to migrate
125
166
  }
167
+
168
+ // Copy qdrant-config.yaml from npm package to user config directory
169
+ // This is critical for global npm installs where Docker cannot mount from /opt/homebrew
170
+ const sourceQdrantConfig = join(projectRoot, 'config', 'qdrant-config.yaml');
171
+ const targetQdrantConfig = join(userConfigDir, 'qdrant-config.yaml');
172
+ try {
173
+ await fs.copyFile(sourceQdrantConfig, targetQdrantConfig);
174
+ console.log('✅ Qdrant config copied to user directory');
175
+ } catch (err) {
176
+ if (err.code !== 'ENOENT') {
177
+ console.log('⚠️ Could not copy qdrant-config.yaml:', err.message);
178
+ console.log(' Docker may have issues starting Qdrant service');
179
+ }
180
+ }
126
181
  } catch (error) {
127
182
  console.log(`❌ Could not create config directory: ${error.message}`);
128
183
  console.log(' This may cause Docker mount issues. Please check permissions.');
@@ -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, Literal
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
- # Test comment: Hot reload test at 2025-09-15
27
- logger.info("CodeReloader initialized with hot reload support")
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 _get_changed_modules(self) -> List[str]:
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
- return changed
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
- try:
65
- # Track what we're reloading
66
- reload_targets = []
67
-
68
- if auto_detect:
69
- # Detect changed modules
70
- changed = self._get_changed_modules()
71
- if changed:
72
- reload_targets.extend(changed)
73
- await ctx.debug(f"Auto-detected changes in: {changed}")
74
-
75
- if modules:
76
- # Add explicitly requested modules
77
- reload_targets.extend(modules)
78
-
79
- if not reload_targets:
80
- return "📊 No modules to reload. All code is up to date!"
81
-
82
- # Perform the reload
83
- reloaded = []
84
- failed = []
85
-
86
- for module_name in reload_targets:
87
- try:
88
- # SECURITY FIX: Validate module is in whitelist
89
- from .security_patches import ModuleWhitelist
90
- if not ModuleWhitelist.is_allowed_module(module_name):
91
- logger.warning(f"Module not in whitelist, skipping: {module_name}")
92
- failed.append((module_name, "Module not in whitelist"))
93
- continue
94
-
95
- if module_name in sys.modules:
96
- # Store old module reference for rollback
97
- old_module = sys.modules[module_name]
98
-
99
- # Reload the module
100
- logger.info(f"Reloading module: {module_name}")
101
- reloaded_module = importlib.reload(sys.modules[module_name])
102
-
103
- # Update any global references if needed
104
- self._update_global_references(module_name, reloaded_module)
105
-
106
- reloaded.append(module_name)
107
- await ctx.debug(f"✅ Reloaded: {module_name}")
108
- else:
109
- # Module not loaded yet, import it
110
- importlib.import_module(module_name)
111
- reloaded.append(module_name)
112
- await ctx.debug(f"✅ Imported: {module_name}")
113
-
114
- except Exception as e:
115
- logger.error(f"Failed to reload {module_name}: {e}", exc_info=True)
116
- failed.append((module_name, str(e)))
117
- await ctx.debug(f"❌ Failed: {module_name} - {e}")
118
-
119
- # Record reload history
120
- self.reload_history.append({
121
- "timestamp": os.environ.get('MCP_REQUEST_ID', 'unknown'),
122
- "reloaded": reloaded,
123
- "failed": failed
124
- })
125
-
126
- # Build response
127
- response = "🔄 **Code Reload Results**\n\n"
128
-
129
- if reloaded:
130
- response += f"**Successfully Reloaded ({len(reloaded)}):**\n"
131
- for module in reloaded:
132
- response += f"- {module}\n"
133
- response += "\n"
134
-
135
- if failed:
136
- response += f"**Failed to Reload ({len(failed)}):**\n"
137
- for module, error in failed:
138
- response += f"- ❌ {module}: {error}\n"
139
- response += "\n"
140
-
141
- response += "**Important Notes:**\n"
142
- response += "- Class instances created before reload keep old code\n"
143
- response += "- New requests will use the reloaded code\n"
144
- response += "- Some changes may require full restart (e.g., new tools)\n"
145
-
146
- return response
147
-
148
- except Exception as e:
149
- logger.error(f"Code reload failed: {e}", exc_info=True)
150
- return f"❌ Code reload failed: {str(e)}"
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._get_changed_modules()
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
 
@@ -176,9 +177,9 @@ async def search_single_collection(
176
177
  'collection_name': collection_name,
177
178
  'raw_payload': point.payload, # Renamed from 'payload' for consistency
178
179
  'code_patterns': point.payload.get('code_patterns'),
179
- 'files_analyzed': point.payload.get('files_analyzed'),
180
- 'tools_used': list(point.payload.get('tools_used', [])) if isinstance(point.payload.get('tools_used'), set) else point.payload.get('tools_used'),
181
- 'concepts': point.payload.get('concepts')
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')
182
183
  }
183
184
  results.append(search_result)
184
185
  else:
@@ -219,9 +220,9 @@ async def search_single_collection(
219
220
  'collection_name': collection_name,
220
221
  'raw_payload': point.payload,
221
222
  'code_patterns': point.payload.get('code_patterns'),
222
- 'files_analyzed': point.payload.get('files_analyzed'),
223
- 'tools_used': list(point.payload.get('tools_used', [])) if isinstance(point.payload.get('tools_used'), set) else point.payload.get('tools_used'),
224
- 'concepts': point.payload.get('concepts')
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')
225
226
  }
226
227
  results.append(search_result)
227
228
 
@@ -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
- for file in result.get('files_analyzed', []):
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
- for tool in result.get('tools_used', []):
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
- for concept in result.get('concepts', []):
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