superlocalmemory 3.6.11 → 3.6.13

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 (29) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +2 -0
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/src/superlocalmemory/cli/commands.py +1 -0
  6. package/src/superlocalmemory/cli/daemon.py +0 -407
  7. package/src/superlocalmemory/cli/main.py +39 -24
  8. package/src/superlocalmemory/cli/version_banner.py +6 -2
  9. package/src/superlocalmemory/core/context_cache.py +4 -1
  10. package/src/superlocalmemory/core/fact_consolidator.py +4 -1
  11. package/src/superlocalmemory/core/remote_mode.py +197 -0
  12. package/src/superlocalmemory/core/summarizer.py +4 -1
  13. package/src/superlocalmemory/llm/backbone.py +7 -1
  14. package/src/superlocalmemory/mcp/agent_context.py +7 -3
  15. package/src/superlocalmemory/mcp/tools_core.py +13 -1
  16. package/src/superlocalmemory/mcp/tools_mesh.py +14 -6
  17. package/src/superlocalmemory/mesh/broker.py +15 -4
  18. package/src/superlocalmemory/optimize/compress/router.py +9 -4
  19. package/src/superlocalmemory/optimize/storage/db.py +16 -2
  20. package/src/superlocalmemory/server/api.py +11 -3
  21. package/src/superlocalmemory/server/routes/mesh.py +13 -0
  22. package/src/superlocalmemory/server/routes/token.py +14 -2
  23. package/src/superlocalmemory/server/routes/v3_api.py +83 -17
  24. package/src/superlocalmemory/server/ui.py +15 -4
  25. package/src/superlocalmemory/server/unified_daemon.py +96 -160
  26. package/src/superlocalmemory/storage/database.py +10 -1
  27. package/src/superlocalmemory/ui/js/auto-settings.js +24 -0
  28. package/src/superlocalmemory.egg-info/PKG-INFO +3 -1
  29. package/src/superlocalmemory.egg-info/SOURCES.txt +1 -0
@@ -189,12 +189,20 @@ async def set_full_config(request: Request):
189
189
  from superlocalmemory.server.routes.helpers import log_mode_change
190
190
  old = SLMConfig.load()
191
191
  old_mode = old.mode.value
192
+ # v3.6.12 (settings-2): honor a custom endpoint for ANY provider — the
193
+ # old code forced api_base="" for everything except ollama, so a
194
+ # llama.cpp / LM Studio / Azure-OpenAI endpoint configured in the
195
+ # dashboard could never be saved (Test Connection then probed the wrong
196
+ # URL → 401). Accept both base_url and endpoint; default ollama locally.
197
+ _endpoint = (body.get("base_url", "") or body.get("endpoint", "")).strip()
198
+ if not _endpoint and provider == "ollama":
199
+ _endpoint = "http://localhost:11434"
192
200
  config = SLMConfig.for_mode(
193
201
  Mode(new_mode),
194
202
  llm_provider=provider if provider != "none" else "",
195
203
  llm_model=model,
196
204
  llm_api_key=api_key,
197
- llm_api_base="http://localhost:11434" if provider == "ollama" else "",
205
+ llm_api_base=_endpoint,
198
206
  embedding_provider=body.get("embedding_provider", ""),
199
207
  embedding_endpoint=body.get("embedding_endpoint", ""),
200
208
  embedding_key=body.get("embedding_key", ""),
@@ -202,7 +210,10 @@ async def set_full_config(request: Request):
202
210
  embedding_dimension=int(body.get("embedding_dimension", 0) or 0),
203
211
  )
204
212
  config.active_profile = old.active_profile
205
- config.save()
213
+ # v3.6.12 (settings-1): an explicit user-driven mode switch must persist.
214
+ # save() without mode_change=True hits the guard that PRESERVES the old
215
+ # mode, so /mode/set silently no-op'd the switch while returning success.
216
+ config.save(mode_change=True)
206
217
 
207
218
  log_mode_change(
208
219
  old_mode, new_mode,
@@ -410,6 +421,39 @@ async def embed_texts(request: Request):
410
421
  return JSONResponse({"error": str(e)}, status_code=500)
411
422
 
412
423
 
424
+ def _validate_provider_url(url: str, client_host: str) -> str | None:
425
+ """SSRF guard for outbound provider-test fetches (v3.6.12 ssrf-1).
426
+
427
+ Returns an error string if ``url`` is unsafe, else None. ALWAYS blocks
428
+ non-http(s) and cloud-metadata hosts. A LOOPBACK caller (the local
429
+ dashboard) may legitimately test local/LAN LLM endpoints, so private IPs
430
+ are allowed for it. A NON-loopback caller may not make the server fetch
431
+ private/loopback/link-local/reserved targets — that is the SSRF abuse.
432
+ """
433
+ from urllib.parse import urlparse
434
+ import ipaddress
435
+ import socket
436
+ p = urlparse(url)
437
+ if p.scheme not in ("http", "https"):
438
+ return "Only http/https endpoints are supported"
439
+ host = p.hostname or ""
440
+ if host.lower() in ("169.254.169.254", "metadata.google.internal", "metadata"):
441
+ return "Cloud metadata endpoints are not allowed"
442
+ if client_host in ("127.0.0.1", "::1", "localhost"):
443
+ return None # local dashboard may target its own local/LAN endpoints
444
+ try:
445
+ ip = ipaddress.ip_address(host)
446
+ except ValueError:
447
+ try:
448
+ ip = ipaddress.ip_address(socket.gethostbyname(host))
449
+ except Exception:
450
+ return None # unresolvable — let the HTTP client fail normally
451
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
452
+ or ip.is_reserved or ip.is_multicast):
453
+ return "Internal/private endpoints are not allowed from a remote client"
454
+ return None
455
+
456
+
413
457
  @router.post("/provider/test")
414
458
  async def test_provider(request: Request):
415
459
  """Test connectivity to an LLM provider."""
@@ -419,9 +463,13 @@ async def test_provider(request: Request):
419
463
  provider = body.get("provider", "")
420
464
  model = body.get("model", "")
421
465
  api_key = body.get("api_key", "")
466
+ _client_host = request.client.host if request.client else ""
422
467
 
423
468
  if provider == "ollama":
424
469
  endpoint = body.get("endpoint", "http://localhost:11434")
470
+ _err = _validate_provider_url(endpoint, _client_host)
471
+ if _err:
472
+ return {"success": False, "error": _err}
425
473
  with httpx.Client(timeout=httpx.Timeout(5.0)) as c:
426
474
  resp = c.get(f"{endpoint}/api/tags")
427
475
  resp.raise_for_status()
@@ -448,6 +496,9 @@ async def test_provider(request: Request):
448
496
  # V3.5.9: custom/local endpoint — api_key is optional (llama.cpp, LM Studio etc.)
449
497
  custom_endpoint = body.get("base_url", "").strip() or body.get("endpoint", "").strip()
450
498
  if custom_endpoint:
499
+ _err = _validate_provider_url(custom_endpoint, _client_host)
500
+ if _err:
501
+ return {"success": False, "error": _err}
451
502
  headers_test = {"Content-Type": "application/json"}
452
503
  if api_key:
453
504
  headers_test["Authorization"] = f"Bearer {api_key}"
@@ -730,23 +781,38 @@ async def trust_dashboard(request: Request):
730
781
  async def math_health(request: Request):
731
782
  """Mathematical layer health: Fisher, sheaf, Langevin status. Queries DB directly."""
732
783
  try:
733
- engine = None # Engine runs in subprocess; query DB directly below
734
-
784
+ # v3.6.12 (math-1): report CONFIG-DERIVED status, not a hardcoded
785
+ # "active"/"healthy" for every layer. The old code had a dead `if engine:`
786
+ # (engine was always None) and returned all-green unconditionally — a
787
+ # false-assurance pane. We can't probe the recall subprocess from here,
788
+ # so report the real configured mode/threshold/temperature and label the
789
+ # status "configured" (or "unknown" if config can't load).
790
+ from superlocalmemory.core.config import SLMConfig
791
+ config = SLMConfig.load()
792
+ math = getattr(config, "math", None)
793
+ _status = "configured" if math is not None else "unknown"
735
794
  health = {
736
- "fisher": {"status": "active", "description": "Fisher-Rao information geometry for similarity"},
737
- "sheaf": {"status": "active", "description": "Sheaf cohomology for consistency detection"},
738
- "langevin": {"status": "active", "description": "Riemannian Langevin dynamics for lifecycle"},
795
+ "fisher": {
796
+ "status": _status,
797
+ "description": "Fisher-Rao information geometry for similarity",
798
+ "mode": getattr(math, "fisher_mode", None) if math else None,
799
+ },
800
+ "sheaf": {
801
+ "status": _status,
802
+ "description": "Sheaf cohomology for consistency detection",
803
+ "threshold": getattr(math, "sheaf_contradiction_threshold", None) if math else None,
804
+ },
805
+ "langevin": {
806
+ "status": _status,
807
+ "description": "Riemannian Langevin dynamics for lifecycle",
808
+ "temperature": getattr(math, "langevin_temperature", None) if math else None,
809
+ },
810
+ }
811
+ return {
812
+ "health": health,
813
+ "overall": _status,
814
+ "note": "config-derived; not a live runtime probe",
739
815
  }
740
-
741
- # Check if math layers are configured
742
- if engine:
743
- from superlocalmemory.core.config import SLMConfig
744
- config = SLMConfig.load()
745
- health["fisher"]["mode"] = config.math.fisher_mode
746
- health["sheaf"]["threshold"] = config.math.sheaf_contradiction_threshold
747
- health["langevin"]["temperature"] = config.math.langevin_temperature
748
-
749
- return {"health": health, "overall": "healthy"}
750
816
  except Exception as e:
751
817
  return JSONResponse({"error": str(e)}, status_code=500)
752
818
 
@@ -82,12 +82,20 @@ def create_app() -> FastAPI:
82
82
  # Rate limiting (graceful)
83
83
  try:
84
84
  from superlocalmemory.infra.rate_limiter import RateLimiter
85
- _write_limiter = RateLimiter(max_requests=30, window_seconds=60)
86
- _read_limiter = RateLimiter(max_requests=120, window_seconds=60)
85
+ from superlocalmemory.core.remote_mode import (
86
+ rate_limit_config,
87
+ is_rate_limit_exempt,
88
+ )
89
+ # v3.6.12 (issue #40): env-tunable thresholds (defaults unchanged).
90
+ _rl_write, _rl_read, _rl_window = rate_limit_config()
91
+ _write_limiter = RateLimiter(max_requests=_rl_write, window_seconds=_rl_window)
92
+ _read_limiter = RateLimiter(max_requests=_rl_read, window_seconds=_rl_window)
87
93
 
88
94
  @application.middleware("http")
89
95
  async def rate_limit_middleware(request, call_next):
90
96
  client_ip = request.client.host if request.client else "unknown"
97
+ if is_rate_limit_exempt(client_ip):
98
+ return await call_next(request)
91
99
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
92
100
  limiter = _write_limiter if is_write else _read_limiter
93
101
  allowed, remaining = limiter.is_allowed(client_ip)
@@ -165,8 +173,11 @@ def create_app() -> FastAPI:
165
173
  try:
166
174
  _mod = __import__(f"superlocalmemory.server.routes.{_module_name}", fromlist=["router"])
167
175
  application.include_router(_mod.router)
168
- except (ImportError, Exception):
169
- pass
176
+ except (ImportError, Exception) as _exc:
177
+ # v3.6.12 (settings-3): was a silent `pass` — a transient import
178
+ # error in learning.py alone 404s 3 dashboard panes (Learning,
179
+ # Patterns, Feedback) with no trace. Log it like the chat loop above.
180
+ logger.warning("Optional router %s failed: %s", _module_name, _exc)
170
181
 
171
182
  # Wire WebSocket manager into routes that need broadcast capability
172
183
  import superlocalmemory.server.routes.profiles as _profiles_mod
@@ -1257,6 +1257,32 @@ def create_app() -> FastAPI:
1257
1257
  allowed_origins=[f"http://{h}" for h in _hosts],
1258
1258
  )
1259
1259
  logger.info("MCP transport security: allowed_hosts=%r", _mcp_allowed)
1260
+ # v3.6.12 (issue #39): stateless MCP transport for distributed/gateway
1261
+ # deployments. SLM's Streamable-HTTP is stateful by default — every call
1262
+ # must replay the Mcp-Session-Id from the initialize handshake. A gateway
1263
+ # (MCP Hub, LAN forwarder) that doesn't replay it gets "-32600 Session
1264
+ # not found" (the mesh-tools symptom in #39). Stateless mode treats each
1265
+ # request independently so any forwarder works. Default OFF (loopback
1266
+ # clients keep full stateful sessions); enabled by SLM_REMOTE=1 or
1267
+ # SLM_MCP_STATELESS=1. Per-agent /mcp/{agent_id} routing is unaffected
1268
+ # (path-based, not session-based).
1269
+ from superlocalmemory.core.remote_mode import mcp_stateless, is_remote_mode
1270
+ if mcp_stateless():
1271
+ _mcp_fastmcp.settings.stateless_http = True
1272
+ _mcp_fastmcp.settings.json_response = True
1273
+ if is_remote_mode():
1274
+ logger.warning(
1275
+ "MCP transport: STATELESS mode ON (SLM_REMOTE) — LAN "
1276
+ "gateways/hubs may forward tool calls without a session id. "
1277
+ "Per-session isolation is relaxed; intended for trusted networks."
1278
+ )
1279
+ else:
1280
+ logger.warning(
1281
+ "MCP transport: STATELESS mode ON (SLM_MCP_STATELESS alone) "
1282
+ "— session isolation relaxed for LOOPBACK clients. Intended "
1283
+ "for a local gateway/hub (e.g. MCP Hub) on 127.0.0.1 only; "
1284
+ "the token endpoint stays loopback-only without SLM_REMOTE."
1285
+ )
1260
1286
  global _mcp_app
1261
1287
  _mcp_app = _mcp_fastmcp.streamable_http_app()
1262
1288
 
@@ -1285,20 +1311,30 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1285
1311
  # Rate limiting (graceful)
1286
1312
  try:
1287
1313
  from superlocalmemory.infra.rate_limiter import RateLimiter
1288
- _write_limiter = RateLimiter(max_requests=30, window_seconds=60)
1289
- _read_limiter = RateLimiter(max_requests=120, window_seconds=60)
1314
+ from superlocalmemory.core.remote_mode import (
1315
+ rate_limit_config,
1316
+ is_rate_limit_exempt,
1317
+ )
1318
+ # v3.6.12 (issue #40): thresholds are env-tunable (SLM_RATE_LIMIT_WRITE/
1319
+ # READ/WINDOW) so distributed/LAN operators can raise them. Defaults
1320
+ # unchanged (30 writes / 120 reads per 60s) for the local case.
1321
+ _rl_write, _rl_read, _rl_window = rate_limit_config()
1322
+ _write_limiter = RateLimiter(max_requests=_rl_write, window_seconds=_rl_window)
1323
+ _read_limiter = RateLimiter(max_requests=_rl_read, window_seconds=_rl_window)
1290
1324
 
1291
1325
  # S9-DASH-09: loopback (127.0.0.1 / ::1) is always the dashboard
1292
1326
  # itself — it legitimately makes many rapid reads (Brain + tabs +
1293
1327
  # polling). Rate-limiting our own UI produces 429s that cascade
1294
1328
  # into blank panels. CORS already restricts origins to localhost,
1295
1329
  # so we don't lose the anti-abuse posture for external callers.
1296
- _LOOPBACK_IPS = frozenset({"127.0.0.1", "::1", "localhost"})
1330
+ # v3.6.12 (issue #40): in SLM_REMOTE mode an allowlisted LAN browser is
1331
+ # the user's own dashboard doing the same rapid polling, so it is exempt
1332
+ # too (is_rate_limit_exempt) — otherwise normal polling trips 429.
1297
1333
 
1298
1334
  @application.middleware("http")
1299
1335
  async def rate_limit_middleware(request, call_next):
1300
1336
  client_ip = request.client.host if request.client else "unknown"
1301
- if client_ip in _LOOPBACK_IPS:
1337
+ if is_rate_limit_exempt(client_ip):
1302
1338
  return await call_next(request)
1303
1339
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
1304
1340
  limiter = _write_limiter if is_write else _read_limiter
@@ -1313,8 +1349,11 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1313
1349
  response = await call_next(request)
1314
1350
  response.headers["X-RateLimit-Remaining"] = str(remaining)
1315
1351
  return response
1316
- except (ImportError, Exception):
1317
- pass
1352
+ except Exception as _rl_exc:
1353
+ # v3.6.12 (failopen-4): don't silently swallow — a missing rate limiter
1354
+ # is anti-abuse degradation worth a log line (unlike auth, this may
1355
+ # fail-open: rate limiting is not a security boundary).
1356
+ logger.warning("Rate-limit middleware not installed (%s)", _rl_exc)
1318
1357
 
1319
1358
  # Auth middleware (graceful)
1320
1359
  try:
@@ -1336,6 +1375,28 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1336
1375
  return await call_next(request)
1337
1376
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
1338
1377
  headers = dict(request.headers)
1378
+ # v3.6.12 (csrf-1): defense-in-depth CSRF/DNS-rebinding guard on
1379
+ # state-changing requests. A cross-origin browser Origin is rejected;
1380
+ # loopback origins (the local dashboard) always pass, and LAN origins
1381
+ # pass only when explicitly allowlisted in SLM_REMOTE mode. Non-browser
1382
+ # clients (CLI/MCP/curl) send no Origin and are unaffected.
1383
+ if is_write:
1384
+ _origin = headers.get("origin", "") or headers.get("Origin", "")
1385
+ if _origin:
1386
+ _ok_origin = any(_origin.startswith(p) for p in (
1387
+ "http://127.0.0.1", "https://127.0.0.1",
1388
+ "http://localhost", "https://localhost",
1389
+ "http://[::1]", "https://[::1]",
1390
+ ))
1391
+ if not _ok_origin:
1392
+ from superlocalmemory.core.remote_mode import is_remote_origin_allowed
1393
+ _ok_origin = is_remote_origin_allowed(_origin)
1394
+ if not _ok_origin:
1395
+ from fastapi.responses import JSONResponse
1396
+ return JSONResponse(
1397
+ status_code=403,
1398
+ content={"error": "cross-origin request rejected"},
1399
+ )
1339
1400
  if not check_api_key(headers, is_write=is_write):
1340
1401
  from fastapi.responses import JSONResponse
1341
1402
  return JSONResponse(
@@ -1343,8 +1404,35 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1343
1404
  content={"error": "Invalid or missing API key."},
1344
1405
  )
1345
1406
  return await call_next(request)
1346
- except (ImportError, Exception):
1347
- pass
1407
+ except Exception as _auth_exc:
1408
+ # v3.6.12 (failopen-1): security middleware must NEVER fail open silently.
1409
+ # The old `except (ImportError, Exception): pass` meant any failure to
1410
+ # install the auth gate left ALL write endpoints unauthenticated. Instead
1411
+ # log critically and install a fail-CLOSED fallback: writes from
1412
+ # non-loopback clients are rejected (loopback dashboard keeps working).
1413
+ logger.critical(
1414
+ "Auth middleware failed to install (%s) — installing fail-CLOSED "
1415
+ "fallback; non-loopback writes will be rejected.", _auth_exc,
1416
+ )
1417
+ try:
1418
+ from superlocalmemory.hooks.prewarm_auth import is_loopback as _is_lb
1419
+ except Exception:
1420
+ def _is_lb(h: str) -> bool:
1421
+ return h in ("127.0.0.1", "::1", "localhost")
1422
+
1423
+ @application.middleware("http")
1424
+ async def _failclosed_auth(request, call_next):
1425
+ if request.url.path.startswith(("/v1/", "/v1beta/", "/mcp")):
1426
+ return await call_next(request)
1427
+ is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
1428
+ client_host = request.client.host if request.client else ""
1429
+ if is_write and not _is_lb(client_host):
1430
+ from fastapi.responses import JSONResponse
1431
+ return JSONResponse(
1432
+ status_code=503,
1433
+ content={"error": "Auth subsystem unavailable; writes disabled."},
1434
+ )
1435
+ return await call_next(request)
1348
1436
 
1349
1437
  # Static files
1350
1438
  from fastapi.staticfiles import StaticFiles
@@ -1463,158 +1551,6 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1463
1551
  })
1464
1552
  return {"providers": providers}
1465
1553
 
1466
- @application.get("/api/v3/mode")
1467
- async def v3_get_mode():
1468
- """Get current mode and available modes."""
1469
- from superlocalmemory.core.config import SLMConfig
1470
- from superlocalmemory.storage.models import Mode as _M
1471
- _base = Path.home() / ".superlocalmemory"
1472
- current = SLMConfig.read_current_mode(_base)
1473
- modes = {}
1474
- for _m in (_M.A, _M.B, _M.C):
1475
- _name = _m.value.lower()
1476
- _path = SLMConfig._mode_config_path(_base, _m)
1477
- _cfg = None
1478
- if _path.exists():
1479
- try:
1480
- _cfg = SLMConfig.load(_path)
1481
- except Exception:
1482
- pass
1483
- modes[_name] = {
1484
- "label": {"a": "Zero-Cloud", "b": "Local AI", "c": "Cloud Power"}[_name],
1485
- "config_exists": _path.exists(),
1486
- "embedding_provider": getattr(_cfg.embedding, "provider", "") if _cfg else "",
1487
- "embedding_model": getattr(_cfg.embedding, "model_name", "") if _cfg else "",
1488
- "llm_provider": getattr(_cfg.llm, "provider", "") if _cfg else "",
1489
- "llm_model": getattr(_cfg.llm, "model", "") if _cfg else "",
1490
- "reranker": _cfg.retrieval.use_cross_encoder if _cfg else True,
1491
- }
1492
- return {"current_mode": current, "modes": modes}
1493
-
1494
- @application.post("/api/v3/mode/set")
1495
- async def v3_set_mode(request: Request):
1496
- """Switch mode and optionally update provider/model. Body matches
1497
- the auto-settings.js saveSettings() payload."""
1498
- from superlocalmemory.core.config import SLMConfig
1499
- try:
1500
- body = await request.json()
1501
- new_mode = (body.get("mode") or body.get("settings_mode") or "").lower().strip()
1502
- if new_mode not in ("a", "b", "c"):
1503
- return JSONResponse(
1504
- {"ok": False, "error": "mode must be a, b, or c"},
1505
- status_code=400,
1506
- )
1507
- config = SLMConfig.switch_mode(new_mode)
1508
-
1509
- # If provider/model were sent, update the saved config
1510
- provider = body.get("provider", "").strip()
1511
- if provider and new_mode != "a":
1512
- _base = Path.home() / ".superlocalmemory"
1513
- from superlocalmemory.core.config import LLMConfig, EmbeddingConfig
1514
- # Update LLM
1515
- model = body.get("model", "").strip()
1516
- api_key = body.get("api_key", "").strip()
1517
- endpoint = body.get("endpoint", "").strip()
1518
- if provider or model:
1519
- config.llm = LLMConfig(
1520
- provider=provider or config.llm.provider,
1521
- model=model or config.llm.model,
1522
- api_key=api_key or config.llm.api_key,
1523
- api_base=endpoint or config.llm.api_base,
1524
- )
1525
- # Update embedding
1526
- emb_provider = body.get("embedding_provider", "").strip()
1527
- emb_model = body.get("embedding_model", "").strip()
1528
- emb_key = body.get("embedding_key", "").strip()
1529
- if emb_provider or emb_model:
1530
- config.embedding = EmbeddingConfig(
1531
- provider=emb_provider or config.embedding.provider,
1532
- model_name=emb_model or config.embedding.model_name,
1533
- dimension=config.embedding.dimension,
1534
- api_key=emb_key or config.embedding.api_key,
1535
- )
1536
- config.save(mode_change=True)
1537
-
1538
- return {
1539
- "ok": True, "mode": new_mode,
1540
- "embedding": f"{config.embedding.provider}/{config.embedding.model_name}",
1541
- "llm": f"{config.llm.provider}/{config.llm.model}",
1542
- "message": f"Switched to Mode {new_mode.upper()}. Run slm restart to apply.",
1543
- }
1544
- except Exception as exc:
1545
- return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
1546
-
1547
- @application.get("/api/v3/ollama/status")
1548
- async def v3_ollama_status():
1549
- """Check if Ollama is running and list available models."""
1550
- try:
1551
- import httpx as _hx
1552
- _r = _hx.get("http://localhost:11434/api/tags", timeout=3.0)
1553
- if _r.status_code == 200:
1554
- _data = _r.json()
1555
- return {
1556
- "running": True,
1557
- "models": [{"name": m["name"], "size": m.get("size", 0)}
1558
- for m in _data.get("models", [])],
1559
- }
1560
- except Exception:
1561
- pass
1562
- return {"running": False, "models": []}
1563
-
1564
- @application.post("/api/v3/provider/test")
1565
- async def v3_provider_test(request: Request):
1566
- """Test a provider connection. Body: {provider, api_key, endpoint}."""
1567
- try:
1568
- body = await request.json()
1569
- provider = body.get("provider", "")
1570
- api_key = body.get("api_key", "")
1571
- endpoint = body.get("endpoint", "")
1572
- if provider == "ollama":
1573
- import httpx as _hx
1574
- _r = _hx.get(f"{endpoint or 'http://localhost:11434'}/api/tags", timeout=3.0)
1575
- return {"ok": _r.status_code == 200, "message": "Ollama reachable" if _r.status_code == 200 else f"HTTP {_r.status_code}"}
1576
- if provider in ("openai", "openrouter"):
1577
- import httpx as _hx
1578
- _url = f"{endpoint or 'https://api.openai.com/v1'}/models"
1579
- _headers = {"Authorization": f"Bearer {api_key}"}
1580
- _r = _hx.get(_url, headers=_headers, timeout=5.0)
1581
- return {"ok": _r.status_code == 200, "message": "API key valid" if _r.status_code == 200 else f"HTTP {_r.status_code}: {_r.text[:200]}"}
1582
- return {"ok": False, "message": f"Unknown provider: {provider}"}
1583
- except Exception as exc:
1584
- return {"ok": False, "message": str(exc)}
1585
-
1586
- @application.get("/api/v3/embedding/config")
1587
- async def v3_get_embedding_config():
1588
- """Get current embedding configuration."""
1589
- engine = getattr(application.state, "engine", None)
1590
- if engine is None:
1591
- return JSONResponse({"ok": False, "error": "engine not initialized"}, status_code=503)
1592
- config = getattr(engine, "_config", None)
1593
- if config is None:
1594
- return JSONResponse({"ok": False, "error": "no config loaded"}, status_code=503)
1595
- return {
1596
- "provider": getattr(config.embedding, "provider", ""),
1597
- "model_name": getattr(config.embedding, "model_name", ""),
1598
- "dimension": getattr(config.embedding, "dimension", 0),
1599
- }
1600
-
1601
- @application.post("/api/v3/embedding/test")
1602
- async def v3_embedding_test(request: Request):
1603
- """Test embedding with current config. Body: {text: \"test\"}."""
1604
- try:
1605
- body = await request.json()
1606
- text = body.get("text", "test embedding")
1607
- engine = getattr(application.state, "engine", None)
1608
- if engine is None:
1609
- return {"ok": False, "error": "engine not initialized"}
1610
- embedder = getattr(engine, "_embedder", None)
1611
- if embedder is None:
1612
- return {"ok": False, "error": "embedder not available"}
1613
- vec = embedder.embed(text)
1614
- return {"ok": True, "dimensions": len(vec) if vec else 0}
1615
- except Exception as exc:
1616
- return {"ok": False, "error": str(exc)}
1617
-
1618
1554
  @application.get("/", response_class=HTMLResponse)
1619
1555
  async def root():
1620
1556
  index_path = UI_DIR / "index.html"
@@ -590,12 +590,21 @@ class DatabaseManager:
590
590
 
591
591
  def search_facts_fts(self, query: str, profile_id: str, limit: int = 20) -> list[AtomicFact]:
592
592
  """Full-text search via FTS5, joined to facts table for reconstruction."""
593
+ # v3.6.12 (search-1): the raw query was passed straight into FTS5 MATCH,
594
+ # so any '?', '-', quote, or trailing boolean keyword (AND/OR/NOT) raised
595
+ # an FTS5 syntax error. Tokenize to word characters, quote each token,
596
+ # and OR-join — mirrors the recall BM25 channel's safe MATCH expression.
597
+ import re as _re
598
+ tokens = [t for t in _re.findall(r"\w+", query.lower()) if t]
599
+ if not tokens:
600
+ return []
601
+ match_expr = " OR ".join(f'"{t}"' for t in tokens)
593
602
  rows = self.execute(
594
603
  """SELECT f.* FROM atomic_facts_fts AS fts
595
604
  JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
596
605
  WHERE fts.atomic_facts_fts MATCH ? AND f.profile_id = ?
597
606
  ORDER BY fts.rank LIMIT ?""",
598
- (query, profile_id, limit),
607
+ (match_expr, profile_id, limit),
599
608
  )
600
609
  return [self._row_to_fact(r) for r in rows]
601
610
 
@@ -170,6 +170,20 @@ async function loadModeSettings() {
170
170
  // Show provider panel and populate model dropdown
171
171
  updateModeUI();
172
172
 
173
+ // v3.6.12 (issue #39/#40): populate the endpoint field from the SAVED
174
+ // config endpoint. updateProviderUI() above sets the field to the
175
+ // provider's DEFAULT (e.g. https://api.openai.com/v1), which hides the
176
+ // user's real custom endpoint (llama.cpp/LM Studio) and makes Test
177
+ // Connection probe the wrong URL → 401. Override with data.endpoint here.
178
+ if (data.endpoint) {
179
+ setTimeout(function() {
180
+ var epEl = document.getElementById('settings-endpoint');
181
+ if (epEl) epEl.value = data.endpoint;
182
+ var epRow = document.getElementById('settings-endpoint-row');
183
+ if (epRow) epRow.style.display = 'block';
184
+ }, 0);
185
+ }
186
+
173
187
  // After provider UI updates, set the saved model value
174
188
  if (model) {
175
189
  setTimeout(function() {
@@ -314,6 +328,11 @@ async function testConnection() {
314
328
  var provider = document.getElementById('settings-provider')?.value || '';
315
329
  var model = document.getElementById('settings-model')?.value || '';
316
330
  var apiKey = document.getElementById('settings-api-key')?.value || '';
331
+ // v3.6.12 (issue #39): include the configured custom endpoint. Without this
332
+ // the backend never sees base_url, treats a custom llama.cpp/LM-Studio server
333
+ // as official OpenAI, and 401s on an empty key. Was the real cause of the
334
+ // "Test Connection fails / API key required" report against Mode B.
335
+ var endpoint = document.getElementById('settings-endpoint')?.value || '';
317
336
  var resultEl = document.getElementById('settings-test-result');
318
337
 
319
338
  if (!provider) {
@@ -326,6 +345,7 @@ async function testConnection() {
326
345
  try {
327
346
  var testBody = {provider: provider, model: model};
328
347
  if (apiKey) testBody.api_key = apiKey;
348
+ if (endpoint) { testBody.base_url = endpoint; testBody.endpoint = endpoint; }
329
349
  var resp = await fetch('/api/v3/provider/test', {
330
350
  method: 'POST',
331
351
  headers: {'Content-Type': 'application/json'},
@@ -348,6 +368,9 @@ async function saveAllSettings() {
348
368
  if (mode === 'a') provider = 'none';
349
369
  var model = document.getElementById('settings-model')?.value || '';
350
370
  var apiKey = document.getElementById('settings-api-key')?.value || '';
371
+ // v3.6.12 (settings-2): persist the custom endpoint too, else a llama.cpp/
372
+ // LM-Studio/Azure endpoint can never be saved (backend reads base_url).
373
+ var endpoint = document.getElementById('settings-endpoint')?.value || '';
351
374
 
352
375
  var statusEl = document.getElementById('settings-save-status');
353
376
  var saveBtn = document.getElementById('settings-save-all');
@@ -358,6 +381,7 @@ async function saveAllSettings() {
358
381
  // V3.4.24: Include embedding params in save payload
359
382
  var embParams = getEmbeddingParams();
360
383
  var payload = Object.assign({mode: mode, provider: provider, model: model, api_key: apiKey}, embParams);
384
+ if (endpoint) { payload.base_url = endpoint; payload.endpoint = endpoint; }
361
385
  var modeResp = await fetch('/api/v3/mode/set', {
362
386
  method: 'POST',
363
387
  headers: {'Content-Type': 'application/json'},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.6.11
3
+ Version: 3.6.13
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: AGPL-3.0-or-later
@@ -128,6 +128,8 @@ Dynamic: license-file
128
128
 
129
129
  > V3.6 is the only local-first layer that SKIPS repeat LLM calls (cache: 100% on a hit), SHRINKS tool outputs and injected context (compress: lossless-by-default, opt-in LLMLingua-2), and DISCOUNTS prefix costs (align: native KV-cache) — and remembers everything — in one install.
130
130
  >
131
+ > **v3.6.12 "Distributed-ready":** Run SLM on a server and reach it across your LAN. `SLM_REMOTE=1` (default off) lets the dashboard load from a remote browser, lets MCP gateways/hubs forward tool calls, and makes custom local LLM endpoints (llama.cpp / LM Studio / Azure) configurable right from the dashboard — plus a batch of stability and security fixes. See [`docs/distributed-deployment.md`](docs/distributed-deployment.md).
132
+ >
131
133
  > **v3.6.11 "Optimize Everywhere":** Three surfaces. **Proxy** (Surface A) — full-turn cache + compress on transport; needs `ANTHROPIC_BASE_URL`, shrinks the context window. **MCP tools** (Surface B) — `slm_compress`, `slm_retrieve`, `slm_cache_set`, `slm_cache_get`, `slm_optimize_stats`; no proxy, no window shrink, works on any Claude subscription. **Skill** (Surface C) — `slm-optimize` installs in `~/.claude/skills/`; zero-config auto-compress for large tool outputs and CLAUDE.md. No proxy, full 1M window. [See Three Surfaces →](#three-surfaces-proxy--mcp-tools--skill)
132
134
  >
133
135
  > **v3.6.10:** cache and compression are now **independent runtime switches** (cache-only, compress-only, both, or neither — toggle live from the dashboard, no restart). Compression was rebuilt to be **lossless by default** (the old string/array/code truncation is gone); aggressive mode adds LLMLingua-2 for **prose only** — never code, numbers, structured data, or the current turn.
@@ -114,6 +114,7 @@ src/superlocalmemory/core/recall_pipeline.py
114
114
  src/superlocalmemory/core/recall_queue.py
115
115
  src/superlocalmemory/core/recall_worker.py
116
116
  src/superlocalmemory/core/registry.py
117
+ src/superlocalmemory/core/remote_mode.py
117
118
  src/superlocalmemory/core/reranker_worker.py
118
119
  src/superlocalmemory/core/safe_fs.py
119
120
  src/superlocalmemory/core/security_primitives.py