superlocalmemory 3.6.8 → 3.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +8 -4
- package/package.json +1 -1
- package/pyproject.toml +6 -1
- package/src/superlocalmemory/__init__.py +6 -2
- package/src/superlocalmemory/cli/compress_cmd.py +32 -70
- package/src/superlocalmemory/cli/daemon.py +25 -3
- package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
- package/src/superlocalmemory/cli/setup_wizard.py +49 -0
- package/src/superlocalmemory/core/config.py +28 -0
- package/src/superlocalmemory/core/engine.py +15 -4
- package/src/superlocalmemory/core/health_monitor.py +32 -9
- package/src/superlocalmemory/mcp/agent_context.py +111 -0
- package/src/superlocalmemory/mcp/tools_active.py +47 -12
- package/src/superlocalmemory/mcp/tools_core.py +22 -2
- package/src/superlocalmemory/mcp/tools_mesh.py +37 -38
- package/src/superlocalmemory/optimize/cache/boundary_store.py +23 -9
- package/src/superlocalmemory/optimize/cache/exact.py +7 -4
- package/src/superlocalmemory/optimize/cache/key_builder.py +13 -0
- package/src/superlocalmemory/optimize/cache/manager.py +70 -8
- package/src/superlocalmemory/optimize/cache/semantic.py +10 -5
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +1 -7
- package/src/superlocalmemory/optimize/compress/router.py +82 -87
- package/src/superlocalmemory/optimize/config/__init__.py +16 -0
- package/src/superlocalmemory/optimize/config/defaults.py +1 -6
- package/src/superlocalmemory/optimize/config/schema.py +2 -19
- package/src/superlocalmemory/optimize/config/store.py +15 -1
- package/src/superlocalmemory/optimize/metrics/counters.py +15 -7
- package/src/superlocalmemory/optimize/proxy/_helpers.py +100 -2
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +12 -0
- package/src/superlocalmemory/optimize/proxy/capture.py +243 -0
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +31 -0
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +12 -0
- package/src/superlocalmemory/optimize/proxy/server.py +29 -0
- package/src/superlocalmemory/optimize/storage/db.py +78 -11
- package/src/superlocalmemory/optimize/storage/schema.py +11 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -3
- package/src/superlocalmemory/server/routes/optimize.py +6 -8
- package/src/superlocalmemory/server/unified_daemon.py +68 -11
- package/src/superlocalmemory/ui/index.html +18 -14
- package/src/superlocalmemory/ui/js/auto-settings.js +3 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +3 -0
- package/src/superlocalmemory/ui/js/optimize.js +9 -9
- package/src/superlocalmemory.egg-info/PKG-INFO +10 -5
- package/src/superlocalmemory.egg-info/SOURCES.txt +2 -2
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
- package/src/superlocalmemory/optimize/compress/extractive_code.py +0 -311
- package/src/superlocalmemory/optimize/compress/extractive_json.py +0 -72
|
@@ -33,6 +33,7 @@ import dataclasses
|
|
|
33
33
|
import json
|
|
34
34
|
import logging
|
|
35
35
|
import os
|
|
36
|
+
import re
|
|
36
37
|
import sqlite3
|
|
37
38
|
import struct
|
|
38
39
|
import time
|
|
@@ -52,6 +53,25 @@ from superlocalmemory.optimize.storage import schema as _schema
|
|
|
52
53
|
logger = logging.getLogger(__name__)
|
|
53
54
|
|
|
54
55
|
_DEFAULT_TENANT: str = "default"
|
|
56
|
+
_TENANT_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_tenant_id(tenant_id: str) -> str:
|
|
60
|
+
"""Match CacheManager.build_key tenant normalization (v3.6.10 fix).
|
|
61
|
+
|
|
62
|
+
The proxy stores entries under SHA-256(tenant) when the tenant is not
|
|
63
|
+
already a 64-char hex digest. Public tenant-scoped helpers (entry_count,
|
|
64
|
+
clear_tenant, entry_exists) MUST apply the same hashing or they query the
|
|
65
|
+
wrong tenant — the cause of the dashboard reporting "0 entries" while the
|
|
66
|
+
cache is in fact populated.
|
|
67
|
+
"""
|
|
68
|
+
tid = tenant_id or _DEFAULT_TENANT
|
|
69
|
+
if _TENANT_HEX64.fullmatch(tid):
|
|
70
|
+
return tid
|
|
71
|
+
import hashlib as _hashlib
|
|
72
|
+
return _hashlib.sha256(tid.encode()).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
55
75
|
_ZLIB_LEVEL: int = 6
|
|
56
76
|
_AES_NONCE_BYTES: int = 12
|
|
57
77
|
_PBKDF2_ITERATIONS: int = 100_000
|
|
@@ -61,6 +81,9 @@ LLMCACHE_DBNAME: str = "llmcache.db"
|
|
|
61
81
|
MID_FILENAME: str = ".llmcache_key"
|
|
62
82
|
SALT_PREFIX: str = "salt:"
|
|
63
83
|
|
|
84
|
+
# C-06: persisted AES key — survives machine-id changes after first run
|
|
85
|
+
_KEY_FILE: Path = Path.home() / LLMCACHE_DIRNAME / "opt-key.bin"
|
|
86
|
+
|
|
64
87
|
_FORBIDDEN_MEMORY_TABLES: frozenset[str] = frozenset({
|
|
65
88
|
"memories", "atomic_facts", "profiles", "canonical_entities",
|
|
66
89
|
"entity_aliases", "consolidation_log", "trust_scores", "bm25_tokens",
|
|
@@ -74,7 +97,13 @@ _FORBIDDEN_MEMORY_TABLES: frozenset[str] = frozenset({
|
|
|
74
97
|
|
|
75
98
|
@dataclass
|
|
76
99
|
class MetricsSnapshot:
|
|
77
|
-
"""Mirror of llmcache_metrics columns — names MUST match exactly.
|
|
100
|
+
"""Mirror of llmcache_metrics columns — names MUST match exactly.
|
|
101
|
+
|
|
102
|
+
S-03 / M-03 note: compress_bytes_original and compress_bytes_after store
|
|
103
|
+
WORD-COUNT proxy values (len(text.split())), NOT byte counts. The column
|
|
104
|
+
names use "bytes" for DB schema backward compatibility. Treat these fields
|
|
105
|
+
as token-count proxies, not literal byte measurements.
|
|
106
|
+
"""
|
|
78
107
|
id: int = 1
|
|
79
108
|
hits: int = 0
|
|
80
109
|
misses: int = 0
|
|
@@ -86,8 +115,8 @@ class MetricsSnapshot:
|
|
|
86
115
|
latency_overhead_ms_sum: float = 0.0
|
|
87
116
|
latency_samples: int = 0
|
|
88
117
|
compress_runs: int = 0
|
|
89
|
-
compress_bytes_original: int = 0
|
|
90
|
-
compress_bytes_after: int = 0
|
|
118
|
+
compress_bytes_original: int = 0 # unit: word-count proxy (see S-03 note above)
|
|
119
|
+
compress_bytes_after: int = 0 # unit: word-count proxy (see S-03 note above)
|
|
91
120
|
cache_size_bytes: int = 0
|
|
92
121
|
cache_entry_count: int = 0
|
|
93
122
|
updated_at: float = 0.0
|
|
@@ -209,8 +238,7 @@ class CacheDB:
|
|
|
209
238
|
except OSError as exc:
|
|
210
239
|
logger.warning("CacheDB: could not chmod 600 on %s: %s", self._db_path, exc)
|
|
211
240
|
self._salt = self._load_or_create_salt()
|
|
212
|
-
|
|
213
|
-
self._aes_key = self._derive_aes_key(machine_id, self._salt)
|
|
241
|
+
self._aes_key = self._get_or_persist_aes_key(self._salt)
|
|
214
242
|
self.assert_no_memory_db_tables()
|
|
215
243
|
|
|
216
244
|
# ---- context manager ----
|
|
@@ -292,6 +320,33 @@ class CacheDB:
|
|
|
292
320
|
logger.warning("CacheDB: could not persist machine id: %s", exc)
|
|
293
321
|
return mid
|
|
294
322
|
|
|
323
|
+
def _get_or_persist_aes_key(self, salt: bytes) -> bytes:
|
|
324
|
+
"""C-06: Load persisted key from disk, or derive + persist on first run.
|
|
325
|
+
|
|
326
|
+
Surviving a machine-id change: after first derivation the key is saved to
|
|
327
|
+
opt-key.bin (0600). On subsequent starts the file is read directly,
|
|
328
|
+
so changing the underlying machine-id string cannot invalidate existing
|
|
329
|
+
cache entries.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
if _KEY_FILE.exists():
|
|
333
|
+
key = _KEY_FILE.read_bytes()
|
|
334
|
+
if len(key) == 32:
|
|
335
|
+
return key
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
logger.warning("CacheDB: could not read persisted AES key: %s", exc)
|
|
338
|
+
|
|
339
|
+
# First run (or corrupted file): derive from machine-id and persist.
|
|
340
|
+
machine_id = self._get_machine_id()
|
|
341
|
+
key = self._derive_aes_key(machine_id, salt)
|
|
342
|
+
try:
|
|
343
|
+
_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
_KEY_FILE.write_bytes(key)
|
|
345
|
+
os.chmod(_KEY_FILE, 0o600)
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
logger.warning("CacheDB: could not persist AES key (fail-open): %s", exc)
|
|
348
|
+
return key
|
|
349
|
+
|
|
295
350
|
def _derive_aes_key(self, machine_id: str, salt: bytes) -> bytes:
|
|
296
351
|
kdf = PBKDF2HMAC(
|
|
297
352
|
algorithm=hashes.SHA256(),
|
|
@@ -626,11 +681,12 @@ class CacheDB:
|
|
|
626
681
|
try:
|
|
627
682
|
dim = int(meta.get("dim", len(vector) // 4))
|
|
628
683
|
model_name = str(meta.get("model", "nomic-ai/nomic-embed-text-v1.5"))
|
|
684
|
+
context_fp = str(meta.get("context_fp", "")) # C-10: persist context fingerprint
|
|
629
685
|
self._db.execute(
|
|
630
686
|
"INSERT OR REPLACE INTO llmcache_semantic_vectors "
|
|
631
|
-
"(entry_id, tenant_id, vector_blob, vector_dim, model_name) "
|
|
632
|
-
"VALUES (?, ?, ?, ?, ?)",
|
|
633
|
-
(entry_id, tenant_id, vector, dim, model_name),
|
|
687
|
+
"(entry_id, tenant_id, vector_blob, vector_dim, model_name, context_fp) "
|
|
688
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
689
|
+
(entry_id, tenant_id, vector, dim, model_name, context_fp),
|
|
634
690
|
)
|
|
635
691
|
except sqlite3.Error as exc:
|
|
636
692
|
logger.warning("CacheDB.vec_add failed: %s", exc)
|
|
@@ -855,14 +911,22 @@ class CacheDB:
|
|
|
855
911
|
|
|
856
912
|
# ---- v2 additions ----
|
|
857
913
|
|
|
858
|
-
def get_all_vectors(self, tenant_id: str) -> list[tuple[str, bytes]]:
|
|
914
|
+
def get_all_vectors(self, tenant_id: str) -> list[tuple[str, bytes, str]]:
|
|
915
|
+
"""Return (entry_id, vector_blob, context_fp) for all vectors in a tenant.
|
|
916
|
+
|
|
917
|
+
C-10: context_fp is included so _lazy_warm_tenant() can restore it without
|
|
918
|
+
recomputing embeddings from messages that may no longer be in scope.
|
|
919
|
+
"""
|
|
859
920
|
try:
|
|
860
921
|
rows = self._db.execute(
|
|
861
|
-
"SELECT entry_id, vector_blob FROM llmcache_semantic_vectors "
|
|
922
|
+
"SELECT entry_id, vector_blob, context_fp FROM llmcache_semantic_vectors "
|
|
862
923
|
"WHERE tenant_id = ?",
|
|
863
924
|
(tenant_id,),
|
|
864
925
|
)
|
|
865
|
-
return [
|
|
926
|
+
return [
|
|
927
|
+
(dict(r)["entry_id"], dict(r)["vector_blob"], dict(r).get("context_fp", ""))
|
|
928
|
+
for r in rows
|
|
929
|
+
]
|
|
866
930
|
except sqlite3.Error as exc:
|
|
867
931
|
logger.warning("CacheDB.get_all_vectors failed: %s", exc)
|
|
868
932
|
return []
|
|
@@ -919,6 +983,7 @@ class CacheDB:
|
|
|
919
983
|
# ---- convenience / non-contract helpers ----
|
|
920
984
|
|
|
921
985
|
def entry_exists(self, cache_key: str, tenant_id: str = _DEFAULT_TENANT) -> bool:
|
|
986
|
+
tenant_id = _normalize_tenant_id(tenant_id)
|
|
922
987
|
try:
|
|
923
988
|
rows = self._db.execute(
|
|
924
989
|
"SELECT 1 FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ? LIMIT 1",
|
|
@@ -929,6 +994,7 @@ class CacheDB:
|
|
|
929
994
|
return False
|
|
930
995
|
|
|
931
996
|
def clear_tenant(self, tenant_id: str) -> int:
|
|
997
|
+
tenant_id = _normalize_tenant_id(tenant_id)
|
|
932
998
|
try:
|
|
933
999
|
with self._db.transaction():
|
|
934
1000
|
rows = self._db.execute(
|
|
@@ -955,6 +1021,7 @@ class CacheDB:
|
|
|
955
1021
|
return 0
|
|
956
1022
|
|
|
957
1023
|
def entry_count(self, tenant_id: str = _DEFAULT_TENANT) -> int:
|
|
1024
|
+
tenant_id = _normalize_tenant_id(tenant_id)
|
|
958
1025
|
try:
|
|
959
1026
|
rows = self._db.execute(
|
|
960
1027
|
"SELECT COUNT(*) AS n FROM llmcache_entries "
|
|
@@ -53,6 +53,7 @@ _DDL_STATEMENTS: tuple[str, ...] = (
|
|
|
53
53
|
vector_blob BLOB NOT NULL,
|
|
54
54
|
vector_dim INTEGER NOT NULL DEFAULT 768,
|
|
55
55
|
model_name TEXT NOT NULL DEFAULT 'nomic-ai/nomic-embed-text-v1.5',
|
|
56
|
+
context_fp TEXT NOT NULL DEFAULT '',
|
|
56
57
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
57
58
|
)
|
|
58
59
|
""",
|
|
@@ -127,10 +128,20 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
|
|
|
127
128
|
|
|
128
129
|
Safe to call repeatedly — all DDL uses IF NOT EXISTS.
|
|
129
130
|
Also seeds the single llmcache_metrics row (id=1) via INSERT OR IGNORE.
|
|
131
|
+
C-10: adds context_fp column to existing DBs via ALTER TABLE migration.
|
|
130
132
|
"""
|
|
131
133
|
for stmt in _DDL_STATEMENTS:
|
|
132
134
|
conn.execute(stmt)
|
|
133
135
|
conn.execute("INSERT OR IGNORE INTO llmcache_metrics(id) VALUES (1)")
|
|
136
|
+
# C-10 migration: add context_fp column if missing (existing installs pre-v3.6.10)
|
|
137
|
+
existing_cols = {
|
|
138
|
+
row[1]
|
|
139
|
+
for row in conn.execute("PRAGMA table_info(llmcache_semantic_vectors)")
|
|
140
|
+
}
|
|
141
|
+
if "context_fp" not in existing_cols:
|
|
142
|
+
conn.execute(
|
|
143
|
+
"ALTER TABLE llmcache_semantic_vectors ADD COLUMN context_fp TEXT NOT NULL DEFAULT ''"
|
|
144
|
+
)
|
|
134
145
|
row = conn.execute(
|
|
135
146
|
"SELECT 1 FROM llmcache_schema_version WHERE version = ?",
|
|
136
147
|
(CACHE_SCHEMA_VERSION,),
|
|
@@ -170,8 +170,11 @@ class SpreadingActivation:
|
|
|
170
170
|
for fact_id, similarity in seeds:
|
|
171
171
|
activations[fact_id] = cfg.alpha * similarity
|
|
172
172
|
|
|
173
|
-
#
|
|
173
|
+
# Cache neighbor lookups and out-degrees across iterations — same node
|
|
174
|
+
# often survives multiple rounds via self-retention (delta=0.5);
|
|
175
|
+
# caching here cuts ~80% of SQL queries vs per-iteration re-query.
|
|
174
176
|
degree_cache: dict[str, int] = {}
|
|
177
|
+
neighbor_cache: dict[str, list] = {}
|
|
175
178
|
|
|
176
179
|
# Steps 2-4, repeated T times
|
|
177
180
|
for _iteration in range(cfg.max_iterations):
|
|
@@ -181,8 +184,10 @@ class SpreadingActivation:
|
|
|
181
184
|
if activation < 0.001:
|
|
182
185
|
continue
|
|
183
186
|
|
|
184
|
-
# Get neighbors from BOTH tables (Rule 13)
|
|
185
|
-
|
|
187
|
+
# Get neighbors from BOTH tables (Rule 13) — cached per node
|
|
188
|
+
if node_id not in neighbor_cache:
|
|
189
|
+
neighbor_cache[node_id] = self._get_unified_neighbors(node_id, profile_id)
|
|
190
|
+
neighbors = neighbor_cache[node_id]
|
|
186
191
|
|
|
187
192
|
# Out-degree for fan effect normalization
|
|
188
193
|
if node_id not in degree_cache:
|
|
@@ -32,17 +32,15 @@ class ConfigUpdateRequest(BaseModel):
|
|
|
32
32
|
semantic_enabled: bool | None = None
|
|
33
33
|
compress_enabled: bool | None = None
|
|
34
34
|
compress_mode: Literal['safe', 'aggressive'] | None = None
|
|
35
|
-
compress_code: bool | None = None
|
|
36
35
|
compress_prose: bool | None = None
|
|
37
|
-
compress_ccr: bool | None = None
|
|
38
36
|
|
|
39
37
|
|
|
40
38
|
@router.get("/config")
|
|
41
39
|
async def get_config() -> dict[str, Any]:
|
|
42
40
|
"""Return current optimize config as JSON."""
|
|
43
41
|
try:
|
|
44
|
-
from superlocalmemory.optimize.config
|
|
45
|
-
cfg =
|
|
42
|
+
from superlocalmemory.optimize.config import get_shared_store
|
|
43
|
+
cfg = get_shared_store().get()
|
|
46
44
|
return cfg.as_dict()
|
|
47
45
|
except Exception as exc:
|
|
48
46
|
logger.warning("GET /api/optimize/config failed: %s", exc)
|
|
@@ -54,8 +52,8 @@ async def put_config(body: ConfigUpdateRequest) -> dict[str, Any]:
|
|
|
54
52
|
"""Update optimize config (partial). Daemon hot-reloads within 2s."""
|
|
55
53
|
try:
|
|
56
54
|
import dataclasses
|
|
57
|
-
from superlocalmemory.optimize.config
|
|
58
|
-
store =
|
|
55
|
+
from superlocalmemory.optimize.config import get_shared_store
|
|
56
|
+
store = get_shared_store()
|
|
59
57
|
cfg = store.get()
|
|
60
58
|
updates: dict[str, Any] = {}
|
|
61
59
|
for field_name in ConfigUpdateRequest.model_fields:
|
|
@@ -80,11 +78,11 @@ async def get_savings() -> dict[str, Any]:
|
|
|
80
78
|
Field names match INTERFACE-CONTRACT §5 exactly.
|
|
81
79
|
"""
|
|
82
80
|
try:
|
|
83
|
-
from superlocalmemory.optimize.config
|
|
81
|
+
from superlocalmemory.optimize.config import get_shared_store
|
|
84
82
|
from superlocalmemory.optimize.metrics.counters import get_metrics
|
|
85
83
|
from superlocalmemory.optimize.storage.db import CacheDB
|
|
86
84
|
|
|
87
|
-
cfg =
|
|
85
|
+
cfg = get_shared_store().get()
|
|
88
86
|
collector = get_metrics()
|
|
89
87
|
db = CacheDB()
|
|
90
88
|
snap = collector.snapshot(
|
|
@@ -746,8 +746,12 @@ async def lifespan(application: FastAPI):
|
|
|
746
746
|
try:
|
|
747
747
|
from superlocalmemory.core.health_monitor import HealthMonitor
|
|
748
748
|
health_config = getattr(config, 'health', None)
|
|
749
|
+
# v3.6.9 BUG-A: env override + RAM-scaled default (HealthMonitor computes
|
|
750
|
+
# 40% of physical RAM when budget=0). SLM_RSS_BUDGET_MB takes priority.
|
|
751
|
+
_env_budget = int(os.environ.get("SLM_RSS_BUDGET_MB", "0") or 0)
|
|
752
|
+
_cfg_budget = getattr(health_config, 'global_rss_budget_mb', 0) if health_config else 0
|
|
749
753
|
monitor = HealthMonitor(
|
|
750
|
-
global_rss_budget_mb=
|
|
754
|
+
global_rss_budget_mb=_env_budget or _cfg_budget or 0,
|
|
751
755
|
heartbeat_timeout_sec=getattr(health_config, 'heartbeat_timeout_sec', 60) if health_config else 60,
|
|
752
756
|
check_interval_sec=getattr(health_config, 'health_check_interval_sec', 15) if health_config else 15,
|
|
753
757
|
enable_structured_logging=getattr(health_config, 'enable_structured_logging', True) if health_config else True,
|
|
@@ -881,12 +885,21 @@ async def lifespan(application: FastAPI):
|
|
|
881
885
|
# lifespan every POST /mcp 500s with "Task group is not initialized."
|
|
882
886
|
# AsyncExitStack enters the context only when _mcp_app was mounted; if the
|
|
883
887
|
# mount failed (non-fatal) the daemon starts normally without HTTP MCP.
|
|
888
|
+
# v3.6.9 (#34): wrap the MCP lifespan in shield so an unhandled exception
|
|
889
|
+
# or tool-level cancellation inside a session manager task group cannot
|
|
890
|
+
# propagate out and trigger uvicorn's graceful-shutdown handler.
|
|
884
891
|
async with AsyncExitStack() as _mcp_stack:
|
|
885
892
|
if _mcp_app is not None:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
893
|
+
try:
|
|
894
|
+
await _mcp_stack.enter_async_context(
|
|
895
|
+
_mcp_app.router.lifespan_context(_mcp_app)
|
|
896
|
+
)
|
|
897
|
+
logger.info("MCP HTTP session manager started (Streamable-HTTP on /mcp)")
|
|
898
|
+
except Exception as _mcp_lifespan_exc:
|
|
899
|
+
logger.warning(
|
|
900
|
+
"MCP HTTP session manager failed to start (non-fatal, stdio still works): %s",
|
|
901
|
+
_mcp_lifespan_exc,
|
|
902
|
+
)
|
|
890
903
|
|
|
891
904
|
yield
|
|
892
905
|
|
|
@@ -1170,18 +1183,31 @@ def create_app() -> FastAPI:
|
|
|
1170
1183
|
# API keys (x-api-key, Authorization), NOT the SLM API key. Auth-exempt
|
|
1171
1184
|
# path prefixes are configured below in the auth_middleware block.
|
|
1172
1185
|
try:
|
|
1173
|
-
from superlocalmemory.optimize.config
|
|
1186
|
+
from superlocalmemory.optimize.config import _set_config_store, get_shared_store
|
|
1174
1187
|
from superlocalmemory.optimize.proxy.server import ProxyApp, build_proxy_router
|
|
1175
1188
|
|
|
1176
|
-
|
|
1189
|
+
# ONE shared ConfigStore for daemon + routes + watchdog + proxy reload
|
|
1190
|
+
# (fixes W-05 fresh-store-per-request; powers runtime hot-reload).
|
|
1191
|
+
_opt_store = get_shared_store()
|
|
1192
|
+
_set_config_store(_opt_store)
|
|
1193
|
+
_opt_cfg = _opt_store.get()
|
|
1194
|
+
# W-03 fix: the proxy path is gated by proxy_enabled ALONE. The master
|
|
1195
|
+
# `enabled` gates only the SDK adapter, never the proxy mount.
|
|
1177
1196
|
if _opt_cfg.proxy_enabled:
|
|
1178
1197
|
_proxy = ProxyApp(config=_opt_cfg)
|
|
1179
1198
|
application.state.optimize_proxy = _proxy
|
|
1180
1199
|
_proxy_router = build_proxy_router(_proxy)
|
|
1181
1200
|
# prefix="" — proxy claims /v1/*, /v1beta/* directly.
|
|
1182
1201
|
application.include_router(_proxy_router, prefix="")
|
|
1202
|
+
# v3.6.10: runtime hot-reload — rebuild the proxy HookChain whenever
|
|
1203
|
+
# optimize.json changes so cache_enabled / compress_enabled can be
|
|
1204
|
+
# toggled INDEPENDENTLY from the UI with no restart. UI save fires the
|
|
1205
|
+
# callback immediately; external edits are caught by the 2s watchdog.
|
|
1206
|
+
_opt_store.register_change_callback(_proxy.reload_from_config)
|
|
1207
|
+
_opt_store.start_watchdog()
|
|
1183
1208
|
logger.info(
|
|
1184
|
-
"optimize.proxy mounted on /v1/*, /v1beta/* port=8765"
|
|
1209
|
+
"optimize.proxy mounted on /v1/*, /v1beta/* port=8765 "
|
|
1210
|
+
"(runtime cache/compress hot-reload enabled)"
|
|
1185
1211
|
)
|
|
1186
1212
|
else:
|
|
1187
1213
|
application.state.optimize_proxy = None
|
|
@@ -1210,10 +1236,39 @@ def create_app() -> FastAPI:
|
|
|
1210
1236
|
from superlocalmemory.mcp.server import server as _mcp_fastmcp
|
|
1211
1237
|
_mcp_fastmcp.settings.streamable_http_path = "/"
|
|
1212
1238
|
_mcp_fastmcp._session_manager = None # Defensive reset for idempotency
|
|
1239
|
+
# v3.6.9 (#36): configure DNS-rebinding protection from env.
|
|
1240
|
+
# Default: localhost-only (safe). Set SLM_MCP_ALLOWED_HOSTS=192.168.x.y:*
|
|
1241
|
+
# (comma-separated, e.g. "192.168.50.144:*,slm.lan:*") to open to a LAN.
|
|
1242
|
+
# Use "*" to disable protection entirely (trusted private network only).
|
|
1243
|
+
# TransportSecuritySettings imported lazily here so that MCP mount
|
|
1244
|
+
# works on older SDK versions when SLM_MCP_ALLOWED_HOSTS is not set.
|
|
1245
|
+
_mcp_allowed = os.environ.get("SLM_MCP_ALLOWED_HOSTS", "").strip()
|
|
1246
|
+
if _mcp_allowed:
|
|
1247
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
1248
|
+
if _mcp_allowed == "*":
|
|
1249
|
+
_mcp_fastmcp.settings.transport_security = TransportSecuritySettings(
|
|
1250
|
+
enable_dns_rebinding_protection=False,
|
|
1251
|
+
)
|
|
1252
|
+
else:
|
|
1253
|
+
_hosts = [h.strip() for h in _mcp_allowed.split(",") if h.strip()]
|
|
1254
|
+
_mcp_fastmcp.settings.transport_security = TransportSecuritySettings(
|
|
1255
|
+
enable_dns_rebinding_protection=True,
|
|
1256
|
+
allowed_hosts=_hosts,
|
|
1257
|
+
allowed_origins=[f"http://{h}" for h in _hosts],
|
|
1258
|
+
)
|
|
1259
|
+
logger.info("MCP transport security: allowed_hosts=%r", _mcp_allowed)
|
|
1213
1260
|
global _mcp_app
|
|
1214
1261
|
_mcp_app = _mcp_fastmcp.streamable_http_app()
|
|
1215
|
-
|
|
1216
|
-
|
|
1262
|
+
|
|
1263
|
+
# v3.6.10: per-agent-ID routing — /mcp/{agent_id} extracts the agent
|
|
1264
|
+
# identity from the URL path and places it in a ContextVar so all MCP
|
|
1265
|
+
# tools (remember, recall, etc.) automatically use the correct namespace.
|
|
1266
|
+
# AgentIDExtractorASGI lives in mcp/agent_context so it is unit-testable
|
|
1267
|
+
# (tests/test_mcp/test_agent_context.py) rather than buried inline here.
|
|
1268
|
+
from superlocalmemory.mcp.agent_context import AgentIDExtractorASGI
|
|
1269
|
+
|
|
1270
|
+
application.mount("/mcp", AgentIDExtractorASGI(_mcp_app))
|
|
1271
|
+
logger.info("MCP HTTP transport mounted at /mcp (Streamable HTTP, port 8765; per-agent routing enabled)")
|
|
1217
1272
|
except Exception as _mcp_exc: # pragma: no cover — defensive
|
|
1218
1273
|
logger.warning("MCP HTTP mount failed (non-fatal, stdio still works): %s", _mcp_exc)
|
|
1219
1274
|
|
|
@@ -2222,7 +2277,9 @@ if __name__ == "__main__":
|
|
|
2222
2277
|
# freshly-sized file.
|
|
2223
2278
|
rotate_oversized_logs()
|
|
2224
2279
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
|
2225
|
-
|
|
2280
|
+
# v3.6.9 (#33): honour SLM_DAEMON_PORT env so operators can configure the
|
|
2281
|
+
# port without changing the launch command. --port= arg takes precedence.
|
|
2282
|
+
port = int(os.environ.get("SLM_DAEMON_PORT", "") or _DEFAULT_PORT)
|
|
2226
2283
|
for arg in sys.argv:
|
|
2227
2284
|
if arg.startswith("--port="):
|
|
2228
2285
|
port = int(arg.split("=")[1])
|
|
@@ -1248,6 +1248,16 @@
|
|
|
1248
1248
|
<input class="form-check-input" type="checkbox" id="opt-enabled">
|
|
1249
1249
|
<label class="form-check-label" for="opt-enabled">Optimize Enabled</label>
|
|
1250
1250
|
</div>
|
|
1251
|
+
<div class="form-check form-switch mb-2">
|
|
1252
|
+
<input class="form-check-input" type="checkbox" id="opt-proxy-enabled">
|
|
1253
|
+
<label class="form-check-label" for="opt-proxy-enabled">
|
|
1254
|
+
Proxy Enabled
|
|
1255
|
+
<span class="text-muted small ms-1">(restart required)</span>
|
|
1256
|
+
</label>
|
|
1257
|
+
</div>
|
|
1258
|
+
<div id="opt-restart-notice" class="alert alert-warning py-1 px-2 small mb-2 d-none">
|
|
1259
|
+
Proxy setting changed. Run <code>slm restart</code> to apply.
|
|
1260
|
+
</div>
|
|
1251
1261
|
<div class="form-check form-switch mb-2">
|
|
1252
1262
|
<input class="form-check-input" type="checkbox" id="opt-cache-enabled">
|
|
1253
1263
|
<label class="form-check-label" for="opt-cache-enabled">Cache Enabled</label>
|
|
@@ -1263,26 +1273,14 @@
|
|
|
1263
1273
|
<div class="mb-2">
|
|
1264
1274
|
<label for="opt-compress-mode" class="form-label">Compression Mode</label>
|
|
1265
1275
|
<select class="form-select form-select-sm" id="opt-compress-mode">
|
|
1266
|
-
<option value="safe">Safe</option>
|
|
1267
|
-
<option value="aggressive">Aggressive</option>
|
|
1276
|
+
<option value="safe">Safe (lossless)</option>
|
|
1277
|
+
<option value="aggressive">Aggressive (LLMLingua-2, prose only)</option>
|
|
1268
1278
|
</select>
|
|
1269
1279
|
</div>
|
|
1270
|
-
<div class="form-check form-switch mb-2">
|
|
1271
|
-
<input class="form-check-input" type="checkbox" id="opt-compress-code">
|
|
1272
|
-
<label class="form-check-label" for="opt-compress-code">Code Compression</label>
|
|
1273
|
-
</div>
|
|
1274
1280
|
<div class="form-check form-switch mb-2">
|
|
1275
1281
|
<input class="form-check-input" type="checkbox" id="opt-compress-prose">
|
|
1276
1282
|
<label class="form-check-label" for="opt-compress-prose">Prose Compression</label>
|
|
1277
1283
|
</div>
|
|
1278
|
-
<div class="form-check form-switch mb-2">
|
|
1279
|
-
<input class="form-check-input" type="checkbox" id="opt-compress-ccr">
|
|
1280
|
-
<label class="form-check-label" for="opt-compress-ccr">CCR</label>
|
|
1281
|
-
</div>
|
|
1282
|
-
<div class="form-check form-switch mb-2">
|
|
1283
|
-
<input class="form-check-input" type="checkbox" id="opt-compress-align">
|
|
1284
|
-
<label class="form-check-label" for="opt-compress-align">Alignment Compression</label>
|
|
1285
|
-
</div>
|
|
1286
1284
|
</div>
|
|
1287
1285
|
<div class="col-md-6">
|
|
1288
1286
|
<h6>Savings</h6>
|
|
@@ -1299,6 +1297,12 @@
|
|
|
1299
1297
|
<div class="small text-muted">Config version: <span id="opt-config-version">-</span></div>
|
|
1300
1298
|
<div id="opt-stale-warning" class="text-warning small mt-1"></div>
|
|
1301
1299
|
<button class="btn btn-sm btn-outline-primary mt-2" id="opt-copy-url">Copy URL</button>
|
|
1300
|
+
<div class="mt-2 small text-muted">
|
|
1301
|
+
<strong>Point agents at the proxy:</strong><br>
|
|
1302
|
+
<code>slm wrap claude</code> — Claude Code<br>
|
|
1303
|
+
<code>--openai-api-base http://localhost:8765/v1</code> — Aider<br>
|
|
1304
|
+
<code>OPENAI_BASE_URL=http://localhost:8765/v1</code> — OpenAI SDK
|
|
1305
|
+
</div>
|
|
1302
1306
|
</div>
|
|
1303
1307
|
</div>
|
|
1304
1308
|
</div>
|
|
@@ -324,10 +324,12 @@ async function testConnection() {
|
|
|
324
324
|
if (resultEl) { resultEl.textContent = 'Testing...'; resultEl.className = 'ms-2 small text-muted'; }
|
|
325
325
|
|
|
326
326
|
try {
|
|
327
|
+
var testBody = {provider: provider, model: model};
|
|
328
|
+
if (apiKey) testBody.api_key = apiKey;
|
|
327
329
|
var resp = await fetch('/api/v3/provider/test', {
|
|
328
330
|
method: 'POST',
|
|
329
331
|
headers: {'Content-Type': 'application/json'},
|
|
330
|
-
body: JSON.stringify(
|
|
332
|
+
body: JSON.stringify(testBody)
|
|
331
333
|
});
|
|
332
334
|
var data = await resp.json();
|
|
333
335
|
if (data.success) {
|
|
@@ -338,6 +338,9 @@
|
|
|
338
338
|
// ── Lazy Load Tab Data ─────────────────────────────────────
|
|
339
339
|
function triggerTabLoad(tabId) {
|
|
340
340
|
switch(tabId) {
|
|
341
|
+
case 'brain-pane':
|
|
342
|
+
if (typeof loadBrain === 'function') loadBrain();
|
|
343
|
+
break;
|
|
341
344
|
case 'graph-pane':
|
|
342
345
|
if (typeof loadGraph === 'function') loadGraph();
|
|
343
346
|
// v3.4.4: Initialize chat panel if not already present
|
|
@@ -23,14 +23,12 @@
|
|
|
23
23
|
if (!resp.ok) return;
|
|
24
24
|
var cfg = await resp.json();
|
|
25
25
|
_setToggle('opt-enabled', cfg.enabled);
|
|
26
|
+
_setToggle('opt-proxy-enabled', cfg.proxy_enabled);
|
|
26
27
|
_setToggle('opt-cache-enabled', cfg.cache_enabled);
|
|
27
28
|
_setToggle('opt-semantic-enabled', cfg.semantic_enabled);
|
|
28
29
|
_setToggle('opt-compress-enabled', cfg.compress_enabled);
|
|
29
30
|
_setSelect('opt-compress-mode', cfg.compress_mode);
|
|
30
|
-
_setToggle('opt-compress-code', cfg.compress_code);
|
|
31
31
|
_setToggle('opt-compress-prose', cfg.compress_prose);
|
|
32
|
-
_setToggle('opt-compress-ccr', cfg.compress_ccr);
|
|
33
|
-
_setToggle('opt-compress-align', cfg.compress_align);
|
|
34
32
|
var verEl = document.getElementById('opt-config-version');
|
|
35
33
|
if (verEl) verEl.textContent = cfg.config_version || '-';
|
|
36
34
|
} catch (e) {
|
|
@@ -70,14 +68,12 @@
|
|
|
70
68
|
var val = e.target.checked;
|
|
71
69
|
|
|
72
70
|
var fieldMap = {
|
|
73
|
-
'opt-enabled':
|
|
74
|
-
'opt-
|
|
71
|
+
'opt-enabled': 'enabled',
|
|
72
|
+
'opt-proxy-enabled': 'proxy_enabled',
|
|
73
|
+
'opt-cache-enabled': 'cache_enabled',
|
|
75
74
|
'opt-semantic-enabled': 'semantic_enabled',
|
|
76
75
|
'opt-compress-enabled': 'compress_enabled',
|
|
77
|
-
'opt-compress-
|
|
78
|
-
'opt-compress-prose': 'compress_prose',
|
|
79
|
-
'opt-compress-ccr': 'compress_ccr',
|
|
80
|
-
'opt-compress-align': 'compress_align'
|
|
76
|
+
'opt-compress-prose': 'compress_prose'
|
|
81
77
|
};
|
|
82
78
|
|
|
83
79
|
if (id === 'opt-compress-mode') {
|
|
@@ -109,6 +105,10 @@
|
|
|
109
105
|
var body = {};
|
|
110
106
|
body[field] = val;
|
|
111
107
|
_putConfig(body);
|
|
108
|
+
if (id === 'opt-proxy-enabled' || id === 'opt-enabled') {
|
|
109
|
+
var notice = document.getElementById('opt-restart-notice');
|
|
110
|
+
if (notice) notice.classList.remove('d-none');
|
|
111
|
+
}
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: superlocalmemory
|
|
3
|
-
Version: 3.6.
|
|
3
|
+
Version: 3.6.10
|
|
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
|
|
@@ -58,6 +58,7 @@ Requires-Dist: huggingface_hub==0.36.2
|
|
|
58
58
|
Requires-Dist: torch==2.11.0
|
|
59
59
|
Requires-Dist: scikit-learn==1.8.0
|
|
60
60
|
Requires-Dist: sqlite-vec==0.1.9
|
|
61
|
+
Requires-Dist: llmlingua==0.2.2
|
|
61
62
|
Provides-Extra: search
|
|
62
63
|
Requires-Dist: sentence-transformers==5.3.0; extra == "search"
|
|
63
64
|
Requires-Dist: optimum==2.1.0; extra == "search"
|
|
@@ -125,18 +126,22 @@ Dynamic: license-file
|
|
|
125
126
|
<details>
|
|
126
127
|
<summary><strong>What's New in V3.6 — Optimize: SKIP, SHRINK, DISCOUNT, REMEMBER</strong> (click to expand)</summary>
|
|
127
128
|
|
|
128
|
-
> V3.6 is the only local-first layer that SKIPS repeat LLM calls (cache: 100% saved), SHRINKS prompts
|
|
129
|
+
> V3.6 is the only local-first layer that SKIPS repeat LLM calls (cache: 100% saved on a hit), SHRINKS prose prompts (compress: lossless-by-default, opt-in LLMLingua-2), and DISCOUNTS prefix costs (align: native KV-cache) — and remembers everything — in one install. **Your first cache hit pays for the install time. Hours of coding on repeat, minimal API cost.**
|
|
130
|
+
>
|
|
131
|
+
> **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.
|
|
129
132
|
|
|
130
133
|
### The Three Levers
|
|
131
134
|
|
|
132
135
|
| Lever | Mechanism | Saving | Off by default? |
|
|
133
136
|
|-------|-----------|:------:|:---------------:|
|
|
134
|
-
| **Cache** | Skip repeat calls — exact-match SQLite lookup, vCache-gated semantic (opt-in) | **100% on a hit** (input + output) | Cache ON, Semantic OFF |
|
|
135
|
-
| **Compress** | Shrink prompts —
|
|
137
|
+
| **Cache** | Skip repeat calls — exact-match SQLite lookup (zero false hits), vCache-gated semantic (opt-in) | **100% on a hit** (input + output) | Cache ON, Semantic OFF |
|
|
138
|
+
| **Compress** | Shrink prompts — **safe = lossless** normalization; **aggressive = LLMLingua-2 prose only** (opt-in) | Safe: small + lossless · Aggressive: large on prose | Safe mode, Aggressive OFF |
|
|
136
139
|
| **Align** | Stabilize prefix — maximize provider prefix-cache discounts | **Lossless extra** | ON when compression is ON |
|
|
137
140
|
|
|
138
141
|
**Memory** (v3.5's existing engine) runs in parallel — it shapes *what is in* the prompt (relevant facts); Optimize decides *whether and how* it is sent.
|
|
139
142
|
|
|
143
|
+
> **Independent at runtime:** enable caching only, compression only, both, or neither — from the dashboard Optimize tab, applied live (no restart). Each AI client can also get its own memory identity over HTTP MCP via `http://127.0.0.1:8765/mcp/{agent_id}`.
|
|
144
|
+
|
|
140
145
|
### Quick Start
|
|
141
146
|
|
|
142
147
|
```bash
|
|
@@ -152,7 +157,7 @@ slm wrap claude
|
|
|
152
157
|
|:--------|:-------------|
|
|
153
158
|
| `slm optimize status\|on\|off\|savings` | Master Optimize control + savings report (USD/INR/tokens) |
|
|
154
159
|
| `slm cache status\|clear\|invalidate\|ttl\|semantic` | Cache sub-control — exact + semantic tiers |
|
|
155
|
-
| `slm compress status\|mode\|
|
|
160
|
+
| `slm compress status\|mode\|prose` | Compression control — safe (lossless) / aggressive (LLMLingua-2 prose) |
|
|
156
161
|
| `slm proxy [--port] [--provider]` | Start the interception proxy (port 8765) |
|
|
157
162
|
| `slm wrap <agent>` | Proxy-activate an agent — one command to start saving |
|
|
158
163
|
| `slm help-optimize [topic]` | Full developer reference + per-agent setup recipes |
|
|
@@ -271,6 +271,7 @@ src/superlocalmemory/mcp/__init__.py
|
|
|
271
271
|
src/superlocalmemory/mcp/_daemon_proxy.py
|
|
272
272
|
src/superlocalmemory/mcp/_pool_adapter.py
|
|
273
273
|
src/superlocalmemory/mcp/_stdin_guard.py
|
|
274
|
+
src/superlocalmemory/mcp/agent_context.py
|
|
274
275
|
src/superlocalmemory/mcp/resources.py
|
|
275
276
|
src/superlocalmemory/mcp/server.py
|
|
276
277
|
src/superlocalmemory/mcp/shared.py
|
|
@@ -309,8 +310,6 @@ src/superlocalmemory/optimize/cache/stampede.py
|
|
|
309
310
|
src/superlocalmemory/optimize/compress/__init__.py
|
|
310
311
|
src/superlocalmemory/optimize/compress/align.py
|
|
311
312
|
src/superlocalmemory/optimize/compress/ccr.py
|
|
312
|
-
src/superlocalmemory/optimize/compress/extractive_code.py
|
|
313
|
-
src/superlocalmemory/optimize/compress/extractive_json.py
|
|
314
313
|
src/superlocalmemory/optimize/compress/prose_llmlingua.py
|
|
315
314
|
src/superlocalmemory/optimize/compress/router.py
|
|
316
315
|
src/superlocalmemory/optimize/config/__init__.py
|
|
@@ -325,6 +324,7 @@ src/superlocalmemory/optimize/metrics/persistence.py
|
|
|
325
324
|
src/superlocalmemory/optimize/proxy/__init__.py
|
|
326
325
|
src/superlocalmemory/optimize/proxy/_helpers.py
|
|
327
326
|
src/superlocalmemory/optimize/proxy/anthropic_surface.py
|
|
327
|
+
src/superlocalmemory/optimize/proxy/capture.py
|
|
328
328
|
src/superlocalmemory/optimize/proxy/gemini_surface.py
|
|
329
329
|
src/superlocalmemory/optimize/proxy/lifecycle.py
|
|
330
330
|
src/superlocalmemory/optimize/proxy/openai_surface.py
|