superlocalmemory 2.3.7 → 2.4.1
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.
- package/CHANGELOG.md +66 -0
- package/README.md +53 -6
- package/hooks/memory-profile-skill.js +7 -18
- package/mcp_server.py +74 -12
- package/package.json +2 -1
- package/src/auto_backup.py +424 -0
- package/src/graph_engine.py +459 -44
- package/src/memory-profiles.py +321 -243
- package/src/memory_store_v2.py +82 -31
- package/src/pattern_learner.py +126 -44
- package/src/setup_validator.py +8 -1
- package/ui/app.js +526 -17
- package/ui/index.html +182 -1
- package/ui_server.py +356 -55
- package/src/__pycache__/cache_manager.cpython-312.pyc +0 -0
- package/src/__pycache__/embedding_engine.cpython-312.pyc +0 -0
- package/src/__pycache__/graph_engine.cpython-312.pyc +0 -0
- package/src/__pycache__/hnsw_index.cpython-312.pyc +0 -0
- package/src/__pycache__/hybrid_search.cpython-312.pyc +0 -0
- package/src/__pycache__/memory-profiles.cpython-312.pyc +0 -0
- package/src/__pycache__/memory-reset.cpython-312.pyc +0 -0
- package/src/__pycache__/memory_compression.cpython-312.pyc +0 -0
- package/src/__pycache__/memory_store_v2.cpython-312.pyc +0 -0
- package/src/__pycache__/migrate_v1_to_v2.cpython-312.pyc +0 -0
- package/src/__pycache__/pattern_learner.cpython-312.pyc +0 -0
- package/src/__pycache__/query_optimizer.cpython-312.pyc +0 -0
- package/src/__pycache__/search_engine_v2.cpython-312.pyc +0 -0
- package/src/__pycache__/setup_validator.cpython-312.pyc +0 -0
- package/src/__pycache__/tree_manager.cpython-312.pyc +0 -0
|
@@ -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)
|