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.
- package/CHANGELOG.md +112 -10
- package/README.md +67 -9
- package/package.json +1 -1
- package/pyproject.toml +6 -1
- package/skills/slm-optimize/README.md +55 -0
- package/skills/slm-optimize/SKILL.md +139 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/compress_cmd.py +32 -70
- package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
- package/src/superlocalmemory/cli/setup_wizard.py +49 -0
- package/src/superlocalmemory/mcp/agent_context.py +111 -0
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_active.py +7 -8
- package/src/superlocalmemory/mcp/tools_core.py +16 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +304 -0
- 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 +102 -11
- package/src/superlocalmemory/optimize/storage/schema.py +11 -0
- package/src/superlocalmemory/server/routes/optimize.py +6 -8
- package/src/superlocalmemory/server/unified_daemon.py +26 -5
- 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 +69 -10
- package/src/superlocalmemory.egg-info/SOURCES.txt +3 -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
|
@@ -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
|
-
|
|
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 [
|
|
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
|
|
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(
|