superlocalmemory 3.6.13 → 3.6.14

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 (124) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.md +187 -741
  3. package/package.json +12 -5
  4. package/plugin/.claude-plugin/plugin.json +20 -0
  5. package/plugin/.mcp.json +12 -0
  6. package/plugin/CLAUDE.md +43 -0
  7. package/plugin/_GENERATED.md +6 -0
  8. package/plugin/agents/slm-memory-advisor.md +43 -0
  9. package/plugin/agents/slm-optimize-advisor.md +38 -0
  10. package/plugin/hooks/hooks.json +14 -0
  11. package/plugin/requirements.txt +1 -0
  12. package/plugin/scripts/ensure-venv.bat +122 -0
  13. package/plugin/scripts/ensure-venv.sh +105 -0
  14. package/plugin/scripts/slm-launch +15 -0
  15. package/plugin/scripts/slm-launch.bat +17 -0
  16. package/plugin/settings.json +16 -0
  17. package/plugin/skills/slm-cache/SKILL.md +140 -0
  18. package/plugin/skills/slm-compress/SKILL.md +143 -0
  19. package/plugin/skills/slm-graph/SKILL.md +300 -0
  20. package/plugin/skills/slm-recall/SKILL.md +196 -0
  21. package/plugin/skills/slm-remember/SKILL.md +182 -0
  22. package/plugin/skills/slm-session/SKILL.md +207 -0
  23. package/plugin/skills/slm-status/SKILL.md +149 -0
  24. package/plugin-src/.mcp.json +12 -0
  25. package/plugin-src/agents/slm-memory-advisor.md +43 -0
  26. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  27. package/plugin-src/commands/slm-optimize.md +22 -0
  28. package/plugin-src/commands/slm-recall.md +16 -0
  29. package/plugin-src/commands/slm-remember.md +16 -0
  30. package/plugin-src/commands/slm-status.md +15 -0
  31. package/plugin-src/hooks/.gitkeep +0 -0
  32. package/plugin-src/hooks/hooks.json +14 -0
  33. package/plugin-src/manifest.json +25 -0
  34. package/plugin-src/requirements.txt +1 -0
  35. package/plugin-src/rules/AGENTS.md +90 -0
  36. package/plugin-src/rules/CLAUDE.md.fragment +43 -0
  37. package/plugin-src/scripts/ensure-venv.bat +122 -0
  38. package/plugin-src/scripts/ensure-venv.sh +105 -0
  39. package/plugin-src/scripts/slm-launch +15 -0
  40. package/plugin-src/scripts/slm-launch.bat +17 -0
  41. package/plugin-src/settings.json +16 -0
  42. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  43. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  45. package/plugin-src/skills/slm-recall/SKILL.md +196 -0
  46. package/plugin-src/skills/slm-remember/SKILL.md +182 -0
  47. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  48. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  49. package/pyproject.toml +6 -2
  50. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  51. package/scripts/_savings_math.py +270 -0
  52. package/scripts/build-plugin.js +742 -0
  53. package/scripts/dogfood_savings.py +490 -0
  54. package/scripts/install-skills.ps1 +4 -334
  55. package/scripts/install-skills.sh +4 -435
  56. package/scripts/postinstall-interactive.js +0 -27
  57. package/scripts/postinstall.js +21 -2
  58. package/src/superlocalmemory/__init__.py +1 -1
  59. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  60. package/src/superlocalmemory/cli/commands.py +348 -39
  61. package/src/superlocalmemory/cli/main.py +47 -4
  62. package/src/superlocalmemory/cli/setup_wizard.py +20 -6
  63. package/src/superlocalmemory/core/config.py +79 -9
  64. package/src/superlocalmemory/core/embeddings.py +10 -5
  65. package/src/superlocalmemory/core/engine.py +2 -2
  66. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  67. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  68. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  69. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  70. package/src/superlocalmemory/mcp/server.py +75 -4
  71. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  72. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  73. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  74. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  75. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  76. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  77. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  78. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  79. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  80. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  81. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  82. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  83. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  84. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  85. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  86. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  87. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  88. package/src/superlocalmemory/server/unified_daemon.py +24 -6
  89. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  90. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  91. package/src/superlocalmemory/ui/index.html +2 -2
  92. package/src/superlocalmemory/ui/js/core.js +98 -0
  93. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  94. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  95. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  96. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  97. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  98. package/src/superlocalmemory.egg-info/PKG-INFO +189 -742
  99. package/src/superlocalmemory.egg-info/SOURCES.txt +6 -9
  100. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  101. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  102. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  103. package/ide/skills/slm-recall/SKILL.md +0 -326
  104. package/ide/skills/slm-remember/SKILL.md +0 -194
  105. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  106. package/ide/skills/slm-status/SKILL.md +0 -363
  107. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  108. package/skills/slm-build-graph/SKILL.md +0 -423
  109. package/skills/slm-list-recent/SKILL.md +0 -348
  110. package/skills/slm-optimize/README.md +0 -55
  111. package/skills/slm-optimize/SKILL.md +0 -139
  112. package/skills/slm-recall/SKILL.md +0 -343
  113. package/skills/slm-remember/SKILL.md +0 -194
  114. package/skills/slm-show-patterns/SKILL.md +0 -224
  115. package/skills/slm-status/SKILL.md +0 -363
  116. package/skills/slm-switch-profile/SKILL.md +0 -442
  117. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  118. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  119. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  120. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  121. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  122. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  123. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  124. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +0 -442
@@ -4,9 +4,9 @@
4
4
 
5
5
  """22 MCP tools for CodeGraph: 17 graph + 5 bridge.
6
6
 
7
- Registered against `server` (NOT `_target`) so they are always visible
8
- when the code-graph extra is installed. Each tool self-guards (returns
9
- error if graph not built) — no risk of confusing users.
7
+ Registered against `_target` (filtered like all other tools) set
8
+ SLM_MCP_ALL_TOOLS=1 or use a profile containing code-graph names to expose
9
+ them. Each tool self-guards (returns error if graph not built).
10
10
 
11
11
  All tools return {"success": bool, ...} envelope. Never raise.
12
12
  """
@@ -19,6 +19,8 @@ from typing import Callable
19
19
 
20
20
  from mcp.types import ToolAnnotations
21
21
 
22
+ from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
23
+
22
24
  logger = logging.getLogger(__name__)
23
25
 
24
26
  _DB_PATH = str(Path.home() / ".superlocalmemory" / "memory.db")
@@ -164,7 +166,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
164
166
 
165
167
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
166
168
  async def recall(
167
- query: str, limit: int = 10, agent_id: str = "mcp_client",
169
+ query: str, limit: int = CANONICAL_RECALL_LIMIT, agent_id: str = "mcp_client",
168
170
  session_id: str = "", fast: bool = False,
169
171
  ) -> dict:
170
172
  """Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking.
@@ -331,6 +333,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
331
333
  async def get_status() -> dict:
332
334
  """Get memory system status: fact count, entity count, mode, profile, db size."""
333
335
  try:
336
+ import os
334
337
  engine = get_engine()
335
338
  pid = engine.profile_id
336
339
  fact_count = engine._db.get_fact_count(pid)
@@ -345,20 +348,25 @@ def register_core_tools(server, get_engine: Callable) -> None:
345
348
  )
346
349
  edge_count = int(dict(edges[0])["c"]) if edges else 0
347
350
 
348
- import os
349
351
  db_size_mb = 0.0
350
352
  db_path = engine._db.db_path
351
353
  if db_path.exists():
352
354
  db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
353
355
 
356
+ # WP-02 D8: additive canonical key set — provider/base_dir/db_path added.
357
+ # All pre-existing keys are preserved (zero removals).
358
+ cfg = engine._config
354
359
  return {
355
360
  "success": True,
356
- "mode": engine._config.mode.value,
361
+ "mode": cfg.mode.value,
362
+ "provider": cfg.llm.provider or "none",
357
363
  "profile": pid,
364
+ "base_dir": str(cfg.base_dir),
365
+ "db_path": str(db_path),
366
+ "db_size_mb": db_size_mb,
358
367
  "fact_count": fact_count,
359
368
  "entity_count": entity_count,
360
369
  "edge_count": edge_count,
361
- "db_size_mb": db_size_mb,
362
370
  }
363
371
  except Exception as exc:
364
372
  logger.exception("get_status failed")
@@ -24,6 +24,7 @@ import logging
24
24
  import math
25
25
  import random
26
26
  import time
27
+ from collections import OrderedDict
27
28
  from dataclasses import dataclass, field
28
29
  from typing import TYPE_CHECKING, Any
29
30
 
@@ -46,6 +47,12 @@ except ImportError: # pragma: no cover
46
47
  _sp_minimize = None # type: ignore[assignment]
47
48
 
48
49
  _BOUNCE_EPS: float = 1e-9
50
+
51
+ # LRU cap for the in-memory write-through cache. Records are durable in
52
+ # SQLite (boundary_upsert), so eviction from _cache is lossless — a miss
53
+ # simply falls back to DB.get(). 50 000 covers the practical warm-cache
54
+ # footprint without unbounded growth on long-running ingest.
55
+ _BOUNDARY_CACHE_MAX: int = 50_000
49
56
  _OVERFLOW_GUARD: float = 500.0
50
57
 
51
58
  # z_{1 - ε/2} for common ε (avoids scipy.stats dependency)
@@ -341,9 +348,11 @@ class BoundaryStore:
341
348
  self._ceiling = ceiling
342
349
  self._step = step
343
350
  self._epsilon = epsilon
344
- # In-memory write-through cache. Populated by load_all() at warm.
351
+ # In-memory write-through LRU cache. Populated by load_all() at warm.
345
352
  # get() checks here first (O(1) hot path), then falls back to DB.
346
- self._cache: dict[str, PerItemBoundaryRecord] = {}
353
+ # Capped at _BOUNDARY_CACHE_MAX entries; oldest is evicted on overflow.
354
+ # Records are always durable in SQLite so eviction is lossless.
355
+ self._cache: OrderedDict[str, PerItemBoundaryRecord] = OrderedDict()
347
356
 
348
357
  def get(self, entry_id: str) -> PerItemBoundaryRecord:
349
358
  """Return the MLE model record for an entry, or a cold-start default.
@@ -352,6 +361,8 @@ class BoundaryStore:
352
361
  Never raises.
353
362
  """
354
363
  if entry_id in self._cache:
364
+ # LRU promotion: move to end (most-recently used).
365
+ self._cache.move_to_end(entry_id)
355
366
  return self._cache[entry_id]
356
367
  try:
357
368
  row = self._db.boundary_get(entry_id)
@@ -406,8 +417,11 @@ class BoundaryStore:
406
417
  updated_at=record.last_updated or time.time(),
407
418
  )
408
419
  self._db.boundary_upsert(record.entry_id, row)
409
- # In-memory write-through (RA-15)
420
+ # LRU write-through (RA-15): insert / refresh position, then evict oldest.
410
421
  self._cache[record.entry_id] = record
422
+ self._cache.move_to_end(record.entry_id)
423
+ if len(self._cache) > _BOUNDARY_CACHE_MAX:
424
+ self._cache.popitem(last=False) # evict LRU (oldest) entry
411
425
  except Exception as exc:
412
426
  logger.warning("BoundaryStore.save failed (fail-open): %s", exc)
413
427
 
@@ -432,7 +446,7 @@ class BoundaryStore:
432
446
  self.save(updated)
433
447
  return updated
434
448
 
435
- def load_all(self) -> dict[str, PerItemBoundaryRecord]:
449
+ def load_all(self) -> OrderedDict[str, PerItemBoundaryRecord]:
436
450
  """Load all boundary records into memory (warm-start).
437
451
 
438
452
  Returns:
@@ -443,7 +457,9 @@ class BoundaryStore:
443
457
  """
444
458
  try:
445
459
  rows = self._db.get_all_boundaries()
446
- result: dict[str, PerItemBoundaryRecord] = {}
460
+ # Return an OrderedDict so the caller assignment
461
+ # (self._boundary_store._cache = load_all()) preserves LRU semantics.
462
+ result: OrderedDict[str, PerItemBoundaryRecord] = OrderedDict()
447
463
  for r in rows:
448
464
  eid = r.get("entry_id")
449
465
  if not eid:
@@ -455,10 +471,13 @@ class BoundaryStore:
455
471
  samples=[],
456
472
  last_updated=float(r.get("updated_at", 0.0)),
457
473
  )
474
+ # Cap at _BOUNDARY_CACHE_MAX — trim oldest if DB has more.
475
+ while len(result) > _BOUNDARY_CACHE_MAX:
476
+ result.popitem(last=False)
458
477
  return result
459
478
  except Exception as exc:
460
479
  logger.warning("BoundaryStore.load_all failed (fail-open): %s", exc)
461
- return {}
480
+ return OrderedDict()
462
481
 
463
482
  def delete(self, entry_id: str) -> None:
464
483
  """Remove boundary record for a deleted cache entry. Fail-open."""
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import logging
23
23
  import threading
24
+ from collections import OrderedDict
24
25
  from typing import TYPE_CHECKING
25
26
 
26
27
  import numpy as np
@@ -33,6 +34,13 @@ logger = logging.getLogger(__name__)
33
34
  _VARIANCE_FLOOR: float = 1e-6
34
35
  _EMBED_DIM: int = 768
35
36
 
37
+ # Stage-9 fix: cap the NUMBER of tenants held in memory. The per-tenant entry
38
+ # caps (WP-A/B) bound depth, but _centroids/_counts grew once per distinct
39
+ # tenant forever (~3 KB/centroid → ~292 MB at 100k tenants on a shared proxy).
40
+ # Evicted tenants rebuild lazily from the DB via rebuild_from_db(), so eviction
41
+ # is lossless. Irrelevant to single-tenant local installs.
42
+ _MAX_TENANTS: int = 10_000
43
+
36
44
 
37
45
  def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
38
46
  """Cosine similarity in [-1, 1]. Returns 0.0 on zero vectors."""
@@ -51,11 +59,19 @@ class CentroidStore:
51
59
  Centroid update rule (Welford running mean — exact, O(1) per update):
52
60
  new_centroid = old_centroid * (n / (n+1)) + new_vec * (1 / (n+1))
53
61
  """
54
- def __init__(self) -> None:
55
- self._centroids: dict[str, np.ndarray] = {} # tenant_id float32 vec
56
- self._counts: dict[str, int] = {} # tenant_idcount
62
+ def __init__(self, max_tenants: int = _MAX_TENANTS) -> None:
63
+ # OrderedDict for O(1) LRU eviction by tenant count.
64
+ self._centroids: "OrderedDict[str, np.ndarray]" = OrderedDict() # tenantvec
65
+ self._counts: "OrderedDict[str, int]" = OrderedDict() # tenant → count
66
+ self._max_tenants = max_tenants
57
67
  self._lock = threading.RLock()
58
68
 
69
+ def _evict_tenants_if_needed(self) -> None:
70
+ """Evict least-recently-updated tenants beyond the cap. Caller holds _lock."""
71
+ while len(self._centroids) > self._max_tenants:
72
+ old_tenant, _ = self._centroids.popitem(last=False)
73
+ self._counts.pop(old_tenant, None)
74
+
59
75
  def rebuild_from_db(self, db: "CacheDB", tenant_id: str) -> None:
60
76
  """Rebuild centroid for a tenant from all stored vectors.
61
77
 
@@ -70,7 +86,7 @@ class CentroidStore:
70
86
  self._counts.pop(tenant_id, None)
71
87
  return
72
88
  vectors: list[np.ndarray] = []
73
- for _entry_id, blob in rows:
89
+ for _entry_id, blob, _ctx_fp in rows:
74
90
  try:
75
91
  vec = np.frombuffer(blob, dtype=np.float32).copy()
76
92
  if vec.shape[0] == _EMBED_DIM:
@@ -83,6 +99,9 @@ class CentroidStore:
83
99
  with self._lock:
84
100
  self._centroids[tenant_id] = centroid
85
101
  self._counts[tenant_id] = len(vectors)
102
+ self._centroids.move_to_end(tenant_id)
103
+ self._counts.move_to_end(tenant_id)
104
+ self._evict_tenants_if_needed()
86
105
  logger.debug(
87
106
  "CentroidStore: rebuilt tenant=%s centroid from %d vectors",
88
107
  tenant_id, len(vectors),
@@ -101,6 +120,7 @@ class CentroidStore:
101
120
  if tenant_id not in self._centroids:
102
121
  self._centroids[tenant_id] = vec.copy()
103
122
  self._counts[tenant_id] = 1
123
+ self._evict_tenants_if_needed()
104
124
  else:
105
125
  n = self._counts[tenant_id]
106
126
  old = self._centroids[tenant_id]
@@ -108,6 +128,9 @@ class CentroidStore:
108
128
  old * (n / (n + 1)) + vec * (1.0 / (n + 1))
109
129
  ).astype(np.float32)
110
130
  self._counts[tenant_id] = n + 1
131
+ # LRU: mark this tenant most-recently used.
132
+ self._centroids.move_to_end(tenant_id)
133
+ self._counts.move_to_end(tenant_id)
111
134
  except Exception as exc:
112
135
  logger.warning("CentroidStore.update failed (fail-open): %s", exc)
113
136
 
@@ -178,7 +178,34 @@ class CacheManager:
178
178
  if not _HEX64.fullmatch(tenant_id or ""):
179
179
  tenant_id = _hashlib.sha256(tenant_id.encode()).hexdigest()
180
180
 
181
- if isinstance(req, ProxyRequest):
181
+ if isinstance(req, ProxyRequest) and req.provider == "vertex":
182
+ # CRIT-2 (WP-11, LOCKED): Vertex bodies have NO model/messages/system.
183
+ # Model is in the PATH; prompts are under 'contents'; system under
184
+ # 'systemInstruction'. Without this branch ALL Vertex requests hash to
185
+ # ONE key → first response poisons every subsequent prompt.
186
+ body = req.body or {}
187
+ model_id = _vertex_model_from_path(req.path)
188
+ # SECURITY (Stage-8 audit): fold project + region into the model
189
+ # identity so two GCP projects (or regions) issuing the same prompt
190
+ # never collide to one cache entry → no cross-tenant response leakage.
191
+ # Model name alone is insufficient (the same model exists in every
192
+ # project). key_builder whitelist-filters raw_params, so extra param
193
+ # keys would be dropped — model_id IS hashed. _KEY_SCHEMA_VERSION
194
+ # unchanged (vertex is a new provider; no live vertex cache; the
195
+ # non-vertex branches are untouched).
196
+ _vproj, _vloc = _vertex_project_location_from_path(req.path)
197
+ if _vproj or _vloc:
198
+ model_id = f"vertex:{_vproj}:{_vloc}:{model_id}"
199
+ messages = body.get("contents", []) or []
200
+ system_raw = body.get("systemInstruction", "") or ""
201
+ if isinstance(system_raw, (dict, list)):
202
+ system = _json.dumps(system_raw, sort_keys=True, separators=(",", ":"))
203
+ else:
204
+ system = str(system_raw)
205
+ # params: everything except the fields extracted above and stream flag.
206
+ _SKIP_VERTEX = frozenset({"contents", "systemInstruction", "stream"})
207
+ params = {k: v for k, v in body.items() if k not in _SKIP_VERTEX}
208
+ elif isinstance(req, ProxyRequest):
182
209
  # Extract semantic fields from the parsed JSON body.
183
210
  body = req.body or {}
184
211
  model_id = body.get("model", "") or ""
@@ -292,16 +319,28 @@ class CacheManager:
292
319
 
293
320
  # ---- CacheHook protocol implementation (INTERFACE-CONTRACT §3) ----
294
321
 
295
- def check(self, req: ProxyRequest) -> "CachedResponse | None":
322
+ def check(
323
+ self, req: ProxyRequest, tenant_id: "str | None" = _DEFAULT_TENANT_HASH
324
+ ) -> "CachedResponse | None":
296
325
  """CacheHook.check() — look up by ProxyRequest; fail-open on error.
297
326
 
298
327
  BUG-FIX (v3.6.3): on_miss() was never called from the proxy path,
299
328
  so MetricsCollector.misses stayed at 0 and the dashboard always showed
300
329
  0 misses. Fixed by calling on_miss() here whenever get() returns a
301
330
  cache-miss result.
331
+
332
+ SECURITY (WP-D): tenant_id is now optional. Surface handlers pass the
333
+ credential-derived tenant so that different API keys never share a cache
334
+ entry. When tenant_id is None (unauthenticated / no credential) the
335
+ cache is SKIPPED entirely — returns None without reading the store.
336
+ The default preserves backward compatibility for callers that do not
337
+ pass a tenant_id.
302
338
  """
339
+ if tenant_id is None:
340
+ # No credential → refuse to serve or populate the cache.
341
+ return None
303
342
  try:
304
- result = self.get(req, tenant_id=_DEFAULT_TENANT_HASH)
343
+ result = self.get(req, tenant_id=tenant_id)
305
344
  if result is not None and not result.hit:
306
345
  MetricsCollector.get_instance().on_miss()
307
346
  return result
@@ -309,10 +348,20 @@ class CacheManager:
309
348
  logger.warning("CacheManager.check raised (fail-open): %s", exc)
310
349
  return None
311
350
 
312
- def store(self, req: ProxyRequest, resp: ProviderResponse) -> None:
313
- """CacheHook.store() persist response; fail-open on error."""
351
+ def store(
352
+ self, req: ProxyRequest, resp: ProviderResponse,
353
+ tenant_id: "str | None" = _DEFAULT_TENANT_HASH,
354
+ ) -> None:
355
+ """CacheHook.store() — persist response; fail-open on error.
356
+
357
+ SECURITY (WP-D): tenant_id is now optional. When None (unauthenticated)
358
+ the store is silently skipped to prevent anonymous requests from
359
+ populating the cache and leaking responses to other tenants.
360
+ """
361
+ if tenant_id is None:
362
+ return
314
363
  try:
315
- self.set(req, resp, tenant_id=_DEFAULT_TENANT_HASH)
364
+ self.set(req, resp, tenant_id=tenant_id)
316
365
  except Exception as exc:
317
366
  logger.warning("CacheManager.store raised (fail-open): %s", exc)
318
367
 
@@ -566,3 +615,40 @@ class _TenantScopedManager:
566
615
  def json_dumps_bytes(d: dict) -> bytes:
567
616
  import json as _json
568
617
  return _json.dumps(d, separators=(",", ":"), default=str).encode("utf-8")
618
+
619
+
620
+ # ---------------------------------------------------------------------------
621
+ # Vertex helpers (WP-11 / CRIT-2)
622
+ # ---------------------------------------------------------------------------
623
+
624
+ def _vertex_project_location_from_path(path: str) -> tuple[str, str]:
625
+ """Extract (project, location) from a Vertex proxy path for cache isolation.
626
+
627
+ Without project+region in the cache key, two GCP projects (or regions)
628
+ issuing an identical prompt collide to one entry → cross-tenant response
629
+ leakage (Stage-8 security finding). Returns ("", "") on parse failure.
630
+ """
631
+ import re as _re
632
+ m = _re.search(
633
+ r"projects/([a-zA-Z0-9._\-]{1,63})/locations/([a-z0-9\-]{1,40})/",
634
+ path or "",
635
+ )
636
+ return (m.group(1), m.group(2)) if m else ("", "")
637
+
638
+
639
+ def _vertex_model_from_path(path: str) -> str:
640
+ """Extract the model name from a Vertex proxy path.
641
+
642
+ Handles both the FastAPI path parameter form:
643
+ /v1/projects/{project}/locations/{loc}/publishers/google/models/{model}:{method}
644
+ and the raw vertex_path parameter:
645
+ {project}/locations/{loc}/publishers/google/models/{model}:{method}
646
+
647
+ Returns empty string on parse failure (key_builder treats it as uncacheable-neutral).
648
+ """
649
+ import re as _re
650
+ _MODEL_RE = _re.compile(r"/models/([a-zA-Z0-9._\-]{1,128}):")
651
+ m = _MODEL_RE.search(path)
652
+ if m:
653
+ return m.group(1)
654
+ return ""
@@ -444,13 +444,32 @@ class VCacheSemantic(SemanticTier):
444
444
  # Persist the cold-start record only if it isn't already in DB
445
445
  self._boundary_store.save(existing)
446
446
 
447
- # Update in-memory index (dedupe)
447
+ # Update in-memory index (dedupe + size cap)
448
+ max_entries: int = int(
449
+ getattr(self._config, "semantic_max_index_entries", 10000)
450
+ )
451
+ max_tenants: int = int(
452
+ getattr(self._config, "semantic_max_tenants", 10000)
453
+ )
448
454
  with self._index_lock:
449
455
  tenant_index = self._index.setdefault(tenant_id, [])
450
456
  self._index[tenant_id] = [
451
457
  e for e in tenant_index if e[0] != entry_id
452
458
  ]
453
459
  self._index[tenant_id].append((entry_id, context_fp, vec))
460
+ # Cap entries per tenant: evict oldest first (lossless — DB is truth).
461
+ if len(self._index[tenant_id]) > max_entries:
462
+ self._index[tenant_id] = self._index[tenant_id][-max_entries:]
463
+ # Stage-9: cap the NUMBER of tenant shards too. Without this, _index
464
+ # grew once per distinct tenant forever on a shared proxy. Evict the
465
+ # oldest-inserted shard (dict preserves insertion order); the evicted
466
+ # tenant rebuilds lazily from the DB on next access. Never evict the
467
+ # shard we just wrote.
468
+ if len(self._index) > max_tenants:
469
+ for _old in list(self._index):
470
+ if _old != tenant_id:
471
+ del self._index[_old]
472
+ break
454
473
 
455
474
  # Update centroid
456
475
  self._centroid_store.update(tenant_id, vec)
@@ -93,6 +93,18 @@ class CCRStore:
93
93
  logger.warning("CCRStore.retrieve failed (ccr_id=%s): %s", ccr_id, exc)
94
94
  return None
95
95
 
96
+ def delete(self, ccr_id: str) -> None:
97
+ """Delete a CCR row by ccr_id. Idempotent — never raises.
98
+
99
+ WP-10 D6: defensive infra. Deleting a non-existent ccr_id is a no-op.
100
+ """
101
+ try:
102
+ db = self._get_db()
103
+ db.ccr_delete(ccr_id)
104
+ logger.debug("CCR deleted ccr_id=%s", ccr_id)
105
+ except Exception as exc:
106
+ logger.warning("CCRStore.delete failed (non-fatal): %s", exc)
107
+
96
108
  def get_mcp_tool_definition(self) -> dict:
97
109
  return {
98
110
  "name": "headroom_retrieve",
@@ -98,6 +98,7 @@ class CompressRouter:
98
98
  request_id=req.request_id,
99
99
  model=body.get("model", ""),
100
100
  tenant_id="default",
101
+ is_proxy=True, # D5-B: proxy path — Layer 2 lossy disabled
101
102
  )
102
103
 
103
104
  # S-01/Stage-9 fix: build new_bytes BEFORE the improvement guard.
@@ -156,6 +157,7 @@ class CompressRouter:
156
157
  request_id: str,
157
158
  model: str,
158
159
  tenant_id: str,
160
+ is_proxy: bool = False,
159
161
  ) -> tuple[list[dict[str, Any]], int, int, str]:
160
162
  total_before = 0
161
163
  total_after = 0
@@ -188,6 +190,7 @@ class CompressRouter:
188
190
  request_id=request_id,
189
191
  model=model,
190
192
  tenant_id=tenant_id,
193
+ is_proxy=is_proxy,
191
194
  )
192
195
  total_before += before
193
196
  total_after += after
@@ -207,9 +210,12 @@ class CompressRouter:
207
210
  request_id: str,
208
211
  model: str,
209
212
  tenant_id: str,
213
+ is_proxy: bool = False,
210
214
  ) -> tuple[Any, int, int, str]:
211
215
  if isinstance(content, str):
212
- return self._compress_text(content, aggressive, request_id, model, tenant_id)
216
+ return self._compress_text(
217
+ content, aggressive, request_id, model, tenant_id, is_proxy=is_proxy
218
+ )
213
219
 
214
220
  if isinstance(content, list):
215
221
  new_blocks: list[Any] = []
@@ -227,7 +233,8 @@ class CompressRouter:
227
233
  new_blocks.append(block)
228
234
  continue
229
235
  new_text, before, after, strat = self._compress_text(
230
- text, aggressive, request_id, model, tenant_id
236
+ text, aggressive, request_id, model, tenant_id,
237
+ is_proxy=is_proxy,
231
238
  )
232
239
  total_before += before
233
240
  total_after += after
@@ -252,6 +259,7 @@ class CompressRouter:
252
259
  request_id: str,
253
260
  model: str,
254
261
  tenant_id: str,
262
+ is_proxy: bool = False,
255
263
  ) -> tuple[str, int, int, str]:
256
264
  tokens_before = _token_estimate(text)
257
265
 
@@ -286,25 +294,37 @@ class CompressRouter:
286
294
  tokens_after_l1 = _token_estimate(normalized)
287
295
 
288
296
  # Layer 2 — LLMLingua-2 prose compression (aggressive + opt-in only)
297
+ # D5-B: proxy path SKIPS Layer 2 entirely — only Layer 1 lossless normalize
298
+ # runs on proxy (ProxyRequest has no response/rehydration hook for CCR markers).
289
299
  cfg = self._get_config()
290
300
  prose_enabled = bool(getattr(cfg, "compress_prose", False))
291
- if aggressive and prose_enabled: # pragma: no cover — LLMLingua optional dep
301
+ if aggressive and prose_enabled and not is_proxy: # pragma: no cover — LLMLingua optional dep
292
302
  compressor = self._get_llmlingua_compressor()
293
303
  if compressor is not None:
294
- # B-03: store original BEFORE lossy compression
295
- ccr_id = self._ccr_store_original(text.encode(), model, tenant_id)
304
+ # D6: compress FIRST, store ONLY inside the reduction branch.
305
+ # Old code stored before compress → orphan row when no reduction occurred.
296
306
  compressed = compressor.compress(normalized)
297
- if ccr_id:
298
- self._ccr_update_compressed(ccr_id, compressed.encode())
299
307
  tokens_after_l2 = _token_estimate(compressed)
300
308
  if tokens_after_l2 < tokens_before:
301
- logger.info(
302
- "[%s] LLMLingua-2 prose compressed rate=%.2f ccr_id=%s (LOSSY)",
303
- request_id,
304
- tokens_after_l2 / tokens_before if tokens_before else 1.0,
305
- ccr_id,
309
+ # Reduction confirmed — now safe to store (no orphan possible)
310
+ ccr_id = self._ccr_store_original(text.encode(), model, tenant_id)
311
+ if ccr_id:
312
+ self._ccr_update_compressed(ccr_id, compressed.encode())
313
+ logger.info(
314
+ "[%s] LLMLingua-2 prose compressed rate=%.2f ccr_id=%s (LOSSY)",
315
+ request_id,
316
+ tokens_after_l2 / tokens_before if tokens_before else 1.0,
317
+ ccr_id,
318
+ )
319
+ return compressed, tokens_before, tokens_after_l2, "llmlingua2_prose"
320
+ # CCR store FAILED → no recoverable id. Returning the lossy form
321
+ # now would destroy the original irreversibly. Refuse it and fall
322
+ # through to lossless Layer 1 (data-safety over compression ratio).
323
+ logger.warning(
324
+ "[%s] CCR store failed — refusing irreversible lossy "
325
+ "compression, falling back to lossless Layer 1", request_id,
306
326
  )
307
- return compressed, tokens_before, tokens_after_l2, "llmlingua2_prose"
327
+ # No reduction (or store-failed) — fall through to lossless Layer 1.
308
328
 
309
329
  # S-01 fix: compare character length, not word count.
310
330
  # _token_estimate() is word-count — whitespace normalization saves characters/bytes
@@ -376,6 +396,19 @@ class CompressRouter:
376
396
  except Exception as exc:
377
397
  logger.debug("CCR update_compressed failed (non-fatal): %s", exc)
378
398
 
399
+ def _ccr_delete(self, ccr_id: str) -> None:
400
+ """WP-10 D6: Defensive delete — idempotent, never raises.
401
+
402
+ Used to clean up a CCR row if post-store processing fails. In the
403
+ store-after-success D6 path this should never be needed (no orphans
404
+ by construction), but kept as defensive infra + sweep parity.
405
+ """
406
+ try:
407
+ store = self._get_ccr_store()
408
+ store.delete(ccr_id)
409
+ except Exception as exc:
410
+ logger.debug("CCR delete failed (non-fatal): %s", exc)
411
+
379
412
  # ── Public convenience method (M-06) ──────────────────────────────────
380
413
 
381
414
  def compress_text(self, text: str, strategy: str = "auto") -> "CompressTextResult":
@@ -87,6 +87,8 @@ class OptimizeConfig:
87
87
  semantic_verifier_model: str = ""
88
88
  semantic_pad_latency_ms: float = 0.0
89
89
  semantic_centroid_min_similarity: float = 0.85
90
+ semantic_max_index_entries: int = 10000
91
+ semantic_max_tenants: int = 10000
90
92
 
91
93
  # Compress
92
94
  compress_enabled: bool = False
@@ -177,6 +179,8 @@ class OptimizeConfig:
177
179
  semantic_centroid_min_similarity=float(
178
180
  d.get("semantic_centroid_min_similarity", 0.85)
179
181
  ),
182
+ semantic_max_index_entries=int(d.get("semantic_max_index_entries", 10000)),
183
+ semantic_max_tenants=int(d.get("semantic_max_tenants", 10000)),
180
184
  compress_enabled=bool(d.get("compress_enabled", False)),
181
185
  compress_mode=str(d.get("compress_mode", "safe")),
182
186
  compress_prose=bool(d.get("compress_prose", False)),
@@ -216,6 +220,8 @@ class OptimizeConfig:
216
220
  "semantic_verifier_model": self.semantic_verifier_model,
217
221
  "semantic_pad_latency_ms": self.semantic_pad_latency_ms,
218
222
  "semantic_centroid_min_similarity": self.semantic_centroid_min_similarity,
223
+ "semantic_max_index_entries": self.semantic_max_index_entries,
224
+ "semantic_max_tenants": self.semantic_max_tenants,
219
225
  "compress_enabled": self.compress_enabled,
220
226
  "compress_mode": self.compress_mode,
221
227
  "compress_prose": self.compress_prose,