superlocalmemory 3.7.6 → 3.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/pyproject.toml +6 -7
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +171 -11
- package/src/superlocalmemory/cli/setup_wizard.py +18 -1
- package/src/superlocalmemory/infra/auth_middleware.py +33 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
- package/src/superlocalmemory/mcp/server.py +1 -0
- package/src/superlocalmemory/mcp/tools_core.py +216 -20
- package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
- package/src/superlocalmemory/optimize/cache/manager.py +7 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
- package/src/superlocalmemory/server/api.py +17 -0
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +12 -6
- package/src/superlocalmemory/server/routes/chat.py +63 -12
- package/src/superlocalmemory/server/routes/helpers.py +9 -16
- package/src/superlocalmemory/server/routes/memories.py +58 -11
- package/src/superlocalmemory/server/routes/profiles.py +24 -14
- package/src/superlocalmemory/server/routes/v3_api.py +128 -52
- package/src/superlocalmemory/server/ui.py +10 -0
- package/src/superlocalmemory/server/unified_daemon.py +290 -74
- package/src/superlocalmemory/storage/migration_runner.py +17 -3
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
- package/src/superlocalmemory/storage/schema_v32.py +0 -9
- package/src/superlocalmemory/ui/index.html +32 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
- package/src/superlocalmemory/ui/js/profiles.js +11 -2
|
@@ -10,6 +10,7 @@ Routes: /api/profiles, /api/profiles/{name}/switch,
|
|
|
10
10
|
SQLite is the single source of truth for profiles. profiles.json
|
|
11
11
|
is kept in sync as a cache for backward compatibility.
|
|
12
12
|
"""
|
|
13
|
+
import asyncio
|
|
13
14
|
import logging
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
|
|
@@ -21,9 +22,13 @@ from .helpers import (
|
|
|
21
22
|
get_db_connection, validate_profile_name,
|
|
22
23
|
ProfileSwitch, DB_PATH,
|
|
23
24
|
sync_profiles, ensure_profile_in_db, ensure_profile_in_json,
|
|
24
|
-
|
|
25
|
+
delete_profile_from_db,
|
|
25
26
|
_load_profiles_json, _save_profiles_json,
|
|
26
27
|
)
|
|
28
|
+
from superlocalmemory.server.profile_runtime import (
|
|
29
|
+
commit_daemon_profile_switch,
|
|
30
|
+
get_profile_runtime,
|
|
31
|
+
)
|
|
27
32
|
|
|
28
33
|
logger = logging.getLogger("superlocalmemory.routes.profiles")
|
|
29
34
|
router = APIRouter()
|
|
@@ -54,12 +59,11 @@ def _get_memory_count(profile: str) -> int:
|
|
|
54
59
|
|
|
55
60
|
|
|
56
61
|
@router.get("/api/profiles")
|
|
57
|
-
async def list_profiles():
|
|
62
|
+
async def list_profiles(request: Request):
|
|
58
63
|
"""List available memory profiles (synced from SQLite + profiles.json)."""
|
|
59
64
|
try:
|
|
60
65
|
merged = sync_profiles()
|
|
61
|
-
|
|
62
|
-
active = json_config.get('active_profile', 'default')
|
|
66
|
+
active = get_profile_runtime(request.app.state).snapshot.profile_id
|
|
63
67
|
|
|
64
68
|
profiles = []
|
|
65
69
|
for p in merged:
|
|
@@ -108,14 +112,17 @@ async def switch_profile(name: str, request: Request):
|
|
|
108
112
|
source_agent_id="http-profile-switch",
|
|
109
113
|
profile_id=name,
|
|
110
114
|
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
runtime = get_profile_runtime(request.app.state)
|
|
116
|
+
previous = runtime.snapshot.profile_id
|
|
117
|
+
snapshot = await asyncio.to_thread(
|
|
118
|
+
runtime.transition,
|
|
119
|
+
name,
|
|
120
|
+
lambda prior, target: commit_daemon_profile_switch(
|
|
121
|
+
request.app.state,
|
|
122
|
+
prior,
|
|
123
|
+
target,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
119
126
|
|
|
120
127
|
count = _get_memory_count(name)
|
|
121
128
|
|
|
@@ -130,6 +137,7 @@ async def switch_profile(name: str, request: Request):
|
|
|
130
137
|
return {
|
|
131
138
|
"success": True, "active_profile": name,
|
|
132
139
|
"previous_profile": previous, "memory_count": count,
|
|
140
|
+
"generation": snapshot.generation,
|
|
133
141
|
"message": f"Switched to profile '{name}' ({count} memories).",
|
|
134
142
|
}
|
|
135
143
|
|
|
@@ -184,10 +192,12 @@ async def delete_profile(name: str, request: Request):
|
|
|
184
192
|
if name not in merged_ids:
|
|
185
193
|
raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")
|
|
186
194
|
|
|
187
|
-
|
|
188
|
-
if
|
|
195
|
+
runtime = get_profile_runtime(request.app.state)
|
|
196
|
+
if runtime.snapshot.profile_id == name:
|
|
189
197
|
raise HTTPException(status_code=400, detail="Cannot delete active profile.")
|
|
190
198
|
|
|
199
|
+
json_config = _load_profiles_json()
|
|
200
|
+
|
|
191
201
|
authorization = authorize_route_mutation(
|
|
192
202
|
request,
|
|
193
203
|
operation="delete",
|
|
@@ -19,6 +19,27 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
router = APIRouter(prefix="/api/v3", tags=["v3"])
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
async def _apply_runtime_config(request: Request, config, *, mode_change: bool) -> None:
|
|
23
|
+
"""Persist and hot-swap config only after the daemon transition succeeds."""
|
|
24
|
+
import asyncio
|
|
25
|
+
|
|
26
|
+
authorization = authorize_route_mutation(
|
|
27
|
+
request,
|
|
28
|
+
operation="update",
|
|
29
|
+
source_agent_id="dashboard-config",
|
|
30
|
+
profile_id=getattr(config, "active_profile", "default"),
|
|
31
|
+
)
|
|
32
|
+
from superlocalmemory.server.profile_runtime import reconfigure_daemon_engine
|
|
33
|
+
|
|
34
|
+
await asyncio.to_thread(
|
|
35
|
+
reconfigure_daemon_engine,
|
|
36
|
+
request.app.state,
|
|
37
|
+
config,
|
|
38
|
+
mode_change=mode_change,
|
|
39
|
+
)
|
|
40
|
+
authorization.complete()
|
|
41
|
+
|
|
42
|
+
|
|
22
43
|
# ── Dashboard ────────────────────────────────────────────────
|
|
23
44
|
|
|
24
45
|
@router.get("/dashboard")
|
|
@@ -26,7 +47,10 @@ async def dashboard(request: Request):
|
|
|
26
47
|
"""Dashboard summary: mode, memory count, health score, recent activity."""
|
|
27
48
|
try:
|
|
28
49
|
from superlocalmemory.core.config import SLMConfig
|
|
29
|
-
config = SLMConfig.load()
|
|
50
|
+
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
51
|
+
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
52
|
+
|
|
53
|
+
active_profile = get_profile_runtime(request.app.state).snapshot.profile_id
|
|
30
54
|
|
|
31
55
|
# Read stats directly from SQLite (dashboard doesn't load engine)
|
|
32
56
|
import sqlite3
|
|
@@ -38,12 +62,24 @@ async def dashboard(request: Request):
|
|
|
38
62
|
conn = sqlite3.connect(str(db_path))
|
|
39
63
|
cursor = conn.cursor()
|
|
40
64
|
try:
|
|
41
|
-
cursor.execute(
|
|
65
|
+
cursor.execute(
|
|
66
|
+
"SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
|
|
67
|
+
(active_profile,),
|
|
68
|
+
)
|
|
42
69
|
fact_count = cursor.fetchone()[0]
|
|
43
70
|
except Exception:
|
|
44
71
|
pass
|
|
45
72
|
try:
|
|
46
|
-
|
|
73
|
+
try:
|
|
74
|
+
cursor.execute(
|
|
75
|
+
"SELECT COUNT(*) FROM memories WHERE profile_id = ?",
|
|
76
|
+
(active_profile,),
|
|
77
|
+
)
|
|
78
|
+
except Exception:
|
|
79
|
+
cursor.execute(
|
|
80
|
+
"SELECT COUNT(*) FROM memories WHERE profile = ?",
|
|
81
|
+
(active_profile,),
|
|
82
|
+
)
|
|
47
83
|
memory_count = cursor.fetchone()[0]
|
|
48
84
|
except Exception:
|
|
49
85
|
pass
|
|
@@ -58,7 +94,7 @@ async def dashboard(request: Request):
|
|
|
58
94
|
"model": config.llm.model or "",
|
|
59
95
|
"memory_count": memory_count,
|
|
60
96
|
"fact_count": fact_count,
|
|
61
|
-
"profile":
|
|
97
|
+
"profile": active_profile,
|
|
62
98
|
"base_dir": str(config.base_dir),
|
|
63
99
|
"version": SLM_VERSION,
|
|
64
100
|
}
|
|
@@ -69,11 +105,11 @@ async def dashboard(request: Request):
|
|
|
69
105
|
# ── Mode ─────────────────────────────────────────────────────
|
|
70
106
|
|
|
71
107
|
@router.get("/mode")
|
|
72
|
-
async def get_mode():
|
|
108
|
+
async def get_mode(request: Request):
|
|
73
109
|
"""Get current mode, provider, model — single source of truth for UI."""
|
|
74
110
|
try:
|
|
75
111
|
from superlocalmemory.core.config import SLMConfig
|
|
76
|
-
config = SLMConfig.load()
|
|
112
|
+
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
77
113
|
current = config.mode.value
|
|
78
114
|
return {
|
|
79
115
|
"mode": current,
|
|
@@ -132,9 +168,10 @@ async def set_mode(request: Request):
|
|
|
132
168
|
old_config.retrieval = _template.retrieval
|
|
133
169
|
old_config.math = _template.math
|
|
134
170
|
old_config.channel_weights = _template.channel_weights
|
|
135
|
-
old_config.save(mode_change=True)
|
|
136
171
|
new_config = old_config
|
|
137
172
|
|
|
173
|
+
await _apply_runtime_config(request, new_config, mode_change=True)
|
|
174
|
+
|
|
138
175
|
# Audit the change before we lose context — proves who/when/what.
|
|
139
176
|
# Captures the phantom-write case where `for_mode(C)` auto-defaults
|
|
140
177
|
# the model to "anthropic/claude-sonnet-4" (see core/config.py).
|
|
@@ -151,10 +188,6 @@ async def set_mode(request: Request):
|
|
|
151
188
|
or old_config.embedding.model_name != new_config.embedding.model_name
|
|
152
189
|
)
|
|
153
190
|
|
|
154
|
-
# Invalidate engine; next engine-backed request lazy-inits with new config.
|
|
155
|
-
if hasattr(request.app.state, "engine"):
|
|
156
|
-
request.app.state.engine = None
|
|
157
|
-
|
|
158
191
|
return {
|
|
159
192
|
"success": True,
|
|
160
193
|
"mode": new_mode,
|
|
@@ -227,7 +260,7 @@ async def set_full_config(request: Request):
|
|
|
227
260
|
|
|
228
261
|
# v3.6.12 (settings-1): mode_change=True is required to persist the new
|
|
229
262
|
# mode — save() without it hits a guard that preserves the old mode.
|
|
230
|
-
config
|
|
263
|
+
await _apply_runtime_config(request, config, mode_change=True)
|
|
231
264
|
|
|
232
265
|
log_mode_change(
|
|
233
266
|
old_mode, new_mode,
|
|
@@ -236,16 +269,14 @@ async def set_full_config(request: Request):
|
|
|
236
269
|
source="POST /api/v3/mode/set",
|
|
237
270
|
)
|
|
238
271
|
|
|
239
|
-
#
|
|
272
|
+
# Recycle only out-of-process fallbacks; the resident daemon engine was
|
|
273
|
+
# already acknowledged and hot-swapped by _apply_runtime_config().
|
|
240
274
|
try:
|
|
241
275
|
from superlocalmemory.core.worker_pool import WorkerPool
|
|
242
276
|
WorkerPool.shared().shutdown()
|
|
243
277
|
except Exception:
|
|
244
278
|
pass
|
|
245
279
|
|
|
246
|
-
if hasattr(request.app.state, "engine"):
|
|
247
|
-
request.app.state.engine = None
|
|
248
|
-
|
|
249
280
|
return {
|
|
250
281
|
"success": True,
|
|
251
282
|
"mode": new_mode,
|
|
@@ -309,7 +340,7 @@ async def set_embedding_config(request: Request):
|
|
|
309
340
|
api_version=old_emb.api_version,
|
|
310
341
|
deployment_name=old_emb.deployment_name,
|
|
311
342
|
)
|
|
312
|
-
config
|
|
343
|
+
await _apply_runtime_config(request, config, mode_change=False)
|
|
313
344
|
|
|
314
345
|
needs_reindex = (
|
|
315
346
|
old_emb.provider != new_provider
|
|
@@ -323,9 +354,6 @@ async def set_embedding_config(request: Request):
|
|
|
323
354
|
WorkerPool.shared().shutdown()
|
|
324
355
|
except Exception:
|
|
325
356
|
pass
|
|
326
|
-
if hasattr(request.app.state, "engine"):
|
|
327
|
-
request.app.state.engine = None
|
|
328
|
-
|
|
329
357
|
return {
|
|
330
358
|
"success": True,
|
|
331
359
|
"provider": new_provider,
|
|
@@ -337,6 +365,55 @@ async def set_embedding_config(request: Request):
|
|
|
337
365
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
338
366
|
|
|
339
367
|
|
|
368
|
+
@router.get("/scope/config")
|
|
369
|
+
async def get_scope_config(request: Request):
|
|
370
|
+
"""Return runtime multi-scope defaults used by daemon writes and recalls."""
|
|
371
|
+
try:
|
|
372
|
+
from superlocalmemory.core.config import SLMConfig
|
|
373
|
+
|
|
374
|
+
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
375
|
+
return {"success": True, **config.scope.as_dict()}
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@router.put("/scope/config")
|
|
381
|
+
async def set_scope_config(request: Request):
|
|
382
|
+
"""Validate, persist, and hot-apply explicit multi-scope defaults."""
|
|
383
|
+
try:
|
|
384
|
+
body = await request.json()
|
|
385
|
+
from superlocalmemory.core.config import SLMConfig, ScopeConfig
|
|
386
|
+
|
|
387
|
+
config = SLMConfig.load()
|
|
388
|
+
current = config.scope
|
|
389
|
+
default_scope = body.get("default_scope", current.default_scope)
|
|
390
|
+
if default_scope not in {"personal", "shared", "global"}:
|
|
391
|
+
return JSONResponse(
|
|
392
|
+
{"error": "default_scope must be personal, shared, or global"},
|
|
393
|
+
status_code=400,
|
|
394
|
+
)
|
|
395
|
+
include_global = body.get(
|
|
396
|
+
"recall_include_global", current.recall_include_global,
|
|
397
|
+
)
|
|
398
|
+
include_shared = body.get(
|
|
399
|
+
"recall_include_shared", current.recall_include_shared,
|
|
400
|
+
)
|
|
401
|
+
if not isinstance(include_global, bool) or not isinstance(include_shared, bool):
|
|
402
|
+
return JSONResponse(
|
|
403
|
+
{"error": "recall scope flags must be booleans"},
|
|
404
|
+
status_code=400,
|
|
405
|
+
)
|
|
406
|
+
config.scope = ScopeConfig(
|
|
407
|
+
default_scope=default_scope,
|
|
408
|
+
recall_include_global=include_global,
|
|
409
|
+
recall_include_shared=include_shared,
|
|
410
|
+
)
|
|
411
|
+
await _apply_runtime_config(request, config, mode_change=False)
|
|
412
|
+
return {"success": True, **config.scope.as_dict()}
|
|
413
|
+
except Exception as exc:
|
|
414
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
415
|
+
|
|
416
|
+
|
|
340
417
|
@router.post("/embedding/test")
|
|
341
418
|
async def test_embedding_endpoint(request: Request):
|
|
342
419
|
"""Test connectivity to a custom embedding endpoint."""
|
|
@@ -1385,7 +1462,8 @@ async def trigger_consolidation(request: Request):
|
|
|
1385
1462
|
"""Trigger consolidation manually.
|
|
1386
1463
|
|
|
1387
1464
|
Body: {"lightweight": false, "profile": ""}
|
|
1388
|
-
|
|
1465
|
+
Runs under the daemon's profile-runtime operation lease (Rule 18) so a
|
|
1466
|
+
concurrent profile switch cannot commit mid-consolidation.
|
|
1389
1467
|
"""
|
|
1390
1468
|
try:
|
|
1391
1469
|
body = await request.json()
|
|
@@ -1401,44 +1479,42 @@ async def trigger_consolidation(request: Request):
|
|
|
1401
1479
|
profile_id=pid,
|
|
1402
1480
|
)
|
|
1403
1481
|
|
|
1404
|
-
#
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
"lightweight": lightweight,
|
|
1412
|
-
})
|
|
1413
|
-
if result and result.get("ok"):
|
|
1414
|
-
authorization.complete()
|
|
1415
|
-
return {"success": True, **result}
|
|
1416
|
-
except Exception:
|
|
1417
|
-
pass
|
|
1418
|
-
|
|
1419
|
-
# Fallback: direct consolidation if WorkerPool unavailable
|
|
1482
|
+
# v3.7.8 SEC-M-01: the prior "WorkerPool" fast path called
|
|
1483
|
+
# ``pool.send_command(...)``, a method that does not exist on
|
|
1484
|
+
# WorkerPool — every call raised AttributeError, was silently
|
|
1485
|
+
# swallowed by the bare ``except``, and fell through to this direct
|
|
1486
|
+
# path unconditionally. That dead branch is removed; consolidation
|
|
1487
|
+
# always runs directly against a lease-protected DB connection so a
|
|
1488
|
+
# concurrent profile switch cannot commit mid-consolidation.
|
|
1420
1489
|
from superlocalmemory.core.config import SLMConfig
|
|
1421
1490
|
from superlocalmemory.storage.database import DatabaseManager
|
|
1422
1491
|
from superlocalmemory.storage import schema as _schema
|
|
1423
1492
|
from superlocalmemory.core.consolidation_engine import ConsolidationEngine
|
|
1493
|
+
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
1424
1494
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1495
|
+
runtime = get_profile_runtime(request.app.state)
|
|
1496
|
+
with runtime.operation():
|
|
1497
|
+
config = SLMConfig.load()
|
|
1498
|
+
db = DatabaseManager(config.db_path)
|
|
1499
|
+
db.initialize(_schema)
|
|
1428
1500
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1501
|
+
engine = ConsolidationEngine(
|
|
1502
|
+
db=db, config=config.consolidation, slm_config=config,
|
|
1503
|
+
)
|
|
1504
|
+
result = engine.consolidate(profile_id=pid, lightweight=lightweight)
|
|
1431
1505
|
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1506
|
+
# v3.4.1: Auto-trigger behavioral pattern mining after consolidation
|
|
1507
|
+
try:
|
|
1508
|
+
from superlocalmemory.learning.consolidation_worker import (
|
|
1509
|
+
ConsolidationWorker,
|
|
1510
|
+
)
|
|
1511
|
+
learning_db = config.base_dir / "learning.db"
|
|
1512
|
+
cw = ConsolidationWorker(str(config.db_path), str(learning_db))
|
|
1513
|
+
pattern_count = cw._generate_patterns(pid, False)
|
|
1514
|
+
result["patterns_mined"] = pattern_count
|
|
1515
|
+
logger.info("Auto-mined %d patterns after consolidation", pattern_count)
|
|
1516
|
+
except Exception as exc:
|
|
1517
|
+
logger.debug("Pattern mining after consolidation failed: %s", exc)
|
|
1442
1518
|
|
|
1443
1519
|
authorization.complete()
|
|
1444
1520
|
return {"success": True, **result}
|
|
@@ -15,6 +15,16 @@ All route handlers live in routes/ directory:
|
|
|
15
15
|
routes/events.py -- /events/stream (SSE), /api/events [v2.5]
|
|
16
16
|
routes/agents.py -- /api/agents, /api/trust [v2.5]
|
|
17
17
|
routes/ws.py -- /ws/updates (WebSocket)
|
|
18
|
+
|
|
19
|
+
v3.7.8 (WS3 F4): ``create_app()`` in this module is a standalone/legacy app
|
|
20
|
+
factory, NOT the app the running daemon serves -- that is
|
|
21
|
+
``superlocalmemory.server.unified_daemon:create_app`` (see that module's
|
|
22
|
+
uvicorn config). This module's ``auth_middleware`` keeps the older,
|
|
23
|
+
unconditional ``check_api_key``-only write gate (no daemon-capability /
|
|
24
|
+
install-token identity layer, no ``SLM_REQUIRE_API_KEY_LOOPBACK`` opt-in --
|
|
25
|
+
see ``infra/auth_middleware.py`` for that). Treat this file as a standalone
|
|
26
|
+
entry point only; the production write-auth boundary lives in
|
|
27
|
+
``unified_daemon.py``.
|
|
18
28
|
"""
|
|
19
29
|
|
|
20
30
|
import logging
|