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.
- package/CHANGELOG.md +38 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +1 -0
- package/src/superlocalmemory/cli/daemon.py +0 -407
- package/src/superlocalmemory/cli/main.py +39 -24
- package/src/superlocalmemory/cli/version_banner.py +6 -2
- package/src/superlocalmemory/core/context_cache.py +4 -1
- package/src/superlocalmemory/core/fact_consolidator.py +4 -1
- package/src/superlocalmemory/core/remote_mode.py +197 -0
- package/src/superlocalmemory/core/summarizer.py +4 -1
- package/src/superlocalmemory/llm/backbone.py +7 -1
- package/src/superlocalmemory/mcp/agent_context.py +7 -3
- package/src/superlocalmemory/mcp/tools_core.py +13 -1
- package/src/superlocalmemory/mcp/tools_mesh.py +14 -6
- package/src/superlocalmemory/mesh/broker.py +15 -4
- package/src/superlocalmemory/optimize/compress/router.py +9 -4
- package/src/superlocalmemory/optimize/storage/db.py +16 -2
- package/src/superlocalmemory/server/api.py +11 -3
- package/src/superlocalmemory/server/routes/mesh.py +13 -0
- package/src/superlocalmemory/server/routes/token.py +14 -2
- package/src/superlocalmemory/server/routes/v3_api.py +83 -17
- package/src/superlocalmemory/server/ui.py +15 -4
- package/src/superlocalmemory/server/unified_daemon.py +96 -160
- package/src/superlocalmemory/storage/database.py +10 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +24 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +3 -1
- 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=
|
|
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
|
-
|
|
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
|
-
|
|
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": {
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1317
|
-
|
|
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
|
|
1347
|
-
|
|
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
|
-
(
|
|
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.
|
|
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
|