superlocalmemory 3.6.13 → 3.6.15

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 (147) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +189 -740
  4. package/package.json +12 -5
  5. package/plugin/.claude-plugin/plugin.json +20 -0
  6. package/plugin/.mcp.json +12 -0
  7. package/plugin/CLAUDE.md +44 -0
  8. package/plugin/_GENERATED.md +6 -0
  9. package/plugin/agents/slm-memory-advisor.md +44 -0
  10. package/plugin/agents/slm-optimize-advisor.md +38 -0
  11. package/plugin/hooks/hooks.json +14 -0
  12. package/plugin/requirements.txt +1 -0
  13. package/plugin/scripts/ensure-venv.bat +122 -0
  14. package/plugin/scripts/ensure-venv.sh +105 -0
  15. package/plugin/scripts/slm-launch +15 -0
  16. package/plugin/scripts/slm-launch.bat +17 -0
  17. package/plugin/settings.json +16 -0
  18. package/plugin/skills/slm-cache/SKILL.md +140 -0
  19. package/plugin/skills/slm-compress/SKILL.md +143 -0
  20. package/plugin/skills/slm-graph/SKILL.md +300 -0
  21. package/plugin/skills/slm-recall/SKILL.md +204 -0
  22. package/plugin/skills/slm-remember/SKILL.md +194 -0
  23. package/plugin/skills/slm-session/SKILL.md +207 -0
  24. package/plugin/skills/slm-status/SKILL.md +149 -0
  25. package/plugin-src/.mcp.json +12 -0
  26. package/plugin-src/agents/slm-memory-advisor.md +44 -0
  27. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  28. package/plugin-src/commands/slm-optimize.md +22 -0
  29. package/plugin-src/commands/slm-recall.md +16 -0
  30. package/plugin-src/commands/slm-remember.md +16 -0
  31. package/plugin-src/commands/slm-status.md +15 -0
  32. package/plugin-src/hooks/.gitkeep +0 -0
  33. package/plugin-src/hooks/hooks.json +14 -0
  34. package/plugin-src/manifest.json +25 -0
  35. package/plugin-src/requirements.txt +1 -0
  36. package/plugin-src/rules/AGENTS.md +91 -0
  37. package/plugin-src/rules/CLAUDE.md.fragment +44 -0
  38. package/plugin-src/scripts/ensure-venv.bat +122 -0
  39. package/plugin-src/scripts/ensure-venv.sh +105 -0
  40. package/plugin-src/scripts/slm-launch +15 -0
  41. package/plugin-src/scripts/slm-launch.bat +17 -0
  42. package/plugin-src/settings.json +16 -0
  43. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  44. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  45. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  46. package/plugin-src/skills/slm-recall/SKILL.md +204 -0
  47. package/plugin-src/skills/slm-remember/SKILL.md +194 -0
  48. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  49. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  50. package/pyproject.toml +6 -2
  51. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  52. package/scripts/_savings_math.py +270 -0
  53. package/scripts/build-plugin.js +742 -0
  54. package/scripts/dogfood_savings.py +490 -0
  55. package/scripts/install-skills.ps1 +4 -334
  56. package/scripts/install-skills.sh +4 -435
  57. package/scripts/postinstall-interactive.js +0 -27
  58. package/scripts/postinstall.js +21 -2
  59. package/src/superlocalmemory/__init__.py +1 -1
  60. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  61. package/src/superlocalmemory/cli/commands.py +439 -41
  62. package/src/superlocalmemory/cli/main.py +92 -4
  63. package/src/superlocalmemory/cli/setup_wizard.py +47 -6
  64. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  65. package/src/superlocalmemory/core/config.py +194 -9
  66. package/src/superlocalmemory/core/embeddings.py +10 -5
  67. package/src/superlocalmemory/core/engine.py +76 -5
  68. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  69. package/src/superlocalmemory/core/platform_utils.py +8 -0
  70. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  71. package/src/superlocalmemory/core/recall_worker.py +7 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  73. package/src/superlocalmemory/core/worker_pool.py +14 -2
  74. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  75. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  76. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  77. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  78. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  79. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  80. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  81. package/src/superlocalmemory/mcp/server.py +75 -4
  82. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  83. package/src/superlocalmemory/mcp/tools_core.py +37 -4
  84. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  85. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  86. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  87. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  88. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  89. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  90. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  91. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  92. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  93. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  94. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  95. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  96. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  97. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  98. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  99. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  100. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  101. package/src/superlocalmemory/retrieval/engine.py +36 -3
  102. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  103. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  105. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  106. package/src/superlocalmemory/server/unified_daemon.py +156 -16
  107. package/src/superlocalmemory/storage/database.py +215 -43
  108. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  109. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  110. package/src/superlocalmemory/storage/models.py +10 -0
  111. package/src/superlocalmemory/storage/schema.py +15 -10
  112. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  113. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  114. package/src/superlocalmemory/ui/index.html +2 -2
  115. package/src/superlocalmemory/ui/js/core.js +98 -0
  116. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  117. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  118. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  119. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  120. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  121. package/src/superlocalmemory.egg-info/PKG-INFO +191 -741
  122. package/src/superlocalmemory.egg-info/SOURCES.txt +7 -9
  123. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  124. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  125. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  126. package/ide/skills/slm-recall/SKILL.md +0 -326
  127. package/ide/skills/slm-remember/SKILL.md +0 -194
  128. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  129. package/ide/skills/slm-status/SKILL.md +0 -363
  130. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  131. package/skills/slm-build-graph/SKILL.md +0 -423
  132. package/skills/slm-list-recent/SKILL.md +0 -348
  133. package/skills/slm-optimize/README.md +0 -55
  134. package/skills/slm-optimize/SKILL.md +0 -139
  135. package/skills/slm-recall/SKILL.md +0 -343
  136. package/skills/slm-remember/SKILL.md +0 -194
  137. package/skills/slm-show-patterns/SKILL.md +0 -224
  138. package/skills/slm-status/SKILL.md +0 -363
  139. package/skills/slm-switch-profile/SKILL.md +0 -442
  140. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  141. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  142. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  143. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  144. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  145. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  146. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  147. 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")
@@ -104,11 +106,17 @@ def register_core_tools(server, get_engine: Callable) -> None:
104
106
  content: str, tags: str = "", project: str = "",
105
107
  importance: int = 5, session_id: str = "",
106
108
  agent_id: str = "mcp_client",
109
+ scope: str | None = None,
110
+ shared_with: str = "",
107
111
  ) -> dict:
108
112
  """Store content to memory with intelligent indexing.
109
113
 
110
114
  Extracts atomic facts, resolves entities, builds graph edges,
111
115
  and indexes for 4-channel retrieval.
116
+
117
+ Multi-scope: ``scope`` sets visibility (personal/shared/global).
118
+ ``shared_with`` is a comma-separated list of profile_ids for
119
+ shared scope.
112
120
  """
113
121
  # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
114
122
  if agent_id == "mcp_client":
@@ -120,6 +128,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
120
128
  "agent_id": agent_id,
121
129
  "session_id": session_id,
122
130
  }
131
+ # Parse shared_with from comma-separated string
132
+ _shared_list = [s.strip() for s in shared_with.split(",") if s.strip()] if shared_with else None
123
133
  # v3.5.5 WRITE-THROUGH: route through the daemon's /remember, which does
124
134
  # a synchronous verbatim insert (memory is keyword/BM25-recallable the
125
135
  # instant this returns) and enqueues async enrichment. This closes the
@@ -134,6 +144,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
134
144
  if await _asyncio.to_thread(is_daemon_running):
135
145
  resp = await _asyncio.to_thread(daemon_request, "POST", "/remember", {
136
146
  "content": content, "tags": tags, "metadata": meta,
147
+ "scope": scope, "shared_with": _shared_list,
137
148
  })
138
149
  if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
139
150
  fids = resp.get("fact_ids") or []
@@ -149,6 +160,13 @@ def register_core_tools(server, get_engine: Callable) -> None:
149
160
 
150
161
  try:
151
162
  from superlocalmemory.cli.pending_store import store_pending
163
+ # v3.6.15: preserve a non-personal scope through the offline path so
164
+ # the materializer replays the right visibility (else --scope global
165
+ # would silently downgrade to personal when the daemon is offline).
166
+ if scope and scope != "personal":
167
+ meta = {**meta, "scope": scope}
168
+ if _shared_list:
169
+ meta["shared_with"] = _shared_list
152
170
  pending_id = store_pending(content, tags=tags, metadata=meta)
153
171
  return {
154
172
  "success": True,
@@ -164,8 +182,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
164
182
 
165
183
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
166
184
  async def recall(
167
- query: str, limit: int = 10, agent_id: str = "mcp_client",
185
+ query: str, limit: int = CANONICAL_RECALL_LIMIT, agent_id: str = "mcp_client",
168
186
  session_id: str = "", fast: bool = False,
187
+ include_global: bool | None = None,
188
+ include_shared: bool | None = None,
169
189
  ) -> dict:
170
190
  """Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking.
171
191
 
@@ -174,6 +194,11 @@ def register_core_tools(server, get_engine: Callable) -> None:
174
194
  engagement signals to this recall. Claude Code should pass its
175
195
  ``CLAUDE_SESSION_ID``. Omitting it degrades to "no closed-loop
176
196
  learning for this recall" — the recall itself always works.
197
+
198
+ Multi-scope: ``include_global`` / ``include_shared`` control which
199
+ scopes participate in retrieval. Leave them unset (``None``) to use the
200
+ configured default — shared memory is OPT-IN, so by default recall
201
+ returns only this profile's own facts. Pass ``True`` to opt in per call.
177
202
  """
178
203
  # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
179
204
  if agent_id == "mcp_client":
@@ -228,6 +253,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
228
253
  result = await asyncio.to_thread(
229
254
  pool.recall, query, limit=limit, session_id=effective_sid,
230
255
  fast=bool(fast),
256
+ include_global=include_global,
257
+ include_shared=include_shared,
231
258
  )
232
259
  if result.get("ok"):
233
260
  # Record implicit feedback: every returned result is a recall_hit
@@ -331,6 +358,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
331
358
  async def get_status() -> dict:
332
359
  """Get memory system status: fact count, entity count, mode, profile, db size."""
333
360
  try:
361
+ import os
334
362
  engine = get_engine()
335
363
  pid = engine.profile_id
336
364
  fact_count = engine._db.get_fact_count(pid)
@@ -345,20 +373,25 @@ def register_core_tools(server, get_engine: Callable) -> None:
345
373
  )
346
374
  edge_count = int(dict(edges[0])["c"]) if edges else 0
347
375
 
348
- import os
349
376
  db_size_mb = 0.0
350
377
  db_path = engine._db.db_path
351
378
  if db_path.exists():
352
379
  db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
353
380
 
381
+ # WP-02 D8: additive canonical key set — provider/base_dir/db_path added.
382
+ # All pre-existing keys are preserved (zero removals).
383
+ cfg = engine._config
354
384
  return {
355
385
  "success": True,
356
- "mode": engine._config.mode.value,
386
+ "mode": cfg.mode.value,
387
+ "provider": cfg.llm.provider or "none",
357
388
  "profile": pid,
389
+ "base_dir": str(cfg.base_dir),
390
+ "db_path": str(db_path),
391
+ "db_size_mb": db_size_mb,
358
392
  "fact_count": fact_count,
359
393
  "entity_count": entity_count,
360
394
  "edge_count": edge_count,
361
- "db_size_mb": db_size_mb,
362
395
  }
363
396
  except Exception as exc:
364
397
  logger.exception("get_status failed")
@@ -289,8 +289,13 @@ def register_v3_tools(server, get_engine: Callable) -> None:
289
289
  limit: Maximum results (default 10).
290
290
  """
291
291
  try:
292
+ import asyncio
292
293
  from superlocalmemory.mcp._daemon_proxy import choose_pool
293
- raw = choose_pool().recall(query=query, limit=limit)
294
+ # choose_pool().recall uses blocking urllib; run off the event loop
295
+ # so recall_trace doesn't stall the MCP server for other tools.
296
+ raw = await asyncio.to_thread(
297
+ lambda: choose_pool().recall(query=query, limit=limit)
298
+ )
294
299
  items = raw.get("results", []) if isinstance(raw, dict) else []
295
300
  results = []
296
301
  for item in items[:limit]:
@@ -222,8 +222,10 @@ def register_v33_tools(server, get_engine: Callable) -> None:
222
222
  # v3.4.26: prefer the daemon's /consolidate/cognitive endpoint
223
223
  # so the heavy CognitiveConsolidator import stays out of the
224
224
  # MCP process. Fall back to local import only if no daemon.
225
- daemon_result = _try_daemon_post(
226
- "/consolidate/cognitive", {"profile_id": pid},
225
+ import asyncio
226
+ # blocking urllib (60s timeout) — keep it off the MCP event loop.
227
+ daemon_result = await asyncio.to_thread(
228
+ _try_daemon_post, "/consolidate/cognitive", {"profile_id": pid},
227
229
  )
228
230
  if daemon_result is not None:
229
231
  _emit_event("ccq.consolidation_complete", {
@@ -434,8 +436,10 @@ def register_v33_tools(server, get_engine: Callable) -> None:
434
436
  # v3.4.26: prefer the daemon so ForgettingScheduler /
435
437
  # ConsolidationWorker / EbbinghausCurve don't load inside
436
438
  # the MCP process.
437
- daemon_result = _try_daemon_post(
438
- "/maintenance/run", {"profile_id": pid},
439
+ import asyncio
440
+ # blocking urllib — keep it off the MCP event loop.
441
+ daemon_result = await asyncio.to_thread(
442
+ _try_daemon_post, "/maintenance/run", {"profile_id": pid},
439
443
  )
440
444
  if daemon_result is not None:
441
445
  daemon_result.setdefault("success", True)
@@ -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":