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.
@@ -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'),
@@ -274,10 +299,10 @@ class SearchTools:
274
299
  return "<search_results><message>No collections available</message></search_results>"
275
300
 
276
301
  # Include both conversation collections and reflection collections
302
+ # Use module-level function for consistency
277
303
  filtered_collections = [
278
304
  c for c in collections
279
- if (c.name.endswith('_local') or c.name.endswith('_voyage') or
280
- c.name.startswith('reflections'))
305
+ if is_searchable_collection(c.name)
281
306
  ]
282
307
  await ctx.debug(f"Searching across {len(filtered_collections)} collections")
283
308
 
@@ -403,8 +428,7 @@ class SearchTools:
403
428
  # Include both conversation collections and reflection collections
404
429
  filtered_collections = [
405
430
  c for c in collections
406
- if (c.name.endswith('_local') or c.name.endswith('_voyage') or
407
- c.name.startswith('reflections'))
431
+ if is_searchable_collection(c.name)
408
432
  ]
409
433
 
410
434
  # Quick PARALLEL count across collections
@@ -493,8 +517,7 @@ class SearchTools:
493
517
  # Include both conversation collections and reflection collections
494
518
  filtered_collections = [
495
519
  c for c in collections
496
- if (c.name.endswith('_local') or c.name.endswith('_voyage') or
497
- c.name.startswith('reflections'))
520
+ if is_searchable_collection(c.name)
498
521
  ]
499
522
 
500
523
  # Gather results for summary using PARALLEL search
@@ -590,8 +613,7 @@ class SearchTools:
590
613
  # Include both conversation collections and reflection collections
591
614
  filtered_collections = [
592
615
  c for c in collections
593
- if (c.name.endswith('_local') or c.name.endswith('_voyage') or
594
- c.name.startswith('reflections'))
616
+ if is_searchable_collection(c.name)
595
617
  ]
596
618
 
597
619
  # Gather all results using PARALLEL search
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-self-reflect",
3
- "version": "4.0.3",
3
+ "version": "5.0.4",
4
4
  "description": "Give Claude perfect memory of all your conversations - Installation wizard for Python MCP server",
5
5
  "keywords": [
6
6
  "claude",
@@ -257,15 +257,18 @@ class FinalASTGrepAnalyzer:
257
257
  return '\n'.join(report)
258
258
 
259
259
 
260
- def run_final_analysis():
260
+ def run_final_analysis(file_path=None):
261
261
  """Run final AST-GREP analysis with unified registry."""
262
262
  print("๐Ÿš€ FINAL AST-GREP Analysis with Unified Registry")
263
263
  print("=" * 60)
264
264
 
265
265
  analyzer = FinalASTGrepAnalyzer()
266
266
 
267
- # Analyze server.py
268
- server_path = "/Users/ramakrishnanannaswamy/projects/claude-self-reflect/mcp-server/src/server.py"
267
+ # Use provided path or default
268
+ # Use relative path from script location
269
+ script_dir = Path(__file__).parent
270
+ default_path = script_dir.parent / "mcp-server" / "src" / "server.py"
271
+ server_path = file_path if file_path else str(default_path)
269
272
 
270
273
  print(f"\nAnalyzing: {server_path}")
271
274
  print("-" * 40)
@@ -299,14 +302,14 @@ def run_final_analysis():
299
302
 
300
303
  # Generate and save report
301
304
  report = analyzer.generate_report(result)
302
- report_path = "/Users/ramakrishnanannaswamy/projects/claude-self-reflect/scripts/final_analysis_report.md"
305
+ report_path = script_dir / "final_analysis_report.md"
303
306
  with open(report_path, 'w') as f:
304
307
  f.write(report)
305
308
 
306
309
  print(f"\n๐Ÿ“ Full report saved to: {report_path}")
307
310
 
308
311
  # Save JSON results
309
- json_path = "/Users/ramakrishnanannaswamy/projects/claude-self-reflect/scripts/final_analysis_result.json"
312
+ json_path = script_dir / "final_analysis_result.json"
310
313
  with open(json_path, 'w') as f:
311
314
  json.dump(result, f, indent=2)
312
315
 
@@ -325,4 +328,11 @@ def run_final_analysis():
325
328
 
326
329
 
327
330
  if __name__ == "__main__":
328
- run_final_analysis()
331
+ import sys
332
+ if len(sys.argv) > 1:
333
+ # Use provided file path
334
+ file_path = sys.argv[1]
335
+ else:
336
+ # Default to server.py
337
+ file_path = str(default_path) # Use the same default path from above
338
+ run_final_analysis(file_path)
@@ -6,6 +6,7 @@ Standalone script that doesn't require venv activation.
6
6
 
7
7
  import json
8
8
  import time
9
+ import os
9
10
  from pathlib import Path
10
11
  from datetime import datetime, timedelta
11
12
  import sys
@@ -147,6 +148,51 @@ def format_statusline_quality(critical=0, medium=0, low=0):
147
148
 
148
149
  def get_session_health():
149
150
  """Get cached session health with icon-based quality display."""
151
+ # Get project-specific cache based on current directory
152
+ project_dir = os.getcwd()
153
+ project_name = os.path.basename(project_dir) if project_dir else "default"
154
+
155
+ # Check project-specific realtime cache first
156
+ quality_dir = Path.home() / ".claude-self-reflect" / "quality_by_project"
157
+ realtime_cache = quality_dir / f"{project_name}.json"
158
+
159
+ # Fallback to global cache if project-specific doesn't exist
160
+ if not realtime_cache.exists():
161
+ realtime_cache = Path.home() / ".claude-self-reflect" / "realtime_quality.json"
162
+
163
+ if realtime_cache.exists():
164
+ try:
165
+ # Check realtime cache age
166
+ mtime = datetime.fromtimestamp(realtime_cache.stat().st_mtime)
167
+ age = datetime.now() - mtime
168
+
169
+ if age < timedelta(minutes=5): # Fresh realtime data
170
+ with open(realtime_cache, 'r') as f:
171
+ realtime_data = json.load(f)
172
+
173
+ if "session_aggregate" in realtime_data:
174
+ agg = realtime_data["session_aggregate"]
175
+ issues = agg.get("total_issues", {})
176
+ critical = issues.get("critical", 0)
177
+ medium = issues.get("medium", 0)
178
+ low = issues.get("low", 0)
179
+
180
+ # Include score in display if significantly below threshold
181
+ score = agg.get("average_score", 100)
182
+ if critical > 0 or medium > 0 or low > 0:
183
+ # Show issue counts when there are any issues
184
+ return format_statusline_quality(critical, medium, low)
185
+ elif score < 70:
186
+ # Use red icon for scores below threshold (no issues but poor score)
187
+ icon = "๐Ÿ”ด"
188
+ return f"{icon} {score:.0f}%"
189
+ else:
190
+ # Good quality, no issues
191
+ return format_statusline_quality(critical, medium, low)
192
+ except Exception:
193
+ pass # Fall back to old cache system
194
+
195
+ # Fall back to old cache system
150
196
  # Check for session edit tracker to show appropriate label
151
197
  tracker_file = Path.home() / ".claude-self-reflect" / "current_session_edits.json"
152
198
 
@@ -363,8 +409,63 @@ def get_compact_status():
363
409
  except:
364
410
  pass
365
411
 
366
- # Get quality grade - PER PROJECT cache
367
- # BUG FIX: Cache must be per-project, not global!
412
+ # Get project-specific cache based on current directory
413
+ project_name = os.path.basename(os.getcwd()) if os.getcwd() else "default"
414
+
415
+ # Check project-specific realtime cache first
416
+ quality_dir = Path.home() / ".claude-self-reflect" / "quality_by_project"
417
+ realtime_cache = quality_dir / f"{project_name}.json"
418
+
419
+ # Fallback to global cache if project-specific doesn't exist
420
+ if not realtime_cache.exists():
421
+ realtime_cache = Path.home() / ".claude-self-reflect" / "realtime_quality.json"
422
+
423
+ grade_str = ""
424
+ quality_valid = False
425
+
426
+ if realtime_cache.exists():
427
+ try:
428
+ mtime = datetime.fromtimestamp(realtime_cache.stat().st_mtime)
429
+ age = datetime.now() - mtime
430
+ if age < timedelta(minutes=5): # Fresh realtime data
431
+ with open(realtime_cache, 'r') as f:
432
+ realtime_data = json.load(f)
433
+
434
+ if "session_aggregate" in realtime_data:
435
+ agg = realtime_data["session_aggregate"]
436
+ issues = agg.get("total_issues", {})
437
+ critical = issues.get("critical", 0)
438
+ medium = issues.get("medium", 0)
439
+ low = issues.get("low", 0)
440
+ score = agg.get("average_score", 100)
441
+
442
+ # Get icon based on score and issues
443
+ if score < 70:
444
+ icon = "๐Ÿ”ด" # Red for below threshold
445
+ else:
446
+ icon = get_quality_icon(critical, medium, low)
447
+
448
+ # Build compact display
449
+ if critical > 0 or medium > 0 or low > 0:
450
+ # Show issue counts when there are any issues
451
+ colored_parts = []
452
+ if critical > 0:
453
+ colored_parts.append(f"C:{critical}")
454
+ if medium > 0:
455
+ colored_parts.append(f"M:{medium}")
456
+ if low > 0:
457
+ colored_parts.append(f"L:{low}")
458
+ grade_str = f"[{icon}:{'ยท'.join(colored_parts)}]"
459
+ elif score < 70:
460
+ # Show score when below threshold but no specific issues
461
+ grade_str = f"[{icon}:{score:.0f}%]"
462
+ else:
463
+ grade_str = f"[{icon}]"
464
+ quality_valid = True
465
+ except:
466
+ pass
467
+
468
+ # Setup cache file path for fallback
368
469
  project_name = os.path.basename(os.getcwd())
369
470
  # Secure sanitization with whitelist approach
370
471
  import re
@@ -372,21 +473,23 @@ def get_compact_status():
372
473
  cache_dir = Path.home() / ".claude-self-reflect" / "quality_cache"
373
474
  cache_file = cache_dir / f"{safe_project_name}.json"
374
475
 
375
- # If the exact cache file doesn't exist, try to find one that ends with this project name
376
- # This handles cases like "metafora-Atlas-gold.json" for project "Atlas-gold"
377
- if not cache_file.exists():
378
- # Look for files ending with the project name
379
- possible_files = list(cache_dir.glob(f"*-{safe_project_name}.json"))
380
- if possible_files:
381
- cache_file = possible_files[0] # Use the first match
382
-
383
- # Validate cache file path stays within cache directory
384
- if cache_file.exists() and not str(cache_file.resolve()).startswith(str(cache_dir.resolve())):
385
- # Security issue - return placeholder
386
- grade_str = "[...]"
387
- else:
388
- cache_file.parent.mkdir(exist_ok=True, parents=True)
389
- grade_str = ""
476
+ # Fall back to old cache if no realtime data
477
+ if not quality_valid:
478
+
479
+ # If the exact cache file doesn't exist, try to find one that ends with this project name
480
+ # This handles cases like "metafora-Atlas-gold.json" for project "Atlas-gold"
481
+ if not cache_file.exists():
482
+ # Look for files ending with the project name
483
+ possible_files = list(cache_dir.glob(f"*-{safe_project_name}.json"))
484
+ if possible_files:
485
+ cache_file = possible_files[0] # Use the first match
486
+
487
+ # Validate cache file path stays within cache directory
488
+ if cache_file.exists() and not str(cache_file.resolve()).startswith(str(cache_dir.resolve())):
489
+ # Security issue - return placeholder
490
+ grade_str = "[...]"
491
+ else:
492
+ cache_file.parent.mkdir(exist_ok=True, parents=True)
390
493
 
391
494
  # Try to get quality data (regenerate if too old or missing)
392
495
  quality_valid = False
@@ -59,7 +59,11 @@ def parse_jsonl_file(file_path):
59
59
  return messages
60
60
 
61
61
  if __name__ == "__main__":
62
- file_path = "/Users/ramakrishnanannaswamy/.claude/projects/-Users-ramakrishnanannaswamy-projects-claude-self-reflect/7b3354ed-d6d2-4eab-b328-1fced4bb63bb.jsonl"
62
+ # Use home directory path
63
+ from pathlib import Path
64
+ home = Path.home()
65
+ file_path = home / ".claude" / "projects" / f"-{home}-projects-claude-self-reflect" / "7b3354ed-d6d2-4eab-b328-1fced4bb63bb.jsonl"
66
+ file_path = str(file_path)
63
67
 
64
68
  print(f"Parsing: {file_path}")
65
69
  print("=" * 60)
@@ -48,9 +48,9 @@ from shared.normalization import normalize_project_name
48
48
  import hashlib
49
49
 
50
50
  test_paths = [
51
- "/Users/ramakrishnanannaswamy/projects/claude-self-reflect",
52
- "/Users/ramakrishnanannaswamy/projects/memento",
53
- "/Users/ramakrishnanannaswamy/projects/cc-enhance"
51
+ str(Path.home() / "projects" / "claude-self-reflect"),
52
+ str(Path.home() / "projects" / "memento"),
53
+ str(Path.home() / "projects" / "cc-enhance")
54
54
  ]
55
55
 
56
56
  for path in test_paths: