superlocalmemory 2.3.7 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SuperLocalMemory V2 - Auto Backup System
4
+ Copyright (c) 2026 Varun Pratap Bhardwaj
5
+ Licensed under MIT License
6
+
7
+ Repository: https://github.com/varun369/SuperLocalMemoryV2
8
+
9
+ Automated backup system for memory.db:
10
+ - Configurable interval: 24h (daily) or 7 days (weekly, default)
11
+ - Timestamped backups in ~/.claude-memory/backups/
12
+ - Retention policy: keeps last N backups (default 10)
13
+ - Auto-triggers on memory operations when backup is due
14
+ - Manual backup via CLI
15
+ """
16
+
17
+ import sqlite3
18
+ import shutil
19
+ import json
20
+ import logging
21
+ from datetime import datetime, timedelta
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ MEMORY_DIR = Path.home() / ".claude-memory"
28
+ DB_PATH = MEMORY_DIR / "memory.db"
29
+ BACKUP_DIR = MEMORY_DIR / "backups"
30
+ CONFIG_FILE = MEMORY_DIR / "backup_config.json"
31
+
32
+ # Defaults
33
+ DEFAULT_INTERVAL_HOURS = 168 # 7 days
34
+ DEFAULT_MAX_BACKUPS = 10
35
+ MIN_INTERVAL_HOURS = 1 # Safety: no more than once per hour
36
+
37
+
38
+ class AutoBackup:
39
+ """Automated backup manager for SuperLocalMemory V2."""
40
+
41
+ def __init__(self, db_path: Path = DB_PATH, backup_dir: Path = BACKUP_DIR):
42
+ self.db_path = db_path
43
+ self.backup_dir = backup_dir
44
+ self.config = self._load_config()
45
+ self._ensure_backup_dir()
46
+
47
+ def _ensure_backup_dir(self):
48
+ """Create backup directory if it doesn't exist."""
49
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
50
+
51
+ def _load_config(self) -> Dict:
52
+ """Load backup configuration."""
53
+ if CONFIG_FILE.exists():
54
+ try:
55
+ with open(CONFIG_FILE, 'r') as f:
56
+ config = json.load(f)
57
+ # Merge with defaults for any missing keys
58
+ defaults = self._default_config()
59
+ for key in defaults:
60
+ if key not in config:
61
+ config[key] = defaults[key]
62
+ return config
63
+ except (json.JSONDecodeError, IOError):
64
+ pass
65
+ return self._default_config()
66
+
67
+ def _default_config(self) -> Dict:
68
+ """Return default configuration."""
69
+ return {
70
+ 'enabled': True,
71
+ 'interval_hours': DEFAULT_INTERVAL_HOURS,
72
+ 'max_backups': DEFAULT_MAX_BACKUPS,
73
+ 'last_backup': None,
74
+ 'last_backup_file': None,
75
+ }
76
+
77
+ def _save_config(self):
78
+ """Save configuration to disk."""
79
+ try:
80
+ with open(CONFIG_FILE, 'w') as f:
81
+ json.dump(self.config, f, indent=2)
82
+ except IOError as e:
83
+ logger.error(f"Failed to save backup config: {e}")
84
+
85
+ def configure(self, interval_hours: Optional[int] = None,
86
+ max_backups: Optional[int] = None,
87
+ enabled: Optional[bool] = None) -> Dict:
88
+ """
89
+ Update backup configuration.
90
+
91
+ Args:
92
+ interval_hours: Hours between backups (24 for daily, 168 for weekly)
93
+ max_backups: Maximum number of backup files to retain
94
+ enabled: Enable/disable auto-backup
95
+
96
+ Returns:
97
+ Updated configuration
98
+ """
99
+ if interval_hours is not None:
100
+ self.config['interval_hours'] = max(MIN_INTERVAL_HOURS, interval_hours)
101
+ if max_backups is not None:
102
+ self.config['max_backups'] = max(1, max_backups)
103
+ if enabled is not None:
104
+ self.config['enabled'] = enabled
105
+
106
+ self._save_config()
107
+ return self.get_status()
108
+
109
+ def is_backup_due(self) -> bool:
110
+ """Check if a backup is due based on configured interval."""
111
+ if not self.config.get('enabled', True):
112
+ return False
113
+
114
+ last_backup = self.config.get('last_backup')
115
+ if not last_backup:
116
+ return True # Never backed up
117
+
118
+ try:
119
+ last_dt = datetime.fromisoformat(last_backup)
120
+ interval = timedelta(hours=self.config.get('interval_hours', DEFAULT_INTERVAL_HOURS))
121
+ return datetime.now() >= last_dt + interval
122
+ except (ValueError, TypeError):
123
+ return True # Invalid date, backup now
124
+
125
+ def check_and_backup(self) -> Optional[str]:
126
+ """
127
+ Check if backup is due and create one if needed.
128
+ Called automatically by memory operations.
129
+
130
+ Returns:
131
+ Backup filename if created, None if not due
132
+ """
133
+ if not self.is_backup_due():
134
+ return None
135
+
136
+ return self.create_backup()
137
+
138
+ def create_backup(self, label: Optional[str] = None) -> str:
139
+ """
140
+ Create a backup of memory.db.
141
+
142
+ Args:
143
+ label: Optional label for the backup (e.g., 'pre-migration')
144
+
145
+ Returns:
146
+ Backup filename
147
+ """
148
+ if not self.db_path.exists():
149
+ logger.warning("No database to backup")
150
+ return ""
151
+
152
+ self._ensure_backup_dir()
153
+
154
+ # Generate timestamped filename
155
+ timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
156
+ label_suffix = f"-{label}" if label else ""
157
+ backup_name = f"memory-{timestamp}{label_suffix}.db"
158
+ backup_path = self.backup_dir / backup_name
159
+
160
+ try:
161
+ # Use SQLite backup API for consistency (safe even during writes)
162
+ source_conn = sqlite3.connect(self.db_path)
163
+ backup_conn = sqlite3.connect(backup_path)
164
+ source_conn.backup(backup_conn)
165
+ backup_conn.close()
166
+ source_conn.close()
167
+
168
+ # Get backup size
169
+ size_mb = backup_path.stat().st_size / (1024 * 1024)
170
+
171
+ # Update config
172
+ self.config['last_backup'] = datetime.now().isoformat()
173
+ self.config['last_backup_file'] = backup_name
174
+ self._save_config()
175
+
176
+ logger.info(f"Backup created: {backup_name} ({size_mb:.1f} MB)")
177
+
178
+ # Enforce retention policy
179
+ self._enforce_retention()
180
+
181
+ return backup_name
182
+
183
+ except Exception as e:
184
+ logger.error(f"Backup failed: {e}")
185
+ # Clean up partial backup
186
+ if backup_path.exists():
187
+ backup_path.unlink()
188
+ return ""
189
+
190
+ def _enforce_retention(self):
191
+ """Remove old backups exceeding max_backups limit."""
192
+ max_backups = self.config.get('max_backups', DEFAULT_MAX_BACKUPS)
193
+
194
+ # List all backup files sorted by modification time (oldest first)
195
+ backups = sorted(
196
+ self.backup_dir.glob('memory-*.db'),
197
+ key=lambda f: f.stat().st_mtime
198
+ )
199
+
200
+ # Remove oldest if exceeding limit
201
+ while len(backups) > max_backups:
202
+ oldest = backups.pop(0)
203
+ try:
204
+ oldest.unlink()
205
+ logger.info(f"Removed old backup: {oldest.name}")
206
+ except OSError as e:
207
+ logger.error(f"Failed to remove old backup {oldest.name}: {e}")
208
+
209
+ def list_backups(self) -> List[Dict]:
210
+ """
211
+ List all available backups.
212
+
213
+ Returns:
214
+ List of backup info dictionaries
215
+ """
216
+ backups = []
217
+
218
+ if not self.backup_dir.exists():
219
+ return backups
220
+
221
+ for backup_file in sorted(
222
+ self.backup_dir.glob('memory-*.db'),
223
+ key=lambda f: f.stat().st_mtime,
224
+ reverse=True
225
+ ):
226
+ stat = backup_file.stat()
227
+ backups.append({
228
+ 'filename': backup_file.name,
229
+ 'path': str(backup_file),
230
+ 'size_mb': round(stat.st_size / (1024 * 1024), 2),
231
+ 'created': datetime.fromtimestamp(stat.st_mtime).isoformat(),
232
+ 'age_hours': round((datetime.now() - datetime.fromtimestamp(stat.st_mtime)).total_seconds() / 3600, 1),
233
+ })
234
+
235
+ return backups
236
+
237
+ def restore_backup(self, filename: str) -> bool:
238
+ """
239
+ Restore database from a backup file.
240
+
241
+ Args:
242
+ filename: Backup filename to restore from
243
+
244
+ Returns:
245
+ Success status
246
+ """
247
+ backup_path = self.backup_dir / filename
248
+
249
+ if not backup_path.exists():
250
+ logger.error(f"Backup file not found: {filename}")
251
+ return False
252
+
253
+ try:
254
+ # Create a safety backup of current state first
255
+ self.create_backup(label='pre-restore')
256
+
257
+ # Restore using SQLite backup API
258
+ source_conn = sqlite3.connect(backup_path)
259
+ target_conn = sqlite3.connect(self.db_path)
260
+ source_conn.backup(target_conn)
261
+ target_conn.close()
262
+ source_conn.close()
263
+
264
+ logger.info(f"Restored from backup: {filename}")
265
+ return True
266
+
267
+ except Exception as e:
268
+ logger.error(f"Restore failed: {e}")
269
+ return False
270
+
271
+ def get_status(self) -> Dict:
272
+ """
273
+ Get backup system status.
274
+
275
+ Returns:
276
+ Status dictionary
277
+ """
278
+ backups = self.list_backups()
279
+ next_backup = None
280
+
281
+ if self.config.get('enabled') and self.config.get('last_backup'):
282
+ try:
283
+ last_dt = datetime.fromisoformat(self.config['last_backup'])
284
+ interval = timedelta(hours=self.config.get('interval_hours', DEFAULT_INTERVAL_HOURS))
285
+ next_dt = last_dt + interval
286
+ if next_dt > datetime.now():
287
+ next_backup = next_dt.isoformat()
288
+ else:
289
+ next_backup = 'overdue'
290
+ except (ValueError, TypeError):
291
+ next_backup = 'unknown'
292
+
293
+ # Calculate interval display
294
+ hours = self.config.get('interval_hours', DEFAULT_INTERVAL_HOURS)
295
+ if hours >= 168:
296
+ interval_display = f"{hours // 168} week(s)"
297
+ elif hours >= 24:
298
+ interval_display = f"{hours // 24} day(s)"
299
+ else:
300
+ interval_display = f"{hours} hour(s)"
301
+
302
+ return {
303
+ 'enabled': self.config.get('enabled', True),
304
+ 'interval_hours': hours,
305
+ 'interval_display': interval_display,
306
+ 'max_backups': self.config.get('max_backups', DEFAULT_MAX_BACKUPS),
307
+ 'last_backup': self.config.get('last_backup'),
308
+ 'last_backup_file': self.config.get('last_backup_file'),
309
+ 'next_backup': next_backup,
310
+ 'backup_count': len(backups),
311
+ 'total_size_mb': round(sum(b['size_mb'] for b in backups), 2),
312
+ 'backups': backups,
313
+ }
314
+
315
+
316
+ # CLI Interface
317
+ if __name__ == "__main__":
318
+ import sys
319
+
320
+ backup = AutoBackup()
321
+
322
+ if len(sys.argv) < 2:
323
+ print("Auto Backup System - SuperLocalMemory V2")
324
+ print("\nUsage:")
325
+ print(" python auto_backup.py status Show backup status")
326
+ print(" python auto_backup.py backup [label] Create backup now")
327
+ print(" python auto_backup.py list List all backups")
328
+ print(" python auto_backup.py restore <filename> Restore from backup")
329
+ print(" python auto_backup.py configure Show/set configuration")
330
+ print(" --interval <hours> Set backup interval (24=daily, 168=weekly)")
331
+ print(" --max-backups <N> Set max retained backups")
332
+ print(" --enable Enable auto-backup")
333
+ print(" --disable Disable auto-backup")
334
+ sys.exit(0)
335
+
336
+ command = sys.argv[1]
337
+
338
+ if command == "status":
339
+ status = backup.get_status()
340
+ print(f"\nAuto-Backup Status")
341
+ print(f"{'='*40}")
342
+ print(f" Enabled: {'Yes' if status['enabled'] else 'No'}")
343
+ print(f" Interval: {status['interval_display']}")
344
+ print(f" Max backups: {status['max_backups']}")
345
+ print(f" Last backup: {status['last_backup'] or 'Never'}")
346
+ print(f" Next backup: {status['next_backup'] or 'N/A'}")
347
+ print(f" Backup count: {status['backup_count']}")
348
+ print(f" Total size: {status['total_size_mb']} MB")
349
+
350
+ elif command == "backup":
351
+ label = sys.argv[2] if len(sys.argv) > 2 else None
352
+ print("Creating backup...")
353
+ result = backup.create_backup(label=label)
354
+ if result:
355
+ print(f"Backup created: {result}")
356
+ else:
357
+ print("Backup failed!")
358
+ sys.exit(1)
359
+
360
+ elif command == "list":
361
+ backups = backup.list_backups()
362
+ if not backups:
363
+ print("No backups found.")
364
+ else:
365
+ print(f"\n{'Filename':<45} {'Size':<10} {'Age':<15} {'Created'}")
366
+ print("-" * 95)
367
+ for b in backups:
368
+ age = f"{b['age_hours']:.0f}h" if b['age_hours'] < 48 else f"{b['age_hours']/24:.0f}d"
369
+ created = b['created'][:19]
370
+ print(f"{b['filename']:<45} {b['size_mb']:<10.2f} {age:<15} {created}")
371
+
372
+ elif command == "restore":
373
+ if len(sys.argv) < 3:
374
+ print("Error: Backup filename required")
375
+ print("Usage: python auto_backup.py restore <filename>")
376
+ sys.exit(1)
377
+ filename = sys.argv[2]
378
+ print(f"Restoring from {filename}...")
379
+ if backup.restore_backup(filename):
380
+ print("Restore successful! Restart any running tools to use the restored data.")
381
+ else:
382
+ print("Restore failed!")
383
+ sys.exit(1)
384
+
385
+ elif command == "configure":
386
+ interval = None
387
+ max_bk = None
388
+ enabled = None
389
+
390
+ i = 2
391
+ while i < len(sys.argv):
392
+ if sys.argv[i] == '--interval' and i + 1 < len(sys.argv):
393
+ interval = int(sys.argv[i + 1])
394
+ i += 2
395
+ elif sys.argv[i] == '--max-backups' and i + 1 < len(sys.argv):
396
+ max_bk = int(sys.argv[i + 1])
397
+ i += 2
398
+ elif sys.argv[i] == '--enable':
399
+ enabled = True
400
+ i += 1
401
+ elif sys.argv[i] == '--disable':
402
+ enabled = False
403
+ i += 1
404
+ else:
405
+ i += 1
406
+
407
+ if interval is not None or max_bk is not None or enabled is not None:
408
+ status = backup.configure(
409
+ interval_hours=interval,
410
+ max_backups=max_bk,
411
+ enabled=enabled
412
+ )
413
+ print("Configuration updated:")
414
+ else:
415
+ status = backup.get_status()
416
+ print("Current configuration:")
417
+
418
+ print(f" Enabled: {'Yes' if status['enabled'] else 'No'}")
419
+ print(f" Interval: {status['interval_display']}")
420
+ print(f" Max backups: {status['max_backups']}")
421
+
422
+ else:
423
+ print(f"Unknown command: {command}")
424
+ sys.exit(1)