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.
Files changed (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/requirements.txt +1 -1
  6. package/plugin-src/manifest.json +1 -1
  7. package/plugin-src/requirements.txt +1 -1
  8. package/pyproject.toml +6 -7
  9. package/src/superlocalmemory/__init__.py +1 -1
  10. package/src/superlocalmemory/cli/commands.py +171 -11
  11. package/src/superlocalmemory/cli/setup_wizard.py +18 -1
  12. package/src/superlocalmemory/infra/auth_middleware.py +33 -5
  13. package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
  14. package/src/superlocalmemory/mcp/server.py +1 -0
  15. package/src/superlocalmemory/mcp/tools_core.py +216 -20
  16. package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
  17. package/src/superlocalmemory/optimize/cache/manager.py +7 -0
  18. package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
  19. package/src/superlocalmemory/server/api.py +17 -0
  20. package/src/superlocalmemory/server/profile_runtime.py +384 -0
  21. package/src/superlocalmemory/server/recall_health.py +12 -6
  22. package/src/superlocalmemory/server/routes/chat.py +63 -12
  23. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  24. package/src/superlocalmemory/server/routes/memories.py +58 -11
  25. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  26. package/src/superlocalmemory/server/routes/v3_api.py +128 -52
  27. package/src/superlocalmemory/server/ui.py +10 -0
  28. package/src/superlocalmemory/server/unified_daemon.py +290 -74
  29. package/src/superlocalmemory/storage/migration_runner.py +17 -3
  30. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  31. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  32. package/src/superlocalmemory/ui/index.html +32 -1
  33. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  34. 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
- set_active_profile_everywhere, delete_profile_from_db,
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
- json_config = _load_profiles_json()
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
- previous = _load_profiles_json().get('active_profile', 'default')
112
- set_active_profile_everywhere(name)
113
-
114
- # Update last_used in profiles.json
115
- json_config = _load_profiles_json()
116
- if name in json_config.get('profiles', {}):
117
- json_config['profiles'][name]['last_used'] = datetime.now(timezone.utc).isoformat()
118
- _save_profiles_json(json_config)
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
- json_config = _load_profiles_json()
188
- if json_config.get('active_profile') == name:
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("SELECT COUNT(*) FROM atomic_facts")
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
- cursor.execute("SELECT COUNT(*) FROM memories")
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": config.active_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.save(mode_change=True)
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
- # Kill existing worker so next request uses new config
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.save()
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
- Uses WorkerPool for thread safety (Rule 18).
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
- # Use WorkerPool to run consolidation in the worker subprocess (Rule 18)
1405
- try:
1406
- from superlocalmemory.core.worker_pool import WorkerPool
1407
- pool = WorkerPool.shared()
1408
- result = pool.send_command({
1409
- "action": "consolidate",
1410
- "profile_id": pid,
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
- config = SLMConfig.load()
1426
- db = DatabaseManager(config.db_path)
1427
- db.initialize(_schema)
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
- engine = ConsolidationEngine(db=db, config=config.consolidation, slm_config=config)
1430
- result = engine.consolidate(profile_id=pid, lightweight=lightweight)
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
- # v3.4.1: Auto-trigger behavioral pattern mining after consolidation
1433
- try:
1434
- from superlocalmemory.learning.consolidation_worker import ConsolidationWorker
1435
- learning_db = config.base_dir / "learning.db"
1436
- cw = ConsolidationWorker(str(config.db_path), str(learning_db))
1437
- pattern_count = cw._generate_patterns(pid, False)
1438
- result["patterns_mined"] = pattern_count
1439
- logger.info("Auto-mined %d patterns after consolidation", pattern_count)
1440
- except Exception as exc:
1441
- logger.debug("Pattern mining after consolidation failed: %s", exc)
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