superlocalmemory 3.4.9 → 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.
- package/README.md +14 -0
- package/docs/cloud-backup.md +174 -0
- package/docs/skill-evolution.md +189 -0
- package/ide/hooks/tool-event-hook.sh +101 -11
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +189 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +81 -29
- package/src/superlocalmemory/cli/main.py +11 -0
- package/src/superlocalmemory/core/consolidation_engine.py +10 -0
- package/src/superlocalmemory/core/engine.py +7 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +24 -3
- package/src/superlocalmemory/encoding/entity_resolver.py +95 -28
- 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 +389 -0
- package/src/superlocalmemory/server/routes/backup.py +512 -8
- package/src/superlocalmemory/server/routes/behavioral.py +23 -5
- package/src/superlocalmemory/storage/schema_v3410.py +159 -0
- package/src/superlocalmemory/ui/index.html +55 -2
- package/src/superlocalmemory/ui/js/core.js +3 -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 +227 -0
- package/src/superlocalmemory/ui/js/settings.js +311 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -594
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -317
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -55
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -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:
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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="⚠", 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> → External → 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> → Create OAuth Client → <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 & 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) — 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="❌", error=f"Google denied access: {error}"))
|
|
428
|
+
|
|
429
|
+
if not code:
|
|
430
|
+
return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="❌", 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="❌", error=result["error"]))
|
|
439
|
+
|
|
440
|
+
return HTMLResponse(_OAUTH_SUCCESS_HTML.format(
|
|
441
|
+
icon="☁️",
|
|
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="⚠", 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;">✅</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="❌", error=f"GitHub denied access: {error}"))
|
|
557
|
+
|
|
558
|
+
if not code:
|
|
559
|
+
return HTMLResponse(_OAUTH_ERROR_HTML.format(icon="❌", 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="❌", 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="❌", 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="❌", error=result["error"]))
|
|
587
|
+
|
|
588
|
+
return HTMLResponse(_OAUTH_SUCCESS_HTML.format(
|
|
589
|
+
icon="✅",
|
|
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="❌", 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
|
|
248
|
-
|
|
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 (?, ?, ?, ?, ?,
|
|
267
|
-
(session_id, profile,
|
|
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()
|