Spaces:
Sleeping
Sleeping
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 +39 -0
- memory/__pycache__/__init__.cpython-311.pyc +0 -0
- memory/__pycache__/git_repo.cpython-311.pyc +0 -0
- memory/__pycache__/log_manager.cpython-311.pyc +0 -0
- memory/__pycache__/memory_system.cpython-311.pyc +0 -0
- memory/git_repo.py +221 -43
- memory/log_manager.py +320 -0
- memory/memory_system.py +113 -14
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return "git"
|
| 24 |
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 25 |
-
logger.warning("System git not found
|
| 26 |
return None
|
| 27 |
|
| 28 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
if not self.git:
|
| 30 |
raise RuntimeError("Git is not available in this environment.")
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
full_cmd = [self.git] + args
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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 |
-
#
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# Pull with strategy X (theirs) to prefer remote state (survival priority)
|
| 55 |
-
|
| 56 |
-
"pull",
|
| 57 |
-
|
| 58 |
-
"
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 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
|
| 68 |
logger.warning("Git history diverged. Performing hard reset to remote origin/main.")
|
| 69 |
-
self._run_git(["
|
| 70 |
-
self._run_git(["reset", "--hard", "origin/main"])
|
| 71 |
return True
|
| 72 |
else:
|
| 73 |
-
logger.error(f"Memory sync failed: {
|
| 74 |
return False
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
except Exception as e:
|
| 77 |
-
logger.error(f"
|
| 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(
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
| 100 |
# 4. Push
|
| 101 |
logger.info("Pushing memory to remote...")
|
| 102 |
-
push_result = self._run_git(
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
return retry_result.returncode == 0
|
|
|
|
| 115 |
return False
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
except Exception as e:
|
| 118 |
-
logger.error(f"
|
| 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
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
class MemorySystem:
|
| 14 |
"""Manages Cain's memory with automatic persistence to Dataset."""
|
| 15 |
|
| 16 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 37 |
-
# GitMemoryBridge doesn't need user/token - it uses environment git config
|
| 38 |
try:
|
| 39 |
-
self.git = GitMemoryBridge(
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
except Exception as e:
|
| 42 |
-
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
return True
|
|
|
|
| 93 |
except Exception as e:
|
| 94 |
-
|
| 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)
|