superlocalmemory 3.4.8 → 3.4.10

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.
@@ -1,43 +1,84 @@
1
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
- """SuperLocalMemory V3 - Backup Routes
5
- - AGPL-3.0-or-later
4
+ """SuperLocalMemory V3.4.10 "Fortress" - Backup Routes
6
5
 
7
- Routes: /api/backup/status, /api/backup/create, /api/backup/configure, /api/backup/list
8
- Uses V3 infra.backup.BackupManager.
6
+ Routes:
7
+ Local: /api/backup/status, /api/backup/create, /api/backup/configure, /api/backup/list
8
+ Cloud: /api/backup/destinations, /api/backup/connect/github, /api/backup/connect/gdrive,
9
+ /api/backup/disconnect/{id}, /api/backup/sync, /api/backup/export
9
10
  """
10
11
  import logging
12
+ import gzip
13
+ import hashlib
14
+ import shutil
15
+ import urllib.parse
11
16
 
12
- from fastapi import APIRouter, HTTPException
17
+ from fastapi import APIRouter, HTTPException, Request
18
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
19
+ from pydantic import BaseModel, Field
20
+ from typing import Optional
13
21
 
14
22
  from .helpers import BackupConfigRequest, DB_PATH, MEMORY_DIR
15
23
 
16
24
  logger = logging.getLogger("superlocalmemory.routes.backup")
17
25
  router = APIRouter()
18
26
 
19
- # Feature flag
27
+ # Feature flags
20
28
  BACKUP_AVAILABLE = False
29
+ CLOUD_AVAILABLE = False
21
30
  try:
22
31
  from superlocalmemory.infra.backup import BackupManager
23
32
  BACKUP_AVAILABLE = True
24
33
  except ImportError:
25
34
  pass
26
35
 
36
+ try:
37
+ from superlocalmemory.infra.cloud_backup import (
38
+ get_destinations, add_destination, remove_destination,
39
+ connect_github, connect_google_drive,
40
+ sync_all_destinations, update_sync_status,
41
+ )
42
+ CLOUD_AVAILABLE = True
43
+ except ImportError:
44
+ pass
45
+
27
46
 
28
47
  def _get_backup_manager() -> "BackupManager":
29
48
  """Get V3 backup manager instance."""
30
49
  return BackupManager(db_path=DB_PATH, base_dir=MEMORY_DIR)
31
50
 
32
51
 
52
+ # ---- Request models -------------------------------------------------------
53
+
54
+ class GitHubConnectRequest(BaseModel):
55
+ pat: str = Field(..., min_length=1)
56
+ repo_name: str = Field(default="slm-backup")
57
+
58
+ class GDriveConnectRequest(BaseModel):
59
+ auth_code: str = Field(..., min_length=1)
60
+ redirect_uri: str = Field(default="http://localhost:8765/api/backup/oauth/callback")
61
+
62
+ class GDriveClientConfig(BaseModel):
63
+ client_id: str = Field(..., min_length=1)
64
+ client_secret: str = Field(..., min_length=1)
65
+
66
+
67
+ # ---- Local backup routes (existing) ---------------------------------------
68
+
33
69
  @router.get("/api/backup/status")
34
70
  async def backup_status():
35
- """Get auto-backup system status."""
71
+ """Get auto-backup system status + cloud destinations."""
36
72
  if not BACKUP_AVAILABLE:
37
73
  return {"status": "not_implemented", "message": "Backup module not available"}
38
74
  try:
39
75
  manager = _get_backup_manager()
40
- return manager.get_status()
76
+ status = manager.get_status()
77
+ if CLOUD_AVAILABLE:
78
+ status["cloud_destinations"] = get_destinations(DB_PATH)
79
+ else:
80
+ status["cloud_destinations"] = []
81
+ return status
41
82
  except Exception as e:
42
83
  raise HTTPException(status_code=500, detail=f"Backup status error: {str(e)}")
43
84
 
@@ -89,3 +130,466 @@ async def backup_list():
89
130
  return {"backups": backups, "count": len(backups)}
90
131
  except Exception as e:
91
132
  raise HTTPException(status_code=500, detail=f"Backup list error: {str(e)}")
133
+
134
+
135
+ # ---- Cloud destination routes (v3.4.10) -----------------------------------
136
+
137
+ @router.get("/api/backup/destinations")
138
+ async def list_destinations():
139
+ """List all configured cloud backup destinations."""
140
+ if not CLOUD_AVAILABLE:
141
+ return {"destinations": [], "cloud_available": False}
142
+ return {"destinations": get_destinations(DB_PATH), "cloud_available": True}
143
+
144
+
145
+ @router.post("/api/backup/connect/github")
146
+ async def connect_github_route(request: GitHubConnectRequest):
147
+ """Connect GitHub as a backup destination using PAT."""
148
+ if not CLOUD_AVAILABLE:
149
+ raise HTTPException(status_code=501, detail="Cloud backup module not available")
150
+ result = connect_github(request.pat, request.repo_name)
151
+ if "error" in result:
152
+ raise HTTPException(status_code=400, detail=result["error"])
153
+ return result
154
+
155
+
156
+ @router.post("/api/backup/connect/gdrive/config")
157
+ async def configure_gdrive_client(request: GDriveClientConfig):
158
+ """Store Google OAuth client credentials (one-time setup)."""
159
+ if not CLOUD_AVAILABLE:
160
+ raise HTTPException(status_code=501, detail="Cloud backup module not available")
161
+ from superlocalmemory.infra.cloud_backup import _store_credential
162
+ _store_credential("gdrive_client_id", request.client_id)
163
+ _store_credential("gdrive_client_secret", request.client_secret)
164
+ return {"success": True, "message": "Google OAuth client configured"}
165
+
166
+
167
+ @router.post("/api/backup/connect/gdrive")
168
+ async def connect_gdrive_route(request: GDriveConnectRequest):
169
+ """Complete Google Drive OAuth2 flow with authorization code."""
170
+ if not CLOUD_AVAILABLE:
171
+ raise HTTPException(status_code=501, detail="Cloud backup module not available")
172
+ result = connect_google_drive(request.auth_code, request.redirect_uri)
173
+ if "error" in result:
174
+ raise HTTPException(status_code=400, detail=result["error"])
175
+ return result
176
+
177
+
178
+ @router.delete("/api/backup/disconnect/{dest_id}")
179
+ async def disconnect_destination(dest_id: str):
180
+ """Remove a cloud backup destination."""
181
+ if not CLOUD_AVAILABLE:
182
+ raise HTTPException(status_code=501, detail="Cloud backup module not available")
183
+ ok = remove_destination(dest_id, DB_PATH)
184
+ if not ok:
185
+ raise HTTPException(status_code=404, detail="Destination not found")
186
+ return {"success": True, "message": "Destination disconnected"}
187
+
188
+
189
+ @router.post("/api/backup/sync")
190
+ async def sync_cloud():
191
+ """Manually trigger sync to all cloud destinations.
192
+
193
+ Runs the upload in a background thread so it doesn't block the
194
+ dashboard. Returns immediately with status 'syncing'. The actual
195
+ upload status is reflected in the destination's last_sync_status.
196
+ """
197
+ import asyncio
198
+ import threading
199
+
200
+ if not CLOUD_AVAILABLE:
201
+ raise HTTPException(status_code=501, detail="Cloud backup module not available")
202
+ if not BACKUP_AVAILABLE:
203
+ raise HTTPException(status_code=501, detail="Backup module not available")
204
+
205
+ # Create backup synchronously (fast — SQLite .backup is ~2s)
206
+ manager = _get_backup_manager()
207
+ filename = manager.create_backup(label="cloud-sync")
208
+ if not filename:
209
+ raise HTTPException(status_code=500, detail="Failed to create backup")
210
+
211
+ # Run the cloud upload in a background thread (non-blocking)
212
+ def _sync_background():
213
+ try:
214
+ sync_all_destinations(DB_PATH)
215
+ except Exception as exc:
216
+ logger.error("Background cloud sync failed: %s", exc)
217
+
218
+ thread = threading.Thread(target=_sync_background, daemon=True)
219
+ thread.start()
220
+
221
+ return {
222
+ "success": True,
223
+ "backup": filename,
224
+ "sync": {"status": "syncing", "message": "Upload started in background. Check destination status for progress."},
225
+ }
226
+
227
+
228
+ # ---- Export / Download route (v3.4.10) ------------------------------------
229
+
230
+ @router.get("/api/backup/export")
231
+ async def export_backup():
232
+ """Create and download a compressed backup archive."""
233
+ if not BACKUP_AVAILABLE:
234
+ raise HTTPException(status_code=501, detail="Backup module not available")
235
+
236
+ manager = _get_backup_manager()
237
+ filename = manager.create_backup(label="export")
238
+ if not filename:
239
+ raise HTTPException(status_code=500, detail="Failed to create backup")
240
+
241
+ backup_path = MEMORY_DIR / "backups" / filename
242
+ if not backup_path.exists():
243
+ raise HTTPException(status_code=500, detail="Backup file not found")
244
+
245
+ # Compress for download
246
+ gz_path = backup_path.with_suffix(".db.gz")
247
+ with open(backup_path, "rb") as f_in:
248
+ with gzip.open(gz_path, "wb") as f_out:
249
+ shutil.copyfileobj(f_in, f_out)
250
+
251
+ return FileResponse(
252
+ path=str(gz_path),
253
+ media_type="application/gzip",
254
+ filename=gz_path.name,
255
+ )
256
+
257
+
258
+ # ---- OAuth SSO Flows (v3.4.10) -------------------------------------------
259
+ # These routes handle the browser popup flow:
260
+ # 1. /start → redirect to provider's login page
261
+ # 2. Provider redirects back to /callback with auth code
262
+ # 3. /callback exchanges code for tokens, stores in keychain, shows success page
263
+
264
+ _OAUTH_SUCCESS_HTML = """<!DOCTYPE html>
265
+ <html><head><title>Connected!</title>
266
+ <style>
267
+ body {{ font-family: -apple-system, system-ui, sans-serif; background: #0a0a0f; color: #e0e0e0;
268
+ display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }}
269
+ .card {{ background: rgba(255,255,255,0.05); border: 1px solid rgba(0,212,170,0.3);
270
+ border-radius: 16px; padding: 40px; text-align: center; max-width: 400px; }}
271
+ .icon {{ font-size: 48px; margin-bottom: 16px; }}
272
+ h2 {{ color: #00D4AA; margin: 0 0 8px; }}
273
+ p {{ color: #999; margin: 0 0 20px; }}
274
+ .btn {{ background: #00D4AA; color: #0a0a0f; border: none; padding: 10px 24px;
275
+ border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }}
276
+ .btn:hover {{ background: #00b894; }}
277
+ </style></head>
278
+ <body><div class="card">
279
+ <div class="icon">{icon}</div>
280
+ <h2>{title}</h2>
281
+ <p>{message}</p>
282
+ <button class="btn" onclick="window.close()">Close Window</button>
283
+ </div></body></html>"""
284
+
285
+ _OAUTH_ERROR_HTML = """<!DOCTYPE html>
286
+ <html><head><title>Connection Failed</title>
287
+ <style>
288
+ body {{ font-family: -apple-system, system-ui, sans-serif; background: #0a0a0f; color: #e0e0e0;
289
+ display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }}
290
+ .card {{ background: rgba(255,255,255,0.05); border: 1px solid rgba(255,71,87,0.3);
291
+ border-radius: 16px; padding: 40px; text-align: center; max-width: 400px; }}
292
+ .icon {{ font-size: 48px; margin-bottom: 16px; }}
293
+ h2 {{ color: #ff4757; margin: 0 0 8px; }}
294
+ p {{ color: #999; margin: 0 0 20px; font-size: 13px; }}
295
+ .btn {{ background: #333; color: #e0e0e0; border: none; padding: 10px 24px;
296
+ border-radius: 8px; cursor: pointer; font-size: 14px; }}
297
+ </style></head>
298
+ <body><div class="card">
299
+ <div class="icon">{icon}</div>
300
+ <h2>Connection Failed</h2>
301
+ <p>{error}</p>
302
+ <button class="btn" onclick="window.close()">Close</button>
303
+ </div></body></html>"""
304
+
305
+
306
+ # ---- Google OAuth SSO Flow ------------------------------------------------
307
+
308
+ @router.get("/api/backup/oauth/google/start")
309
+ async def google_oauth_start(request: Request):
310
+ """Start Google OAuth2 flow — redirects to Google's login page."""
311
+ if not CLOUD_AVAILABLE:
312
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x26A0;", error="Cloud backup module not available"))
313
+
314
+ from superlocalmemory.infra.cloud_backup import _get_credential
315
+
316
+ client_id = _get_credential("gdrive_client_id")
317
+ if not client_id:
318
+ return HTMLResponse("""<!DOCTYPE html>
319
+ <html><head><title>Set Up Google Drive</title>
320
+ <style>
321
+ body { font-family: -apple-system, system-ui, sans-serif; background: #0a0a0f; color: #e0e0e0;
322
+ display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 20px; }
323
+ .card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
324
+ border-radius: 16px; padding: 32px; max-width: 520px; width: 100%; }
325
+ h2 { color: #e0e0e0; margin: 0 0 4px; font-size: 20px; }
326
+ .sub { color: #999; font-size: 13px; margin-bottom: 20px; }
327
+ .step { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
328
+ .step-num { display: inline-block; width: 24px; height: 24px; border-radius: 50%;
329
+ background: rgba(0,212,170,0.15); color: #00D4AA; text-align: center; line-height: 24px;
330
+ font-size: 12px; font-weight: 600; margin-right: 8px; }
331
+ .step-text { color: #ccc; font-size: 13px; }
332
+ code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
333
+ .fields { margin-top: 20px; }
334
+ label { display: block; color: #bbb; font-size: 12px; margin-bottom: 4px; }
335
+ input { width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 8px;
336
+ border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
337
+ color: #e0e0e0; font-size: 13px; margin-bottom: 12px; }
338
+ input:focus { outline: none; border-color: #00D4AA; }
339
+ .btn { background: #00D4AA; color: #0a0a0f; border: none; padding: 10px 24px;
340
+ border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; width: 100%; }
341
+ .btn:hover { background: #00b894; }
342
+ .btn:disabled { opacity: 0.5; }
343
+ #status { margin-top: 8px; font-size: 12px; }
344
+ a { color: #00D4AA; }
345
+ </style></head>
346
+ <body><div class="card">
347
+ <h2>Connect Google Drive</h2>
348
+ <p class="sub">Google Drive backup requires a one-time OAuth setup. Follow these steps:</p>
349
+
350
+ <div class="step"><span class="step-num">1</span>
351
+ <span class="step-text">Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a> and create a project</span></div>
352
+
353
+ <div class="step"><span class="step-num">2</span>
354
+ <span class="step-text">Enable <strong>Google Drive API</strong> and <strong>People API</strong></span></div>
355
+
356
+ <div class="step"><span class="step-num">3</span>
357
+ <span class="step-text">Go to <strong>OAuth consent screen</strong> &rarr; External &rarr; add your email as test user</span></div>
358
+
359
+ <div class="step"><span class="step-num">4</span>
360
+ <span class="step-text">Go to <strong>Credentials</strong> &rarr; Create OAuth Client &rarr; <strong>Web application</strong></span></div>
361
+
362
+ <div class="step"><span class="step-num">5</span>
363
+ <span class="step-text">Add redirect URI: <code>http://localhost:8765/api/backup/oauth/google/callback</code></span></div>
364
+
365
+ <div class="step" style="border:none"><span class="step-num">6</span>
366
+ <span class="step-text">Paste Client ID and Secret below:</span></div>
367
+
368
+ <div class="fields">
369
+ <label>Client ID</label>
370
+ <input type="text" id="cid" placeholder="xxxx.apps.googleusercontent.com">
371
+ <label>Client Secret</label>
372
+ <input type="password" id="csec" placeholder="GOCSPX-xxxx">
373
+ <button class="btn" id="saveBtn" onclick="saveAndConnect()">Save &amp; Connect Google Drive</button>
374
+ <div id="status"></div>
375
+ </div>
376
+
377
+ <p style="color:#555;font-size:11px;margin-top:16px;">
378
+ Your credentials are stored in your OS keychain (macOS Keychain / Windows Credential Locker) &mdash; never in plaintext.
379
+ Full guide: <a href="https://github.com/qualixar/superlocalmemory/wiki/Cloud-Backup#google-drive-backup" target="_blank">Cloud Backup Wiki</a>
380
+ </p>
381
+ </div>
382
+ <script>
383
+ async function saveAndConnect() {
384
+ var cid = document.getElementById('cid').value.trim();
385
+ var csec = document.getElementById('csec').value.trim();
386
+ if (!cid || !csec) { document.getElementById('status').innerHTML = '<span style="color:#ff4757">Both fields required</span>'; return; }
387
+ document.getElementById('saveBtn').disabled = true;
388
+ document.getElementById('status').innerHTML = '<span style="color:#999">Saving...</span>';
389
+ try {
390
+ var resp = await fetch('/api/backup/connect/gdrive/config', {
391
+ method: 'POST', headers: {'Content-Type': 'application/json'},
392
+ body: JSON.stringify({client_id: cid, client_secret: csec})
393
+ });
394
+ if (resp.ok) {
395
+ window.location.href = '/api/backup/oauth/google/start';
396
+ } else {
397
+ document.getElementById('status').innerHTML = '<span style="color:#ff4757">Failed to save</span>';
398
+ document.getElementById('saveBtn').disabled = false;
399
+ }
400
+ } catch(e) {
401
+ document.getElementById('status').innerHTML = '<span style="color:#ff4757">Error: ' + e.message + '</span>';
402
+ document.getElementById('saveBtn').disabled = false;
403
+ }
404
+ }
405
+ </script></body></html>""")
406
+
407
+ # Build the Google OAuth URL
408
+ base_url = str(request.base_url).rstrip("/")
409
+ redirect_uri = f"{base_url}/api/backup/oauth/google/callback"
410
+
411
+ params = urllib.parse.urlencode({
412
+ "client_id": client_id,
413
+ "redirect_uri": redirect_uri,
414
+ "response_type": "code",
415
+ "scope": "https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email",
416
+ "access_type": "offline",
417
+ "prompt": "consent",
418
+ })
419
+
420
+ return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
421
+
422
+
423
+ @router.get("/api/backup/oauth/google/callback")
424
+ async def google_oauth_callback(request: Request, code: str = "", error: str = ""):
425
+ """Google OAuth2 callback — exchanges code for tokens."""
426
+ if error:
427
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error=f"Google denied access: {error}"))
428
+
429
+ if not code:
430
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error="No authorization code received"))
431
+
432
+ base_url = str(request.base_url).rstrip("/")
433
+ redirect_uri = f"{base_url}/api/backup/oauth/google/callback"
434
+
435
+ result = connect_google_drive(code, redirect_uri)
436
+
437
+ if "error" in result:
438
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error=result["error"]))
439
+
440
+ return HTMLResponse(_OAUTH_SUCCESS_HTML.format(
441
+ icon="&#x2601;&#xFE0F;",
442
+ title="Google Drive Connected!",
443
+ message=f"Signed in as {result.get('email', 'unknown')}. Your memories will be backed up automatically."
444
+ ))
445
+
446
+
447
+ # ---- GitHub OAuth SSO Flow ------------------------------------------------
448
+ # Uses GitHub Device Flow (no OAuth App needed — works with PATs too)
449
+ # But for best UX, we use GitHub OAuth Web Flow if a GitHub App is configured,
450
+ # otherwise fall back to Device Flow with a nice UI.
451
+
452
+ @router.get("/api/backup/oauth/github/start")
453
+ async def github_oauth_start(request: Request):
454
+ """Start GitHub OAuth flow."""
455
+ if not CLOUD_AVAILABLE:
456
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x26A0;", error="Cloud backup module not available"))
457
+
458
+ from superlocalmemory.infra.cloud_backup import _get_credential
459
+
460
+ # Check if GitHub OAuth App is configured (client_id)
461
+ gh_client_id = _get_credential("github_client_id")
462
+
463
+ if gh_client_id:
464
+ # Full OAuth Web Flow — browser redirects to GitHub login
465
+ base_url = str(request.base_url).rstrip("/")
466
+ redirect_uri = f"{base_url}/api/backup/oauth/github/callback"
467
+
468
+ params = urllib.parse.urlencode({
469
+ "client_id": gh_client_id,
470
+ "redirect_uri": redirect_uri,
471
+ "scope": "repo",
472
+ "state": hashlib.sha256(base_url.encode()).hexdigest()[:16],
473
+ })
474
+ return RedirectResponse(f"https://github.com/login/oauth/authorize?{params}")
475
+
476
+ # No OAuth App — show a friendly PAT entry form (still in the popup)
477
+ return HTMLResponse("""<!DOCTYPE html>
478
+ <html><head><title>Connect GitHub</title>
479
+ <style>
480
+ body { font-family: -apple-system, system-ui, sans-serif; background: #0a0a0f; color: #e0e0e0;
481
+ display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
482
+ .card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
483
+ border-radius: 16px; padding: 32px; max-width: 440px; width: 100%; }
484
+ h2 { color: #e0e0e0; margin: 0 0 4px; font-size: 20px; }
485
+ .sub { color: #999; font-size: 13px; margin-bottom: 20px; }
486
+ label { display: block; color: #bbb; font-size: 13px; margin-bottom: 4px; font-weight: 500; }
487
+ input { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
488
+ border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
489
+ color: #e0e0e0; font-size: 14px; margin-bottom: 16px; }
490
+ input:focus { outline: none; border-color: #00D4AA; }
491
+ .btn { background: #00D4AA; color: #0a0a0f; border: none; padding: 10px 24px;
492
+ border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; width: 100%; }
493
+ .btn:hover { background: #00b894; }
494
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
495
+ a { color: #00D4AA; }
496
+ .hint { color: #666; font-size: 12px; margin-top: 12px; }
497
+ #status { margin-top: 12px; font-size: 13px; }
498
+ </style></head>
499
+ <body><div class="card">
500
+ <h2>Connect GitHub</h2>
501
+ <p class="sub">Back up your memories to a private GitHub repository.</p>
502
+
503
+ <label>Personal Access Token</label>
504
+ <input type="password" id="pat" placeholder="ghp_xxxxxxxxxxxxxxxxxxxx">
505
+
506
+ <label>Repository Name</label>
507
+ <input type="text" id="repo" value="slm-backup" placeholder="slm-backup">
508
+
509
+ <button class="btn" id="connectBtn" onclick="doConnect()">Connect</button>
510
+ <div id="status"></div>
511
+
512
+ <p class="hint">
513
+ Need a token? <a href="https://github.com/settings/tokens/new?scopes=repo&description=SLM+Backup" target="_blank">Create one here</a> (select <code>repo</code> scope).
514
+ Your token is stored securely in your OS keychain.
515
+ </p>
516
+ </div>
517
+ <script>
518
+ async function doConnect() {
519
+ var pat = document.getElementById('pat').value.trim();
520
+ var repo = document.getElementById('repo').value.trim();
521
+ if (!pat) { document.getElementById('status').innerHTML = '<span style="color:#ff4757">Token required</span>'; return; }
522
+
523
+ var btn = document.getElementById('connectBtn');
524
+ btn.disabled = true; btn.textContent = 'Connecting...';
525
+ document.getElementById('status').innerHTML = '<span style="color:#999">Verifying token and creating repo...</span>';
526
+
527
+ try {
528
+ var resp = await fetch('/api/backup/connect/github', {
529
+ method: 'POST',
530
+ headers: {'Content-Type': 'application/json'},
531
+ body: JSON.stringify({pat: pat, repo_name: repo || 'slm-backup'})
532
+ });
533
+ var data = await resp.json();
534
+ if (resp.ok) {
535
+ document.body.innerHTML = '<div class="card" style="text-align:center;background:rgba(255,255,255,0.05);border:1px solid rgba(0,212,170,0.3);border-radius:16px;padding:40px;max-width:400px;">' +
536
+ '<div style="font-size:48px;margin-bottom:16px;">&#x2705;</div>' +
537
+ '<h2 style="color:#00D4AA;margin:0 0 8px;">GitHub Connected!</h2>' +
538
+ '<p style="color:#999;margin:0 0 20px;">Repository: ' + (data.repo || repo) + '</p>' +
539
+ '<button class="btn" style="background:#00D4AA;color:#0a0a0f;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;font-weight:600;" onclick="window.close()">Close Window</button></div>';
540
+ } else {
541
+ document.getElementById('status').innerHTML = '<span style="color:#ff4757">' + (data.detail || 'Connection failed') + '</span>';
542
+ btn.disabled = false; btn.textContent = 'Connect';
543
+ }
544
+ } catch(e) {
545
+ document.getElementById('status').innerHTML = '<span style="color:#ff4757">Connection failed</span>';
546
+ btn.disabled = false; btn.textContent = 'Connect';
547
+ }
548
+ }
549
+ </script></body></html>""")
550
+
551
+
552
+ @router.get("/api/backup/oauth/github/callback")
553
+ async def github_oauth_callback(request: Request, code: str = "", error: str = ""):
554
+ """GitHub OAuth callback — exchanges code for access token."""
555
+ if error:
556
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error=f"GitHub denied access: {error}"))
557
+
558
+ if not code:
559
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error="No authorization code received"))
560
+
561
+ from superlocalmemory.infra.cloud_backup import _get_credential, _store_credential
562
+ import httpx
563
+
564
+ gh_client_id = _get_credential("github_client_id")
565
+ gh_client_secret = _get_credential("github_client_secret")
566
+
567
+ if not gh_client_id or not gh_client_secret:
568
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error="GitHub OAuth App not configured"))
569
+
570
+ try:
571
+ # Exchange code for access token
572
+ resp = httpx.post(
573
+ "https://github.com/login/oauth/access_token",
574
+ json={"client_id": gh_client_id, "client_secret": gh_client_secret, "code": code},
575
+ headers={"Accept": "application/json"},
576
+ timeout=15,
577
+ )
578
+ data = resp.json()
579
+ access_token = data.get("access_token")
580
+ if not access_token:
581
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error=data.get("error_description", "Failed to get access token")))
582
+
583
+ # Use the token to connect
584
+ result = connect_github(access_token, "slm-backup")
585
+ if "error" in result:
586
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error=result["error"]))
587
+
588
+ return HTMLResponse(_OAUTH_SUCCESS_HTML.format(
589
+ icon="&#x2705;",
590
+ title="GitHub Connected!",
591
+ message=f"Repository: {result.get('repo', 'slm-backup')}. Your memories will be backed up automatically."
592
+ ))
593
+
594
+ except Exception as exc:
595
+ return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="&#x274C;", error=str(exc)))
@@ -244,8 +244,18 @@ async def get_soft_prompts():
244
244
  async def log_tool_event_api(data: dict):
245
245
  """Log a tool event via HTTP (called by PostToolUse hook).
246
246
 
247
- Body: { "tool_name": "Read", "event_type": "complete" }
248
- Lightweight — no LLM, just an INSERT. For the shipped hook.
247
+ Body (v3.4.10 enriched):
248
+ {
249
+ "tool_name": "Skill",
250
+ "event_type": "complete",
251
+ "input_summary": "{\"skill\": \"superpowers:brainstorming\"}",
252
+ "output_summary": "{\"success\": true}",
253
+ "session_id": "abc123",
254
+ "project_path": "/path/to/project"
255
+ }
256
+
257
+ All fields except tool_name are optional for backward compatibility.
258
+ Lightweight — no LLM, just an INSERT.
249
259
  """
250
260
  try:
251
261
  import sqlite3 as _sqlite3
@@ -254,17 +264,25 @@ async def log_tool_event_api(data: dict):
254
264
 
255
265
  tool_name = data.get("tool_name", "unknown")
256
266
  event_type = data.get("event_type", "complete")
267
+ input_summary = data.get("input_summary", "")
268
+ output_summary = data.get("output_summary", "")
269
+ session_id = data.get("session_id") or os.environ.get("CLAUDE_SESSION_ID", "hook")
270
+ project_path = data.get("project_path", "")
257
271
  now = datetime.now(timezone.utc).isoformat()
258
- session_id = data.get("session_id", os.environ.get("CLAUDE_SESSION_ID", "hook"))
259
272
  profile = get_active_profile()
260
273
 
274
+ # Truncate to prevent oversized payloads (defense in depth)
275
+ input_summary = str(input_summary)[:500] if input_summary else ""
276
+ output_summary = str(output_summary)[:500] if output_summary else ""
277
+
261
278
  conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
262
279
  conn.execute(
263
280
  "INSERT INTO tool_events "
264
281
  "(session_id, profile_id, project_path, tool_name, event_type, "
265
282
  " input_summary, output_summary, duration_ms, metadata, created_at) "
266
- "VALUES (?, ?, ?, ?, ?, '', '', 0, '{}', ?)",
267
- (session_id, profile, "", tool_name, event_type, now),
283
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)",
284
+ (session_id, profile, project_path, tool_name, event_type,
285
+ input_summary, output_summary, now),
268
286
  )
269
287
  conn.commit()
270
288
  conn.close()