Claude Code commited on
Commit
0bbe516
·
1 Parent(s): 6995f69

Claude Code: Review the memory system implementation, specifically focusing on git op

Browse files
memory/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cain's Memory System Package.
3
+
4
+ Provides persistent memory storage with git synchronization and log management.
5
+
6
+ Key exports:
7
+ - MemorySystem: Main memory management class
8
+ - GitMemoryBridge: Git-based persistence layer with timeout controls
9
+ - LogManager: Log rotation and cleanup utilities
10
+ """
11
+
12
+ from .memory_system import MemorySystem, get_memory
13
+ from .git_repo import GitMemoryBridge, GitOperationError, GitTimeoutError, TIMEOUT_CONFIG
14
+ from .log_manager import (
15
+ LogManager,
16
+ LogRetentionConfig,
17
+ rotate_logs,
18
+ get_log_health
19
+ )
20
+
21
+ __all__ = [
22
+ # Memory system
23
+ "MemorySystem",
24
+ "get_memory",
25
+
26
+ # Git operations
27
+ "GitMemoryBridge",
28
+ "GitOperationError",
29
+ "GitTimeoutError",
30
+ "TIMEOUT_CONFIG",
31
+
32
+ # Log management
33
+ "LogManager",
34
+ "LogRetentionConfig",
35
+ "rotate_logs",
36
+ "get_log_health",
37
+ ]
38
+
39
+ __version__ = "1.1.0"
memory/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.02 kB). View file
 
memory/__pycache__/git_repo.cpython-311.pyc ADDED
Binary file (16.6 kB). View file
 
memory/__pycache__/log_manager.cpython-311.pyc ADDED
Binary file (16.5 kB). View file
 
memory/__pycache__/memory_system.cpython-311.pyc ADDED
Binary file (13.1 kB). View file
 
memory/git_repo.py CHANGED
@@ -1,89 +1,249 @@
1
  """
2
  Git-based Memory Bridge for HuggingFace Spaces.
3
  Allows the AI to persist memory to the Dataset repository.
 
 
4
  """
5
  import logging
6
  import subprocess
7
  import json
8
  import time
9
- from typing import Optional, Dict, Any
10
 
11
  logger = logging.getLogger(__name__)
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  class GitMemoryBridge:
14
- def __init__(self, repo_path: str = "/data", max_retries: int = 3):
 
 
 
 
 
 
 
 
 
 
15
  self.repo_path = repo_path
16
  self.max_retries = max_retries
 
17
  self.git = self._get_git_command()
18
 
19
- def _get_git_command(self) -> str:
20
  """Check if git is available."""
21
  try:
22
- subprocess.run(["git", "--version"], capture_output=True, check=True)
 
 
 
 
 
 
23
  return "git"
24
- except (subprocess.CalledProcessError, FileNotFoundError):
25
- logger.warning("System git not found, attempting fallback logic if available.")
26
  return None
27
 
28
- def _run_git(self, args: list, check: bool = True) -> subprocess.CompletedProcess:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  if not self.git:
30
  raise RuntimeError("Git is not available in this environment.")
31
-
 
 
 
 
 
 
32
  full_cmd = [self.git] + args
33
- logger.debug(f"Running git command: {' '.join(full_cmd)}")
34
-
35
- return subprocess.run(
36
- full_cmd,
37
- cwd=self.repo_path,
38
- capture_output=True,
39
- text=True,
40
- check=check
41
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  def sync_memory(self) -> bool:
44
  """
45
  Pulls latest changes from the remote dataset repository.
46
  Returns True if successful, False otherwise.
 
 
47
  """
48
  try:
49
  logger.info("Syncing memory from remote dataset...")
50
-
51
- # Configure git to handle credentials (usually auto-handled in Spaces, but good to be safe)
52
- # We assume the environment is already authenticated for the huggingface-cli / git
53
-
 
 
 
 
 
 
 
 
54
  # Pull with strategy X (theirs) to prefer remote state (survival priority)
55
- result = self._run_git([
56
- "pull",
57
- "origin",
58
- "main",
59
- "--strategy-option=theirs"
60
- ], check=False)
61
-
62
- if result.returncode == 0:
63
- logger.info(f"Memory sync successful: {result.stdout.strip()}")
64
  return True
65
  else:
66
  # If it fails due to divergent branches or history, try a harder reset
67
- if "divergent" in result.stderr or "refusing to merge" in result.stderr:
68
  logger.warning("Git history diverged. Performing hard reset to remote origin/main.")
69
- self._run_git(["fetch", "origin", "main"])
70
- self._run_git(["reset", "--hard", "origin/main"])
71
  return True
72
  else:
73
- logger.error(f"Memory sync failed: {result.stderr}")
74
  return False
75
 
 
 
 
 
 
 
76
  except Exception as e:
77
- logger.error(f"Exception during memory sync: {e}")
78
  return False
79
 
80
  def save_memory(self, commit_message: str = "Update memory state") -> bool:
81
  """
82
  Commits and pushes local changes to the remote dataset repository.
 
 
 
 
 
 
 
83
  """
84
  try:
85
  # 1. Check status
86
- status = self._run_git(["status", "--porcelain"])
87
  if not status.stdout.strip():
88
  logger.info("No new memories to save.")
89
  return True
@@ -91,16 +251,23 @@ class GitMemoryBridge:
91
  logger.info(f"Saving memory changes: {status.stdout}")
92
 
93
  # 2. Add changes
94
- self._run_git(["add", "-A"])
95
 
96
  # 3. Commit
97
  # Use --no-verify to skip pre-commit hooks that might fail
98
- self._run_git(["commit", "-m", commit_message, "--no-verify"])
99
-
 
 
 
100
  # 4. Push
101
  logger.info("Pushing memory to remote...")
102
- push_result = self._run_git(["push", "origin", "main"], check=False)
103
-
 
 
 
 
104
  if push_result.returncode == 0:
105
  logger.info("Memory saved successfully.")
106
  return True
@@ -110,12 +277,23 @@ class GitMemoryBridge:
110
  logger.warning("Remote updated during save. Pulling and retrying...")
111
  self.sync_memory()
112
  # Retry push once
113
- retry_result = self._run_git(["push", "origin", "main"], check=False)
 
 
 
 
114
  return retry_result.returncode == 0
 
115
  return False
116
 
 
 
 
 
 
 
117
  except Exception as e:
118
- logger.error(f"Exception during memory save: {e}")
119
  return False
120
 
121
  def read_file(self, path: str) -> Optional[str]:
 
1
  """
2
  Git-based Memory Bridge for HuggingFace Spaces.
3
  Allows the AI to persist memory to the Dataset repository.
4
+
5
+ Enhanced with timeout controls and retry mechanisms for reliability.
6
  """
7
  import logging
8
  import subprocess
9
  import json
10
  import time
11
+ from typing import Optional, Dict, Any, List
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
+ # Timeout values for different git operations (in seconds)
16
+ TIMEOUT_CONFIG = {
17
+ "fetch": 60, # Network operation, can be slow
18
+ "pull": 90, # Pull includes merge, may take time
19
+ "push": 120, # Push is network-heavy
20
+ "commit": 30, # Local operation, should be fast
21
+ "status": 15, # Quick local check
22
+ "add": 20, # Local operation
23
+ "reset": 30, # Local operation
24
+ "default": 45, # Fallback for unknown operations
25
+ }
26
+
27
+ class GitOperationError(Exception):
28
+ """Custom exception for git operation failures."""
29
+ pass
30
+
31
+ class GitTimeoutError(GitOperationError):
32
+ """Exception raised when git operation times out."""
33
+ pass
34
+
35
  class GitMemoryBridge:
36
+ """Git-based memory persistence with timeout and retry support."""
37
+
38
+ def __init__(self, repo_path: str = "/data", max_retries: int = 3, base_timeout: int = None):
39
+ """
40
+ Initialize the Git memory bridge.
41
+
42
+ Args:
43
+ repo_path: Path to the git repository
44
+ max_retries: Maximum number of retry attempts for failed operations
45
+ base_timeout: Override default timeout (in seconds). None uses operation-specific timeouts.
46
+ """
47
  self.repo_path = repo_path
48
  self.max_retries = max_retries
49
+ self.base_timeout = base_timeout
50
  self.git = self._get_git_command()
51
 
52
+ def _get_git_command(self) -> Optional[str]:
53
  """Check if git is available."""
54
  try:
55
+ result = subprocess.run(
56
+ ["git", "--version"],
57
+ capture_output=True,
58
+ check=True,
59
+ timeout=10
60
+ )
61
+ logger.info(f"Git version: {result.stdout.strip()}")
62
  return "git"
63
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e:
64
+ logger.warning(f"System git not found or timed out: {e}")
65
  return None
66
 
67
+ def _get_timeout_for_command(self, command: str) -> int:
68
+ """Get appropriate timeout based on git command."""
69
+ cmd = command.lower()
70
+ if self.base_timeout:
71
+ return self.base_timeout
72
+ return TIMEOUT_CONFIG.get(cmd, TIMEOUT_CONFIG["default"])
73
+
74
+ def _run_git(
75
+ self,
76
+ args: List[str],
77
+ check: bool = True,
78
+ timeout: Optional[int] = None,
79
+ retry_count: int = 0
80
+ ) -> subprocess.CompletedProcess:
81
+ """
82
+ Run a git command with timeout and retry logic.
83
+
84
+ Args:
85
+ args: Git command arguments (e.g., ['push', 'origin', 'main'])
86
+ check: Whether to raise an error on non-zero exit code
87
+ timeout: Override timeout for this specific call
88
+ retry_count: Current retry attempt (internal use)
89
+
90
+ Returns:
91
+ CompletedProcess result
92
+
93
+ Raises:
94
+ GitTimeoutError: If operation times out after all retries
95
+ GitOperationError: If operation fails after all retries
96
+ RuntimeError: If git is not available
97
+ """
98
  if not self.git:
99
  raise RuntimeError("Git is not available in this environment.")
100
+
101
+ # Determine timeout
102
+ if timeout is None and args:
103
+ timeout = self._get_timeout_for_command(args[0])
104
+ elif timeout is None:
105
+ timeout = TIMEOUT_CONFIG["default"]
106
+
107
  full_cmd = [self.git] + args
108
+ cmd_str = ' '.join(full_cmd)
109
+ logger.debug(f"Running git command: {cmd_str} (timeout: {timeout}s, attempt: {retry_count + 1}/{self.max_retries + 1})")
110
+
111
+ attempt = 0
112
+ last_error = None
113
+
114
+ while attempt <= self.max_retries:
115
+ try:
116
+ start_time = time.time()
117
+ result = subprocess.run(
118
+ full_cmd,
119
+ cwd=self.repo_path,
120
+ capture_output=True,
121
+ text=True,
122
+ check=check,
123
+ timeout=timeout
124
+ )
125
+ elapsed = time.time() - start_time
126
+
127
+ if result.stdout:
128
+ logger.debug(f"Git stdout: {result.stdout.strip()[:200]}")
129
+
130
+ logger.info(f"Git command completed successfully in {elapsed:.2f}s: {args[0]}")
131
+
132
+ return result
133
+
134
+ except subprocess.TimeoutExpired as e:
135
+ last_error = e
136
+ elapsed = time.time() - start_time
137
+ logger.warning(
138
+ f"Git command timed out after {elapsed:.2f}s: {args[0]} "
139
+ f"(attempt {attempt + 1}/{self.max_retries + 1})"
140
+ )
141
+
142
+ if attempt < self.max_retries:
143
+ # Exponential backoff: 2^attempt seconds, max 30s
144
+ backoff = min(2 ** attempt, 30)
145
+ logger.info(f"Retrying in {backoff}s...")
146
+ time.sleep(backoff)
147
+ else:
148
+ error_msg = f"Git operation timed out after {self.max_retries + 1} attempts: {args[0]}"
149
+ logger.error(error_msg)
150
+ raise GitTimeoutError(error_msg) from e
151
+
152
+ except subprocess.CalledProcessError as e:
153
+ last_error = e
154
+ logger.warning(
155
+ f"Git command failed with exit code {e.returncode}: {args[0]} "
156
+ f"(attempt {attempt + 1}/{self.max_retries + 1})"
157
+ )
158
+
159
+ if e.stderr:
160
+ logger.debug(f"Git stderr: {e.stderr.strip()[:200]}")
161
+
162
+ # Don't retry on certain errors
163
+ if any(msg in (e.stderr or "") for msg in ["fatal:", "error:"]):
164
+ # Permanent error, don't retry
165
+ raise GitOperationError(f"Git command failed: {e.stderr.strip()}") from e
166
+
167
+ if attempt < self.max_retries:
168
+ backoff = min(2 ** attempt, 30)
169
+ logger.info(f"Retrying in {backoff}s...")
170
+ time.sleep(backoff)
171
+ else:
172
+ error_msg = f"Git operation failed after {self.max_retries + 1} attempts: {args[0]}"
173
+ logger.error(error_msg)
174
+ raise GitOperationError(error_msg) from e
175
+
176
+ attempt += 1
177
+
178
+ # Should not reach here, but just in case
179
+ if last_error:
180
+ raise GitOperationError(f"Git operation failed: {last_error}") from last_error
181
 
182
  def sync_memory(self) -> bool:
183
  """
184
  Pulls latest changes from the remote dataset repository.
185
  Returns True if successful, False otherwise.
186
+
187
+ Uses timeout-controlled git operations with retry logic.
188
  """
189
  try:
190
  logger.info("Syncing memory from remote dataset...")
191
+
192
+ # First, fetch to get latest remote state with timeout
193
+ fetch_result = self._run_git(
194
+ ["fetch", "origin", "main"],
195
+ check=False,
196
+ timeout=TIMEOUT_CONFIG["fetch"]
197
+ )
198
+
199
+ if fetch_result.returncode != 0:
200
+ logger.warning(f"Fetch failed: {fetch_result.stderr}")
201
+ return False
202
+
203
  # Pull with strategy X (theirs) to prefer remote state (survival priority)
204
+ pull_result = self._run_git(
205
+ ["pull", "origin", "main", "--strategy-option=theirs"],
206
+ check=False,
207
+ timeout=TIMEOUT_CONFIG["pull"]
208
+ )
209
+
210
+ if pull_result.returncode == 0:
211
+ logger.info(f"Memory sync successful: {pull_result.stdout.strip()}")
 
212
  return True
213
  else:
214
  # If it fails due to divergent branches or history, try a harder reset
215
+ if "divergent" in pull_result.stderr or "refusing to merge" in pull_result.stderr:
216
  logger.warning("Git history diverged. Performing hard reset to remote origin/main.")
217
+ self._run_git(["reset", "--hard", "origin/main"], timeout=TIMEOUT_CONFIG["reset"])
 
218
  return True
219
  else:
220
+ logger.error(f"Memory sync failed: {pull_result.stderr}")
221
  return False
222
 
223
+ except GitTimeoutError as e:
224
+ logger.error(f"Memory sync timed out: {e}")
225
+ return False
226
+ except GitOperationError as e:
227
+ logger.error(f"Memory sync operation failed: {e}")
228
+ return False
229
  except Exception as e:
230
+ logger.error(f"Unexpected exception during memory sync: {e}")
231
  return False
232
 
233
  def save_memory(self, commit_message: str = "Update memory state") -> bool:
234
  """
235
  Commits and pushes local changes to the remote dataset repository.
236
+ Uses timeout-controlled git operations with retry logic.
237
+
238
+ Args:
239
+ commit_message: Message for the git commit
240
+
241
+ Returns:
242
+ True if successful, False otherwise
243
  """
244
  try:
245
  # 1. Check status
246
+ status = self._run_git(["status", "--porcelain"], timeout=TIMEOUT_CONFIG["status"])
247
  if not status.stdout.strip():
248
  logger.info("No new memories to save.")
249
  return True
 
251
  logger.info(f"Saving memory changes: {status.stdout}")
252
 
253
  # 2. Add changes
254
+ self._run_git(["add", "-A"], timeout=TIMEOUT_CONFIG["add"])
255
 
256
  # 3. Commit
257
  # Use --no-verify to skip pre-commit hooks that might fail
258
+ self._run_git(
259
+ ["commit", "-m", commit_message, "--no-verify"],
260
+ timeout=TIMEOUT_CONFIG["commit"]
261
+ )
262
+
263
  # 4. Push
264
  logger.info("Pushing memory to remote...")
265
+ push_result = self._run_git(
266
+ ["push", "origin", "main"],
267
+ check=False,
268
+ timeout=TIMEOUT_CONFIG["push"]
269
+ )
270
+
271
  if push_result.returncode == 0:
272
  logger.info("Memory saved successfully.")
273
  return True
 
277
  logger.warning("Remote updated during save. Pulling and retrying...")
278
  self.sync_memory()
279
  # Retry push once
280
+ retry_result = self._run_git(
281
+ ["push", "origin", "main"],
282
+ check=False,
283
+ timeout=TIMEOUT_CONFIG["push"]
284
+ )
285
  return retry_result.returncode == 0
286
+ logger.error(f"Push failed: {push_result.stderr}")
287
  return False
288
 
289
+ except GitTimeoutError as e:
290
+ logger.error(f"Memory save timed out: {e}")
291
+ return False
292
+ except GitOperationError as e:
293
+ logger.error(f"Memory save operation failed: {e}")
294
+ return False
295
  except Exception as e:
296
+ logger.error(f"Unexpected exception during memory save: {e}")
297
  return False
298
 
299
  def read_file(self, path: str) -> Optional[str]:
memory/log_manager.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Log Management Module for Cain Memory System.
3
+
4
+ Provides log rotation, compression, and cleanup functionality to prevent
5
+ unbounded log growth and maintain system health.
6
+
7
+ Features:
8
+ - Retains logs for 7 days
9
+ - Compresses logs older than 3 days using gzip
10
+ - Cleans up .bak files, keeping only the 3 most recent
11
+ - Provides health statistics about log storage
12
+ """
13
+
14
+ import os
15
+ import gzip
16
+ import shutil
17
+ import logging
18
+ from datetime import datetime, timedelta
19
+ from typing import List, Dict, Tuple
20
+ from pathlib import Path
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ class LogRetentionConfig:
25
+ """Configuration for log retention policy."""
26
+
27
+ # Retention periods
28
+ MAX_LOG_AGE_DAYS = 7 # Delete logs older than 7 days
29
+ COMPRESS_AGE_DAYS = 3 # Compress logs older than 3 days
30
+ MAX_BAK_FILES = 3 # Keep only 3 most recent .bak files
31
+
32
+ # Log directories to manage
33
+ LOG_DIRECTORIES = [
34
+ "/home/node/logs",
35
+ "/tmp/claude-workspace/.openclaw/logs",
36
+ "/data/logs",
37
+ "/app/logs",
38
+ ]
39
+
40
+ # File patterns to consider as logs
41
+ LOG_PATTERNS = ["*.log", "*.txt", "*.json"]
42
+
43
+ @classmethod
44
+ def add_log_directory(cls, path: str):
45
+ """Add a custom log directory to manage."""
46
+ if path not in cls.LOG_DIRECTORIES:
47
+ cls.LOG_DIRECTORIES.append(path)
48
+
49
+
50
+ class LogManager:
51
+ """Manages log rotation, compression, and cleanup."""
52
+
53
+ def __init__(self, config: LogRetentionConfig = None):
54
+ """
55
+ Initialize the log manager.
56
+
57
+ Args:
58
+ config: Log retention configuration. Uses defaults if None.
59
+ """
60
+ self.config = config or LogRetentionConfig()
61
+ self.stats = {
62
+ "processed_dirs": 0,
63
+ "logs_compressed": 0,
64
+ "logs_deleted": 0,
65
+ "bak_files_deleted": 0,
66
+ "space_freed_bytes": 0,
67
+ "errors": []
68
+ }
69
+
70
+ def rotate_all_logs(self) -> Dict[str, any]:
71
+ """
72
+ Run log rotation on all configured directories.
73
+
74
+ Returns:
75
+ Dictionary with rotation statistics.
76
+ """
77
+ self.stats = {
78
+ "processed_dirs": 0,
79
+ "logs_compressed": 0,
80
+ "logs_deleted": 0,
81
+ "bak_files_deleted": 0,
82
+ "space_freed_bytes": 0,
83
+ "errors": []
84
+ }
85
+
86
+ logger.info("Starting log rotation process...")
87
+
88
+ for log_dir in self.config.LOG_DIRECTORIES:
89
+ if os.path.isdir(log_dir):
90
+ try:
91
+ self._rotate_directory(log_dir)
92
+ self.stats["processed_dirs"] += 1
93
+ except Exception as e:
94
+ error_msg = f"Failed to rotate {log_dir}: {e}"
95
+ logger.error(error_msg)
96
+ self.stats["errors"].append(error_msg)
97
+ else:
98
+ logger.debug(f"Log directory not found: {log_dir}")
99
+
100
+ # Log summary
101
+ logger.info(
102
+ f"Log rotation complete. "
103
+ f"Dirs: {self.stats['processed_dirs']}, "
104
+ f"Compressed: {self.stats['logs_compressed']}, "
105
+ f"Deleted: {self.stats['logs_deleted']}, "
106
+ f"BAK cleaned: {self.stats['bak_files_deleted']}, "
107
+ f"Space freed: {self._format_bytes(self.stats['space_freed_bytes'])}"
108
+ )
109
+
110
+ return self.stats
111
+
112
+ def _rotate_directory(self, log_dir: str):
113
+ """Rotate logs in a single directory."""
114
+ logger.debug(f"Processing log directory: {log_dir}")
115
+
116
+ # Get all log files
117
+ log_files = self._get_log_files(log_dir)
118
+
119
+ for log_file in log_files:
120
+ try:
121
+ self._process_log_file(log_file)
122
+ except Exception as e:
123
+ error_msg = f"Failed to process {log_file}: {e}"
124
+ logger.warning(error_msg)
125
+ self.stats["errors"].append(error_msg)
126
+
127
+ # Clean up .bak files
128
+ self._cleanup_bak_files(log_dir)
129
+
130
+ def _get_log_files(self, log_dir: str) -> List[Path]:
131
+ """Get all log files in the directory."""
132
+ log_files = []
133
+ log_path = Path(log_dir)
134
+
135
+ for pattern in self.config.LOG_PATTERNS:
136
+ log_files.extend(log_path.glob(pattern))
137
+ # Also check for already compressed files
138
+ log_files.extend(log_path.glob(f"{pattern}.gz"))
139
+
140
+ # Filter out directories and sort by modification time
141
+ return sorted(
142
+ [f for f in log_files if f.is_file()],
143
+ key=lambda f: f.stat().st_mtime
144
+ )
145
+
146
+ def _process_log_file(self, log_file: Path):
147
+ """Process a single log file (compress or delete)."""
148
+ mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
149
+ age_days = (datetime.now() - mtime).days
150
+ size_before = log_file.stat().st_size
151
+
152
+ # Skip if already compressed
153
+ if log_file.suffix == ".gz":
154
+ if age_days > self.config.MAX_LOG_AGE_DAYS:
155
+ logger.debug(f"Deleting old compressed log: {log_file} ({age_days} days old)")
156
+ log_file.unlink()
157
+ self.stats["logs_deleted"] += 1
158
+ self.stats["space_freed_bytes"] += size_before
159
+ return
160
+
161
+ # Delete old logs
162
+ if age_days > self.config.MAX_LOG_AGE_DAYS:
163
+ logger.debug(f"Deleting old log: {log_file} ({age_days} days old)")
164
+ log_file.unlink()
165
+ self.stats["logs_deleted"] += 1
166
+ self.stats["space_freed_bytes"] += size_before
167
+ return
168
+
169
+ # Compress logs older than threshold
170
+ if age_days > self.config.COMPRESS_AGE_DAYS:
171
+ self._compress_log(log_file)
172
+ self.stats["logs_compressed"] += 1
173
+ # Calculate space saved
174
+ if log_file.with_suffix(log_file.suffix + ".gz").exists():
175
+ size_after = log_file.with_suffix(log_file.suffix + ".gz").stat().st_size
176
+ self.stats["space_freed_bytes"] += (size_before - size_after)
177
+
178
+ def _compress_log(self, log_file: Path):
179
+ """Compress a log file using gzip."""
180
+ compressed_path = log_file.with_suffix(log_file.suffix + ".gz")
181
+
182
+ if compressed_path.exists():
183
+ logger.debug(f"Compressed file already exists: {compressed_path}")
184
+ return
185
+
186
+ logger.debug(f"Compressing: {log_file}")
187
+ with open(log_file, 'rb') as f_in:
188
+ with gzip.open(compressed_path, 'wb') as f_out:
189
+ shutil.copyfileobj(f_in, f_out)
190
+
191
+ # Remove original after successful compression
192
+ log_file.unlink()
193
+ logger.debug(f"Compressed: {log_file} -> {compressed_path}")
194
+
195
+ def _cleanup_bak_files(self, log_dir: str):
196
+ """Clean up .bak files, keeping only the most recent ones."""
197
+ log_path = Path(log_dir)
198
+ bak_files = sorted(
199
+ log_path.glob("*.bak"),
200
+ key=lambda f: f.stat().st_mtime,
201
+ reverse=True # Newest first
202
+ )
203
+
204
+ # Keep the N most recent, delete the rest
205
+ to_delete = bak_files[self.config.MAX_BAK_FILES:]
206
+
207
+ for bak_file in to_delete:
208
+ size = bak_file.stat().st_size
209
+ logger.debug(f"Deleting old .bak file: {bak_file}")
210
+ bak_file.unlink()
211
+ self.stats["bak_files_deleted"] += 1
212
+ self.stats["space_freed_bytes"] += size
213
+
214
+ def get_log_health_status(self) -> Dict[str, any]:
215
+ """
216
+ Get health status of log directories.
217
+
218
+ Returns:
219
+ Dictionary with log health information.
220
+ """
221
+ status = {
222
+ "directories": {},
223
+ "total_size_bytes": 0,
224
+ "total_files": 0,
225
+ "needs_rotation": False
226
+ }
227
+
228
+ for log_dir in self.config.LOG_DIRECTORIES:
229
+ if not os.path.isdir(log_dir):
230
+ continue
231
+
232
+ dir_info = self._get_directory_info(log_dir)
233
+ status["directories"][log_dir] = dir_info
234
+ status["total_size_bytes"] += dir_info["size_bytes"]
235
+ status["total_files"] += dir_info["file_count"]
236
+
237
+ if dir_info.get("has_old_logs", False):
238
+ status["needs_rotation"] = True
239
+
240
+ status["total_size_human"] = self._format_bytes(status["total_size_bytes"])
241
+ return status
242
+
243
+ def _get_directory_info(self, log_dir: str) -> Dict[str, any]:
244
+ """Get information about a log directory."""
245
+ log_path = Path(log_dir)
246
+ files = list(log_path.glob("*"))
247
+ total_size = sum(f.stat().st_size for f in files if f.is_file())
248
+
249
+ # Check for old logs
250
+ now = datetime.now()
251
+ has_old_logs = False
252
+ oldest_age_days = 0
253
+
254
+ for f in files:
255
+ if f.is_file():
256
+ mtime = datetime.fromtimestamp(f.stat().st_mtime)
257
+ age_days = (now - mtime).days
258
+ if age_days > self.config.COMPRESS_AGE_DAYS:
259
+ has_old_logs = True
260
+ oldest_age_days = max(oldest_age_days, age_days)
261
+
262
+ return {
263
+ "size_bytes": total_size,
264
+ "size_human": self._format_bytes(total_size),
265
+ "file_count": len(files),
266
+ "has_old_logs": has_old_logs,
267
+ "oldest_age_days": oldest_age_days
268
+ }
269
+
270
+ @staticmethod
271
+ def _format_bytes(size_bytes: int) -> str:
272
+ """Format byte count to human-readable string."""
273
+ for unit in ['B', 'KB', 'MB', 'GB']:
274
+ if size_bytes < 1024.0:
275
+ return f"{size_bytes:.1f}{unit}"
276
+ size_bytes /= 1024.0
277
+ return f"{size_bytes:.1f}TB"
278
+
279
+
280
+ def rotate_logs() -> Dict[str, any]:
281
+ """
282
+ Convenience function to rotate logs with default configuration.
283
+
284
+ Returns:
285
+ Dictionary with rotation statistics.
286
+ """
287
+ manager = LogManager()
288
+ return manager.rotate_all_logs()
289
+
290
+
291
+ def get_log_health() -> Dict[str, any]:
292
+ """
293
+ Convenience function to get log health status.
294
+
295
+ Returns:
296
+ Dictionary with log health information.
297
+ """
298
+ manager = LogManager()
299
+ return manager.get_log_health_status()
300
+
301
+
302
+ if __name__ == "__main__":
303
+ # Run log rotation when executed directly
304
+ logging.basicConfig(
305
+ level=logging.INFO,
306
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
307
+ )
308
+
309
+ stats = rotate_logs()
310
+ print(f"\n=== Log Rotation Summary ===")
311
+ print(f"Directories processed: {stats['processed_dirs']}")
312
+ print(f"Logs compressed: {stats['logs_compressed']}")
313
+ print(f"Logs deleted: {stats['logs_deleted']}")
314
+ print(f"BAK files deleted: {stats['bak_files_deleted']}")
315
+ print(f"Space freed: {LogManager._format_bytes(stats['space_freed_bytes'])}")
316
+
317
+ if stats['errors']:
318
+ print(f"\nErrors encountered: {len(stats['errors'])}")
319
+ for error in stats['errors']:
320
+ print(f" - {error}")
memory/memory_system.py CHANGED
@@ -1,19 +1,43 @@
1
  """
2
  Cain's Memory System - Handles loading, saving, and processing of memory data.
3
  Now supports persistent storage via HuggingFace Datasets.
 
 
 
 
 
4
  """
5
 
6
  import os
7
  import json
8
  import fcntl
 
9
  from typing import Dict, Any, Optional
10
  from datetime import datetime
11
- from .git_repo import GitMemoryBridge # Import the Git utility
 
 
 
12
 
13
  class MemorySystem:
14
  """Manages Cain's memory with automatic persistence to Dataset."""
15
 
16
- def __init__(self, repo_path: str = "/data"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  # Determine paths
18
  self.base_path = repo_path
19
  # SURVIVAL: Align path with actual Dataset structure
@@ -22,26 +46,47 @@ class MemorySystem:
22
 
23
  self.memory_data: Dict[str, Any] = {}
24
  self.git: Optional[GitMemoryBridge] = None
 
 
25
 
26
  # Initialize directories and load memory
27
  self._ensure_structure()
28
- self._init_git()
 
29
  self.load_memory()
30
 
31
  def _ensure_structure(self):
32
  """Ensures the directory structure exists."""
33
  os.makedirs(os.path.dirname(self.memory_file), exist_ok=True)
34
 
35
- def _init_git(self):
36
- """Initialize Git repo connection if credentials are present."""
37
- # GitMemoryBridge doesn't need user/token - it uses environment git config
38
  try:
39
- self.git = GitMemoryBridge(repo_path=self.base_path)
40
- print(f"🧠 [Memory] Git persistence enabled.")
 
 
 
 
 
41
  except Exception as e:
42
- print(f"⚠️ [Memory] Failed to init Git: {e}")
43
  print("🧠 [Memory] Running without Git persistence (ephemeral mode).")
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def load_memory(self) -> Dict[str, Any]:
46
  """Loads memory from the JSON file."""
47
  if os.path.exists(self.memory_file):
@@ -77,23 +122,77 @@ class MemorySystem:
77
  }
78
 
79
  def save_memory(self) -> bool:
80
- """Saves current memory state to file and optionally commits to Git."""
 
 
 
 
 
81
  try:
82
  # Atomic write
83
  temp_path = self.memory_file + ".tmp"
84
  with open(temp_path, 'w', encoding='utf-8') as f:
85
  json.dump(self.memory_data, f, indent=2)
86
  os.rename(temp_path, self.memory_file)
87
-
88
  # Sync to Dataset if Git is available
89
  if self.git:
90
- self.git.save_memory(f"Update memory state: {datetime.utcnow().isoformat()}")
91
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  return True
 
93
  except Exception as e:
94
- print(f"❌ [Memory] Failed to save: {e}")
95
  return False
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def get(self, key: str, default=None):
98
  """Retrieve a value from memory."""
99
  return self.memory_data.get(key, default)
 
1
  """
2
  Cain's Memory System - Handles loading, saving, and processing of memory data.
3
  Now supports persistent storage via HuggingFace Datasets.
4
+
5
+ Enhanced with:
6
+ - Timeout-controlled git operations
7
+ - Log rotation and management
8
+ - Comprehensive error handling and logging
9
  """
10
 
11
  import os
12
  import json
13
  import fcntl
14
+ import logging
15
  from typing import Dict, Any, Optional
16
  from datetime import datetime
17
+ from .git_repo import GitMemoryBridge, GitOperationError, GitTimeoutError
18
+ from .log_manager import LogManager, LogRetentionConfig
19
+
20
+ logger = logging.getLogger(__name__)
21
 
22
  class MemorySystem:
23
  """Manages Cain's memory with automatic persistence to Dataset."""
24
 
25
+ def __init__(
26
+ self,
27
+ repo_path: str = "/data",
28
+ git_timeout: int = None,
29
+ git_max_retries: int = 3,
30
+ enable_log_rotation: bool = True
31
+ ):
32
+ """
33
+ Initialize the memory system.
34
+
35
+ Args:
36
+ repo_path: Path to the repository/dataset
37
+ git_timeout: Override default git operation timeout (seconds)
38
+ git_max_retries: Maximum retry attempts for git operations
39
+ enable_log_rotation: Whether to enable automatic log rotation
40
+ """
41
  # Determine paths
42
  self.base_path = repo_path
43
  # SURVIVAL: Align path with actual Dataset structure
 
46
 
47
  self.memory_data: Dict[str, Any] = {}
48
  self.git: Optional[GitMemoryBridge] = None
49
+ self.log_manager: Optional[LogManager] = None
50
+ self.enable_log_rotation = enable_log_rotation
51
 
52
  # Initialize directories and load memory
53
  self._ensure_structure()
54
+ self._init_git(git_timeout, git_max_retries)
55
+ self._init_log_manager()
56
  self.load_memory()
57
 
58
  def _ensure_structure(self):
59
  """Ensures the directory structure exists."""
60
  os.makedirs(os.path.dirname(self.memory_file), exist_ok=True)
61
 
62
+ def _init_git(self, timeout: int = None, max_retries: int = 3):
63
+ """Initialize Git repo connection with timeout and retry settings."""
 
64
  try:
65
+ self.git = GitMemoryBridge(
66
+ repo_path=self.base_path,
67
+ base_timeout=timeout,
68
+ max_retries=max_retries
69
+ )
70
+ logger.info("🧠 [Memory] Git persistence enabled.")
71
+ print(f"🧠 [Memory] Git persistence enabled (timeout: {timeout or 'default'}, retries: {max_retries}).")
72
  except Exception as e:
73
+ logger.warning(f"Failed to init Git: {e}")
74
  print("🧠 [Memory] Running without Git persistence (ephemeral mode).")
75
 
76
+ def _init_log_manager(self):
77
+ """Initialize log manager for automatic cleanup."""
78
+ if self.enable_log_rotation:
79
+ try:
80
+ self.log_manager = LogManager()
81
+ # Add custom log paths if they exist
82
+ for log_path in ["/home/node/logs", "/app/logs", "/tmp/logs"]:
83
+ if os.path.isdir(log_path):
84
+ LogRetentionConfig.add_log_directory(log_path)
85
+ logger.info("🧠 [Memory] Log rotation enabled.")
86
+ except Exception as e:
87
+ logger.warning(f"Failed to init log manager: {e}")
88
+ self.log_manager = None
89
+
90
  def load_memory(self) -> Dict[str, Any]:
91
  """Loads memory from the JSON file."""
92
  if os.path.exists(self.memory_file):
 
122
  }
123
 
124
  def save_memory(self) -> bool:
125
+ """
126
+ Saves current memory state to file and optionally commits to Git.
127
+
128
+ Includes proper error handling for git timeout and operation errors.
129
+ Performs periodic log rotation to prevent unbounded log growth.
130
+ """
131
  try:
132
  # Atomic write
133
  temp_path = self.memory_file + ".tmp"
134
  with open(temp_path, 'w', encoding='utf-8') as f:
135
  json.dump(self.memory_data, f, indent=2)
136
  os.rename(temp_path, self.memory_file)
137
+
138
  # Sync to Dataset if Git is available
139
  if self.git:
140
+ try:
141
+ commit_msg = f"Update memory state: {datetime.utcnow().isoformat()}"
142
+ if self.git.save_memory(commit_msg):
143
+ logger.info("��� [Memory] Successfully saved to git.")
144
+ else:
145
+ logger.warning("⚠️ [Memory] Git save returned False (may be non-critical).")
146
+ except GitTimeoutError as e:
147
+ logger.error(f"❌ [Memory] Git operation timed out: {e}")
148
+ # Continue anyway - local save succeeded
149
+ except GitOperationError as e:
150
+ logger.error(f"❌ [Memory] Git operation failed: {e}")
151
+ # Continue anyway - local save succeeded
152
+ except Exception as e:
153
+ logger.error(f"❌ [Memory] Unexpected git error: {e}")
154
+ # Continue anyway - local save succeeded
155
+
156
+ # Periodic log rotation (every 10 saves)
157
+ self._maybe_rotate_logs()
158
+
159
  return True
160
+
161
  except Exception as e:
162
+ logger.error(f"❌ [Memory] Failed to save memory: {e}")
163
  return False
164
 
165
+ def _maybe_rotate_logs(self):
166
+ """Perform log rotation periodically (every 10 calls)."""
167
+ if not self.log_manager:
168
+ return
169
+
170
+ # Use a counter stored in memory_data
171
+ save_count = self.memory_data.get("_save_count", 0)
172
+ save_count += 1
173
+ self.memory_data["_save_count"] = save_count
174
+
175
+ if save_count >= 10:
176
+ try:
177
+ logger.info("🧠 [Memory] Running periodic log rotation...")
178
+ stats = self.log_manager.rotate_all_logs()
179
+ logger.info(f"🧠 [Memory] Log rotation complete: {stats}")
180
+ self.memory_data["_save_count"] = 0
181
+ except Exception as e:
182
+ logger.warning(f"⚠️ [Memory] Log rotation failed (non-critical): {e}")
183
+
184
+ def get_log_health(self) -> Dict[str, Any]:
185
+ """Get health status of log directories."""
186
+ if self.log_manager:
187
+ return self.log_manager.get_log_health_status()
188
+ return {"status": "Log manager not initialized"}
189
+
190
+ def rotate_logs_now(self) -> Dict[str, Any]:
191
+ """Force immediate log rotation."""
192
+ if self.log_manager:
193
+ return self.log_manager.rotate_all_logs()
194
+ return {"error": "Log manager not initialized"}
195
+
196
  def get(self, key: str, default=None):
197
  """Retrieve a value from memory."""
198
  return self.memory_data.get(key, default)