claude-mpm 3.9.2__py3-none-any.whl → 3.9.5__py3-none-any.whl

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.
@@ -33,6 +33,7 @@ class ClaudeMPMPaths:
33
33
 
34
34
  _instance: Optional['ClaudeMPMPaths'] = None
35
35
  _project_root: Optional[Path] = None
36
+ _is_installed: bool = False
36
37
 
37
38
  def __new__(cls) -> 'ClaudeMPMPaths':
38
39
  """Singleton pattern to ensure single instance."""
@@ -43,6 +44,7 @@ class ClaudeMPMPaths:
43
44
  def __init__(self):
44
45
  """Initialize paths if not already done."""
45
46
  if self._project_root is None:
47
+ self._is_installed = False
46
48
  self._detect_project_root()
47
49
 
48
50
  def _detect_project_root(self) -> None:
@@ -53,16 +55,29 @@ class ClaudeMPMPaths:
53
55
  1. Look for definitive project markers (pyproject.toml, setup.py)
54
56
  2. Look for combination of markers to ensure we're at the right level
55
57
  3. Walk up from current file location
58
+ 4. Handle both development and installed environments
56
59
  """
57
60
  # Start from this file's location
58
61
  current = Path(__file__).resolve()
59
62
 
60
- # Walk up the directory tree
63
+ # Check if we're in an installed environment (site-packages)
64
+ # In pip/pipx installs, the package is directly in site-packages
65
+ if 'site-packages' in str(current) or 'dist-packages' in str(current):
66
+ # We're in an installed environment
67
+ # The claude_mpm package directory itself is the "root" for resources
68
+ import claude_mpm
69
+ self._project_root = Path(claude_mpm.__file__).parent
70
+ self._is_installed = True
71
+ logger.debug(f"Installed environment detected, using package dir: {self._project_root}")
72
+ return
73
+
74
+ # We're in a development environment, look for project markers
61
75
  for parent in current.parents:
62
76
  # Check for definitive project root indicators
63
77
  # Prioritize pyproject.toml and setup.py as they're only at root
64
78
  if (parent / 'pyproject.toml').exists() or (parent / 'setup.py').exists():
65
79
  self._project_root = parent
80
+ self._is_installed = False
66
81
  logger.debug(f"Project root detected at: {parent} (found pyproject.toml or setup.py)")
67
82
  return
68
83
 
@@ -70,6 +85,7 @@ class ClaudeMPMPaths:
70
85
  # This combination is more likely to be the real project root
71
86
  if (parent / '.git').exists() and (parent / 'VERSION').exists():
72
87
  self._project_root = parent
88
+ self._is_installed = False
73
89
  logger.debug(f"Project root detected at: {parent} (found .git and VERSION)")
74
90
  return
75
91
 
@@ -77,12 +93,14 @@ class ClaudeMPMPaths:
77
93
  for parent in current.parents:
78
94
  if parent.name == 'claude-mpm':
79
95
  self._project_root = parent
96
+ self._is_installed = False
80
97
  logger.debug(f"Project root detected at: {parent} (by directory name)")
81
98
  return
82
99
 
83
100
  # Last resort fallback: 3 levels up from this file
84
101
  # paths.py is in src/claude_mpm/config/
85
102
  self._project_root = current.parent.parent.parent
103
+ self._is_installed = False
86
104
  logger.warning(f"Project root fallback to: {self._project_root}")
87
105
 
88
106
  @property
@@ -95,16 +113,26 @@ class ClaudeMPMPaths:
95
113
  @property
96
114
  def src_dir(self) -> Path:
97
115
  """Get the src directory."""
116
+ if hasattr(self, '_is_installed') and self._is_installed:
117
+ # In installed environment, there's no src directory
118
+ # Return the package directory itself
119
+ return self.project_root.parent
98
120
  return self.project_root / "src"
99
121
 
100
122
  @property
101
123
  def claude_mpm_dir(self) -> Path:
102
124
  """Get the main claude_mpm package directory."""
125
+ if hasattr(self, '_is_installed') and self._is_installed:
126
+ # In installed environment, project_root IS the claude_mpm directory
127
+ return self.project_root
103
128
  return self.src_dir / "claude_mpm"
104
129
 
105
130
  @property
106
131
  def agents_dir(self) -> Path:
107
132
  """Get the agents directory."""
133
+ if hasattr(self, '_is_installed') and self._is_installed:
134
+ # In installed environment, agents is directly under the package
135
+ return self.project_root / "agents"
108
136
  return self.claude_mpm_dir / "agents"
109
137
 
110
138
  @property
@@ -140,36 +168,58 @@ class ClaudeMPMPaths:
140
168
  @property
141
169
  def scripts_dir(self) -> Path:
142
170
  """Get the scripts directory."""
171
+ if hasattr(self, '_is_installed') and self._is_installed:
172
+ # In installed environment, scripts might be in a different location or not exist
173
+ # Return a path that won't cause issues but indicates it's not available
174
+ return Path.home() / '.claude-mpm' / 'scripts'
143
175
  return self.project_root / "scripts"
144
176
 
145
177
  @property
146
178
  def tests_dir(self) -> Path:
147
179
  """Get the tests directory."""
180
+ if hasattr(self, '_is_installed') and self._is_installed:
181
+ # Tests aren't distributed with installed packages
182
+ return Path.home() / '.claude-mpm' / 'tests'
148
183
  return self.project_root / "tests"
149
184
 
150
185
  @property
151
186
  def docs_dir(self) -> Path:
152
187
  """Get the documentation directory."""
188
+ if hasattr(self, '_is_installed') and self._is_installed:
189
+ # Docs might be installed separately or not at all
190
+ return Path.home() / '.claude-mpm' / 'docs'
153
191
  return self.project_root / "docs"
154
192
 
155
193
  @property
156
194
  def logs_dir(self) -> Path:
157
195
  """Get the logs directory (creates if doesn't exist)."""
158
- logs = self.project_root / "logs"
159
- logs.mkdir(exist_ok=True)
196
+ if hasattr(self, '_is_installed') and self._is_installed:
197
+ # Use user's home directory for logs in installed environment
198
+ logs = Path.home() / '.claude-mpm' / 'logs'
199
+ else:
200
+ logs = self.project_root / "logs"
201
+ logs.mkdir(parents=True, exist_ok=True)
160
202
  return logs
161
203
 
162
204
  @property
163
205
  def temp_dir(self) -> Path:
164
206
  """Get the temporary files directory (creates if doesn't exist)."""
165
- temp = self.project_root / ".tmp"
166
- temp.mkdir(exist_ok=True)
207
+ if hasattr(self, '_is_installed') and self._is_installed:
208
+ # Use user's home directory for temp files in installed environment
209
+ temp = Path.home() / '.claude-mpm' / '.tmp'
210
+ else:
211
+ temp = self.project_root / ".tmp"
212
+ temp.mkdir(parents=True, exist_ok=True)
167
213
  return temp
168
214
 
169
215
  @property
170
216
  def claude_mpm_dir_hidden(self) -> Path:
171
217
  """Get the hidden .claude-mpm directory (creates if doesn't exist)."""
172
- hidden = self.project_root / ".claude-mpm"
218
+ if hasattr(self, '_is_installed') and self._is_installed:
219
+ # Use current working directory in installed environment
220
+ hidden = Path.cwd() / ".claude-mpm"
221
+ else:
222
+ hidden = self.project_root / ".claude-mpm"
173
223
  hidden.mkdir(exist_ok=True)
174
224
  return hidden
175
225
 
claude_mpm/constants.py CHANGED
@@ -30,6 +30,7 @@ class CLICommands(str, Enum):
30
30
  MONITOR = "monitor"
31
31
  CONFIG = "config"
32
32
  AGGREGATE = "aggregate"
33
+ CLEANUP = "cleanup-memory"
33
34
 
34
35
  def with_prefix(self, prefix: CLIPrefix = CLIPrefix.MPM) -> str:
35
36
  """Get command with prefix."""
@@ -1160,6 +1160,12 @@ Use these agents to delegate specialized work via the Task tool.
1160
1160
  version = __version__
1161
1161
  method_used = "package_import"
1162
1162
  self.logger.debug(f"Version obtained via package import: {version}")
1163
+ # If version already includes build number (PEP 440 format), extract it
1164
+ if '+build.' in version:
1165
+ parts = version.split('+build.')
1166
+ version = parts[0] # Base version without build
1167
+ build_number = int(parts[1]) if len(parts) > 1 else None
1168
+ self.logger.debug(f"Extracted base version: {version}, build: {build_number}")
1163
1169
  except ImportError as e:
1164
1170
  self.logger.debug(f"Package import failed: {e}")
1165
1171
  except Exception as e:
@@ -1192,19 +1198,20 @@ Use these agents to delegate specialized work via the Task tool.
1192
1198
  except Exception as e:
1193
1199
  self.logger.warning(f"Failed to read VERSION file: {e}")
1194
1200
 
1195
- # Try to read build number
1196
- try:
1197
- build_file = paths.project_root / "BUILDVERSION"
1198
- if build_file.exists():
1199
- build_content = build_file.read_text().strip()
1200
- build_number = int(build_content)
1201
- self.logger.debug(f"Build number obtained: {build_number}")
1202
- except (ValueError, IOError) as e:
1203
- self.logger.debug(f"Could not read BUILDVERSION: {e}")
1204
- build_number = None
1205
- except Exception as e:
1206
- self.logger.debug(f"Unexpected error reading BUILDVERSION: {e}")
1207
- build_number = None
1201
+ # Try to read build number (only if not already obtained from version string)
1202
+ if build_number is None:
1203
+ try:
1204
+ build_file = paths.project_root / "BUILD_NUMBER"
1205
+ if build_file.exists():
1206
+ build_content = build_file.read_text().strip()
1207
+ build_number = int(build_content)
1208
+ self.logger.debug(f"Build number obtained from file: {build_number}")
1209
+ except (ValueError, IOError) as e:
1210
+ self.logger.debug(f"Could not read BUILD_NUMBER: {e}")
1211
+ build_number = None
1212
+ except Exception as e:
1213
+ self.logger.debug(f"Unexpected error reading BUILD_NUMBER: {e}")
1214
+ build_number = None
1208
1215
 
1209
1216
  # Log final result
1210
1217
  if version == "0.0.0":
@@ -1215,8 +1222,14 @@ Use these agents to delegate specialized work via the Task tool.
1215
1222
  self.logger.debug(f"Final version: {version} (method: {method_used})")
1216
1223
 
1217
1224
  # Format version with build number if available
1225
+ # For development: Use PEP 440 format (e.g., "3.9.5+build.275")
1226
+ # For UI/logging: Use dash format (e.g., "v3.9.5-build.275")
1227
+ # For PyPI releases: Use clean version (e.g., "3.9.5")
1228
+
1229
+ # Determine formatting context (default to UI format for claude_runner)
1218
1230
  if build_number is not None:
1219
- return f"v{version}-{build_number:05d}"
1231
+ # UI/logging format with 'v' prefix and dash separator
1232
+ return f"v{version}-build.{build_number}"
1220
1233
  else:
1221
1234
  return f"v{version}"
1222
1235
 
claude_mpm/core/config.py CHANGED
@@ -281,6 +281,21 @@ class Config:
281
281
  # Task and issue tracking
282
282
  "enable_persistent_tracking": True,
283
283
  "fallback_tracking_method": "logging", # Options: "logging", "file", "disabled"
284
+ # Memory management configuration
285
+ "memory_management": {
286
+ "enabled": True,
287
+ "claude_json_warning_threshold_kb": 500, # Warn at 500KB
288
+ "claude_json_critical_threshold_kb": 1024, # Critical at 1MB
289
+ "auto_archive_enabled": False, # Don't auto-archive by default
290
+ "archive_retention_days": 90, # Keep archives for 90 days
291
+ "session_retention_hours": 24, # Keep active sessions for 24 hours
292
+ "conversation_retention_days": 30, # Keep conversations for 30 days
293
+ "monitor_memory_usage": True, # Monitor memory usage
294
+ "memory_usage_log_interval": 300, # Log memory usage every 5 minutes
295
+ "max_memory_usage_mb": 2048, # Warn if memory usage exceeds 2GB
296
+ "cleanup_on_startup": False, # Don't auto-cleanup on startup
297
+ "compress_archives": True # Compress archived files
298
+ },
284
299
  # Evaluation system - Phase 2 Mirascope integration
285
300
  "enable_evaluation": True,
286
301
  "evaluation_storage_path": str(ConfigPaths.get_user_config_dir() / "training"),
@@ -1,9 +1,11 @@
1
1
  """Session ID management for Claude subprocess optimization."""
2
2
 
3
3
  import uuid
4
- from typing import Optional, Dict, Any
4
+ from typing import Optional, Dict, Any, List
5
5
  from datetime import datetime, timedelta
6
6
  import json
7
+ import shutil
8
+ import gzip
7
9
  from pathlib import Path
8
10
 
9
11
  from ..core.logger import get_logger
@@ -95,11 +97,15 @@ class SessionManager:
95
97
  self.active_sessions[session_id]["last_used"] = datetime.now().isoformat()
96
98
  self._save_sessions()
97
99
 
98
- def cleanup_old_sessions(self, max_age_hours: int = 24):
100
+ def cleanup_old_sessions(self, max_age_hours: int = 24, archive: bool = True):
99
101
  """Remove sessions older than max_age_hours.
100
102
 
103
+ WHY: We archive old sessions instead of just deleting them to preserve
104
+ conversation history while reducing active memory usage.
105
+
101
106
  Args:
102
107
  max_age_hours: Maximum age in hours
108
+ archive: Whether to archive sessions before removing
103
109
  """
104
110
  now = datetime.now()
105
111
  max_age = timedelta(hours=max_age_hours)
@@ -110,6 +116,10 @@ class SessionManager:
110
116
  if now - created > max_age:
111
117
  expired.append(session_id)
112
118
 
119
+ # Archive sessions if requested
120
+ if archive and expired:
121
+ self._archive_sessions([self.active_sessions[sid] for sid in expired])
122
+
113
123
  for session_id in expired:
114
124
  del self.active_sessions[session_id]
115
125
  logger.info(f"Cleaned up expired session: {session_id}")
@@ -180,11 +190,105 @@ class SessionManager:
180
190
  with open(session_file, 'r') as f:
181
191
  self.active_sessions = json.load(f)
182
192
 
183
- # Clean up old sessions on load
184
- self.cleanup_old_sessions()
193
+ # Clean up old sessions on load (archive by default)
194
+ self.cleanup_old_sessions(archive=True)
195
+
196
+ # Also check and clean .claude.json if needed
197
+ self._check_claude_json_size()
185
198
  except Exception as e:
186
199
  logger.error(f"Failed to load sessions: {e}")
187
200
  self.active_sessions = {}
201
+
202
+ def _archive_sessions(self, sessions: List[Dict[str, Any]]):
203
+ """Archive sessions to compressed files.
204
+
205
+ WHY: Archiving preserves conversation history while reducing the size
206
+ of active memory files like .claude.json.
207
+
208
+ Args:
209
+ sessions: List of session data dictionaries to archive
210
+ """
211
+ if not sessions:
212
+ return
213
+
214
+ archive_dir = self.session_dir.parent / "archives" / "sessions"
215
+ archive_dir.mkdir(parents=True, exist_ok=True)
216
+
217
+ # Create timestamped archive file
218
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
219
+ archive_name = f"sessions_archive_{timestamp}.json.gz"
220
+ archive_path = archive_dir / archive_name
221
+
222
+ try:
223
+ # Compress and save sessions
224
+ with gzip.open(archive_path, 'wt', encoding='utf-8') as f:
225
+ json.dump(sessions, f, indent=2)
226
+
227
+ logger.info(f"Archived {len(sessions)} sessions to {archive_path}")
228
+ except Exception as e:
229
+ logger.error(f"Failed to archive sessions: {e}")
230
+
231
+ def _check_claude_json_size(self):
232
+ """Check .claude.json size and suggest cleanup if needed.
233
+
234
+ WHY: Large .claude.json files cause memory issues. This provides
235
+ proactive monitoring and suggestions for cleanup.
236
+ """
237
+ claude_json_path = Path.home() / ".claude.json"
238
+
239
+ if not claude_json_path.exists():
240
+ return
241
+
242
+ file_size = claude_json_path.stat().st_size
243
+ warning_threshold = 500 * 1024 # 500KB
244
+
245
+ if file_size > warning_threshold:
246
+ size_mb = file_size / (1024 * 1024)
247
+ logger.warning(f".claude.json is {size_mb:.1f}MB - consider running 'claude-mpm cleanup-memory'")
248
+
249
+ def archive_claude_json(self, keep_days: int = 30) -> bool:
250
+ """Archive old conversations from .claude.json.
251
+
252
+ WHY: This is called by the cleanup command to reduce memory usage
253
+ while preserving conversation history.
254
+
255
+ Args:
256
+ keep_days: Number of days of history to keep
257
+
258
+ Returns:
259
+ True if successful, False otherwise
260
+ """
261
+ claude_json_path = Path.home() / ".claude.json"
262
+
263
+ if not claude_json_path.exists():
264
+ logger.info("No .claude.json file to archive")
265
+ return True
266
+
267
+ try:
268
+ # Create backup first
269
+ archive_dir = Path.home() / ".claude-mpm" / "archives"
270
+ archive_dir.mkdir(parents=True, exist_ok=True)
271
+
272
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
273
+ backup_name = f"claude_json_backup_{timestamp}.json.gz"
274
+ backup_path = archive_dir / backup_name
275
+
276
+ # Compress and backup current file
277
+ with open(claude_json_path, 'rb') as f_in:
278
+ with gzip.open(backup_path, 'wb') as f_out:
279
+ shutil.copyfileobj(f_in, f_out)
280
+
281
+ logger.info(f"Created backup at {backup_path}")
282
+
283
+ # For now, we don't modify the original .claude.json
284
+ # as we don't know its exact structure.
285
+ # The cleanup command handles this.
286
+
287
+ return True
288
+
289
+ except Exception as e:
290
+ logger.error(f"Failed to archive .claude.json: {e}")
291
+ return False
188
292
 
189
293
 
190
294
  class OrchestrationSession: