superlocalmemory 3.5.8 → 3.6.0

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 (72) hide show
  1. package/ATTRIBUTION.md +24 -0
  2. package/CHANGELOG.md +35 -0
  3. package/README.md +142 -35
  4. package/package.json +1 -1
  5. package/pyproject.toml +2 -1
  6. package/src/superlocalmemory/__init__.py +1 -1
  7. package/src/superlocalmemory/cli/cache_cmd.py +198 -0
  8. package/src/superlocalmemory/cli/commands.py +80 -2
  9. package/src/superlocalmemory/cli/compress_cmd.py +179 -0
  10. package/src/superlocalmemory/cli/help_cmd.py +197 -0
  11. package/src/superlocalmemory/cli/main.py +122 -0
  12. package/src/superlocalmemory/cli/optimize_cmd.py +175 -0
  13. package/src/superlocalmemory/cli/optimize_constants.py +31 -0
  14. package/src/superlocalmemory/cli/proxy_cmd.py +95 -0
  15. package/src/superlocalmemory/core/config.py +5 -0
  16. package/src/superlocalmemory/core/engine.py +23 -0
  17. package/src/superlocalmemory/core/mcp_embedder_proxy.py +89 -0
  18. package/src/superlocalmemory/llm/backbone.py +10 -4
  19. package/src/superlocalmemory/mcp/server.py +34 -0
  20. package/src/superlocalmemory/mcp/tools_v3.py +6 -2
  21. package/src/superlocalmemory/optimize/NOTICE +11 -0
  22. package/src/superlocalmemory/optimize/__init__.py +0 -0
  23. package/src/superlocalmemory/optimize/adapters/__init__.py +68 -0
  24. package/src/superlocalmemory/optimize/adapters/_agent_registry.py +120 -0
  25. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +115 -0
  26. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +125 -0
  27. package/src/superlocalmemory/optimize/adapters/wrap.py +188 -0
  28. package/src/superlocalmemory/optimize/cache/__init__.py +31 -0
  29. package/src/superlocalmemory/optimize/cache/boundary_store.py +455 -0
  30. package/src/superlocalmemory/optimize/cache/centroid_store.py +158 -0
  31. package/src/superlocalmemory/optimize/cache/context_key.py +67 -0
  32. package/src/superlocalmemory/optimize/cache/exact.py +85 -0
  33. package/src/superlocalmemory/optimize/cache/invalidation.py +36 -0
  34. package/src/superlocalmemory/optimize/cache/key_builder.py +98 -0
  35. package/src/superlocalmemory/optimize/cache/manager.py +452 -0
  36. package/src/superlocalmemory/optimize/cache/semantic.py +568 -0
  37. package/src/superlocalmemory/optimize/cache/stampede.py +50 -0
  38. package/src/superlocalmemory/optimize/compress/__init__.py +17 -0
  39. package/src/superlocalmemory/optimize/compress/align.py +153 -0
  40. package/src/superlocalmemory/optimize/compress/ccr.py +157 -0
  41. package/src/superlocalmemory/optimize/compress/extractive_code.py +311 -0
  42. package/src/superlocalmemory/optimize/compress/extractive_json.py +72 -0
  43. package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +77 -0
  44. package/src/superlocalmemory/optimize/compress/router.py +548 -0
  45. package/src/superlocalmemory/optimize/config/__init__.py +35 -0
  46. package/src/superlocalmemory/optimize/config/defaults.py +48 -0
  47. package/src/superlocalmemory/optimize/config/schema.py +255 -0
  48. package/src/superlocalmemory/optimize/config/store.py +209 -0
  49. package/src/superlocalmemory/optimize/metrics/__init__.py +8 -0
  50. package/src/superlocalmemory/optimize/metrics/counters.py +138 -0
  51. package/src/superlocalmemory/optimize/metrics/estimator.py +90 -0
  52. package/src/superlocalmemory/optimize/metrics/exporters.py +77 -0
  53. package/src/superlocalmemory/optimize/metrics/persistence.py +115 -0
  54. package/src/superlocalmemory/optimize/proxy/__init__.py +28 -0
  55. package/src/superlocalmemory/optimize/proxy/_helpers.py +257 -0
  56. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +171 -0
  57. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +121 -0
  58. package/src/superlocalmemory/optimize/proxy/lifecycle.py +126 -0
  59. package/src/superlocalmemory/optimize/proxy/openai_surface.py +125 -0
  60. package/src/superlocalmemory/optimize/proxy/server.py +151 -0
  61. package/src/superlocalmemory/optimize/storage/__init__.py +0 -0
  62. package/src/superlocalmemory/optimize/storage/db.py +1016 -0
  63. package/src/superlocalmemory/optimize/storage/schema.py +184 -0
  64. package/src/superlocalmemory/server/routes/optimize.py +166 -0
  65. package/src/superlocalmemory/server/routes/v3_api.py +63 -1
  66. package/src/superlocalmemory/server/unified_daemon.py +105 -0
  67. package/src/superlocalmemory/ui/index.html +98 -0
  68. package/src/superlocalmemory/ui/js/ng-shell.js +2 -1
  69. package/src/superlocalmemory/ui/js/optimize.js +173 -0
  70. package/src/superlocalmemory.egg-info/PKG-INFO +2 -1
  71. package/src/superlocalmemory.egg-info/SOURCES.txt +51 -0
  72. package/src/superlocalmemory.egg-info/requires.txt +1 -0
@@ -0,0 +1,1016 @@
1
+ """CacheDB — wraps DatabaseManager for llmcache.db operations.
2
+
3
+ INTERFACE-CONTRACT §1 CONFORMANCE: Every method below is the canonical API.
4
+ No implementer may add aliases. Consumers (LLD-02/03/04/08) import this exact API.
5
+
6
+ REUSE: DatabaseManager from superlocalmemory/src/superlocalmemory/storage/database.py
7
+ - WAL mode (database.py:66-74)
8
+ - busy_timeout=10000ms (database.py:41)
9
+ - _MAX_RETRIES=5 exponential backoff (database.py:127-148)
10
+ - _connect() with row_factory (database.py:95-100)
11
+ - transaction() context manager (database.py:102-116)
12
+ - execute() with retry (database.py:118-148)
13
+
14
+ ENCRYPTION (resolves SEC-C-01 / CWE-312, NEW-M-01, NEW-M-02):
15
+ - All value BLOBs (llmcache_entries.value_blob) are AES-256-GCM encrypted.
16
+ - CCR original_blob is ALSO AES-256-GCM encrypted.
17
+ - Key derivation: PBKDF2-HMAC-SHA256(password=machine_id, salt=_per_db_salt, iter=100_000)
18
+ - Salt: os.urandom(32) generated ONCE at DB creation, stored in
19
+ llmcache_schema_version.description='salt:<hex>'. NO hardcoded salt.
20
+ - Nonce (12 bytes random) prepended to each ciphertext.
21
+ - llmcache.db file permissions set to 0o600 at creation.
22
+
23
+ ISOLATION GUARANTEE: CacheDB MUST NOT import from superlocalmemory.storage.models.
24
+ Enforced by test_no_memory_db_import() in test_db.py.
25
+
26
+ FAIL-OPEN CONTRACT: Every public method catches sqlite3.Error, logs at WARNING,
27
+ and returns a safe default (None, [], False, 0) rather than raising.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import dataclasses
33
+ import json
34
+ import logging
35
+ import os
36
+ import sqlite3
37
+ import struct
38
+ import time
39
+ import uuid
40
+ import zlib
41
+ from dataclasses import dataclass, field
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
46
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
47
+ from cryptography.hazmat.primitives import hashes
48
+
49
+ from superlocalmemory.storage.database import DatabaseManager
50
+ from superlocalmemory.optimize.storage import schema as _schema
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ _DEFAULT_TENANT: str = "default"
55
+ _ZLIB_LEVEL: int = 6
56
+ _AES_NONCE_BYTES: int = 12
57
+ _PBKDF2_ITERATIONS: int = 100_000
58
+
59
+ LLMCACHE_DIRNAME: str = ".superlocalmemory"
60
+ LLMCACHE_DBNAME: str = "llmcache.db"
61
+ MID_FILENAME: str = ".llmcache_key"
62
+ SALT_PREFIX: str = "salt:"
63
+
64
+ _FORBIDDEN_MEMORY_TABLES: frozenset[str] = frozenset({
65
+ "memories", "atomic_facts", "profiles", "canonical_entities",
66
+ "entity_aliases", "consolidation_log", "trust_scores", "bm25_tokens",
67
+ "fact_retention", "core_memory_blocks", "ccq_consolidated_blocks",
68
+ })
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # MetricsSnapshot — single definition (INTERFACE-CONTRACT v2.2 §6)
73
+ # ---------------------------------------------------------------------------
74
+
75
+ @dataclass
76
+ class MetricsSnapshot:
77
+ """Mirror of llmcache_metrics columns — names MUST match exactly."""
78
+ id: int = 1
79
+ hits: int = 0
80
+ misses: int = 0
81
+ calls_skipped: int = 0
82
+ tokens_saved_input: int = 0
83
+ tokens_saved_output: int = 0
84
+ tokens_saved_compress: int = 0
85
+ evictions: int = 0
86
+ latency_overhead_ms_sum: float = 0.0
87
+ latency_samples: int = 0
88
+ compress_runs: int = 0
89
+ compress_bytes_original: int = 0
90
+ compress_bytes_after: int = 0
91
+ cache_size_bytes: int = 0
92
+ cache_entry_count: int = 0
93
+ updated_at: float = 0.0
94
+
95
+ @property
96
+ def hit_rate(self) -> float:
97
+ total = self.hits + self.misses
98
+ return self.hits / total if total > 0 else 0.0
99
+
100
+ @property
101
+ def avg_latency_overhead_ms(self) -> float:
102
+ return (
103
+ self.latency_overhead_ms_sum / self.latency_samples
104
+ if self.latency_samples > 0 else 0.0
105
+ )
106
+
107
+ @property
108
+ def compression_ratio(self) -> float:
109
+ if self.compress_bytes_original == 0:
110
+ return 1.0
111
+ return self.compress_bytes_after / self.compress_bytes_original
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Supporting dataclasses
116
+ # ---------------------------------------------------------------------------
117
+
118
+ @dataclass
119
+ class CacheRow:
120
+ """Return type of CacheDB.get() and get_by_id().
121
+
122
+ All fields are optional via default so that SELECT * on a table with
123
+ a different schema version does not blow up the constructor. The
124
+ caller is expected to read attributes they need; missing fields
125
+ return the default value.
126
+ """
127
+ entry_id: str = ""
128
+ cache_key: str = ""
129
+ tenant_id: str = "default"
130
+ model: str = ""
131
+ provider: str = ""
132
+ value: bytes = b""
133
+ created_at: str = ""
134
+ last_hit_at: str | None = None
135
+ ttl_expires: float | None = None
136
+ hit_count: int = 0
137
+ byte_size: int = 0
138
+ tag_json: str = "[]"
139
+ cache_tier: str = "exact"
140
+ compressed: int = 1
141
+ tags: list[str] = field(default_factory=list)
142
+
143
+
144
+ @dataclass
145
+ class BoundaryRow:
146
+ """Per-item vCache MLE boundary record."""
147
+ entry_id: str
148
+ logistic_t: float = 0.95
149
+ logistic_gamma: float = 10.0
150
+ sample_count: int = 0
151
+ updated_at: float = 0.0
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # CacheDB
156
+ # ---------------------------------------------------------------------------
157
+
158
+ class CacheDB:
159
+ """SQLite cache store backed by DatabaseManager. Canonical API per INTERFACE-CONTRACT §1."""
160
+
161
+ _default_instance: "CacheDB | None" = None
162
+ _default_lock: Any = None # lazy-import threading.Lock on first use
163
+
164
+ def __init__(self, db_path: Path | None = None) -> None:
165
+ if db_path is None:
166
+ db_path = Path.home() / LLMCACHE_DIRNAME / LLMCACHE_DBNAME
167
+ self._db_path = Path(db_path)
168
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
169
+ # If the file exists but is not a valid SQLite database (e.g. user
170
+ # accidentally pointed us at a binary blob), delete it and start fresh
171
+ # so that the proxy never gets wedged on init. The proxy is the
172
+ # critical path — we must be able to start even if the cache file
173
+ # is corrupted. The previous file is left in a `.corrupt` sidecar
174
+ # for forensic inspection.
175
+ if self._db_path.exists():
176
+ try:
177
+ import sqlite3 as _sq
178
+ test_conn = _sq.connect(str(self._db_path))
179
+ test_conn.execute("PRAGMA schema_version")
180
+ test_conn.close()
181
+ except Exception as exc:
182
+ corrupt_sidecar = self._db_path.with_suffix(
183
+ self._db_path.suffix + ".corrupt"
184
+ )
185
+ try:
186
+ if corrupt_sidecar.exists():
187
+ corrupt_sidecar.unlink()
188
+ self._db_path.replace(corrupt_sidecar)
189
+ try:
190
+ os.chmod(corrupt_sidecar, 0o600)
191
+ except OSError:
192
+ pass
193
+ logger.error(
194
+ "CacheDB: %s was not a SQLite file (%s) — moved to %s. "
195
+ "Starting with a fresh cache.",
196
+ self._db_path, exc, corrupt_sidecar,
197
+ )
198
+ except OSError as move_exc:
199
+ logger.error(
200
+ "CacheDB: %s is not a SQLite file and could not be moved: %s",
201
+ self._db_path, move_exc,
202
+ )
203
+ raise
204
+ self._db = DatabaseManager(self._db_path)
205
+ self._db.initialize(_schema)
206
+ # chmod 600 (SEC-C-01)
207
+ try:
208
+ os.chmod(self._db_path, 0o600)
209
+ except OSError as exc:
210
+ logger.warning("CacheDB: could not chmod 600 on %s: %s", self._db_path, exc)
211
+ 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)
214
+ self.assert_no_memory_db_tables()
215
+
216
+ # ---- context manager ----
217
+ def __enter__(self) -> "CacheDB":
218
+ return self
219
+
220
+ def __exit__(self, *args: Any) -> None:
221
+ self.close()
222
+
223
+ def close(self) -> None:
224
+ """No-op (per-call connection model)."""
225
+ self._db.close()
226
+
227
+ @property
228
+ def db_path(self) -> str:
229
+ return str(self._db_path)
230
+
231
+ # ---- encryption helpers ----
232
+
233
+ def _load_or_create_salt(self) -> bytes:
234
+ rows = self._db.execute(
235
+ "SELECT description FROM llmcache_schema_version "
236
+ "WHERE description LIKE 'salt:%' LIMIT 1"
237
+ )
238
+ if rows:
239
+ hex_salt = dict(rows[0])["description"][len(SALT_PREFIX):]
240
+ if len(hex_salt) != 64:
241
+ raise RuntimeError(
242
+ f"llmcache.db: salt row is malformed (len={len(hex_salt)}) — DB may be corrupted."
243
+ )
244
+ return bytes.fromhex(hex_salt)
245
+ salt = os.urandom(32)
246
+ self._db.execute(
247
+ "INSERT INTO llmcache_schema_version (version, description) VALUES (?, ?)",
248
+ (1, f"{SALT_PREFIX}{salt.hex()}"),
249
+ )
250
+ return salt
251
+
252
+ def _get_machine_id(self) -> str:
253
+ """Return a stable machine identifier for AES key derivation."""
254
+ system = os.uname().sysname
255
+ mid: str | None = None
256
+ if system == "Darwin":
257
+ try:
258
+ import plistlib
259
+ import subprocess
260
+ out = subprocess.run(
261
+ ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
262
+ capture_output=True, timeout=2,
263
+ )
264
+ if out.returncode == 0:
265
+ text = out.stdout.decode("utf-8", errors="ignore")
266
+ for line in text.splitlines():
267
+ if "IOPlatformUUID" in line and "=" in line:
268
+ mid = line.split("=", 1)[1].strip().strip('"')
269
+ if mid:
270
+ break
271
+ except Exception:
272
+ mid = None
273
+ elif system == "Linux":
274
+ try:
275
+ mid = Path("/etc/machine-id").read_text(encoding="utf-8").strip()
276
+ except OSError:
277
+ mid = None
278
+ if not mid:
279
+ mid_file = Path.home() / LLMCACHE_DIRNAME / MID_FILENAME
280
+ if mid_file.exists():
281
+ try:
282
+ mid = mid_file.read_text(encoding="utf-8").strip()
283
+ except OSError:
284
+ mid = None
285
+ if not mid:
286
+ mid = uuid.uuid4().hex
287
+ try:
288
+ mid_file.parent.mkdir(parents=True, exist_ok=True)
289
+ mid_file.write_text(mid, encoding="utf-8")
290
+ os.chmod(mid_file, 0o600)
291
+ except OSError as exc:
292
+ logger.warning("CacheDB: could not persist machine id: %s", exc)
293
+ return mid
294
+
295
+ def _derive_aes_key(self, machine_id: str, salt: bytes) -> bytes:
296
+ kdf = PBKDF2HMAC(
297
+ algorithm=hashes.SHA256(),
298
+ length=32,
299
+ salt=salt,
300
+ iterations=_PBKDF2_ITERATIONS,
301
+ )
302
+ return kdf.derive(machine_id.encode("utf-8"))
303
+
304
+ def _encrypt(self, plaintext: bytes) -> bytes:
305
+ nonce = os.urandom(_AES_NONCE_BYTES)
306
+ aesgcm = AESGCM(self._aes_key)
307
+ ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)
308
+ return nonce + ciphertext
309
+
310
+ def _decrypt(self, blob: bytes) -> bytes:
311
+ if len(blob) <= _AES_NONCE_BYTES:
312
+ raise ValueError("Encrypted blob too short")
313
+ nonce = blob[:_AES_NONCE_BYTES]
314
+ ciphertext = blob[_AES_NONCE_BYTES:]
315
+ aesgcm = AESGCM(self._aes_key)
316
+ return aesgcm.decrypt(nonce, ciphertext, associated_data=None)
317
+
318
+ # ---- assertion ----
319
+
320
+ def assert_no_memory_db_tables(self) -> None:
321
+ """Raise RuntimeError if memory.db tables are present."""
322
+ rows = self._db.execute(
323
+ "SELECT name FROM sqlite_master WHERE type='table'"
324
+ )
325
+ names = {dict(r)["name"] for r in rows}
326
+ found = _FORBIDDEN_MEMORY_TABLES & names
327
+ if found:
328
+ raise RuntimeError(
329
+ f"ISOLATION VIOLATION: llmcache.db contains memory.db tables: {sorted(found)}. "
330
+ "Wrong DB file opened. Aborting."
331
+ )
332
+
333
+ # ---- exact CRUD ----
334
+
335
+ def get(self, key: str, tenant_id: str) -> CacheRow | None:
336
+ try:
337
+ rows = self._db.execute(
338
+ "SELECT * FROM llmcache_entries "
339
+ "WHERE cache_key = ? AND tenant_id = ? "
340
+ "AND (ttl_expires IS NULL OR ttl_expires > ?) "
341
+ "LIMIT 1",
342
+ (key, tenant_id, time.time()),
343
+ )
344
+ if not rows:
345
+ return None
346
+ row = dict(rows[0])
347
+ # increment hit stats
348
+ self._db.execute(
349
+ "UPDATE llmcache_entries SET hit_count = hit_count + 1, "
350
+ "last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
351
+ "WHERE cache_key = ? AND tenant_id = ?",
352
+ (key, tenant_id),
353
+ )
354
+ row["hit_count"] = row["hit_count"] + 1
355
+ # decrypt + decompress value
356
+ try:
357
+ plaintext = self._decrypt(row["value_blob"])
358
+ if row.get("compressed", 0):
359
+ plaintext = zlib.decompress(plaintext)
360
+ row["value"] = plaintext
361
+ except (ValueError, zlib.error) as exc:
362
+ logger.warning("CacheDB.get decrypt failed (cache miss): %s", exc)
363
+ return None
364
+ return self._row_to_cacherow(row)
365
+ except sqlite3.Error as exc:
366
+ logger.warning("CacheDB.get failed (cache miss): %s", exc)
367
+ return None
368
+
369
+ def set(
370
+ self,
371
+ key: str,
372
+ tenant_id: str,
373
+ value: bytes,
374
+ *,
375
+ model: str,
376
+ ttl_expires: float | None,
377
+ tags: list[str],
378
+ ) -> None:
379
+ try:
380
+ # 1) zlib-compress
381
+ compressed_blob = zlib.compress(value, level=_ZLIB_LEVEL)
382
+ # 2) AES-256-GCM encrypt
383
+ encrypted_blob = self._encrypt(compressed_blob)
384
+ entry_id = uuid.uuid4().hex
385
+ self._db.execute(
386
+ "INSERT INTO llmcache_entries "
387
+ "(entry_id, cache_key, tenant_id, model, provider, value_blob, compressed, "
388
+ " ttl_expires, tag_json, byte_size) "
389
+ "VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) "
390
+ "ON CONFLICT(cache_key, tenant_id) DO UPDATE SET "
391
+ " value_blob=excluded.value_blob, model=excluded.model, provider=excluded.provider, "
392
+ " ttl_expires=excluded.ttl_expires, tag_json=excluded.tag_json, "
393
+ " byte_size=excluded.byte_size, hit_count=0, last_hit_at=NULL, "
394
+ " created_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')",
395
+ (
396
+ entry_id, key, tenant_id, model, "",
397
+ encrypted_blob, ttl_expires,
398
+ json.dumps(tags or []), len(encrypted_blob),
399
+ ),
400
+ )
401
+ except sqlite3.Error as exc:
402
+ logger.warning("CacheDB.set failed (fail-open): %s", exc)
403
+
404
+ def set_with_entry_id(
405
+ self,
406
+ key: str,
407
+ tenant_id: str,
408
+ value: bytes,
409
+ entry_id: str,
410
+ ttl_seconds: int | None = None,
411
+ tags: list[str] | None = None,
412
+ ) -> None:
413
+ """Like set(), but writes a caller-supplied entry_id instead of auto-generating one.
414
+
415
+ Used by semantic layer (index_entry) which pre-computes the entry_id from
416
+ the embedding vector to enable O(1) semantic-to-cache cross-reference.
417
+ """
418
+ try:
419
+ compressed_blob = zlib.compress(value, level=_ZLIB_LEVEL)
420
+ encrypted_blob = self._encrypt(compressed_blob)
421
+ ttl_expires = (time.time() + ttl_seconds) if ttl_seconds else None
422
+ self._db.execute(
423
+ "INSERT INTO llmcache_entries "
424
+ "(entry_id, cache_key, tenant_id, model, provider, value_blob, compressed, "
425
+ " ttl_expires, tag_json, byte_size) "
426
+ "VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) "
427
+ "ON CONFLICT(cache_key, tenant_id) DO UPDATE SET "
428
+ " entry_id=excluded.entry_id, value_blob=excluded.value_blob, "
429
+ " model=excluded.model, provider=excluded.provider, "
430
+ " ttl_expires=excluded.ttl_expires, tag_json=excluded.tag_json, "
431
+ " byte_size=excluded.byte_size, hit_count=0, last_hit_at=NULL, "
432
+ " created_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')",
433
+ (
434
+ entry_id, key, tenant_id, "", "",
435
+ encrypted_blob, ttl_expires,
436
+ json.dumps(tags or []), len(encrypted_blob),
437
+ ),
438
+ )
439
+ except sqlite3.Error as exc:
440
+ logger.warning("CacheDB.set_with_entry_id failed (fail-open): %s", exc)
441
+
442
+ def get_by_id(self, entry_id: str) -> CacheRow | None:
443
+ try:
444
+ rows = self._db.execute(
445
+ "SELECT * FROM llmcache_entries WHERE entry_id = ? LIMIT 1",
446
+ (entry_id,),
447
+ )
448
+ if not rows:
449
+ return None
450
+ row = dict(rows[0])
451
+ try:
452
+ plaintext = self._decrypt(row["value_blob"])
453
+ if row.get("compressed", 0):
454
+ plaintext = zlib.decompress(plaintext)
455
+ row["value"] = plaintext
456
+ except (ValueError, zlib.error) as exc:
457
+ logger.warning("CacheDB.get_by_id decrypt failed: %s", exc)
458
+ return None
459
+ return self._row_to_cacherow(row)
460
+ except sqlite3.Error as exc:
461
+ logger.warning("CacheDB.get_by_id failed: %s", exc)
462
+ return None
463
+
464
+ def delete(self, key: str, tenant_id: str) -> None:
465
+ try:
466
+ with self._db.transaction():
467
+ rows = self._db.execute(
468
+ "SELECT entry_id FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
469
+ (key, tenant_id),
470
+ )
471
+ entry_ids = [dict(r)["entry_id"] for r in rows]
472
+ if entry_ids:
473
+ placeholders = ",".join("?" for _ in entry_ids)
474
+ self._db.execute(
475
+ f"DELETE FROM llmcache_semantic_vectors "
476
+ f"WHERE entry_id IN ({placeholders})",
477
+ tuple(entry_ids),
478
+ )
479
+ self._db.execute(
480
+ "DELETE FROM llmcache_tags WHERE cache_key = ? AND tenant_id = ?",
481
+ (key, tenant_id),
482
+ )
483
+ self._db.execute(
484
+ "DELETE FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
485
+ (key, tenant_id),
486
+ )
487
+ except sqlite3.Error as exc:
488
+ logger.warning("CacheDB.delete failed (fail-open): %s", exc)
489
+
490
+ def delete_batch(self, keys: list[tuple[str, str]]) -> int:
491
+ if not keys:
492
+ return 0
493
+ try:
494
+ deleted = 0
495
+ with self._db.transaction():
496
+ for key, tenant_id in keys:
497
+ rows = self._db.execute(
498
+ "SELECT entry_id FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
499
+ (key, tenant_id),
500
+ )
501
+ entry_ids = [dict(r)["entry_id"] for r in rows]
502
+ if entry_ids:
503
+ placeholders = ",".join("?" for _ in entry_ids)
504
+ self._db.execute(
505
+ f"DELETE FROM llmcache_semantic_vectors "
506
+ f"WHERE entry_id IN ({placeholders})",
507
+ tuple(entry_ids),
508
+ )
509
+ self._db.execute(
510
+ "DELETE FROM llmcache_tags WHERE cache_key = ? AND tenant_id = ?",
511
+ (key, tenant_id),
512
+ )
513
+ self._db.execute(
514
+ "DELETE FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ?",
515
+ (key, tenant_id),
516
+ )
517
+ deleted += 1
518
+ return deleted
519
+ except sqlite3.Error as exc:
520
+ logger.warning("CacheDB.delete_batch failed: %s", exc)
521
+ return 0
522
+
523
+ def sweep_expired(self, now: float) -> int:
524
+ try:
525
+ with self._db.transaction():
526
+ rows = self._db.execute(
527
+ "SELECT entry_id FROM llmcache_entries "
528
+ "WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
529
+ (now,),
530
+ )
531
+ entry_ids = [dict(r)["entry_id"] for r in rows]
532
+ entries_deleted = len(entry_ids)
533
+ if entry_ids:
534
+ placeholders = ",".join("?" for _ in entry_ids)
535
+ self._db.execute(
536
+ f"DELETE FROM llmcache_semantic_vectors "
537
+ f"WHERE entry_id IN ({placeholders})",
538
+ tuple(entry_ids),
539
+ )
540
+ self._db.execute(
541
+ f"DELETE FROM llmcache_tags "
542
+ f"WHERE cache_key IN ("
543
+ f" SELECT cache_key FROM llmcache_entries WHERE entry_id IN ({placeholders})"
544
+ f")",
545
+ tuple(entry_ids),
546
+ )
547
+ self._db.execute(
548
+ "DELETE FROM llmcache_entries "
549
+ "WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
550
+ (now,),
551
+ )
552
+ ccr_row = self._db.execute(
553
+ "SELECT COUNT(*) AS n FROM llmcache_ccr_originals "
554
+ "WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
555
+ (now,),
556
+ )
557
+ ccr_count = int(dict(ccr_row[0])["n"]) if ccr_row else 0
558
+ self._db.execute(
559
+ "DELETE FROM llmcache_ccr_originals "
560
+ "WHERE ttl_expires IS NOT NULL AND ttl_expires < ?",
561
+ (now,),
562
+ )
563
+ return entries_deleted + ccr_count
564
+ except sqlite3.Error as exc:
565
+ logger.warning("CacheDB.sweep_expired failed: %s", exc)
566
+ return 0
567
+
568
+ # ---- tags ----
569
+
570
+ def tag_register(self, key: str, tenant_id: str, tags: list[str]) -> None:
571
+ if not tags:
572
+ return
573
+ try:
574
+ with self._db.transaction():
575
+ for tag in tags:
576
+ self._db.execute(
577
+ "INSERT OR IGNORE INTO llmcache_tags (tag, cache_key, tenant_id) "
578
+ "VALUES (?, ?, ?)",
579
+ (tag, key, tenant_id),
580
+ )
581
+ except sqlite3.Error as exc:
582
+ logger.warning("CacheDB.tag_register failed: %s", exc)
583
+
584
+ def tag_keys(self, tag: str) -> list[tuple[str, str]]:
585
+ try:
586
+ rows = self._db.execute(
587
+ "SELECT cache_key, tenant_id FROM llmcache_tags WHERE tag = ?",
588
+ (tag,),
589
+ )
590
+ return [(dict(r)["cache_key"], dict(r)["tenant_id"]) for r in rows]
591
+ except sqlite3.Error as exc:
592
+ logger.warning("CacheDB.tag_keys failed: %s", exc)
593
+ return []
594
+
595
+ def invalidate_by_tag(self, tag: str) -> int:
596
+ try:
597
+ with self._db.transaction():
598
+ rows = self._db.execute(
599
+ "SELECT DISTINCT e.entry_id FROM llmcache_entries e "
600
+ "JOIN llmcache_tags t ON t.cache_key = e.cache_key AND t.tenant_id = e.tenant_id "
601
+ "WHERE t.tag = ?",
602
+ (tag,),
603
+ )
604
+ entry_ids = [dict(r)["entry_id"] for r in rows]
605
+ if not entry_ids:
606
+ self._db.execute("DELETE FROM llmcache_tags WHERE tag = ?", (tag,))
607
+ return 0
608
+ placeholders = ",".join("?" for _ in entry_ids)
609
+ self._db.execute(
610
+ f"DELETE FROM llmcache_semantic_vectors WHERE entry_id IN ({placeholders})",
611
+ tuple(entry_ids),
612
+ )
613
+ self._db.execute(
614
+ f"DELETE FROM llmcache_entries WHERE entry_id IN ({placeholders})",
615
+ tuple(entry_ids),
616
+ )
617
+ self._db.execute("DELETE FROM llmcache_tags WHERE tag = ?", (tag,))
618
+ return len(entry_ids)
619
+ except sqlite3.Error as exc:
620
+ logger.warning("CacheDB.invalidate_by_tag failed: %s", exc)
621
+ return 0
622
+
623
+ # ---- semantic vectors ----
624
+
625
+ def vec_add(self, entry_id: str, tenant_id: str, vector: bytes, meta: dict) -> None:
626
+ try:
627
+ dim = int(meta.get("dim", len(vector) // 4))
628
+ model_name = str(meta.get("model", "nomic-ai/nomic-embed-text-v1.5"))
629
+ self._db.execute(
630
+ "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),
634
+ )
635
+ except sqlite3.Error as exc:
636
+ logger.warning("CacheDB.vec_add failed: %s", exc)
637
+
638
+ def vec_search(self, tenant_id: str, vector: bytes, top_k: int) -> list[tuple[str, float]]:
639
+ try:
640
+ rows = self._db.execute(
641
+ "SELECT entry_id, vector_blob FROM llmcache_semantic_vectors "
642
+ "WHERE tenant_id = ?",
643
+ (tenant_id,),
644
+ )
645
+ try:
646
+ import numpy as _np
647
+ except ImportError:
648
+ return []
649
+ q = _np.frombuffer(vector, dtype=_np.float32)
650
+ q_norm = float(_np.linalg.norm(q))
651
+ if q_norm == 0:
652
+ return []
653
+ scored: list[tuple[str, float]] = []
654
+ for r in rows:
655
+ rd = dict(r)
656
+ v = _np.frombuffer(rd["vector_blob"], dtype=_np.float32)
657
+ v_norm = float(_np.linalg.norm(v))
658
+ if v_norm == 0:
659
+ continue
660
+ cos = float(_np.dot(q, v) / (q_norm * v_norm))
661
+ scored.append((rd["entry_id"], cos))
662
+ scored.sort(key=lambda t: t[1], reverse=True)
663
+ return scored[: max(0, top_k)]
664
+ except sqlite3.Error as exc:
665
+ logger.warning("CacheDB.vec_search failed: %s", exc)
666
+ return []
667
+
668
+ def vec_delete(self, entry_id: str) -> None:
669
+ try:
670
+ self._db.execute(
671
+ "DELETE FROM llmcache_semantic_vectors WHERE entry_id = ?",
672
+ (entry_id,),
673
+ )
674
+ except sqlite3.Error as exc:
675
+ logger.warning("CacheDB.vec_delete failed: %s", exc)
676
+
677
+ # ---- vCache boundary + centroids (Phase 3 hooks, stub-implemented) ----
678
+
679
+ def boundary_get(self, entry_id: str) -> BoundaryRow | None:
680
+ try:
681
+ rows = self._db.execute(
682
+ "SELECT * FROM llmcache_boundaries WHERE entry_id = ? LIMIT 1",
683
+ (entry_id,),
684
+ )
685
+ if not rows:
686
+ return None
687
+ d = dict(rows[0])
688
+ return BoundaryRow(**d)
689
+ except sqlite3.Error as exc:
690
+ logger.warning("CacheDB.boundary_get failed: %s", exc)
691
+ return None
692
+
693
+ def boundary_upsert(self, entry_id: str, row: BoundaryRow) -> None:
694
+ try:
695
+ self._db.execute(
696
+ "INSERT INTO llmcache_boundaries "
697
+ "(entry_id, logistic_t, logistic_gamma, sample_count, updated_at) "
698
+ "VALUES (?, ?, ?, ?, ?) "
699
+ "ON CONFLICT(entry_id) DO UPDATE SET "
700
+ " logistic_t=excluded.logistic_t, logistic_gamma=excluded.logistic_gamma, "
701
+ " sample_count=excluded.sample_count, updated_at=excluded.updated_at",
702
+ (
703
+ entry_id, row.logistic_t, row.logistic_gamma,
704
+ row.sample_count, row.updated_at,
705
+ ),
706
+ )
707
+ except sqlite3.Error as exc:
708
+ logger.warning("CacheDB.boundary_upsert failed: %s", exc)
709
+
710
+ def centroid_get(self, tenant_id: str) -> bytes | None:
711
+ try:
712
+ rows = self._db.execute(
713
+ "SELECT centroid_blob FROM llmcache_centroids WHERE tenant_id = ? LIMIT 1",
714
+ (tenant_id,),
715
+ )
716
+ if not rows:
717
+ return None
718
+ return dict(rows[0])["centroid_blob"]
719
+ except sqlite3.Error as exc:
720
+ logger.warning("CacheDB.centroid_get failed: %s", exc)
721
+ return None
722
+
723
+ def centroid_update(self, tenant_id: str, centroid: bytes, n: int) -> None:
724
+ try:
725
+ self._db.execute(
726
+ "INSERT INTO llmcache_centroids (tenant_id, centroid_blob, n) "
727
+ "VALUES (?, ?, ?) "
728
+ "ON CONFLICT(tenant_id) DO UPDATE SET "
729
+ " centroid_blob=excluded.centroid_blob, n=excluded.n, "
730
+ " updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')",
731
+ (tenant_id, centroid, n),
732
+ )
733
+ except sqlite3.Error as exc:
734
+ logger.warning("CacheDB.centroid_update failed: %s", exc)
735
+
736
+ # ---- v2.2 supplementary boundary methods (LLD-03 Phase 3) ----
737
+
738
+ def get_all_boundaries(self) -> list[dict[str, Any]]:
739
+ """Return all vCache MLE boundary records (warm-start / rebuild).
740
+
741
+ LLD-03 §4.4: called at VCacheSemantic startup to populate the
742
+ in-memory boundary cache.
743
+
744
+ Returns:
745
+ List of dicts with keys: entry_id, t_hat, gamma_hat,
746
+ sample_count, updated_at. [] on error.
747
+ """
748
+ try:
749
+ rows = self._db.execute(
750
+ "SELECT entry_id, logistic_t, logistic_gamma, sample_count, updated_at "
751
+ "FROM llmcache_boundaries"
752
+ )
753
+ return [dict(r) for r in rows]
754
+ except sqlite3.Error as exc:
755
+ logger.warning("CacheDB.get_all_boundaries failed: %s", exc)
756
+ return []
757
+
758
+ def delete_boundary(self, entry_id: str) -> None:
759
+ """Delete a vCache boundary record. Fail-open."""
760
+ try:
761
+ self._db.execute(
762
+ "DELETE FROM llmcache_boundaries WHERE entry_id = ?",
763
+ (entry_id,),
764
+ )
765
+ except sqlite3.Error as exc:
766
+ logger.warning("CacheDB.delete_boundary failed: %s", exc)
767
+
768
+ def get_entry_by_id(self, entry_id: str) -> dict[str, Any] | None:
769
+ """Fetch a cache entry by entry_id and return its decoded response dict.
770
+
771
+ LLD-03 §4.5 _fetch_response: needed to retrieve the response for a
772
+ semantically matched entry_id. Decrypts + decompresses the value_blob.
773
+
774
+ Returns:
775
+ Response dict (parsed JSON), or None on miss / error.
776
+ """
777
+ try:
778
+ rows = self._db.execute(
779
+ "SELECT value_blob, compressed FROM llmcache_entries "
780
+ "WHERE entry_id = ? LIMIT 1",
781
+ (entry_id,),
782
+ )
783
+ if not rows:
784
+ return None
785
+ row = dict(rows[0])
786
+ try:
787
+ plaintext = self._decrypt(row["value_blob"])
788
+ if row.get("compressed", 0):
789
+ plaintext = zlib.decompress(plaintext)
790
+ except (ValueError, zlib.error) as exc:
791
+ logger.warning("CacheDB.get_entry_by_id decrypt failed: %s", exc)
792
+ return None
793
+ try:
794
+ import json as _json
795
+ return _json.loads(plaintext.decode("utf-8"))
796
+ except (UnicodeDecodeError, json.JSONDecodeError):
797
+ return {"raw_bytes": plaintext.hex()}
798
+ except sqlite3.Error as exc:
799
+ logger.warning("CacheDB.get_entry_by_id failed: %s", exc)
800
+ return None
801
+
802
+ # ---- CCR originals ----
803
+
804
+ def ccr_put(
805
+ self,
806
+ ccr_id: str,
807
+ original: bytes,
808
+ ttl_expires: float | None = None,
809
+ ) -> None:
810
+ import hashlib
811
+ try:
812
+ compressed = zlib.compress(original, level=_ZLIB_LEVEL)
813
+ encrypted = self._encrypt(compressed)
814
+ self._db.execute(
815
+ "INSERT OR REPLACE INTO llmcache_ccr_originals "
816
+ "(ccr_id, original_blob, compressed_hash, byte_size_orig, byte_size_comp, ttl_expires) "
817
+ "VALUES (?, ?, ?, ?, ?, ?)",
818
+ (
819
+ ccr_id, encrypted,
820
+ hashlib.sha256(original).hexdigest(),
821
+ len(original), len(compressed),
822
+ ttl_expires,
823
+ ),
824
+ )
825
+ except sqlite3.Error as exc:
826
+ logger.warning("CacheDB.ccr_put failed: %s", exc)
827
+
828
+ def ccr_get(self, ccr_id: str) -> bytes | None:
829
+ try:
830
+ rows = self._db.execute(
831
+ "SELECT original_blob FROM llmcache_ccr_originals "
832
+ "WHERE ccr_id = ? "
833
+ "AND (ttl_expires IS NULL OR ttl_expires > ?)",
834
+ (ccr_id, time.time()),
835
+ )
836
+ if not rows:
837
+ return None
838
+ blob = dict(rows[0])["original_blob"]
839
+ plaintext = self._decrypt(blob)
840
+ return zlib.decompress(plaintext)
841
+ except (sqlite3.Error, ValueError, zlib.error) as exc:
842
+ logger.warning("CacheDB.ccr_get failed: %s", exc)
843
+ return None
844
+
845
+ def ccr_update_compressed(self, ccr_id: str, compressed: bytes) -> None:
846
+ import hashlib
847
+ try:
848
+ self._db.execute(
849
+ "UPDATE llmcache_ccr_originals "
850
+ "SET compressed_hash = ?, byte_size_comp = ? WHERE ccr_id = ?",
851
+ (hashlib.sha256(compressed).hexdigest(), len(compressed), ccr_id),
852
+ )
853
+ except sqlite3.Error as exc:
854
+ logger.warning("CacheDB.ccr_update_compressed failed: %s", exc)
855
+
856
+ # ---- v2 additions ----
857
+
858
+ def get_all_vectors(self, tenant_id: str) -> list[tuple[str, bytes]]:
859
+ try:
860
+ rows = self._db.execute(
861
+ "SELECT entry_id, vector_blob FROM llmcache_semantic_vectors "
862
+ "WHERE tenant_id = ?",
863
+ (tenant_id,),
864
+ )
865
+ return [(dict(r)["entry_id"], dict(r)["vector_blob"]) for r in rows]
866
+ except sqlite3.Error as exc:
867
+ logger.warning("CacheDB.get_all_vectors failed: %s", exc)
868
+ return []
869
+
870
+ # ---- metrics ----
871
+
872
+ def metrics_load(self) -> MetricsSnapshot:
873
+ try:
874
+ rows = self._db.execute("SELECT * FROM llmcache_metrics WHERE id = 1")
875
+ if not rows:
876
+ return MetricsSnapshot()
877
+ d = dict(rows[0])
878
+ return MetricsSnapshot(**d)
879
+ except sqlite3.Error as exc:
880
+ logger.warning("CacheDB.metrics_load failed: %s", exc)
881
+ return MetricsSnapshot()
882
+
883
+ def metrics_flush(self, snap: MetricsSnapshot) -> None:
884
+ try:
885
+ params = dataclasses.asdict(snap)
886
+ sql = (
887
+ "INSERT INTO llmcache_metrics ("
888
+ " id, hits, misses, calls_skipped, "
889
+ " tokens_saved_input, tokens_saved_output, tokens_saved_compress, "
890
+ " evictions, latency_overhead_ms_sum, latency_samples, "
891
+ " compress_runs, compress_bytes_original, compress_bytes_after, "
892
+ " cache_size_bytes, cache_entry_count, updated_at) "
893
+ "VALUES ("
894
+ " :id, :hits, :misses, :calls_skipped, "
895
+ " :tokens_saved_input, :tokens_saved_output, :tokens_saved_compress, "
896
+ " :evictions, :latency_overhead_ms_sum, :latency_samples, "
897
+ " :compress_runs, :compress_bytes_original, :compress_bytes_after, "
898
+ " :cache_size_bytes, :cache_entry_count, :updated_at) "
899
+ "ON CONFLICT(id) DO UPDATE SET "
900
+ " hits=excluded.hits, misses=excluded.misses, calls_skipped=excluded.calls_skipped, "
901
+ " tokens_saved_input=excluded.tokens_saved_input, "
902
+ " tokens_saved_output=excluded.tokens_saved_output, "
903
+ " tokens_saved_compress=excluded.tokens_saved_compress, "
904
+ " evictions=excluded.evictions, "
905
+ " latency_overhead_ms_sum=excluded.latency_overhead_ms_sum, "
906
+ " latency_samples=excluded.latency_samples, "
907
+ " compress_runs=excluded.compress_runs, "
908
+ " compress_bytes_original=excluded.compress_bytes_original, "
909
+ " compress_bytes_after=excluded.compress_bytes_after, "
910
+ " cache_size_bytes=excluded.cache_size_bytes, "
911
+ " cache_entry_count=excluded.cache_entry_count, "
912
+ " updated_at=excluded.updated_at"
913
+ )
914
+ with self._db.transaction():
915
+ self._db.execute(sql, params)
916
+ except sqlite3.Error as exc:
917
+ logger.warning("CacheDB.metrics_flush failed: %s", exc)
918
+
919
+ # ---- convenience / non-contract helpers ----
920
+
921
+ def entry_exists(self, cache_key: str, tenant_id: str = _DEFAULT_TENANT) -> bool:
922
+ try:
923
+ rows = self._db.execute(
924
+ "SELECT 1 FROM llmcache_entries WHERE cache_key = ? AND tenant_id = ? LIMIT 1",
925
+ (cache_key, tenant_id),
926
+ )
927
+ return bool(rows)
928
+ except sqlite3.Error:
929
+ return False
930
+
931
+ def clear_tenant(self, tenant_id: str) -> int:
932
+ try:
933
+ with self._db.transaction():
934
+ rows = self._db.execute(
935
+ "SELECT entry_id FROM llmcache_entries WHERE tenant_id = ?",
936
+ (tenant_id,),
937
+ )
938
+ entry_ids = [dict(r)["entry_id"] for r in rows]
939
+ if entry_ids:
940
+ placeholders = ",".join("?" for _ in entry_ids)
941
+ self._db.execute(
942
+ f"DELETE FROM llmcache_semantic_vectors "
943
+ f"WHERE entry_id IN ({placeholders})",
944
+ tuple(entry_ids),
945
+ )
946
+ self._db.execute(
947
+ "DELETE FROM llmcache_tags WHERE tenant_id = ?", (tenant_id,),
948
+ )
949
+ self._db.execute(
950
+ "DELETE FROM llmcache_entries WHERE tenant_id = ?", (tenant_id,),
951
+ )
952
+ return len(entry_ids)
953
+ except sqlite3.Error as exc:
954
+ logger.warning("CacheDB.clear_tenant failed: %s", exc)
955
+ return 0
956
+
957
+ def entry_count(self, tenant_id: str = _DEFAULT_TENANT) -> int:
958
+ try:
959
+ rows = self._db.execute(
960
+ "SELECT COUNT(*) AS n FROM llmcache_entries "
961
+ "WHERE tenant_id = ? AND (ttl_expires IS NULL OR ttl_expires > ?)",
962
+ (tenant_id, time.time()),
963
+ )
964
+ return int(dict(rows[0])["n"]) if rows else 0
965
+ except sqlite3.Error:
966
+ return 0
967
+
968
+ def db_size_bytes(self) -> int:
969
+ try:
970
+ rows = self._db.execute("PRAGMA page_count")
971
+ page_count = int(dict(rows[0])["page_count"]) if rows else 0
972
+ rows = self._db.execute("PRAGMA page_size")
973
+ page_size = int(dict(rows[0])["page_size"]) if rows else 0
974
+ return page_count * page_size
975
+ except sqlite3.Error:
976
+ return 0
977
+
978
+ # ---- internal helpers ----
979
+
980
+ def _row_to_cacherow(self, row: dict) -> "CacheRow":
981
+ """Build a CacheRow from a sqlite3.Row dict, robust to extra/missing keys."""
982
+ kwargs: dict[str, Any] = {}
983
+ for fname in CacheRow.__dataclass_fields__:
984
+ if fname == "value":
985
+ continue
986
+ if fname in row:
987
+ kwargs[fname] = row[fname]
988
+ kwargs["value"] = row.get("value", b"")
989
+ try:
990
+ kwargs["tags"] = json.loads(row.get("tag_json") or "[]")
991
+ except (json.JSONDecodeError, TypeError):
992
+ kwargs["tags"] = []
993
+ return CacheRow(**kwargs)
994
+
995
+ # ---- singleton (INTERFACE-CONTRACT v2.2 §1) ----
996
+
997
+ @classmethod
998
+ def get_default(cls) -> "CacheDB":
999
+ if cls._default_instance is None:
1000
+ import threading as _t
1001
+ if cls._default_lock is None:
1002
+ cls._default_lock = _t.Lock()
1003
+ with cls._default_lock:
1004
+ if cls._default_instance is None:
1005
+ cls._default_instance = cls()
1006
+ return cls._default_instance
1007
+
1008
+ @classmethod
1009
+ def reset_default(cls) -> None:
1010
+ """Reset the singleton (testing only)."""
1011
+ if cls._default_instance is not None:
1012
+ try:
1013
+ cls._default_instance.close()
1014
+ except Exception:
1015
+ pass
1016
+ cls._default_instance = None