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.
- package/README.md +23 -3
- package/docs/cloud-backup.md +174 -0
- package/docs/skill-evolution.md +256 -0
- package/ide/hooks/tool-event-hook.sh +101 -11
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/cli/commands.py +359 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +81 -29
- package/src/superlocalmemory/cli/main.py +32 -0
- package/src/superlocalmemory/cli/setup_wizard.py +54 -11
- package/src/superlocalmemory/core/config.py +35 -0
- package/src/superlocalmemory/core/consolidation_engine.py +138 -0
- package/src/superlocalmemory/core/embedding_worker.py +1 -1
- package/src/superlocalmemory/core/engine.py +19 -0
- package/src/superlocalmemory/core/fact_consolidator.py +425 -0
- package/src/superlocalmemory/core/graph_pruner.py +290 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -3
- package/src/superlocalmemory/core/recall_pipeline.py +9 -0
- package/src/superlocalmemory/core/tier_manager.py +325 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +96 -28
- package/src/superlocalmemory/evolution/__init__.py +29 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
- package/src/superlocalmemory/evolution/evolution_store.py +302 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
- package/src/superlocalmemory/evolution/triggers.py +367 -0
- package/src/superlocalmemory/evolution/types.py +92 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
- package/src/superlocalmemory/infra/backup.py +63 -20
- package/src/superlocalmemory/infra/cloud_backup.py +703 -0
- package/src/superlocalmemory/learning/skill_performance_miner.py +422 -0
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
- package/src/superlocalmemory/retrieval/engine.py +64 -4
- package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
- package/src/superlocalmemory/retrieval/strategy.py +2 -2
- package/src/superlocalmemory/server/routes/backup.py +512 -8
- package/src/superlocalmemory/server/routes/behavioral.py +39 -17
- package/src/superlocalmemory/server/routes/evolution.py +213 -0
- package/src/superlocalmemory/server/routes/tiers.py +195 -0
- package/src/superlocalmemory/server/unified_daemon.py +36 -5
- package/src/superlocalmemory/storage/schema_v3410.py +159 -0
- package/src/superlocalmemory/storage/schema_v3411.py +149 -0
- package/src/superlocalmemory/ui/index.html +59 -3
- package/src/superlocalmemory/ui/js/core.js +3 -0
- package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
- package/src/superlocalmemory/ui/js/ng-entities.js +27 -3
- package/src/superlocalmemory/ui/js/ng-shell.js +33 -0
- package/src/superlocalmemory/ui/js/ng-skills.js +611 -0
- package/src/superlocalmemory/ui/js/settings.js +311 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +16 -1
- 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}
|