superlocalmemory 3.6.9 → 3.6.11

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 (46) hide show
  1. package/CHANGELOG.md +112 -10
  2. package/README.md +67 -9
  3. package/package.json +1 -1
  4. package/pyproject.toml +6 -1
  5. package/skills/slm-optimize/README.md +55 -0
  6. package/skills/slm-optimize/SKILL.md +139 -0
  7. package/src/superlocalmemory/__init__.py +1 -1
  8. package/src/superlocalmemory/cli/compress_cmd.py +32 -70
  9. package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
  10. package/src/superlocalmemory/cli/setup_wizard.py +49 -0
  11. package/src/superlocalmemory/mcp/agent_context.py +111 -0
  12. package/src/superlocalmemory/mcp/server.py +4 -0
  13. package/src/superlocalmemory/mcp/tools_active.py +7 -8
  14. package/src/superlocalmemory/mcp/tools_core.py +16 -0
  15. package/src/superlocalmemory/mcp/tools_optimize.py +304 -0
  16. package/src/superlocalmemory/optimize/cache/boundary_store.py +23 -9
  17. package/src/superlocalmemory/optimize/cache/exact.py +7 -4
  18. package/src/superlocalmemory/optimize/cache/key_builder.py +13 -0
  19. package/src/superlocalmemory/optimize/cache/manager.py +70 -8
  20. package/src/superlocalmemory/optimize/cache/semantic.py +10 -5
  21. package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +1 -7
  22. package/src/superlocalmemory/optimize/compress/router.py +82 -87
  23. package/src/superlocalmemory/optimize/config/__init__.py +16 -0
  24. package/src/superlocalmemory/optimize/config/defaults.py +1 -6
  25. package/src/superlocalmemory/optimize/config/schema.py +2 -19
  26. package/src/superlocalmemory/optimize/config/store.py +15 -1
  27. package/src/superlocalmemory/optimize/metrics/counters.py +15 -7
  28. package/src/superlocalmemory/optimize/proxy/_helpers.py +100 -2
  29. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +12 -0
  30. package/src/superlocalmemory/optimize/proxy/capture.py +243 -0
  31. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +31 -0
  32. package/src/superlocalmemory/optimize/proxy/openai_surface.py +12 -0
  33. package/src/superlocalmemory/optimize/proxy/server.py +29 -0
  34. package/src/superlocalmemory/optimize/storage/db.py +102 -11
  35. package/src/superlocalmemory/optimize/storage/schema.py +11 -0
  36. package/src/superlocalmemory/server/routes/optimize.py +6 -8
  37. package/src/superlocalmemory/server/unified_daemon.py +26 -5
  38. package/src/superlocalmemory/ui/index.html +18 -14
  39. package/src/superlocalmemory/ui/js/auto-settings.js +3 -1
  40. package/src/superlocalmemory/ui/js/ng-shell.js +3 -0
  41. package/src/superlocalmemory/ui/js/optimize.js +9 -9
  42. package/src/superlocalmemory.egg-info/PKG-INFO +69 -10
  43. package/src/superlocalmemory.egg-info/SOURCES.txt +3 -2
  44. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  45. package/src/superlocalmemory/optimize/compress/extractive_code.py +0 -311
  46. package/src/superlocalmemory/optimize/compress/extractive_json.py +0 -72
@@ -0,0 +1,243 @@
1
+ """capture.py — Lossless shadow-capture of real proxy traffic (v3.6.10, plan §7).
2
+
3
+ Purpose: build a dogfood corpus of real {request, response, model, tokens,
4
+ content_type} pairs so the cache + compression benchmark (benchmarks/optimize/)
5
+ can be replayed against authentic traffic instead of only synthetic prompts.
6
+
7
+ Activation: set ``SLM_OPTIMIZE_CAPTURE=1`` in the daemon's environment. When on:
8
+ * the proxy runs in PURE PASSTHROUGH — cache + compression hooks are disabled
9
+ at load time (see server._load_hooks), so capture never observes a mutated
10
+ request or a cache hit; every line is a genuine upstream exchange.
11
+ * each completed exchange is appended as one JSON line to
12
+ ``~/.superlocalmemory/optimize_capture.jsonl`` (0600, gitignored).
13
+
14
+ ISOLATION GUARANTEE: this module writes ONLY to optimize_capture.jsonl. It never
15
+ opens memory.db, llmcache.db, or any SLM memory store. (Plan §9 hard rule.)
16
+
17
+ FAIL-OPEN: a capture failure (disk full, permission, encode error) is logged and
18
+ swallowed — it MUST NOT break the proxied request the user is waiting on.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import logging
26
+ import os
27
+ import threading
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ logger = logging.getLogger("slm.optimize.proxy.capture")
32
+
33
+ _CAPTURE_DIRNAME = ".superlocalmemory"
34
+ _CAPTURE_FILENAME = "optimize_capture.jsonl"
35
+ _CAPTURE_ENV = "SLM_OPTIMIZE_CAPTURE"
36
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
37
+
38
+ # Cap a single captured body so a pathological 10 MB request can't bloat the
39
+ # corpus line beyond what the replay harness will read back. Bodies above this
40
+ # are recorded truncated with a marker (capture is for benchmarking, not audit).
41
+ _MAX_CAPTURE_BODY_BYTES = 1 * 1024 * 1024 # 1 MB per side
42
+
43
+ # Providers whose response bodies follow the OpenAI usage schema
44
+ # ({"usage": {"prompt_tokens", "completion_tokens"}, "model"}).
45
+ _OPENAI_FORMAT_PROVIDERS = frozenset({"openai", "gemini-openai-compat"})
46
+
47
+
48
+ def capture_enabled() -> bool:
49
+ """True iff ``SLM_OPTIMIZE_CAPTURE`` is set to a truthy value."""
50
+ return os.environ.get(_CAPTURE_ENV, "").strip().lower() in _TRUTHY
51
+
52
+
53
+ def _capture_path() -> Path:
54
+ return Path.home() / _CAPTURE_DIRNAME / _CAPTURE_FILENAME
55
+
56
+
57
+ class ShadowCapture:
58
+ """Thread-safe append-only JSONL writer for proxy exchanges (singleton)."""
59
+
60
+ _instance: "ShadowCapture | None" = None
61
+ _instance_lock = threading.Lock()
62
+
63
+ def __init__(self, path: Path | None = None) -> None:
64
+ self._path = path or _capture_path()
65
+ self._write_lock = threading.Lock()
66
+ self._count = 0
67
+
68
+ @classmethod
69
+ def get_instance(cls) -> "ShadowCapture":
70
+ # Double-checked locking: cheap fast-path after first construction.
71
+ if cls._instance is None:
72
+ with cls._instance_lock:
73
+ if cls._instance is None:
74
+ cls._instance = cls()
75
+ return cls._instance
76
+
77
+ @classmethod
78
+ def reset_instance(cls) -> None:
79
+ """Test hook — drop the singleton so a fresh path can be injected."""
80
+ with cls._instance_lock:
81
+ cls._instance = None
82
+
83
+ @property
84
+ def path(self) -> Path:
85
+ return self._path
86
+
87
+ @property
88
+ def count(self) -> int:
89
+ return self._count
90
+
91
+ def record(self, entry: dict[str, Any]) -> bool:
92
+ """Append one capture entry as a JSON line. Returns True on success.
93
+
94
+ Fail-open: any error is logged and False is returned; never raised.
95
+
96
+ Security: opens with a single ``os.open`` carrying ``O_CREAT |
97
+ O_APPEND | O_NOFOLLOW`` and mode ``0o600`` on EVERY write. O_NOFOLLOW
98
+ refuses a symlink pre-placed at the path (symlink-append attack), and
99
+ the unconditional 0600-on-create removes the stat/exists TOCTOU that
100
+ could otherwise drop the file to the process umask.
101
+ """
102
+ try:
103
+ line = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
104
+ except (TypeError, ValueError) as exc:
105
+ logger.warning("capture: entry not JSON-serialisable, dropped: %r", exc)
106
+ return False
107
+
108
+ try:
109
+ with self._write_lock:
110
+ self._path.parent.mkdir(parents=True, exist_ok=True)
111
+ flags = os.O_CREAT | os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
112
+ fd = os.open(self._path, flags, 0o600)
113
+ with os.fdopen(fd, "a", encoding="utf-8") as fh:
114
+ fh.write(line + "\n")
115
+ self._count += 1
116
+ return True
117
+ except OSError as exc:
118
+ # PermissionError / symlink-refusal (ELOOP) are security-relevant —
119
+ # surface the errno but still fail open so the request is never blocked.
120
+ logger.warning("capture: write failed (fail-open): %r", exc)
121
+ return False
122
+
123
+
124
+ def _truncate(raw: bytes) -> tuple[str, bool]:
125
+ """Decode bytes for storage; truncate beyond the per-side cap."""
126
+ truncated = len(raw) > _MAX_CAPTURE_BODY_BYTES
127
+ head = raw[:_MAX_CAPTURE_BODY_BYTES] if truncated else raw
128
+ return head.decode("utf-8", errors="replace"), truncated
129
+
130
+
131
+ def build_entry(
132
+ *,
133
+ provider: str,
134
+ model: str,
135
+ request_body: bytes,
136
+ response_body: bytes,
137
+ content_type: str,
138
+ input_tokens: int,
139
+ output_tokens: int,
140
+ status_code: int,
141
+ stream: bool,
142
+ ) -> dict[str, Any]:
143
+ """Construct a capture entry dict from a completed exchange.
144
+
145
+ No timestamp is stamped here (Date.now is intentionally avoided in some
146
+ runtimes); the replay harness keys on content, not time, and the file's own
147
+ line order preserves arrival sequence.
148
+ """
149
+ req_str, req_trunc = _truncate(request_body)
150
+ resp_str, resp_trunc = _truncate(response_body)
151
+ return {
152
+ "provider": provider,
153
+ "model": model,
154
+ "content_type": content_type,
155
+ "stream": stream,
156
+ "status_code": status_code,
157
+ "input_tokens": int(input_tokens),
158
+ "output_tokens": int(output_tokens),
159
+ "request": req_str,
160
+ "response": resp_str,
161
+ "request_truncated": req_trunc,
162
+ "response_truncated": resp_trunc,
163
+ }
164
+
165
+
166
+ def extract_usage(provider: str, body: bytes | None) -> tuple[int, int, str]:
167
+ """Best-effort (input_tokens, output_tokens, model) from a provider JSON body.
168
+
169
+ Works on the normalised JSON the SSE parsers emit AND on non-streaming
170
+ upstream JSON. Returns (0, 0, "") when the body is missing/unparseable —
171
+ capture must never fail because usage couldn't be read.
172
+ """
173
+ if not body:
174
+ return 0, 0, ""
175
+ try:
176
+ data = json.loads(body)
177
+ except (json.JSONDecodeError, ValueError, TypeError):
178
+ return 0, 0, ""
179
+ if not isinstance(data, dict):
180
+ return 0, 0, ""
181
+
182
+ if provider == "gemini":
183
+ usage = data.get("usageMetadata") or {}
184
+ return (
185
+ int(usage.get("promptTokenCount", 0) or 0),
186
+ int(usage.get("candidatesTokenCount", 0) or 0),
187
+ str(data.get("modelVersion", "") or ""),
188
+ )
189
+
190
+ usage = data.get("usage") or {}
191
+ if provider == "anthropic":
192
+ return (
193
+ int(usage.get("input_tokens", 0) or 0),
194
+ int(usage.get("output_tokens", 0) or 0),
195
+ str(data.get("model", "") or ""),
196
+ )
197
+ # OpenAI-format providers (explicit allowlist so a future provider variant
198
+ # is not silently parsed with the wrong schema — it warns + returns zeros).
199
+ if provider not in _OPENAI_FORMAT_PROVIDERS:
200
+ logger.warning(
201
+ "capture.extract_usage: unknown provider %r — recording zero tokens", provider
202
+ )
203
+ return 0, 0, str(data.get("model", "") or "")
204
+ return (
205
+ int(usage.get("prompt_tokens", 0) or 0),
206
+ int(usage.get("completion_tokens", 0) or 0),
207
+ str(data.get("model", "") or ""),
208
+ )
209
+
210
+
211
+ def record_exchange(
212
+ *,
213
+ provider: str,
214
+ model: str,
215
+ request_body: bytes,
216
+ response_body: bytes,
217
+ content_type: str = "application/json",
218
+ input_tokens: int = 0,
219
+ output_tokens: int = 0,
220
+ status_code: int = 200,
221
+ stream: bool = False,
222
+ ) -> bool:
223
+ """Build + append a capture entry. Fail-open. Returns True on success."""
224
+ entry = build_entry(
225
+ provider=provider,
226
+ model=model,
227
+ request_body=request_body,
228
+ response_body=response_body,
229
+ content_type=content_type,
230
+ input_tokens=input_tokens,
231
+ output_tokens=output_tokens,
232
+ status_code=status_code,
233
+ stream=stream,
234
+ )
235
+ return ShadowCapture.get_instance().record(entry)
236
+
237
+
238
+ async def record_exchange_async(**kwargs: Any) -> bool:
239
+ """Async wrapper for ``record_exchange`` that offloads the synchronous file
240
+ write to a worker thread so it never blocks the proxy event loop — relevant
241
+ when many streaming responses complete in the same loop iteration.
242
+ """
243
+ return await asyncio.to_thread(lambda: record_exchange(**kwargs))
@@ -47,7 +47,10 @@ from superlocalmemory.optimize.proxy._helpers import (
47
47
  _safe_compress,
48
48
  _stream_and_cache_forward,
49
49
  _stream_forward,
50
+ capture_passthrough_forward,
50
51
  )
52
+ from superlocalmemory.optimize.proxy.capture import capture_enabled
53
+ from superlocalmemory.optimize.proxy.openai_surface import _parse_openai_sse_to_json
51
54
  from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
52
55
 
53
56
  logger = logging.getLogger("slm.optimize.proxy.gemini")
@@ -238,6 +241,24 @@ async def handle_gemini_native(
238
241
  stream = "streamGenerateContent" in model_and_method
239
242
  has_tools = _body_has_tools(body)
240
243
 
244
+ # v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
245
+ if capture_enabled():
246
+ cap_model = model_and_method.split(":", 1)[0].replace("models/", "")
247
+ cap_url = upstream_url
248
+ if stream:
249
+ _allowed = {
250
+ k: v for k, v in request.query_params.items()
251
+ if k.lower() in _GEMINI_ALLOWED_QUERY_PARAMS
252
+ }
253
+ _allowed["alt"] = "sse"
254
+ cap_url = f"{upstream_url}?{urllib.parse.urlencode(_allowed)}"
255
+ return await capture_passthrough_forward(
256
+ proxy, request, provider="gemini", upstream_url=cap_url,
257
+ allowed_headers=_GEMINI_NATIVE_FORWARD_HEADERS, request_id=request_id,
258
+ model_hint=cap_model,
259
+ sse_parser=_parse_gemini_sse_to_json, is_stream=stream,
260
+ )
261
+
241
262
  ctx = ProxyRequest(
242
263
  provider="gemini",
243
264
  method="POST",
@@ -407,6 +428,16 @@ async def handle_gemini_openai_compat(proxy: object, request: Request) -> Respon
407
428
  has_tools = _body_has_tools(body)
408
429
  stream = bool(body.get("stream", False))
409
430
 
431
+ # v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
432
+ if capture_enabled():
433
+ return await capture_passthrough_forward(
434
+ proxy, request, provider="gemini-openai-compat",
435
+ upstream_url=upstream_url,
436
+ allowed_headers=_GEMINI_OPENAI_COMPAT_FORWARD_HEADERS,
437
+ request_id=request_id, model_hint=str(body.get("model", "")),
438
+ sse_parser=_parse_openai_sse_to_json, is_stream=stream,
439
+ )
440
+
410
441
  ctx = ProxyRequest(
411
442
  provider="gemini-openai-compat",
412
443
  method="POST",
@@ -33,7 +33,9 @@ from superlocalmemory.optimize.proxy._helpers import (
33
33
  _safe_compress,
34
34
  _stream_and_cache_forward,
35
35
  _stream_forward,
36
+ capture_passthrough_forward,
36
37
  )
38
+ from superlocalmemory.optimize.proxy.capture import capture_enabled
37
39
  from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
38
40
 
39
41
  logger = logging.getLogger("slm.optimize.proxy.openai")
@@ -316,6 +318,16 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
316
318
 
317
319
  stream = bool(body.get("stream", False))
318
320
  has_tools = _body_has_tools(body)
321
+
322
+ # v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
323
+ if capture_enabled():
324
+ return await capture_passthrough_forward(
325
+ proxy, request, provider="openai", upstream_url=upstream_url,
326
+ allowed_headers=_OPENAI_FORWARD_HEADERS, request_id=request_id,
327
+ model_hint=str(body.get("model", "")),
328
+ sse_parser=_parse_openai_sse_to_json, is_stream=stream,
329
+ )
330
+
319
331
  ctx = ProxyRequest(
320
332
  provider="openai", method="POST", path="/v1/chat/completions",
321
333
  headers=_redact_headers(dict(request.headers)),
@@ -75,6 +75,24 @@ class ProxyApp:
75
75
  self._request_counter += 1
76
76
  return f"slm_{int(time.monotonic() * 1000)}_{self._request_counter:06d}"
77
77
 
78
+ def reload_from_config(self, config: OptimizeConfig) -> None:
79
+ """Hot-swap cache/compress behavior when optimize.json changes (v3.6.10).
80
+
81
+ Called by the ConfigStore change-callback (UI save → immediate; external
82
+ file/CLI edit → within the 2s watchdog poll). Rebuilds the HookChain so
83
+ ``cache_enabled`` and ``compress_enabled`` can be toggled INDEPENDENTLY
84
+ at runtime with no daemon restart. Note: ``proxy_enabled`` (whether the
85
+ proxy claims /v1/* at all) is a startup decision and is NOT changed here.
86
+ """
87
+ self.config = config
88
+ self.hooks = _load_hooks(config)
89
+ logger.info(
90
+ "slm.optimize.proxy reloaded (config v%s): cache=%s compress=%s",
91
+ getattr(config, "config_version", "?"),
92
+ type(self.hooks.cache).__name__ if self.hooks.cache else "None",
93
+ type(self.hooks.compress).__name__ if self.hooks.compress else "None",
94
+ )
95
+
78
96
 
79
97
  def build_proxy_router(proxy: ProxyApp) -> APIRouter:
80
98
  """Build and return the FastAPI router for all proxy surfaces."""
@@ -132,6 +150,17 @@ def build_proxy_router(proxy: ProxyApp) -> APIRouter:
132
150
 
133
151
 
134
152
  def _load_hooks(config: OptimizeConfig) -> HookChain:
153
+ # v3.6.10 shadow-capture (plan §7): capture mode is PURE passthrough — no
154
+ # cache, no compression — so the corpus records only authentic upstream
155
+ # exchanges. This is defense-in-depth alongside the per-surface guard.
156
+ from superlocalmemory.optimize.proxy.capture import capture_enabled
157
+ if capture_enabled():
158
+ logger.info(
159
+ "slm.optimize.proxy: SLM_OPTIMIZE_CAPTURE on — cache/compress "
160
+ "DISABLED, recording exchanges to optimize_capture.jsonl"
161
+ )
162
+ return HookChain.empty()
163
+
135
164
  cache_hook = None
136
165
  compress_hook = None
137
166
 
@@ -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
- machine_id = self._get_machine_id()
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(),
@@ -366,6 +421,30 @@ class CacheDB:
366
421
  logger.warning("CacheDB.get failed (cache miss): %s", exc)
367
422
  return None
368
423
 
424
+ def get_value(self, cache_key: str, tenant_id: str) -> bytes | None:
425
+ """Pure value lookup — no hit_count increment (unlike get()).
426
+
427
+ Used by MCP KV tools which manage their own hit/miss counters.
428
+ Caller must have already normalized tenant_id. Fail-open: returns None on error.
429
+ """
430
+ try:
431
+ rows = self._db.execute(
432
+ "SELECT value_blob, compressed FROM llmcache_entries "
433
+ "WHERE cache_key = ? AND tenant_id = ? "
434
+ "AND (ttl_expires IS NULL OR ttl_expires > ?) LIMIT 1",
435
+ (cache_key, tenant_id, time.time()),
436
+ )
437
+ if not rows:
438
+ return None
439
+ row = dict(rows[0])
440
+ plaintext = self._decrypt(row["value_blob"])
441
+ if row.get("compressed", 0):
442
+ plaintext = zlib.decompress(plaintext)
443
+ return plaintext
444
+ except (sqlite3.Error, ValueError, zlib.error) as exc:
445
+ logger.warning("CacheDB.get_value failed (fail-open): %s", exc)
446
+ return None
447
+
369
448
  def set(
370
449
  self,
371
450
  key: str,
@@ -626,11 +705,12 @@ class CacheDB:
626
705
  try:
627
706
  dim = int(meta.get("dim", len(vector) // 4))
628
707
  model_name = str(meta.get("model", "nomic-ai/nomic-embed-text-v1.5"))
708
+ context_fp = str(meta.get("context_fp", "")) # C-10: persist context fingerprint
629
709
  self._db.execute(
630
710
  "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),
711
+ "(entry_id, tenant_id, vector_blob, vector_dim, model_name, context_fp) "
712
+ "VALUES (?, ?, ?, ?, ?, ?)",
713
+ (entry_id, tenant_id, vector, dim, model_name, context_fp),
634
714
  )
635
715
  except sqlite3.Error as exc:
636
716
  logger.warning("CacheDB.vec_add failed: %s", exc)
@@ -855,14 +935,22 @@ class CacheDB:
855
935
 
856
936
  # ---- v2 additions ----
857
937
 
858
- def get_all_vectors(self, tenant_id: str) -> list[tuple[str, bytes]]:
938
+ def get_all_vectors(self, tenant_id: str) -> list[tuple[str, bytes, str]]:
939
+ """Return (entry_id, vector_blob, context_fp) for all vectors in a tenant.
940
+
941
+ C-10: context_fp is included so _lazy_warm_tenant() can restore it without
942
+ recomputing embeddings from messages that may no longer be in scope.
943
+ """
859
944
  try:
860
945
  rows = self._db.execute(
861
- "SELECT entry_id, vector_blob FROM llmcache_semantic_vectors "
946
+ "SELECT entry_id, vector_blob, context_fp FROM llmcache_semantic_vectors "
862
947
  "WHERE tenant_id = ?",
863
948
  (tenant_id,),
864
949
  )
865
- return [(dict(r)["entry_id"], dict(r)["vector_blob"]) for r in rows]
950
+ return [
951
+ (dict(r)["entry_id"], dict(r)["vector_blob"], dict(r).get("context_fp", ""))
952
+ for r in rows
953
+ ]
866
954
  except sqlite3.Error as exc:
867
955
  logger.warning("CacheDB.get_all_vectors failed: %s", exc)
868
956
  return []
@@ -919,6 +1007,7 @@ class CacheDB:
919
1007
  # ---- convenience / non-contract helpers ----
920
1008
 
921
1009
  def entry_exists(self, cache_key: str, tenant_id: str = _DEFAULT_TENANT) -> bool:
1010
+ tenant_id = _normalize_tenant_id(tenant_id)
922
1011
  try:
923
1012
  rows = self._db.execute(
924
1013
  "SELECT 1 FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ? LIMIT 1",
@@ -929,6 +1018,7 @@ class CacheDB:
929
1018
  return False
930
1019
 
931
1020
  def clear_tenant(self, tenant_id: str) -> int:
1021
+ tenant_id = _normalize_tenant_id(tenant_id)
932
1022
  try:
933
1023
  with self._db.transaction():
934
1024
  rows = self._db.execute(
@@ -955,6 +1045,7 @@ class CacheDB:
955
1045
  return 0
956
1046
 
957
1047
  def entry_count(self, tenant_id: str = _DEFAULT_TENANT) -> int:
1048
+ tenant_id = _normalize_tenant_id(tenant_id)
958
1049
  try:
959
1050
  rows = self._db.execute(
960
1051
  "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,),
@@ -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.store import ConfigStore
45
- cfg = ConfigStore().get()
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.store import ConfigStore
58
- store = ConfigStore()
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.store import ConfigStore
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 = ConfigStore().get()
85
+ cfg = get_shared_store().get()
88
86
  collector = get_metrics()
89
87
  db = CacheDB()
90
88
  snap = collector.snapshot(