superlocalmemory 3.4.9 → 3.4.11

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.
Files changed (52) hide show
  1. package/README.md +23 -3
  2. package/docs/cloud-backup.md +174 -0
  3. package/docs/skill-evolution.md +256 -0
  4. package/ide/hooks/tool-event-hook.sh +101 -11
  5. package/package.json +1 -1
  6. package/pyproject.toml +3 -2
  7. package/src/superlocalmemory/cli/commands.py +359 -0
  8. package/src/superlocalmemory/cli/ingest_cmd.py +81 -29
  9. package/src/superlocalmemory/cli/main.py +32 -0
  10. package/src/superlocalmemory/cli/setup_wizard.py +54 -11
  11. package/src/superlocalmemory/core/config.py +35 -0
  12. package/src/superlocalmemory/core/consolidation_engine.py +138 -0
  13. package/src/superlocalmemory/core/embedding_worker.py +1 -1
  14. package/src/superlocalmemory/core/engine.py +19 -0
  15. package/src/superlocalmemory/core/fact_consolidator.py +425 -0
  16. package/src/superlocalmemory/core/graph_pruner.py +290 -0
  17. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -3
  18. package/src/superlocalmemory/core/recall_pipeline.py +9 -0
  19. package/src/superlocalmemory/core/tier_manager.py +325 -0
  20. package/src/superlocalmemory/encoding/entity_resolver.py +96 -28
  21. package/src/superlocalmemory/evolution/__init__.py +29 -0
  22. package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
  23. package/src/superlocalmemory/evolution/evolution_store.py +302 -0
  24. package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
  25. package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
  26. package/src/superlocalmemory/evolution/triggers.py +367 -0
  27. package/src/superlocalmemory/evolution/types.py +92 -0
  28. package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
  29. package/src/superlocalmemory/infra/backup.py +63 -20
  30. package/src/superlocalmemory/infra/cloud_backup.py +703 -0
  31. package/src/superlocalmemory/learning/skill_performance_miner.py +422 -0
  32. package/src/superlocalmemory/mcp/server.py +4 -0
  33. package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
  34. package/src/superlocalmemory/retrieval/engine.py +64 -4
  35. package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
  36. package/src/superlocalmemory/retrieval/strategy.py +2 -2
  37. package/src/superlocalmemory/server/routes/backup.py +512 -8
  38. package/src/superlocalmemory/server/routes/behavioral.py +39 -17
  39. package/src/superlocalmemory/server/routes/evolution.py +213 -0
  40. package/src/superlocalmemory/server/routes/tiers.py +195 -0
  41. package/src/superlocalmemory/server/unified_daemon.py +36 -5
  42. package/src/superlocalmemory/storage/schema_v3410.py +159 -0
  43. package/src/superlocalmemory/storage/schema_v3411.py +149 -0
  44. package/src/superlocalmemory/ui/index.html +59 -3
  45. package/src/superlocalmemory/ui/js/core.js +3 -0
  46. package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
  47. package/src/superlocalmemory/ui/js/ng-entities.js +27 -3
  48. package/src/superlocalmemory/ui/js/ng-shell.js +33 -0
  49. package/src/superlocalmemory/ui/js/ng-skills.js +611 -0
  50. package/src/superlocalmemory/ui/js/settings.js +311 -1
  51. package/src/superlocalmemory.egg-info/PKG-INFO +16 -1
  52. package/src/superlocalmemory.egg-info/SOURCES.txt +18 -0
@@ -0,0 +1,703 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SuperLocalMemory V3.4.10 "Fortress" — Cloud Backup Infrastructure.
6
+
7
+ Pushes local SQLite backups to configured cloud destinations:
8
+ - Google Drive (OAuth2 + Drive API v3)
9
+ - GitHub (PAT + Releases API)
10
+
11
+ All credentials stored in OS keychain via `keyring` library.
12
+ All operations are non-blocking and failure-tolerant.
13
+
14
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import sqlite3
22
+ from datetime import datetime, UTC
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ logger = logging.getLogger("superlocalmemory.cloud_backup")
27
+
28
+ MEMORY_DIR = Path.home() / ".superlocalmemory"
29
+ DB_PATH = MEMORY_DIR / "memory.db"
30
+ KEYRING_SERVICE = "superlocalmemory"
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Credential management (OS keychain)
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def _get_credential_store() -> Path:
38
+ """Fallback encrypted credential file for systems without a keychain."""
39
+ return MEMORY_DIR / ".credentials.json"
40
+
41
+
42
+ def _store_credential(key: str, value: str) -> bool:
43
+ """Store a credential in the OS keychain (macOS/Windows/Linux).
44
+
45
+ Falls back to an encrypted local file on headless Linux or if
46
+ the keyring backend is unavailable.
47
+ """
48
+ # Try OS keychain first (macOS Keychain, Windows Credential Locker, Linux SecretService)
49
+ try:
50
+ import keyring
51
+ from keyring.errors import NoKeyringError
52
+ keyring.set_password(KEYRING_SERVICE, key, value)
53
+ return True
54
+ except (ImportError, NoKeyringError):
55
+ pass # No keyring backend — use fallback
56
+ except Exception as exc:
57
+ logger.debug("Keyring store failed, using fallback: %s", exc)
58
+
59
+ # Fallback: local file with restricted permissions (0600)
60
+ try:
61
+ store_path = _get_credential_store()
62
+ existing = {}
63
+ if store_path.exists():
64
+ existing = json.loads(store_path.read_text())
65
+ existing[key] = value
66
+ store_path.write_text(json.dumps(existing))
67
+ store_path.chmod(0o600) # Owner read/write only
68
+ return True
69
+ except Exception as exc:
70
+ logger.warning("Failed to store credential '%s': %s", key, exc)
71
+ return False
72
+
73
+
74
+ def _get_credential(key: str) -> str | None:
75
+ """Retrieve a credential from the OS keychain or fallback store."""
76
+ # Try OS keychain first
77
+ try:
78
+ import keyring
79
+ from keyring.errors import NoKeyringError
80
+ val = keyring.get_password(KEYRING_SERVICE, key)
81
+ if val is not None:
82
+ return val
83
+ except (ImportError, NoKeyringError):
84
+ pass
85
+ except Exception:
86
+ pass
87
+
88
+ # Fallback: local file
89
+ try:
90
+ store_path = _get_credential_store()
91
+ if store_path.exists():
92
+ data = json.loads(store_path.read_text())
93
+ return data.get(key)
94
+ except Exception:
95
+ pass
96
+
97
+ return None
98
+
99
+
100
+ def _delete_credential(key: str) -> bool:
101
+ """Delete a credential from the OS keychain and fallback store."""
102
+ deleted = False
103
+
104
+ try:
105
+ import keyring
106
+ from keyring.errors import NoKeyringError
107
+ keyring.delete_password(KEYRING_SERVICE, key)
108
+ deleted = True
109
+ except (ImportError, NoKeyringError):
110
+ pass
111
+ except Exception:
112
+ pass
113
+
114
+ # Also clean from fallback
115
+ try:
116
+ store_path = _get_credential_store()
117
+ if store_path.exists():
118
+ data = json.loads(store_path.read_text())
119
+ if key in data:
120
+ del data[key]
121
+ store_path.write_text(json.dumps(data))
122
+ store_path.chmod(0o600)
123
+ deleted = True
124
+ except Exception:
125
+ pass
126
+
127
+ return deleted
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Destination registry (backed by SQLite)
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def get_destinations(db_path: Path | None = None) -> list[dict[str, Any]]:
136
+ """List all configured backup destinations."""
137
+ path = db_path or DB_PATH
138
+ if not path.exists():
139
+ return []
140
+ conn = sqlite3.connect(str(path))
141
+ conn.row_factory = sqlite3.Row
142
+ try:
143
+ rows = conn.execute(
144
+ "SELECT * FROM backup_destinations ORDER BY created_at"
145
+ ).fetchall()
146
+ return [dict(r) for r in rows]
147
+ except sqlite3.OperationalError:
148
+ return []
149
+ finally:
150
+ conn.close()
151
+
152
+
153
+ def add_destination(
154
+ destination_type: str,
155
+ display_name: str,
156
+ config: dict[str, Any],
157
+ credentials_ref: str = "",
158
+ db_path: Path | None = None,
159
+ ) -> str:
160
+ """Register a new backup destination. Returns destination ID."""
161
+ from superlocalmemory.storage.models import _new_id
162
+
163
+ dest_id = _new_id()
164
+ path = db_path or DB_PATH
165
+ conn = sqlite3.connect(str(path))
166
+ try:
167
+ conn.execute(
168
+ "INSERT INTO backup_destinations "
169
+ "(id, destination_type, display_name, credentials_ref, config, "
170
+ "created_at, enabled) VALUES (?, ?, ?, ?, ?, ?, 1)",
171
+ (dest_id, destination_type, display_name, credentials_ref,
172
+ json.dumps(config), datetime.now(UTC).isoformat()),
173
+ )
174
+ conn.commit()
175
+ logger.info("Added backup destination: %s (%s)", display_name, destination_type)
176
+ return dest_id
177
+ finally:
178
+ conn.close()
179
+
180
+
181
+ def remove_destination(dest_id: str, db_path: Path | None = None) -> bool:
182
+ """Remove a backup destination and its credentials."""
183
+ path = db_path or DB_PATH
184
+ conn = sqlite3.connect(str(path))
185
+ try:
186
+ row = conn.execute(
187
+ "SELECT credentials_ref FROM backup_destinations WHERE id = ?",
188
+ (dest_id,),
189
+ ).fetchone()
190
+ if row and row[0]:
191
+ _delete_credential(row[0])
192
+ conn.execute("DELETE FROM backup_destinations WHERE id = ?", (dest_id,))
193
+ conn.commit()
194
+ return True
195
+ except Exception as exc:
196
+ logger.error("Failed to remove destination %s: %s", dest_id, exc)
197
+ return False
198
+ finally:
199
+ conn.close()
200
+
201
+
202
+ def update_sync_status(
203
+ dest_id: str,
204
+ status: str,
205
+ error: str = "",
206
+ db_path: Path | None = None,
207
+ ) -> None:
208
+ """Update the sync status of a destination."""
209
+ path = db_path or DB_PATH
210
+ conn = sqlite3.connect(str(path))
211
+ try:
212
+ conn.execute(
213
+ "UPDATE backup_destinations SET last_sync_at = ?, "
214
+ "last_sync_status = ?, last_sync_error = ? WHERE id = ?",
215
+ (datetime.now(UTC).isoformat(), status, error, dest_id),
216
+ )
217
+ conn.commit()
218
+ except Exception:
219
+ pass
220
+ finally:
221
+ conn.close()
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Google Drive integration
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ def connect_google_drive(auth_code: str, redirect_uri: str) -> dict[str, Any]:
230
+ """Complete Google Drive OAuth2 flow and register as a destination.
231
+
232
+ Args:
233
+ auth_code: Authorization code from OAuth2 consent screen redirect.
234
+ redirect_uri: The redirect URI used in the OAuth2 flow.
235
+
236
+ Returns:
237
+ Dict with destination_id, user_email, and status.
238
+ """
239
+ try:
240
+ from google.oauth2.credentials import Credentials
241
+ from google_auth_oauthlib.flow import Flow
242
+ except ImportError:
243
+ return {"error": "google-auth-oauthlib not installed. Run: pip install google-auth-oauthlib google-api-python-client"}
244
+
245
+ # OAuth2 client config (public client — no secret needed for installed apps)
246
+ client_config = {
247
+ "installed": {
248
+ "client_id": _get_credential("gdrive_client_id") or "",
249
+ "client_secret": _get_credential("gdrive_client_secret") or "",
250
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
251
+ "token_uri": "https://oauth2.googleapis.com/token",
252
+ "redirect_uris": ["http://localhost"],
253
+ }
254
+ }
255
+
256
+ if not client_config["installed"]["client_id"]:
257
+ return {"error": "Google OAuth client not configured. Set client_id via dashboard."}
258
+
259
+ try:
260
+ flow = Flow.from_client_config(
261
+ client_config,
262
+ scopes=[
263
+ "openid",
264
+ "https://www.googleapis.com/auth/drive.file",
265
+ "https://www.googleapis.com/auth/userinfo.email",
266
+ ],
267
+ redirect_uri=redirect_uri,
268
+ )
269
+ flow.fetch_token(code=auth_code)
270
+ creds = flow.credentials
271
+
272
+ # Store refresh token in keychain
273
+ cred_key = "gdrive_refresh_token"
274
+ _store_credential(cred_key, creds.refresh_token or "")
275
+ _store_credential("gdrive_access_token", creds.token or "")
276
+
277
+ # Get user email for display
278
+ from googleapiclient.discovery import build
279
+ service = build("oauth2", "v2", credentials=creds)
280
+ user_info = service.userinfo().get().execute()
281
+ email = user_info.get("email", "unknown")
282
+
283
+ # Register destination
284
+ dest_id = add_destination(
285
+ destination_type="google_drive",
286
+ display_name=f"Google Drive ({email})",
287
+ config={"email": email, "folder": "SLM-Backup"},
288
+ credentials_ref=cred_key,
289
+ )
290
+
291
+ return {"destination_id": dest_id, "email": email, "status": "connected"}
292
+
293
+ except Exception as exc:
294
+ logger.error("Google Drive connection failed: %s", exc)
295
+ return {"error": str(exc)}
296
+
297
+
298
+ def _get_drive_service() -> Any | None:
299
+ """Build an authenticated Google Drive service."""
300
+ try:
301
+ from google.oauth2.credentials import Credentials
302
+ from googleapiclient.discovery import build
303
+
304
+ refresh_token = _get_credential("gdrive_refresh_token")
305
+ client_id = _get_credential("gdrive_client_id")
306
+ client_secret = _get_credential("gdrive_client_secret")
307
+
308
+ if not all([refresh_token, client_id, client_secret]):
309
+ return None
310
+
311
+ creds = Credentials(
312
+ token=_get_credential("gdrive_access_token"),
313
+ refresh_token=refresh_token,
314
+ token_uri="https://oauth2.googleapis.com/token",
315
+ client_id=client_id,
316
+ client_secret=client_secret,
317
+ )
318
+ return build("drive", "v3", credentials=creds)
319
+ except Exception as exc:
320
+ logger.warning("Failed to build Drive service: %s", exc)
321
+ return None
322
+
323
+
324
+ def sync_to_google_drive(backup_path: Path, dest_config: dict) -> bool:
325
+ """Upload a backup file to Google Drive."""
326
+ service = _get_drive_service()
327
+ if service is None:
328
+ logger.warning("Google Drive not connected")
329
+ return False
330
+
331
+ try:
332
+ from googleapiclient.http import MediaFileUpload
333
+
334
+ folder_name = dest_config.get("folder", "SLM-Backup")
335
+
336
+ # Find or create the SLM-Backup folder
337
+ query = f"name='{folder_name}' and mimeType='application/vnd.google-apps.folder' and trashed=false"
338
+ results = service.files().list(q=query, spaces="drive", fields="files(id)").execute()
339
+ folders = results.get("files", [])
340
+
341
+ if folders:
342
+ folder_id = folders[0]["id"]
343
+ else:
344
+ folder_meta = {
345
+ "name": folder_name,
346
+ "mimeType": "application/vnd.google-apps.folder",
347
+ }
348
+ folder = service.files().create(body=folder_meta, fields="id").execute()
349
+ folder_id = folder["id"]
350
+
351
+ # Upload the backup file
352
+ file_meta = {"name": backup_path.name, "parents": [folder_id]}
353
+
354
+ # Check if file already exists (update instead of create duplicate)
355
+ existing = service.files().list(
356
+ q=f"name='{backup_path.name}' and '{folder_id}' in parents and trashed=false",
357
+ spaces="drive",
358
+ fields="files(id)",
359
+ ).execute().get("files", [])
360
+
361
+ media = MediaFileUpload(
362
+ str(backup_path),
363
+ mimetype="application/x-sqlite3",
364
+ resumable=True,
365
+ )
366
+
367
+ if existing:
368
+ service.files().update(
369
+ fileId=existing[0]["id"],
370
+ media_body=media,
371
+ ).execute()
372
+ else:
373
+ service.files().create(
374
+ body=file_meta,
375
+ media_body=media,
376
+ fields="id",
377
+ ).execute()
378
+
379
+ logger.info("Uploaded %s to Google Drive/%s", backup_path.name, folder_name)
380
+ return True
381
+
382
+ except Exception as exc:
383
+ logger.error("Google Drive upload failed: %s", exc)
384
+ return False
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # GitHub integration
389
+ # ---------------------------------------------------------------------------
390
+
391
+
392
+ def connect_github(pat: str, repo_name: str = "slm-backup") -> dict[str, Any]:
393
+ """Register GitHub as a backup destination using a PAT.
394
+
395
+ Args:
396
+ pat: Personal Access Token with 'repo' scope.
397
+ repo_name: Name for the backup repo (created if doesn't exist).
398
+
399
+ Returns:
400
+ Dict with destination_id, username, repo, and status.
401
+ """
402
+ import httpx
403
+
404
+ headers = {
405
+ "Authorization": f"token {pat}",
406
+ "Accept": "application/vnd.github.v3+json",
407
+ }
408
+
409
+ try:
410
+ # Verify PAT and get username
411
+ resp = httpx.get("https://api.github.com/user", headers=headers, timeout=15)
412
+ if resp.status_code != 200:
413
+ return {"error": f"Invalid GitHub token (HTTP {resp.status_code})"}
414
+
415
+ username = resp.json()["login"]
416
+ full_repo = f"{username}/{repo_name}"
417
+
418
+ # Check if repo exists, create if not
419
+ resp = httpx.get(f"https://api.github.com/repos/{full_repo}", headers=headers, timeout=15)
420
+ repo_created = False
421
+ if resp.status_code == 404:
422
+ resp = httpx.post(
423
+ "https://api.github.com/user/repos",
424
+ headers=headers,
425
+ json={
426
+ "name": repo_name,
427
+ "private": True,
428
+ "description": "SuperLocalMemory automated backup — managed by SLM",
429
+ "auto_init": True, # Creates initial commit with README
430
+ },
431
+ timeout=15,
432
+ )
433
+ if resp.status_code not in (200, 201):
434
+ return {"error": f"Failed to create repo: {resp.text}"}
435
+ repo_created = True
436
+
437
+ # Ensure repo has at least one commit (for Releases API to work)
438
+ if not repo_created:
439
+ commits_resp = httpx.get(
440
+ f"https://api.github.com/repos/{full_repo}/commits",
441
+ headers=headers,
442
+ params={"per_page": 1},
443
+ timeout=15,
444
+ )
445
+ if commits_resp.status_code != 200 or not commits_resp.json():
446
+ # Empty repo — create initial file via Contents API
447
+ import base64
448
+ readme = base64.b64encode(
449
+ b"# SLM Backup\nAutomated SuperLocalMemory backup.\nEach release = one backup snapshot.\n"
450
+ ).decode()
451
+ httpx.put(
452
+ f"https://api.github.com/repos/{full_repo}/contents/README.md",
453
+ headers=headers,
454
+ json={"message": "init: SLM backup repo", "content": readme},
455
+ timeout=15,
456
+ )
457
+
458
+ # Store PAT in keychain
459
+ cred_key = "github_pat"
460
+ _store_credential(cred_key, pat)
461
+
462
+ # Register destination
463
+ dest_id = add_destination(
464
+ destination_type="github",
465
+ display_name=f"GitHub ({full_repo})",
466
+ config={"username": username, "repo": repo_name, "full_repo": full_repo},
467
+ credentials_ref=cred_key,
468
+ )
469
+
470
+ return {"destination_id": dest_id, "username": username, "repo": full_repo, "status": "connected"}
471
+
472
+ except Exception as exc:
473
+ logger.error("GitHub connection failed: %s", exc)
474
+ return {"error": str(exc)}
475
+
476
+
477
+ def sync_to_github(backup_files: list[Path] | Path, dest_config: dict) -> bool:
478
+ """Upload backup files as GitHub Release assets.
479
+
480
+ Creates ONE release per sync, with ALL database backups as assets.
481
+ Uses Releases API to avoid Git LFS (100MB file limit per asset,
482
+ 2GB per release — plenty for SLM databases).
483
+
484
+ Args:
485
+ backup_files: Single path or list of paths to upload.
486
+ dest_config: Destination config with full_repo key.
487
+ """
488
+ import httpx
489
+
490
+ # Accept both single path and list
491
+ if isinstance(backup_files, Path):
492
+ backup_files = [backup_files]
493
+
494
+ pat = _get_credential("github_pat")
495
+ if not pat:
496
+ logger.warning("GitHub PAT not found in keychain")
497
+ return False
498
+
499
+ headers = {
500
+ "Authorization": f"token {pat}",
501
+ "Accept": "application/vnd.github.v3+json",
502
+ }
503
+
504
+ full_repo = dest_config.get("full_repo", "")
505
+ if not full_repo:
506
+ return False
507
+
508
+ try:
509
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
510
+ tag_name = f"backup-{timestamp}"
511
+ total_mb = sum(f.stat().st_size for f in backup_files) / (1024 * 1024)
512
+ file_list = ", ".join(f"{f.name} ({f.stat().st_size / 1024 / 1024:.1f} MB)" for f in backup_files)
513
+
514
+ # Create ONE release for all files
515
+ release_resp = httpx.post(
516
+ f"https://api.github.com/repos/{full_repo}/releases",
517
+ headers=headers,
518
+ json={
519
+ "tag_name": tag_name,
520
+ "name": f"SLM Backup {timestamp}",
521
+ "body": f"Automated backup — {len(backup_files)} databases ({total_mb:.1f} MB total)\n\n{file_list}",
522
+ "draft": False,
523
+ "prerelease": False,
524
+ },
525
+ timeout=30,
526
+ )
527
+
528
+ if release_resp.status_code not in (200, 201):
529
+ logger.error("GitHub release creation failed: %s", release_resp.text[:200])
530
+ return False
531
+
532
+ upload_url = release_resp.json()["upload_url"].split("{")[0]
533
+ uploaded = 0
534
+
535
+ # Upload each database as a separate asset on the same release
536
+ for backup_path in backup_files:
537
+ try:
538
+ with open(backup_path, "rb") as f:
539
+ upload_resp = httpx.post(
540
+ upload_url,
541
+ params={"name": backup_path.name},
542
+ headers={
543
+ "Authorization": f"token {pat}",
544
+ "Content-Type": "application/octet-stream",
545
+ },
546
+ content=f.read(),
547
+ timeout=600,
548
+ )
549
+ if upload_resp.status_code in (200, 201):
550
+ uploaded += 1
551
+ logger.info("Uploaded %s to release %s", backup_path.name, tag_name)
552
+ else:
553
+ logger.warning("Failed to upload %s: %s", backup_path.name, upload_resp.text[:100])
554
+ except Exception as exc:
555
+ logger.warning("Failed to upload %s: %s", backup_path.name, exc)
556
+
557
+ logger.info("GitHub sync: %d/%d files uploaded to release %s", uploaded, len(backup_files), tag_name)
558
+
559
+ # Cleanup: keep only last MAX_GITHUB_RELEASES releases to prevent repo bloat
560
+ _cleanup_old_releases(full_repo, headers)
561
+
562
+ return uploaded > 0
563
+
564
+ except Exception as exc:
565
+ logger.error("GitHub sync failed: %s", exc)
566
+ return False
567
+
568
+
569
+ MAX_GITHUB_RELEASES = 5 # Keep last 5 backups, delete older ones
570
+
571
+
572
+ def _cleanup_old_releases(full_repo: str, headers: dict) -> None:
573
+ """Delete old GitHub releases to prevent repo storage from exploding.
574
+
575
+ Keeps the most recent MAX_GITHUB_RELEASES releases and deletes the rest.
576
+ """
577
+ import httpx
578
+
579
+ try:
580
+ resp = httpx.get(
581
+ f"https://api.github.com/repos/{full_repo}/releases",
582
+ headers=headers,
583
+ params={"per_page": 100},
584
+ timeout=15,
585
+ )
586
+ if resp.status_code != 200:
587
+ return
588
+
589
+ releases = resp.json()
590
+ if len(releases) <= MAX_GITHUB_RELEASES:
591
+ return
592
+
593
+ # Sort by creation date (newest first), delete everything after MAX
594
+ releases.sort(key=lambda r: r.get("created_at", ""), reverse=True)
595
+ to_delete = releases[MAX_GITHUB_RELEASES:]
596
+
597
+ for release in to_delete:
598
+ release_id = release["id"]
599
+ tag = release.get("tag_name", "")
600
+
601
+ # Delete release
602
+ httpx.delete(
603
+ f"https://api.github.com/repos/{full_repo}/releases/{release_id}",
604
+ headers=headers,
605
+ timeout=15,
606
+ )
607
+ # Delete the tag too (releases leave orphan tags)
608
+ httpx.delete(
609
+ f"https://api.github.com/repos/{full_repo}/git/refs/tags/{tag}",
610
+ headers=headers,
611
+ timeout=15,
612
+ )
613
+ logger.info("Cleaned up old release: %s", tag)
614
+
615
+ logger.info("GitHub cleanup: removed %d old releases, kept %d", len(to_delete), MAX_GITHUB_RELEASES)
616
+
617
+ except Exception as exc:
618
+ logger.warning("GitHub release cleanup failed (non-critical): %s", exc)
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # Sync orchestrator
623
+ # ---------------------------------------------------------------------------
624
+
625
+
626
+ def _find_latest_backup_set(backup_dir: Path) -> list[Path]:
627
+ """Find the latest complete backup set (all DBs from the same timestamp).
628
+
629
+ Returns list of backup files (memory + all companions) from the most
630
+ recent backup run.
631
+ """
632
+ # Find latest memory backup to get the timestamp
633
+ memory_backups = sorted(
634
+ backup_dir.glob("memory-*.db"),
635
+ key=lambda f: f.stat().st_mtime,
636
+ reverse=True,
637
+ )
638
+ if not memory_backups:
639
+ return []
640
+
641
+ latest = memory_backups[0]
642
+ # Extract timestamp+suffix from "memory-20260414-212803-cloud-sync.db"
643
+ ts_suffix = latest.name.replace("memory-", "").replace(".db", "")
644
+
645
+ # Find all companion DB backups with the same timestamp
646
+ backup_set = [latest]
647
+ for f in backup_dir.iterdir():
648
+ if f == latest or not f.name.endswith(".db"):
649
+ continue
650
+ if ts_suffix in f.name:
651
+ backup_set.append(f)
652
+
653
+ return backup_set
654
+
655
+
656
+ def sync_all_destinations(db_path: Path | None = None) -> dict[str, Any]:
657
+ """Sync latest backup set (ALL databases) to all enabled cloud destinations."""
658
+ path = db_path or DB_PATH
659
+ results: dict[str, Any] = {}
660
+
661
+ destinations = get_destinations(path)
662
+ if not destinations:
663
+ return {"synced": 0, "message": "No cloud destinations configured"}
664
+
665
+ backup_dir = path.parent / "backups"
666
+ if not backup_dir.exists():
667
+ return {"synced": 0, "message": "No backups directory"}
668
+
669
+ backup_set = _find_latest_backup_set(backup_dir)
670
+ if not backup_set:
671
+ return {"synced": 0, "message": "No backup files found"}
672
+
673
+ synced = 0
674
+ db_names = [f.name for f in backup_set]
675
+ total_size_mb = sum(f.stat().st_size for f in backup_set) / (1024 * 1024)
676
+
677
+ for dest in destinations:
678
+ if not dest.get("enabled"):
679
+ continue
680
+
681
+ dest_id = dest["id"]
682
+ dest_type = dest["destination_type"]
683
+ config = json.loads(dest.get("config", "{}"))
684
+
685
+ try:
686
+ if dest_type == "google_drive":
687
+ ok = all(sync_to_google_drive(f, config) for f in backup_set)
688
+ elif dest_type == "github":
689
+ ok = sync_to_github(backup_set, config)
690
+ else:
691
+ ok = False
692
+
693
+ status = "success" if ok else "failed"
694
+ update_sync_status(dest_id, status, db_path=path)
695
+ if ok:
696
+ synced += 1
697
+ results[dest_id] = {"type": dest_type, "status": status}
698
+
699
+ except Exception as exc:
700
+ update_sync_status(dest_id, "failed", str(exc), db_path=path)
701
+ results[dest_id] = {"type": dest_type, "status": "failed", "error": str(exc)}
702
+
703
+ return {"synced": synced, "total": len(destinations), "results": results}